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;
+}