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; 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 | null { return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; } export function parseSmartRulesToFilters( rules: Record | 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 { const all: Record[] = []; 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 = { all }; rules.limit = Math.max(1, Math.min(LIMIT_MAX, Number(filters.limit) || 50)); rules.sort = filters.sort; return rules; }