From 2380543d59e208e3cee87641408ef7723738a971 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 15:52:09 +0200 Subject: [PATCH] =?UTF-8?q?refactor(playlists):=20G.79=20=E2=80=94=20extra?= =?UTF-8?q?ct=20smart=20helpers=20+=20cover=20images=20+=202=20lazy-fetch?= =?UTF-8?q?=20hooks=20(cluster)=20(#646)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four-cut cluster opening the Playlists refactor. 1039 → 763 LOC (−276). playlistsSmart — full smart-playlist module: SMART_PREFIX / LIMIT_MAX / YEAR_MIN / YEAR_MAX constants, GenreMode / YearMode / SmartFilters / PendingSmartPlaylist / NdSmartRuleNode types, defaultSmartFilters seed, clampYear / isSmartPlaylistName / displayPlaylistName / asRecord helpers, parseSmartRulesToFilters (the Navidrome JSON rule walker), and buildSmartRulesPayload (the reverse — page-state → Navidrome JSON). The payload builder becomes parameterized on the filters object so it lives outside the component. PlaylistCoverImages — two tiny CachedImage wrappers (PlaylistSmartCoverCell for the 200 px collage cells, PlaylistCardMainCover for the 256 px main card cover). useSmartCoverCollage — replaces the inline useEffect that builds the 2×2 cover collage for each smart playlist (pulls playlist tracks, filters to active library scope, collects up to four unique cover-art ids). Returns the per-playlist id map and re-fetches on playlist list change or library filter version bump. usePlaylistsLibraryScopeCounts — replaces the inline useEffect that recomputes per-playlist song count + total duration under the current library scope. Chunked into batches of four parallel fetches. Playlists drops the inline definitions; pure code move otherwise. --- .../playlists/PlaylistCoverImages.tsx | 29 ++ src/hooks/usePlaylistsLibraryScopeCounts.ts | 66 ++++ src/hooks/useSmartCoverCollage.ts | 57 ++++ src/pages/Playlists.tsx | 304 +----------------- src/utils/playlistsSmart.ts | 179 +++++++++++ 5 files changed, 345 insertions(+), 290 deletions(-) create mode 100644 src/components/playlists/PlaylistCoverImages.tsx create mode 100644 src/hooks/usePlaylistsLibraryScopeCounts.ts create mode 100644 src/hooks/useSmartCoverCollage.ts create mode 100644 src/utils/playlistsSmart.ts diff --git a/src/components/playlists/PlaylistCoverImages.tsx b/src/components/playlists/PlaylistCoverImages.tsx new file mode 100644 index 00000000..0befd4d1 --- /dev/null +++ b/src/components/playlists/PlaylistCoverImages.tsx @@ -0,0 +1,29 @@ +import React, { useMemo } from 'react'; +import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl'; +import CachedImage from '../CachedImage'; + +export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) { + const src = useMemo(() => buildCoverArtUrl(coverId, 200), [coverId]); + const cacheKey = useMemo(() => coverArtCacheKey(coverId, 200), [coverId]); + return ( + + ); +} + +export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) { + const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]); + const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]); + return ( + + ); +} diff --git a/src/hooks/usePlaylistsLibraryScopeCounts.ts b/src/hooks/usePlaylistsLibraryScopeCounts.ts new file mode 100644 index 00000000..f6bd0ff7 --- /dev/null +++ b/src/hooks/usePlaylistsLibraryScopeCounts.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; +import { getPlaylist } from '../api/subsonicPlaylists'; +import type { SubsonicPlaylist } from '../api/subsonicTypes'; + +export interface PlaylistsLibraryScopeCountsResult { + filteredSongCountByPlaylist: Record; + filteredDurationByPlaylist: Record; +} + +/** + * Recompute song count + total duration for each playlist under the current + * library scope. Chunked into batches of 4 parallel fetches to avoid hammering + * Navidrome on large playlists. Re-runs when the playlist list changes or + * when the active library filter version bumps. + */ +export function usePlaylistsLibraryScopeCounts( + playlists: SubsonicPlaylist[], + musicLibraryFilterVersion: number, +): PlaylistsLibraryScopeCountsResult { + const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState>({}); + const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState>({}); + + useEffect(() => { + let cancelled = false; + const run = async () => { + if (playlists.length === 0) { + if (!cancelled) { + setFilteredSongCountByPlaylist({}); + setFilteredDurationByPlaylist({}); + } + return; + } + const ids = playlists.map((pl) => pl.id); + const next: Record = {}; + const nextDuration: Record = {}; + for (let i = 0; i < ids.length; i += 4) { + const chunk = ids.slice(i, i + 4); + const rows = await Promise.all( + chunk.map(async (id) => { + try { + const { songs } = await getPlaylist(id); + const filtered = await filterSongsToActiveLibrary(songs); + const duration = filtered.reduce((acc, s) => acc + (s.duration ?? 0), 0); + return [id, filtered.length, duration] as const; + } catch { + return [id, -1, -1] as const; + } + }), + ); + for (const [id, count, duration] of rows) { + if (count >= 0) next[id] = count; + if (duration >= 0) nextDuration[id] = duration; + } + } + if (!cancelled) { + setFilteredSongCountByPlaylist(next); + setFilteredDurationByPlaylist(nextDuration); + } + }; + run(); + return () => { cancelled = true; }; + }, [playlists, musicLibraryFilterVersion]); + + return { filteredSongCountByPlaylist, filteredDurationByPlaylist }; +} diff --git a/src/hooks/useSmartCoverCollage.ts b/src/hooks/useSmartCoverCollage.ts new file mode 100644 index 00000000..270eeddc --- /dev/null +++ b/src/hooks/useSmartCoverCollage.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; +import { getPlaylist } from '../api/subsonicPlaylists'; +import type { SubsonicPlaylist } from '../api/subsonicTypes'; +import { isSmartPlaylistName } from '../utils/playlistsSmart'; + +/** + * Build the 2×2 cover collage for each smart playlist. Pulls each smart + * playlist's tracks (filtered to the active library scope) and collects up + * to four unique cover-art IDs. Re-runs when the playlist list changes or + * when the active library filter version bumps. + */ +export function useSmartCoverCollage( + playlists: SubsonicPlaylist[], + musicLibraryFilterVersion: number, +): Record { + const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState>({}); + + useEffect(() => { + let cancelled = false; + const run = async () => { + const smart = playlists.filter(pl => isSmartPlaylistName(pl.name)); + if (smart.length === 0) { + if (!cancelled) setSmartCoverIdsByPlaylist({}); + return; + } + const rows = await Promise.all( + smart.map(async (pl) => { + try { + const { songs } = await getPlaylist(pl.id); + const filtered = await filterSongsToActiveLibrary(songs); + const ids: string[] = []; + const seen = new Set(); + for (const s of filtered) { + const cid = s.coverArt; + if (!cid || seen.has(cid)) continue; + seen.add(cid); + ids.push(cid); + if (ids.length >= 4) break; + } + return [pl.id, ids] as const; + } catch { + return [pl.id, [] as string[]] as const; + } + }), + ); + if (cancelled) return; + const next: Record = {}; + for (const [id, ids] of rows) next[id] = ids; + setSmartCoverIdsByPlaylist(next); + }; + run(); + return () => { cancelled = true; }; + }, [playlists, musicLibraryFilterVersion]); + + return smartCoverIdsByPlaylist; +} diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 1136dba3..90771e26 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -18,185 +18,20 @@ import { showToast } from '../utils/toast'; import { ndCreateSmartPlaylist, ndGetSmartPlaylist, ndListSmartPlaylists, ndUpdateSmartPlaylist } from '../api/navidromeSmart'; import { useRangeSelection } from '../hooks/useRangeSelection'; +import { + SMART_PREFIX, LIMIT_MAX, YEAR_MIN, YEAR_MAX, + defaultSmartFilters, parseSmartRulesToFilters, buildSmartRulesPayload, + isSmartPlaylistName, displayPlaylistName, clampYear, + type SmartFilters, type PendingSmartPlaylist, +} from '../utils/playlistsSmart'; +import { PlaylistSmartCoverCell, PlaylistCardMainCover } from '../components/playlists/PlaylistCoverImages'; +import { useSmartCoverCollage } from '../hooks/useSmartCoverCollage'; +import { usePlaylistsLibraryScopeCounts } from '../hooks/usePlaylistsLibraryScopeCounts'; + function formatDuration(seconds: number): string { return formatHumanHoursMinutes(seconds); } -function PlaylistSmartCoverCell({ coverId }: { coverId: string }) { - const src = useMemo(() => buildCoverArtUrl(coverId, 200), [coverId]); - const cacheKey = useMemo(() => coverArtCacheKey(coverId, 200), [coverId]); - return ( - - ); -} - -function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) { - const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]); - const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]); - return ( - - ); -} - -const SMART_PREFIX = 'psy-smart-'; -const LIMIT_MAX = 500; -const YEAR_MIN = 1950; -const YEAR_MAX = new Date().getFullYear() + 1; - -type GenreMode = 'include' | 'exclude'; -type YearMode = 'include' | 'exclude'; - -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; -}; - -type PendingSmartPlaylist = { - name: string; - id?: string; - firstSeenCoverArt?: string; - attempts: number; -}; - -type NdSmartRuleNode = Record; - -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', -}; - -function clampYear(v: number): number { - return Math.max(YEAR_MIN, Math.min(YEAR_MAX, v)); -} - -function isSmartPlaylistName(name: string): boolean { - return (name ?? '').toLowerCase().startsWith(SMART_PREFIX); -} - -function displayPlaylistName(name: string): string { - const n = name ?? ''; - if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length); - return n; -} - -function asRecord(v: unknown): Record | null { - return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; -} - -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; - - 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; -} - export default function Playlists() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -222,9 +57,9 @@ export default function Playlists() { const [creatingSmartBusy, setCreatingSmartBusy] = useState(false); const [editingSmartId, setEditingSmartId] = useState(null); const [pendingSmart, setPendingSmart] = useState([]); - const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState>({}); - const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState>({}); - const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState>({}); + const smartCoverIdsByPlaylist = useSmartCoverCollage(playlists, musicLibraryFilterVersion); + const { filteredSongCountByPlaylist, filteredDurationByPlaylist } = + usePlaylistsLibraryScopeCounts(playlists, musicLibraryFilterVersion); const [playingId, setPlayingId] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const nameInputRef = useRef(null); @@ -259,86 +94,6 @@ export default function Playlists() { getGenres().then(setGenres).catch(() => {}); }, [fetchPlaylists]); - // Smart playlists: build 2x2 cover collage from tracks inside the active library scope. - useEffect(() => { - let cancelled = false; - const run = async () => { - const smart = playlists.filter(pl => isSmartPlaylistName(pl.name)); - if (smart.length === 0) { - if (!cancelled) setSmartCoverIdsByPlaylist({}); - return; - } - const rows = await Promise.all( - smart.map(async (pl) => { - try { - const { songs } = await getPlaylist(pl.id); - const filtered = await filterSongsToActiveLibrary(songs); - const ids: string[] = []; - const seen = new Set(); - for (const s of filtered) { - const cid = s.coverArt; - if (!cid || seen.has(cid)) continue; - seen.add(cid); - ids.push(cid); - if (ids.length >= 4) break; - } - return [pl.id, ids] as const; - } catch { - return [pl.id, [] as string[]] as const; - } - }), - ); - if (cancelled) return; - const next: Record = {}; - for (const [id, ids] of rows) next[id] = ids; - setSmartCoverIdsByPlaylist(next); - }; - run(); - return () => { cancelled = true; }; - }, [playlists, musicLibraryFilterVersion]); - - // Playlist list should reflect active library scope for song counts. - useEffect(() => { - let cancelled = false; - const run = async () => { - if (playlists.length === 0) { - if (!cancelled) { - setFilteredSongCountByPlaylist({}); - setFilteredDurationByPlaylist({}); - } - return; - } - const ids = playlists.map((pl) => pl.id); - const next: Record = {}; - const nextDuration: Record = {}; - for (let i = 0; i < ids.length; i += 4) { - const chunk = ids.slice(i, i + 4); - const rows = await Promise.all( - chunk.map(async (id) => { - try { - const { songs } = await getPlaylist(id); - const filtered = await filterSongsToActiveLibrary(songs); - const duration = filtered.reduce((acc, s) => acc + (s.duration ?? 0), 0); - return [id, filtered.length, duration] as const; - } catch { - return [id, -1, -1] as const; - } - }), - ); - for (const [id, count, duration] of rows) { - if (count >= 0) next[id] = count; - if (duration >= 0) nextDuration[id] = duration; - } - } - if (!cancelled) { - setFilteredSongCountByPlaylist(next); - setFilteredDurationByPlaylist(nextDuration); - } - }; - run(); - return () => { cancelled = true; }; - }, [playlists, musicLibraryFilterVersion]); - useEffect(() => { if (creating) nameInputRef.current?.focus(); }, [creating]); @@ -360,37 +115,6 @@ export default function Playlists() { setNewName(''); }; - const buildSmartRulesPayload = (): Record => { - const all: Record[] = []; - if (smartFilters.artistContains.trim()) all.push({ contains: { artist: smartFilters.artistContains.trim() } }); - if (smartFilters.albumContains.trim()) all.push({ contains: { album: smartFilters.albumContains.trim() } }); - if (smartFilters.titleContains.trim()) all.push({ contains: { title: smartFilters.titleContains.trim() } }); - - const minRating = Number(smartFilters.minRating); - if (Number.isFinite(minRating) && minRating > 0) all.push({ gt: { rating: minRating } }); - else if (smartFilters.excludeUnrated) all.push({ gt: { rating: 0 } }); - if (smartFilters.compilationOnly) all.push({ is: { compilation: true } }); - - if (smartFilters.selectedGenres.length > 0) { - if (smartFilters.genreMode === 'include') { - all.push({ any: smartFilters.selectedGenres.map(v => ({ contains: { genre: v } })) }); - } else { - for (const g of smartFilters.selectedGenres) all.push({ notContains: { genre: g } }); - } - } - - if (smartFilters.yearMode === 'include') { - all.push({ inTheRange: { year: [smartFilters.yearFrom, smartFilters.yearTo] } }); - } else { - all.push({ any: [{ lt: { year: smartFilters.yearFrom } }, { gt: { year: smartFilters.yearTo } }] }); - } - - const rules: Record = { all }; - rules.limit = Math.max(1, Math.min(LIMIT_MAX, Number(smartFilters.limit) || 50)); - rules.sort = smartFilters.sort; - return rules; - }; - const handleOpenSmartEditor = async (pl: SubsonicPlaylist) => { if (!isNavidromeServer || !isSmartPlaylistName(pl.name)) return; setCreatingSmartBusy(true); @@ -459,7 +183,7 @@ export default function Playlists() { ordinal += 1; } } - const rules = buildSmartRulesPayload(); + const rules = buildSmartRulesPayload(smartFilters); const fullName = `${SMART_PREFIX}${baseName}`; if (editingSmartId) { await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true); diff --git a/src/utils/playlistsSmart.ts b/src/utils/playlistsSmart.ts new file mode 100644 index 00000000..c01affb2 --- /dev/null +++ b/src/utils/playlistsSmart.ts @@ -0,0 +1,179 @@ +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; +}; + +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', +}; + +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; + + 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; +} + +export function buildSmartRulesPayload(filters: SmartFilters): 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 (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; +}