mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user