refactor(playlists): G.79 — extract smart helpers + cover images + 2 lazy-fetch hooks (cluster) (#646)

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.
This commit is contained in:
Frank Stellmacher
2026-05-13 15:52:09 +02:00
committed by GitHub
parent 84c682aeb8
commit 2380543d59
5 changed files with 345 additions and 290 deletions
@@ -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 (
<CachedImage
className="playlist-cover-cell"
src={src}
cacheKey={cacheKey}
alt=""
/>
);
}
export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]);
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]);
return (
<CachedImage
src={src}
cacheKey={cacheKey}
alt={alt}
className="album-card-cover-img"
/>
);
}
@@ -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<string, number>;
filteredDurationByPlaylist: Record<string, number>;
}
/**
* 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<Record<string, number>>({});
const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState<Record<string, number>>({});
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<string, number> = {};
const nextDuration: Record<string, number> = {};
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 };
}
+57
View File
@@ -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<string, string[]> {
const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState<Record<string, string[]>>({});
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<string>();
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<string, string[]> = {};
for (const [id, ids] of rows) next[id] = ids;
setSmartCoverIdsByPlaylist(next);
};
run();
return () => { cancelled = true; };
}, [playlists, musicLibraryFilterVersion]);
return smartCoverIdsByPlaylist;
}
+14 -290
View File
@@ -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 (
<CachedImage
className="playlist-cover-cell"
src={src}
cacheKey={cacheKey}
alt=""
/>
);
}
function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]);
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]);
return (
<CachedImage
src={src}
cacheKey={cacheKey}
alt={alt}
className="album-card-cover-img"
/>
);
}
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<string, unknown>;
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<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
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;
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<string | null>(null);
const [pendingSmart, setPendingSmart] = useState<PendingSmartPlaylist[]>([]);
const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState<Record<string, string[]>>({});
const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState<Record<string, number>>({});
const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState<Record<string, number>>({});
const smartCoverIdsByPlaylist = useSmartCoverCollage(playlists, musicLibraryFilterVersion);
const { filteredSongCountByPlaylist, filteredDurationByPlaylist } =
usePlaylistsLibraryScopeCounts(playlists, musicLibraryFilterVersion);
const [playingId, setPlayingId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(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<string>();
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<string, string[]> = {};
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<string, number> = {};
const nextDuration: Record<string, number> = {};
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<string, unknown> => {
const all: Record<string, unknown>[] = [];
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<string, unknown> = { 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);
+179
View File
@@ -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<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',
};
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;
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<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 (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;
}