Files
Psychotoxical-psysonic/src/utils/playlist/playlistsSmart.ts
T
cucadmuh 82c414d7bc fix(playlists): Smart Playlist editor theme, toggles, and exclude-all-genres (#970)
* fix(playlists): Smart Playlist editor theme, toggles, and exclude-all-genres

Replace native sort select with CustomSelect, fix mode-button layout shift,
color-code included vs excluded genres, collapse exclude-all to untagged rule,
and handle empty smart playlists without false "not found".

* docs: CHANGELOG and credits for Smart Playlist editor fix (PR #970)

* chore(credits): drop minor fix entries from PR #958 onward
2026-06-04 00:22:29 +03:00

205 lines
7.0 KiB
TypeScript

export const SMART_PREFIX = 'psy-smart-';
export const LIMIT_MAX = 500;
export const YEAR_MIN = 1950;
export const YEAR_MAX = new Date().getFullYear() + 1;
export type GenreMode = 'include' | 'exclude';
export type YearMode = 'include' | 'exclude';
export type SmartFilters = {
name: string;
limit: string;
sort: string;
artistContains: string;
albumContains: string;
titleContains: string;
minRating: number;
excludeUnrated: boolean;
compilationOnly: boolean;
selectedGenres: string[];
genreMode: GenreMode;
yearFrom: number;
yearTo: number;
yearMode: YearMode;
/** Navidrome `{ is: { genre: '' } }` — tracks with no genre tag. */
untaggedGenresOnly: boolean;
};
export type BuildSmartRulesOptions = {
/** Full genre catalog — used to collapse “exclude every genre” into an untagged-only rule. */
allGenres?: string[];
};
export type PendingSmartPlaylist = {
name: string;
id?: string;
firstSeenCoverArt?: string;
attempts: number;
};
export type NdSmartRuleNode = Record<string, unknown>;
export const defaultSmartFilters: SmartFilters = {
name: '',
limit: '50',
sort: '+random',
artistContains: '',
albumContains: '',
titleContains: '',
minRating: 0,
excludeUnrated: false,
compilationOnly: false,
selectedGenres: [],
genreMode: 'include',
yearFrom: YEAR_MIN,
yearTo: YEAR_MAX,
yearMode: 'include',
untaggedGenresOnly: false,
};
export function clampYear(v: number): number {
return Math.max(YEAR_MIN, Math.min(YEAR_MAX, v));
}
export function isSmartPlaylistName(name: string): boolean {
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
}
export function displayPlaylistName(name: string): string {
const n = name ?? '';
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
return n;
}
export function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
export function parseSmartRulesToFilters(
rules: Record<string, unknown> | undefined,
playlistName: string,
): SmartFilters {
const next: SmartFilters = {
...defaultSmartFilters,
name: displayPlaylistName(playlistName),
};
if (!rules) return next;
if (typeof rules.limit === 'number' && Number.isFinite(rules.limit)) {
next.limit = String(Math.max(1, Math.min(LIMIT_MAX, Number(rules.limit))));
}
if (typeof rules.sort === 'string' && rules.sort.trim()) next.sort = rules.sort;
const includeGenres: string[] = [];
const excludeGenres: string[] = [];
const all = Array.isArray(rules.all) ? rules.all : [];
for (const node of all) {
const obj = asRecord(node);
if (!obj) continue;
const contains = asRecord(obj.contains);
if (contains) {
if (typeof contains.artist === 'string') next.artistContains = contains.artist;
if (typeof contains.album === 'string') next.albumContains = contains.album;
if (typeof contains.title === 'string') next.titleContains = contains.title;
}
const gt = asRecord(obj.gt);
if (gt && typeof gt.rating === 'number') {
if (gt.rating > 0) next.minRating = Math.max(0, Math.min(5, Math.floor(gt.rating)));
else if (gt.rating === 0) next.excludeUnrated = true;
}
const is = asRecord(obj.is);
if (is?.compilation === true) next.compilationOnly = true;
if (is && is.genre === '') {
next.genreMode = 'exclude';
next.untaggedGenresOnly = true;
}
const notContains = asRecord(obj.notContains);
if (notContains && typeof notContains.genre === 'string') excludeGenres.push(notContains.genre);
const inTheRange = asRecord(obj.inTheRange);
if (inTheRange && Array.isArray(inTheRange.year) && inTheRange.year.length === 2) {
const from = Number(inTheRange.year[0]);
const to = Number(inTheRange.year[1]);
if (Number.isFinite(from) && Number.isFinite(to)) {
next.yearMode = 'include';
next.yearFrom = clampYear(Math.min(from, to));
next.yearTo = clampYear(Math.max(from, to));
}
}
const any = Array.isArray(obj.any) ? (obj.any as NdSmartRuleNode[]) : [];
if (any.length > 0) {
const parsedGenreIncludes = any
.map((item) => asRecord(asRecord(item)?.contains)?.genre)
.filter((v): v is string => typeof v === 'string');
if (parsedGenreIncludes.length > 0) includeGenres.push(...parsedGenreIncludes);
const ltYear = any.map((item) => asRecord(asRecord(item)?.lt)?.year).find((v) => typeof v === 'number');
const gtYear = any.map((item) => asRecord(asRecord(item)?.gt)?.year).find((v) => typeof v === 'number');
if (typeof ltYear === 'number' && typeof gtYear === 'number') {
next.yearMode = 'exclude';
next.yearFrom = clampYear(Math.min(ltYear, gtYear));
next.yearTo = clampYear(Math.max(ltYear, gtYear));
}
}
}
if (includeGenres.length > 0) {
next.genreMode = 'include';
next.selectedGenres = [...new Set(includeGenres)];
} else if (excludeGenres.length > 0) {
next.genreMode = 'exclude';
next.selectedGenres = [...new Set(excludeGenres)];
}
return next;
}
function shouldUseUntaggedGenreRule(filters: SmartFilters, allGenres?: string[]): boolean {
if (filters.untaggedGenresOnly) return true;
if (filters.genreMode !== 'exclude' || filters.selectedGenres.length === 0) return false;
if (!allGenres || allGenres.length === 0) return false;
const selected = new Set(filters.selectedGenres);
return allGenres.every(g => selected.has(g));
}
export function buildSmartRulesPayload(
filters: SmartFilters,
opts?: BuildSmartRulesOptions,
): Record<string, unknown> {
const all: Record<string, unknown>[] = [];
if (filters.artistContains.trim()) all.push({ contains: { artist: filters.artistContains.trim() } });
if (filters.albumContains.trim()) all.push({ contains: { album: filters.albumContains.trim() } });
if (filters.titleContains.trim()) all.push({ contains: { title: filters.titleContains.trim() } });
const minRating = Number(filters.minRating);
if (Number.isFinite(minRating) && minRating > 0) all.push({ gt: { rating: minRating } });
else if (filters.excludeUnrated) all.push({ gt: { rating: 0 } });
if (filters.compilationOnly) all.push({ is: { compilation: true } });
if (shouldUseUntaggedGenreRule(filters, opts?.allGenres)) {
all.push({ is: { genre: '' } });
} else if (filters.selectedGenres.length > 0) {
if (filters.genreMode === 'include') {
all.push({ any: filters.selectedGenres.map(v => ({ contains: { genre: v } })) });
} else {
for (const g of filters.selectedGenres) all.push({ notContains: { genre: g } });
}
}
if (filters.yearMode === 'include') {
all.push({ inTheRange: { year: [filters.yearFrom, filters.yearTo] } });
} else {
all.push({ any: [{ lt: { year: filters.yearFrom } }, { gt: { year: filters.yearTo } }] });
}
const rules: Record<string, unknown> = { all };
rules.limit = Math.max(1, Math.min(LIMIT_MAX, Number(filters.limit) || 50));
rules.sort = filters.sort;
return rules;
}