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
This commit is contained in:
cucadmuh
2026-06-04 00:22:29 +03:00
committed by GitHub
parent c9b2d140d9
commit 82c414d7bc
12 changed files with 194 additions and 33 deletions
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { buildSmartRulesPayload, defaultSmartFilters, parseSmartRulesToFilters } from './playlistsSmart';
describe('buildSmartRulesPayload', () => {
it('collapses exclude-all-genres into an untagged-only rule', () => {
const filters = {
...defaultSmartFilters,
genreMode: 'exclude' as const,
selectedGenres: ['Rock', 'Jazz', 'Pop'],
};
const rules = buildSmartRulesPayload(filters, { allGenres: ['Rock', 'Jazz', 'Pop'] });
const all = rules.all as Record<string, unknown>[];
expect(all.some(r => (r as { is?: { genre?: string } }).is?.genre === '')).toBe(true);
expect(all.filter(r => 'notContains' in r)).toHaveLength(0);
});
it('keeps per-genre exclusions when only some genres are selected', () => {
const filters = {
...defaultSmartFilters,
genreMode: 'exclude' as const,
selectedGenres: ['Rock'],
};
const rules = buildSmartRulesPayload(filters, { allGenres: ['Rock', 'Jazz'] });
const all = rules.all as Record<string, unknown>[];
expect(all).toContainEqual({ notContains: { genre: 'Rock' } });
});
});
describe('parseSmartRulesToFilters', () => {
it('restores untagged-only exclude rules', () => {
const parsed = parseSmartRulesToFilters(
{ all: [{ is: { genre: '' } }], limit: 50, sort: '+random' },
'psy-smart-test',
);
expect(parsed.untaggedGenresOnly).toBe(true);
expect(parsed.genreMode).toBe('exclude');
});
});
+27 -2
View File
@@ -21,6 +21,13 @@ export type SmartFilters = {
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 = {
@@ -47,6 +54,7 @@ export const defaultSmartFilters: SmartFilters = {
yearFrom: YEAR_MIN,
yearTo: YEAR_MAX,
yearMode: 'include',
untaggedGenresOnly: false,
};
export function clampYear(v: number): number {
@@ -104,6 +112,10 @@ export function parseSmartRulesToFilters(
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);
@@ -147,7 +159,18 @@ export function parseSmartRulesToFilters(
return next;
}
export function buildSmartRulesPayload(filters: SmartFilters): Record<string, unknown> {
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() } });
@@ -158,7 +181,9 @@ export function buildSmartRulesPayload(filters: SmartFilters): Record<string, un
else if (filters.excludeUnrated) all.push({ gt: { rating: 0 } });
if (filters.compilationOnly) all.push({ is: { compilation: true } });
if (filters.selectedGenres.length > 0) {
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 {
+6 -1
View File
@@ -2,6 +2,7 @@ import type React from 'react';
import { getPlaylist } from '../../api/subsonicPlaylists';
import { filterSongsToActiveLibrary } from '../../api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
import { usePlaylistStore } from '../../store/playlistStore';
export interface RunPlaylistLoadDeps {
id: string;
@@ -31,7 +32,11 @@ export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void>
setRatings(init);
setStarredSongs(starred);
} catch {
// intentional swallow; load failure leaves loading false + playlist null
const stub = usePlaylistStore.getState().playlists.find(p => p.id === id);
if (stub) {
setPlaylist(stub);
setSongs([]);
}
} finally {
setLoading(false);
}
@@ -1,7 +1,7 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { ndGetSmartPlaylist, ndListSmartPlaylists } from '../../api/navidromeSmart';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import type { SubsonicGenre, SubsonicPlaylist } from '../../api/subsonicTypes';
import {
defaultSmartFilters, displayPlaylistName, isSmartPlaylistName,
parseSmartRulesToFilters, type SmartFilters,
@@ -11,6 +11,7 @@ import { showToast } from '../ui/toast';
export interface RunPlaylistsOpenSmartEditorDeps {
pl: SubsonicPlaylist;
isNavidromeServer: boolean;
allGenres: SubsonicGenre[];
t: TFunction;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
@@ -22,7 +23,7 @@ export interface RunPlaylistsOpenSmartEditorDeps {
export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEditorDeps): Promise<void> {
const {
pl, isNavidromeServer, t,
pl, isNavidromeServer, allGenres, t,
setSmartFilters, setEditingSmartId, setGenreQuery,
setCreating, setCreatingSmart, setCreatingSmartBusy,
} = deps;
@@ -47,7 +48,11 @@ export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEdi
) ?? null;
}
if (target) {
setSmartFilters(parseSmartRulesToFilters(target.rules, target.name));
const parsed = parseSmartRulesToFilters(target.rules, target.name);
if (parsed.untaggedGenresOnly) {
parsed.selectedGenres = allGenres.map(g => g.value);
}
setSmartFilters(parsed);
setEditingSmartId(target.id);
} else {
// Fallback: allow editing even if Navidrome smart list endpoint
+3 -2
View File
@@ -12,6 +12,7 @@ import { showToast } from '../ui/toast';
export interface RunPlaylistsSaveSmartDeps {
isNavidromeServer: boolean;
smartFilters: SmartFilters;
allGenres: string[];
editingSmartId: string | null;
playlists: SubsonicPlaylist[];
fetchPlaylists: () => Promise<void>;
@@ -26,7 +27,7 @@ export interface RunPlaylistsSaveSmartDeps {
export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Promise<void> {
const {
isNavidromeServer, smartFilters, editingSmartId, playlists, fetchPlaylists, t,
isNavidromeServer, smartFilters, allGenres, editingSmartId, playlists, fetchPlaylists, t,
setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters,
setGenreQuery, setCreatingSmartBusy,
} = deps;
@@ -47,7 +48,7 @@ export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Pr
ordinal += 1;
}
}
const rules = buildSmartRulesPayload(smartFilters);
const rules = buildSmartRulesPayload(smartFilters, { allGenres });
const fullName = `${SMART_PREFIX}${baseName}`;
if (editingSmartId) {
await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true);