mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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:
@@ -498,6 +498,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
* Floating mode no longer stretches the player bar between sidebar and queue with fixed `left`/`right` — only the centered pill is painted over the page instead of a full-width black band behind the rounded corners.
|
* Floating mode no longer stretches the player bar between sidebar and queue with fixed `left`/`right` — only the centered pill is painted over the page instead of a full-width black band behind the rounded corners.
|
||||||
|
|
||||||
|
|
||||||
|
### Smart Playlist editor — themed sort, stable toggles, exclude-all genres
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#970](https://github.com/Psychotoxical/psysonic/pull/970)**
|
||||||
|
|
||||||
|
* Sort dropdown uses the themed `CustomSelect` instead of a native `<select>` whose option list followed system styling.
|
||||||
|
* Include/Exclude genre and year-range mode buttons no longer jump ~1px when selected — matched button box model and disabled hover translate on mode toggles.
|
||||||
|
* Selected genres are color-coded (primary for include, danger for exclude) so they are distinguishable from available chips.
|
||||||
|
* Excluding all genres collapses to a single untagged-genre rule instead of hundreds of `notContains` filters that stalled Navidrome; empty smart playlists settle without a false "Playlist not found" after a long spinner.
|
||||||
|
|
||||||
|
|
||||||
### In-page browse — virtual scroll and cover-art priority
|
### In-page browse — virtual scroll and cover-art priority
|
||||||
|
|
||||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)**
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)**
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
|
import CustomSelect from '../CustomSelect';
|
||||||
import {
|
import {
|
||||||
LIMIT_MAX, YEAR_MAX, YEAR_MIN, clampYear, defaultSmartFilters,
|
LIMIT_MAX, YEAR_MAX, YEAR_MIN, clampYear, defaultSmartFilters,
|
||||||
type SmartFilters,
|
type SmartFilters,
|
||||||
@@ -27,6 +28,34 @@ export default function PlaylistsSmartEditor({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const sortOptions = useMemo(() => ([
|
||||||
|
{ value: '+random', label: t('smartPlaylists.sortRandom') },
|
||||||
|
{ value: '+title', label: t('smartPlaylists.sortTitleAsc') },
|
||||||
|
{ value: '-title', label: t('smartPlaylists.sortTitleDesc') },
|
||||||
|
{ value: '-year', label: t('smartPlaylists.sortYearDesc') },
|
||||||
|
{ value: '+year', label: t('smartPlaylists.sortYearAsc') },
|
||||||
|
{ value: '-playcount', label: t('smartPlaylists.sortPlayCountDesc') },
|
||||||
|
]), [t]);
|
||||||
|
|
||||||
|
const selectedGenreChipClass =
|
||||||
|
smartFilters.genreMode === 'include' ? 'btn btn-primary' : 'btn btn-danger';
|
||||||
|
|
||||||
|
const addGenre = (genre: string) => {
|
||||||
|
setSmartFilters(v => ({
|
||||||
|
...v,
|
||||||
|
untaggedGenresOnly: false,
|
||||||
|
selectedGenres: [...v.selectedGenres, genre],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGenre = (genre: string) => {
|
||||||
|
setSmartFilters(v => ({
|
||||||
|
...v,
|
||||||
|
untaggedGenresOnly: false,
|
||||||
|
selectedGenres: v.selectedGenres.filter(x => x !== genre),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: '1rem', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '0.9rem', background: 'var(--bg-card)' }}>
|
<div style={{ marginBottom: '1rem', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '0.9rem', background: 'var(--bg-card)' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
@@ -38,22 +67,31 @@ export default function PlaylistsSmartEditor({
|
|||||||
<input className="input" type="number" min={1} max={LIMIT_MAX} placeholder={t('smartPlaylists.limit')} value={smartFilters.limit} onChange={e => setSmartFilters(v => ({ ...v, limit: e.target.value }))} />
|
<input className="input" type="number" min={1} max={LIMIT_MAX} placeholder={t('smartPlaylists.limit')} value={smartFilters.limit} onChange={e => setSmartFilters(v => ({ ...v, limit: e.target.value }))} />
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.limitHint', { max: LIMIT_MAX })}</span>
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.limitHint', { max: LIMIT_MAX })}</span>
|
||||||
</div>
|
</div>
|
||||||
<select className="input" value={smartFilters.sort} onChange={e => setSmartFilters(v => ({ ...v, sort: e.target.value }))}>
|
<CustomSelect
|
||||||
<option value="+random">{t('smartPlaylists.sortRandom')}</option>
|
value={smartFilters.sort}
|
||||||
<option value="+title">{t('smartPlaylists.sortTitleAsc')}</option>
|
options={sortOptions}
|
||||||
<option value="-title">{t('smartPlaylists.sortTitleDesc')}</option>
|
onChange={sort => setSmartFilters(v => ({ ...v, sort }))}
|
||||||
<option value="-year">{t('smartPlaylists.sortYearDesc')}</option>
|
/>
|
||||||
<option value="+year">{t('smartPlaylists.sortYearAsc')}</option>
|
|
||||||
<option value="-playcount">{t('smartPlaylists.sortPlayCountDesc')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
|
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionGenres')}</div>
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionGenres')}</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
<div className="smart-playlist-mode-toggle" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.genreMode')}</span>
|
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.genreMode')}</span>
|
||||||
<button className={`btn ${smartFilters.genreMode === 'include' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'include' }))}>{t('smartPlaylists.genreModeInclude')}</button>
|
<button
|
||||||
<button className={`btn ${smartFilters.genreMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'exclude' }))}>{t('smartPlaylists.genreModeExclude')}</button>
|
type="button"
|
||||||
|
className={`btn ${smartFilters.genreMode === 'include' ? 'btn-primary' : 'btn-surface'}`}
|
||||||
|
onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'include', untaggedGenresOnly: false }))}
|
||||||
|
>
|
||||||
|
{t('smartPlaylists.genreModeInclude')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${smartFilters.genreMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`}
|
||||||
|
onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'exclude', untaggedGenresOnly: false }))}
|
||||||
|
>
|
||||||
|
{t('smartPlaylists.genreModeExclude')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input className="input" placeholder={t('smartPlaylists.genreSearchPlaceholder')} value={genreQuery} onChange={e => setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} />
|
<input className="input" placeholder={t('smartPlaylists.genreSearchPlaceholder')} value={genreQuery} onChange={e => setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} />
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
@@ -61,7 +99,15 @@ export default function PlaylistsSmartEditor({
|
|||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.availableGenres')}</div>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.availableGenres')}</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||||
{availableGenres.map(g => (
|
{availableGenres.map(g => (
|
||||||
<button key={g} className="btn btn-surface" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSmartFilters(v => ({ ...v, selectedGenres: [...v.selectedGenres, g] }))}>{g}</button>
|
<button
|
||||||
|
key={g}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-surface"
|
||||||
|
style={{ fontSize: 12, padding: '2px 8px' }}
|
||||||
|
onClick={() => addGenre(g)}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +115,15 @@ export default function PlaylistsSmartEditor({
|
|||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.selectedGenres')}</div>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.selectedGenres')}</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||||
{smartFilters.selectedGenres.map(g => (
|
{smartFilters.selectedGenres.map(g => (
|
||||||
<button key={g} className="btn btn-surface" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSmartFilters(v => ({ ...v, selectedGenres: v.selectedGenres.filter(x => x !== g) }))}>× {g}</button>
|
<button
|
||||||
|
key={g}
|
||||||
|
type="button"
|
||||||
|
className={selectedGenreChipClass}
|
||||||
|
style={{ fontSize: 12, padding: '2px 8px' }}
|
||||||
|
onClick={() => removeGenre(g)}
|
||||||
|
>
|
||||||
|
× {g}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,10 +131,22 @@ export default function PlaylistsSmartEditor({
|
|||||||
</section>
|
</section>
|
||||||
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
|
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionYearsAndFilters')}</div>
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionYearsAndFilters')}</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
<div className="smart-playlist-mode-toggle" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.yearMode')}</span>
|
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.yearMode')}</span>
|
||||||
<button className={`btn ${smartFilters.yearMode === 'include' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'include' }))}>{t('smartPlaylists.yearModeInclude')}</button>
|
<button
|
||||||
<button className={`btn ${smartFilters.yearMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'exclude' }))}>{t('smartPlaylists.yearModeExclude')}</button>
|
type="button"
|
||||||
|
className={`btn ${smartFilters.yearMode === 'include' ? 'btn-primary' : 'btn-surface'}`}
|
||||||
|
onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'include' }))}
|
||||||
|
>
|
||||||
|
{t('smartPlaylists.yearModeInclude')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${smartFilters.yearMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`}
|
||||||
|
onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'exclude' }))}
|
||||||
|
>
|
||||||
|
{t('smartPlaylists.yearModeExclude')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-muted)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
<span>{t('smartPlaylists.fromYear')}: {smartFilters.yearFrom}</span>
|
<span>{t('smartPlaylists.fromYear')}: {smartFilters.yearFrom}</span>
|
||||||
@@ -113,6 +179,7 @@ export default function PlaylistsSmartEditor({
|
|||||||
</section>
|
</section>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="btn btn-surface"
|
className="btn btn-surface"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCreatingSmart(false);
|
setCreatingSmart(false);
|
||||||
@@ -123,7 +190,7 @@ export default function PlaylistsSmartEditor({
|
|||||||
>
|
>
|
||||||
{t('playlists.cancel')}
|
{t('playlists.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary" onClick={onSave} disabled={creatingSmartBusy}>
|
<button type="button" className="btn btn-primary" onClick={onSave} disabled={creatingSmartBusy}>
|
||||||
<Plus size={15} /> {editingSmartId ? t('smartPlaylists.save') : t('smartPlaylists.create')}
|
<Plus size={15} /> {editingSmartId ? t('smartPlaylists.save') : t('smartPlaylists.create')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,10 +151,6 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Performance Probe: on-demand (ui) cover throughput alongside backfill (lib) cpm (PR #947)',
|
'Performance Probe: on-demand (ui) cover throughput alongside backfill (lib) cpm (PR #947)',
|
||||||
'Performance Probe: throughput (analysis tpm, cover cpm) measured over a trailing 5s window so the rate reacts promptly instead of coasting on minute-long inertia (PR #948)',
|
'Performance Probe: throughput (analysis tpm, cover cpm) measured over a trailing 5s window so the rate reacts promptly instead of coasting on minute-long inertia (PR #948)',
|
||||||
'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)',
|
'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)',
|
||||||
'Player: persist volume/repeat/queue visibility/Last.fm cache outside quota-bound queue blob (report: norp on Psysonic Discord) (PR #958)',
|
|
||||||
'Player transport: custom delay input capped to browser timer limit; preview/countdown aligned for fractional minutes (report: zunoz on Psysonic Discord) (PR #967)',
|
|
||||||
'Settings: in-page search indexes AudioMuse + shortcut rows; rejects junk fuzzy matches (report: zunoz on Psysonic Discord) (PR #968)',
|
|
||||||
'Floating player bar: center shrink-wrapped pill instead of full-width background strip (report: Asra on Psysonic Discord) (PR #969)',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,7 +72,11 @@ export function usePendingSmartPolling(
|
|||||||
// Wait until we see actual content and cover changed from the first placeholder-ish cover.
|
// Wait until we see actual content and cover changed from the first placeholder-ish cover.
|
||||||
// Fallback timeout keeps UI from waiting forever on servers that never update cover id.
|
// Fallback timeout keeps UI from waiting forever on servers that never update cover id.
|
||||||
const hardTimeoutReached = item.attempts >= 18; // ~3 minutes (18 * 10s)
|
const hardTimeoutReached = item.attempts >= 18; // ~3 minutes (18 * 10s)
|
||||||
const ready = songCount > 0 && (!placeholderStillThere || hardTimeoutReached);
|
const emptySettled = songCount === 0 && item.attempts >= 3; // ~30s — valid empty result
|
||||||
|
const ready =
|
||||||
|
hardTimeoutReached
|
||||||
|
|| emptySettled
|
||||||
|
|| (songCount > 0 && (!placeholderStillThere || hardTimeoutReached));
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
next.push({
|
next.push({
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -119,13 +119,13 @@ export default function Playlists() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenSmartEditor = (pl: SubsonicPlaylist) => runPlaylistsOpenSmartEditor({
|
const handleOpenSmartEditor = (pl: SubsonicPlaylist) => runPlaylistsOpenSmartEditor({
|
||||||
pl, isNavidromeServer, t,
|
pl, isNavidromeServer, allGenres: genres, t,
|
||||||
setSmartFilters, setEditingSmartId, setGenreQuery,
|
setSmartFilters, setEditingSmartId, setGenreQuery,
|
||||||
setCreating, setCreatingSmart, setCreatingSmartBusy,
|
setCreating, setCreatingSmart, setCreatingSmartBusy,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreateSmart = () => runPlaylistsSaveSmart({
|
const handleCreateSmart = () => runPlaylistsSaveSmart({
|
||||||
isNavidromeServer, smartFilters, editingSmartId, playlists, fetchPlaylists, t,
|
isNavidromeServer, smartFilters, allGenres: genres.map(g => g.value), editingSmartId, playlists, fetchPlaylists, t,
|
||||||
setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters,
|
setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters,
|
||||||
setGenreQuery, setCreatingSmartBusy,
|
setGenreQuery, setCreatingSmartBusy,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -154,3 +154,10 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Smart playlist editor — mode toggles stay aligned when primary/surface swaps. */
|
||||||
|
.smart-playlist-mode-toggle .btn-primary:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--ctp-crust);
|
color: var(--ctp-crust);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
/* Match .btn-surface border box so toggling primary/surface does not shift layout. */
|
||||||
|
border: 1px solid transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
|
|||||||
@@ -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;
|
yearFrom: number;
|
||||||
yearTo: number;
|
yearTo: number;
|
||||||
yearMode: YearMode;
|
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 = {
|
export type PendingSmartPlaylist = {
|
||||||
@@ -47,6 +54,7 @@ export const defaultSmartFilters: SmartFilters = {
|
|||||||
yearFrom: YEAR_MIN,
|
yearFrom: YEAR_MIN,
|
||||||
yearTo: YEAR_MAX,
|
yearTo: YEAR_MAX,
|
||||||
yearMode: 'include',
|
yearMode: 'include',
|
||||||
|
untaggedGenresOnly: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function clampYear(v: number): number {
|
export function clampYear(v: number): number {
|
||||||
@@ -104,6 +112,10 @@ export function parseSmartRulesToFilters(
|
|||||||
|
|
||||||
const is = asRecord(obj.is);
|
const is = asRecord(obj.is);
|
||||||
if (is?.compilation === true) next.compilationOnly = true;
|
if (is?.compilation === true) next.compilationOnly = true;
|
||||||
|
if (is && is.genre === '') {
|
||||||
|
next.genreMode = 'exclude';
|
||||||
|
next.untaggedGenresOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
const notContains = asRecord(obj.notContains);
|
const notContains = asRecord(obj.notContains);
|
||||||
if (notContains && typeof notContains.genre === 'string') excludeGenres.push(notContains.genre);
|
if (notContains && typeof notContains.genre === 'string') excludeGenres.push(notContains.genre);
|
||||||
@@ -147,7 +159,18 @@ export function parseSmartRulesToFilters(
|
|||||||
return next;
|
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>[] = [];
|
const all: Record<string, unknown>[] = [];
|
||||||
if (filters.artistContains.trim()) all.push({ contains: { artist: filters.artistContains.trim() } });
|
if (filters.artistContains.trim()) all.push({ contains: { artist: filters.artistContains.trim() } });
|
||||||
if (filters.albumContains.trim()) all.push({ contains: { album: filters.albumContains.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 } });
|
else if (filters.excludeUnrated) all.push({ gt: { rating: 0 } });
|
||||||
if (filters.compilationOnly) all.push({ is: { compilation: true } });
|
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') {
|
if (filters.genreMode === 'include') {
|
||||||
all.push({ any: filters.selectedGenres.map(v => ({ contains: { genre: v } })) });
|
all.push({ any: filters.selectedGenres.map(v => ({ contains: { genre: v } })) });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type React from 'react';
|
|||||||
import { getPlaylist } from '../../api/subsonicPlaylists';
|
import { getPlaylist } from '../../api/subsonicPlaylists';
|
||||||
import { filterSongsToActiveLibrary } from '../../api/subsonicLibrary';
|
import { filterSongsToActiveLibrary } from '../../api/subsonicLibrary';
|
||||||
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
|
import { usePlaylistStore } from '../../store/playlistStore';
|
||||||
|
|
||||||
export interface RunPlaylistLoadDeps {
|
export interface RunPlaylistLoadDeps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,7 +32,11 @@ export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void>
|
|||||||
setRatings(init);
|
setRatings(init);
|
||||||
setStarredSongs(starred);
|
setStarredSongs(starred);
|
||||||
} catch {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { ndGetSmartPlaylist, ndListSmartPlaylists } from '../../api/navidromeSmart';
|
import { ndGetSmartPlaylist, ndListSmartPlaylists } from '../../api/navidromeSmart';
|
||||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
import type { SubsonicGenre, SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||||
import {
|
import {
|
||||||
defaultSmartFilters, displayPlaylistName, isSmartPlaylistName,
|
defaultSmartFilters, displayPlaylistName, isSmartPlaylistName,
|
||||||
parseSmartRulesToFilters, type SmartFilters,
|
parseSmartRulesToFilters, type SmartFilters,
|
||||||
@@ -11,6 +11,7 @@ import { showToast } from '../ui/toast';
|
|||||||
export interface RunPlaylistsOpenSmartEditorDeps {
|
export interface RunPlaylistsOpenSmartEditorDeps {
|
||||||
pl: SubsonicPlaylist;
|
pl: SubsonicPlaylist;
|
||||||
isNavidromeServer: boolean;
|
isNavidromeServer: boolean;
|
||||||
|
allGenres: SubsonicGenre[];
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
|
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
|
||||||
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
|
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
@@ -22,7 +23,7 @@ export interface RunPlaylistsOpenSmartEditorDeps {
|
|||||||
|
|
||||||
export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEditorDeps): Promise<void> {
|
export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEditorDeps): Promise<void> {
|
||||||
const {
|
const {
|
||||||
pl, isNavidromeServer, t,
|
pl, isNavidromeServer, allGenres, t,
|
||||||
setSmartFilters, setEditingSmartId, setGenreQuery,
|
setSmartFilters, setEditingSmartId, setGenreQuery,
|
||||||
setCreating, setCreatingSmart, setCreatingSmartBusy,
|
setCreating, setCreatingSmart, setCreatingSmartBusy,
|
||||||
} = deps;
|
} = deps;
|
||||||
@@ -47,7 +48,11 @@ export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEdi
|
|||||||
) ?? null;
|
) ?? null;
|
||||||
}
|
}
|
||||||
if (target) {
|
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);
|
setEditingSmartId(target.id);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: allow editing even if Navidrome smart list endpoint
|
// Fallback: allow editing even if Navidrome smart list endpoint
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { showToast } from '../ui/toast';
|
|||||||
export interface RunPlaylistsSaveSmartDeps {
|
export interface RunPlaylistsSaveSmartDeps {
|
||||||
isNavidromeServer: boolean;
|
isNavidromeServer: boolean;
|
||||||
smartFilters: SmartFilters;
|
smartFilters: SmartFilters;
|
||||||
|
allGenres: string[];
|
||||||
editingSmartId: string | null;
|
editingSmartId: string | null;
|
||||||
playlists: SubsonicPlaylist[];
|
playlists: SubsonicPlaylist[];
|
||||||
fetchPlaylists: () => Promise<void>;
|
fetchPlaylists: () => Promise<void>;
|
||||||
@@ -26,7 +27,7 @@ export interface RunPlaylistsSaveSmartDeps {
|
|||||||
|
|
||||||
export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Promise<void> {
|
export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Promise<void> {
|
||||||
const {
|
const {
|
||||||
isNavidromeServer, smartFilters, editingSmartId, playlists, fetchPlaylists, t,
|
isNavidromeServer, smartFilters, allGenres, editingSmartId, playlists, fetchPlaylists, t,
|
||||||
setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters,
|
setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters,
|
||||||
setGenreQuery, setCreatingSmartBusy,
|
setGenreQuery, setCreatingSmartBusy,
|
||||||
} = deps;
|
} = deps;
|
||||||
@@ -47,7 +48,7 @@ export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Pr
|
|||||||
ordinal += 1;
|
ordinal += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rules = buildSmartRulesPayload(smartFilters);
|
const rules = buildSmartRulesPayload(smartFilters, { allGenres });
|
||||||
const fullName = `${SMART_PREFIX}${baseName}`;
|
const fullName = `${SMART_PREFIX}${baseName}`;
|
||||||
if (editingSmartId) {
|
if (editingSmartId) {
|
||||||
await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true);
|
await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true);
|
||||||
|
|||||||
Reference in New Issue
Block a user