diff --git a/src/components/playlists/PlaylistsSmartEditor.tsx b/src/components/playlists/PlaylistsSmartEditor.tsx new file mode 100644 index 00000000..e3a43d4b --- /dev/null +++ b/src/components/playlists/PlaylistsSmartEditor.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Plus } from 'lucide-react'; +import StarRating from '../StarRating'; +import { + LIMIT_MAX, YEAR_MAX, YEAR_MIN, clampYear, defaultSmartFilters, + type SmartFilters, +} from '../../utils/playlistsSmart'; + +interface Props { + smartFilters: SmartFilters; + setSmartFilters: React.Dispatch>; + availableGenres: string[]; + genreQuery: string; + setGenreQuery: React.Dispatch>; + editingSmartId: string | null; + creatingSmartBusy: boolean; + setCreatingSmart: React.Dispatch>; + setEditingSmartId: React.Dispatch>; + onSave: () => void; +} + +export default function PlaylistsSmartEditor({ + smartFilters, setSmartFilters, availableGenres, + genreQuery, setGenreQuery, editingSmartId, creatingSmartBusy, + setCreatingSmart, setEditingSmartId, onSave, +}: Props) { + const { t } = useTranslation(); + + return ( +
+
+
+
{t('smartPlaylists.sectionBasic')}
+
+ setSmartFilters(v => ({ ...v, name: e.target.value }))} /> +
+ setSmartFilters(v => ({ ...v, limit: e.target.value }))} /> + {t('smartPlaylists.limitHint', { max: LIMIT_MAX })} +
+ +
+
+
+
{t('smartPlaylists.sectionGenres')}
+
+ {t('smartPlaylists.genreMode')} + + +
+ setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} /> +
+
+
{t('smartPlaylists.availableGenres')}
+
+ {availableGenres.map(g => ( + + ))} +
+
+
+
{t('smartPlaylists.selectedGenres')}
+
+ {smartFilters.selectedGenres.map(g => ( + + ))} +
+
+
+
+
+
{t('smartPlaylists.sectionYearsAndFilters')}
+
+ {t('smartPlaylists.yearMode')} + + +
+
+ {t('smartPlaylists.fromYear')}: {smartFilters.yearFrom} + {t('smartPlaylists.toYear')}: {smartFilters.yearTo} +
+
+
+
+ setSmartFilters(v => ({ ...v, yearFrom: Math.min(clampYear(Number(e.target.value)), v.yearTo) }))} /> + setSmartFilters(v => ({ ...v, yearTo: Math.max(clampYear(Number(e.target.value)), v.yearFrom) }))} /> +
+
+ setSmartFilters(v => ({ ...v, artistContains: e.target.value }))} /> + setSmartFilters(v => ({ ...v, albumContains: e.target.value }))} /> + setSmartFilters(v => ({ ...v, titleContains: e.target.value }))} /> +
{t('smartPlaylists.minRating')}: {smartFilters.minRating}★
+ setSmartFilters(v => ({ ...v, minRating: rating }))} ariaLabel={t('smartPlaylists.minRatingAria')} /> + {t('smartPlaylists.minRatingHint')} +
+
+ + +
+
+
+ + +
+
+
+ ); +} diff --git a/src/hooks/usePendingSmartPolling.ts b/src/hooks/usePendingSmartPolling.ts new file mode 100644 index 00000000..7b0492db --- /dev/null +++ b/src/hooks/usePendingSmartPolling.ts @@ -0,0 +1,90 @@ +import { useEffect } from 'react'; +import type React from 'react'; +import { getPlaylist } from '../api/subsonicPlaylists'; +import type { SubsonicPlaylist } from '../api/subsonicTypes'; +import { usePlaylistStore } from '../store/playlistStore'; +import type { PendingSmartPlaylist } from '../utils/playlistsSmart'; + +/** + * Poll Navidrome every 10 s for each pending smart playlist until its + * rules finish processing on the server. We stop polling for an item when + * (a) it has at least one song AND (b) its cover-art id has changed from + * the placeholder we first saw — or after ~3 minutes as a fallback. + * + * Side-effects: + * - rehydrates the playlist store with fresh detail-endpoint metadata + * (cover, song count) as soon as it's available + * - shrinks `pendingSmart` as items finish + */ +export function usePendingSmartPolling( + pendingSmart: PendingSmartPlaylist[], + setPendingSmart: React.Dispatch>, + fetchPlaylists: () => Promise, +): void { + useEffect(() => { + if (pendingSmart.length === 0) return; + const interval = window.setInterval(async () => { + await fetchPlaylists(); + const listNow = usePlaylistStore.getState().playlists; + const hydrated = pendingSmart.map(item => { + if (item.id) return item; + const found = listNow.find(p => p.name === item.name); + return found ? { ...item, id: found.id } : item; + }); + // Detail endpoint tends to reflect fresh metadata earlier than list endpoint. + const ids = hydrated.map(p => p.id).filter((v): v is string => Boolean(v)); + const details = await Promise.all( + ids.map(async (id) => { + try { + const { playlist } = await getPlaylist(id); + return playlist; + } catch { + return null; + } + }), + ); + const freshById = new Map( + details.filter((p): p is SubsonicPlaylist => p !== null).map(p => [p.id, p]), + ); + if (freshById.size > 0) { + usePlaylistStore.setState((s) => ({ + playlists: s.playlists.map((p) => { + const fresh = freshById.get(p.id); + return fresh ? { ...p, ...fresh } : p; + }), + })); + } + const current = usePlaylistStore.getState().playlists; + setPendingSmart(() => { + const next: PendingSmartPlaylist[] = []; + for (const item of hydrated) { + const pl = item.id + ? current.find(p => p.id === item.id) + : current.find(p => p.name === item.name); + if (!pl) { + next.push({ ...item, attempts: item.attempts + 1 }); + continue; + } + const songCount = pl.songCount ?? 0; + const currentCover = pl.coverArt; + const firstCover = item.firstSeenCoverArt ?? currentCover; + const placeholderStillThere = Boolean(firstCover) && currentCover === firstCover; + // 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. + const hardTimeoutReached = item.attempts >= 18; // ~3 minutes (18 * 10s) + const ready = songCount > 0 && (!placeholderStillThere || hardTimeoutReached); + if (!ready) { + next.push({ + ...item, + id: pl.id, + firstSeenCoverArt: firstCover, + attempts: item.attempts + 1, + }); + } + } + return next; + }); + }, 10000); + return () => window.clearInterval(interval); + }, [pendingSmart, fetchPlaylists, setPendingSmart]); +} diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 90771e26..8f26b5cd 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -15,18 +15,19 @@ import StarRating from '../components/StarRating'; import { useTranslation } from 'react-i18next'; import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; 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, + defaultSmartFilters, displayPlaylistName, isSmartPlaylistName, type SmartFilters, type PendingSmartPlaylist, } from '../utils/playlistsSmart'; import { PlaylistSmartCoverCell, PlaylistCardMainCover } from '../components/playlists/PlaylistCoverImages'; import { useSmartCoverCollage } from '../hooks/useSmartCoverCollage'; import { usePlaylistsLibraryScopeCounts } from '../hooks/usePlaylistsLibraryScopeCounts'; +import { usePendingSmartPolling } from '../hooks/usePendingSmartPolling'; +import { runPlaylistsOpenSmartEditor } from '../utils/runPlaylistsOpenSmartEditor'; +import { runPlaylistsSaveSmart } from '../utils/runPlaylistsSaveSmart'; +import PlaylistsSmartEditor from '../components/playlists/PlaylistsSmartEditor'; function formatDuration(seconds: number): string { return formatHumanHoursMinutes(seconds); @@ -115,179 +116,20 @@ export default function Playlists() { setNewName(''); }; - const handleOpenSmartEditor = async (pl: SubsonicPlaylist) => { - if (!isNavidromeServer || !isSmartPlaylistName(pl.name)) return; - setCreatingSmartBusy(true); - try { - let target: { id: string; name: string; rules?: Record } | null = null; - try { - // Prefer direct endpoint for this playlist: returns freshest rules. - const direct = await ndGetSmartPlaylist(pl.id); - if (direct.id && (direct.rules || isSmartPlaylistName(direct.name))) target = direct; - } catch { - // Fallback to list endpoint below. - } - if (!target) { - const smart = await ndListSmartPlaylists(); - target = smart.find((v) => - v.id === pl.id || - v.name === pl.name || - displayPlaylistName(v.name) === displayPlaylistName(pl.name), - ) ?? null; - } - if (target) { - setSmartFilters(parseSmartRulesToFilters(target.rules, target.name)); - setEditingSmartId(target.id); - } else { - // Fallback: allow editing even if Navidrome smart list endpoint - // doesn't return this playlist (shared/migrated/legacy edge cases). - setSmartFilters({ - ...defaultSmartFilters, - name: displayPlaylistName(pl.name), - }); - setEditingSmartId(pl.id); - } - setGenreQuery(''); - setCreating(false); - setCreatingSmart(true); - } catch { - // Degrade gracefully instead of blocking the editor on transient/API errors. - setSmartFilters({ - ...defaultSmartFilters, - name: displayPlaylistName(pl.name), - }); - setGenreQuery(''); - setEditingSmartId(pl.id); - setCreating(false); - setCreatingSmart(true); - showToast(t('smartPlaylists.loadFailed'), 3500, 'warning'); - } finally { - setCreatingSmartBusy(false); - } - }; + const handleOpenSmartEditor = (pl: SubsonicPlaylist) => runPlaylistsOpenSmartEditor({ + pl, isNavidromeServer, t, + setSmartFilters, setEditingSmartId, setGenreQuery, + setCreating, setCreatingSmart, setCreatingSmartBusy, + }); - const handleCreateSmart = async () => { - if (!isNavidromeServer) { - showToast(t('smartPlaylists.navidromeOnly'), 3500, 'error'); - return; - } - setCreatingSmartBusy(true); - try { - let baseName = smartFilters.name.trim() || `mix-${new Date().toISOString().slice(0, 10)}`; - if (!editingSmartId) { - const existingNames = new Set(playlists.map((p) => (p.name ?? '').toLowerCase())); - const requestedBaseName = baseName; - let ordinal = 2; - while (existingNames.has(`${SMART_PREFIX}${baseName}`.toLowerCase())) { - baseName = `${requestedBaseName}-${ordinal}`; - ordinal += 1; - } - } - const rules = buildSmartRulesPayload(smartFilters); - const fullName = `${SMART_PREFIX}${baseName}`; - if (editingSmartId) { - await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true); - } else { - await ndCreateSmartPlaylist(fullName, rules, true); - } - await fetchPlaylists(); - const createdName = fullName; - const updatedId = editingSmartId; - setPendingSmart(prev => { - const existing = prev.find(p => p.id === updatedId || p.name === createdName); - if (existing) return prev; - const created = usePlaylistStore.getState().playlists.find((p) => p.id === updatedId || p.name === createdName); - return [ - ...prev, - { - name: createdName, - id: updatedId ?? created?.id, - firstSeenCoverArt: created?.coverArt, - attempts: 0, - }, - ]; - }); - setCreatingSmart(false); - setEditingSmartId(null); - setSmartFilters(defaultSmartFilters); - setGenreQuery(''); - if (updatedId) showToast(t('smartPlaylists.updated', { name: createdName }), 3500, 'success'); - else showToast(t('smartPlaylists.created', { name: createdName }), 3500, 'success'); - } catch { - showToast(editingSmartId ? t('smartPlaylists.updateFailed') : t('smartPlaylists.createFailed'), 3500, 'error'); - } finally { - setCreatingSmartBusy(false); - } - }; + const handleCreateSmart = () => runPlaylistsSaveSmart({ + isNavidromeServer, smartFilters, editingSmartId, playlists, fetchPlaylists, t, + setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters, + setGenreQuery, setCreatingSmartBusy, + }); // Smart playlist rules are processed asynchronously on server. - // Poll list every 10s and keep waiting through Navidrome placeholder cover. - useEffect(() => { - if (pendingSmart.length === 0) return; - const interval = window.setInterval(async () => { - await fetchPlaylists(); - const listNow = usePlaylistStore.getState().playlists; - const hydrated = pendingSmart.map(item => { - if (item.id) return item; - const found = listNow.find(p => p.name === item.name); - return found ? { ...item, id: found.id } : item; - }); - // Detail endpoint tends to reflect fresh metadata earlier than list endpoint. - const ids = hydrated.map(p => p.id).filter((v): v is string => Boolean(v)); - const details = await Promise.all( - ids.map(async (id) => { - try { - const { playlist } = await getPlaylist(id); - return playlist; - } catch { - return null; - } - }), - ); - const freshById = new Map( - details.filter((p): p is SubsonicPlaylist => p !== null).map(p => [p.id, p]), - ); - if (freshById.size > 0) { - usePlaylistStore.setState((s) => ({ - playlists: s.playlists.map((p) => { - const fresh = freshById.get(p.id); - return fresh ? { ...p, ...fresh } : p; - }), - })); - } - const current = usePlaylistStore.getState().playlists; - setPendingSmart(() => { - const next: PendingSmartPlaylist[] = []; - for (const item of hydrated) { - const pl = item.id - ? current.find(p => p.id === item.id) - : current.find(p => p.name === item.name); - if (!pl) { - next.push({ ...item, attempts: item.attempts + 1 }); - continue; - } - const songCount = pl.songCount ?? 0; - const currentCover = pl.coverArt; - const firstCover = item.firstSeenCoverArt ?? currentCover; - const placeholderStillThere = Boolean(firstCover) && currentCover === firstCover; - // 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. - const hardTimeoutReached = item.attempts >= 18; // ~3 minutes (18 * 10s) - const ready = songCount > 0 && (!placeholderStillThere || hardTimeoutReached); - if (!ready) { - next.push({ - ...item, - id: pl.id, - firstSeenCoverArt: firstCover, - attempts: item.attempts + 1, - }); - } - } - return next; - }); - }, 10000); - return () => window.clearInterval(interval); - }, [pendingSmart, fetchPlaylists]); + usePendingSmartPolling(pendingSmart, setPendingSmart, fetchPlaylists); const handlePlay = async (e: React.MouseEvent, pl: SubsonicPlaylist) => { e.stopPropagation(); @@ -512,107 +354,18 @@ export default function Playlists() { {creatingSmart && ( -
-
-
-
{t('smartPlaylists.sectionBasic')}
-
- setSmartFilters(v => ({ ...v, name: e.target.value }))} /> -
- setSmartFilters(v => ({ ...v, limit: e.target.value }))} /> - {t('smartPlaylists.limitHint', { max: LIMIT_MAX })} -
- -
-
-
-
{t('smartPlaylists.sectionGenres')}
-
- {t('smartPlaylists.genreMode')} - - -
- setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} /> -
-
-
{t('smartPlaylists.availableGenres')}
-
- {availableGenres.map(g => ( - - ))} -
-
-
-
{t('smartPlaylists.selectedGenres')}
-
- {smartFilters.selectedGenres.map(g => ( - - ))} -
-
-
-
-
-
{t('smartPlaylists.sectionYearsAndFilters')}
-
- {t('smartPlaylists.yearMode')} - - -
-
- {t('smartPlaylists.fromYear')}: {smartFilters.yearFrom} - {t('smartPlaylists.toYear')}: {smartFilters.yearTo} -
-
-
-
- setSmartFilters(v => ({ ...v, yearFrom: Math.min(clampYear(Number(e.target.value)), v.yearTo) }))} /> - setSmartFilters(v => ({ ...v, yearTo: Math.max(clampYear(Number(e.target.value)), v.yearFrom) }))} /> -
-
- setSmartFilters(v => ({ ...v, artistContains: e.target.value }))} /> - setSmartFilters(v => ({ ...v, albumContains: e.target.value }))} /> - setSmartFilters(v => ({ ...v, titleContains: e.target.value }))} /> -
{t('smartPlaylists.minRating')}: {smartFilters.minRating}★
- setSmartFilters(v => ({ ...v, minRating: rating }))} ariaLabel={t('smartPlaylists.minRatingAria')} /> - {t('smartPlaylists.minRatingHint')} -
-
- - -
-
-
- - -
-
-
+ )} {/* ── Grid ── */} diff --git a/src/utils/runPlaylistsOpenSmartEditor.ts b/src/utils/runPlaylistsOpenSmartEditor.ts new file mode 100644 index 00000000..6c5dd4ad --- /dev/null +++ b/src/utils/runPlaylistsOpenSmartEditor.ts @@ -0,0 +1,78 @@ +import type React from 'react'; +import type { TFunction } from 'i18next'; +import { ndGetSmartPlaylist, ndListSmartPlaylists } from '../api/navidromeSmart'; +import type { SubsonicPlaylist } from '../api/subsonicTypes'; +import { + defaultSmartFilters, displayPlaylistName, isSmartPlaylistName, + parseSmartRulesToFilters, type SmartFilters, +} from './playlistsSmart'; +import { showToast } from './toast'; + +export interface RunPlaylistsOpenSmartEditorDeps { + pl: SubsonicPlaylist; + isNavidromeServer: boolean; + t: TFunction; + setSmartFilters: React.Dispatch>; + setEditingSmartId: React.Dispatch>; + setGenreQuery: React.Dispatch>; + setCreating: React.Dispatch>; + setCreatingSmart: React.Dispatch>; + setCreatingSmartBusy: React.Dispatch>; +} + +export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEditorDeps): Promise { + const { + pl, isNavidromeServer, t, + setSmartFilters, setEditingSmartId, setGenreQuery, + setCreating, setCreatingSmart, setCreatingSmartBusy, + } = deps; + + if (!isNavidromeServer || !isSmartPlaylistName(pl.name)) return; + setCreatingSmartBusy(true); + try { + let target: { id: string; name: string; rules?: Record } | null = null; + try { + // Prefer direct endpoint for this playlist: returns freshest rules. + const direct = await ndGetSmartPlaylist(pl.id); + if (direct.id && (direct.rules || isSmartPlaylistName(direct.name))) target = direct; + } catch { + // Fallback to list endpoint below. + } + if (!target) { + const smart = await ndListSmartPlaylists(); + target = smart.find((v) => + v.id === pl.id || + v.name === pl.name || + displayPlaylistName(v.name) === displayPlaylistName(pl.name), + ) ?? null; + } + if (target) { + setSmartFilters(parseSmartRulesToFilters(target.rules, target.name)); + setEditingSmartId(target.id); + } else { + // Fallback: allow editing even if Navidrome smart list endpoint + // doesn't return this playlist (shared/migrated/legacy edge cases). + setSmartFilters({ + ...defaultSmartFilters, + name: displayPlaylistName(pl.name), + }); + setEditingSmartId(pl.id); + } + setGenreQuery(''); + setCreating(false); + setCreatingSmart(true); + } catch { + // Degrade gracefully instead of blocking the editor on transient/API errors. + setSmartFilters({ + ...defaultSmartFilters, + name: displayPlaylistName(pl.name), + }); + setGenreQuery(''); + setEditingSmartId(pl.id); + setCreating(false); + setCreatingSmart(true); + showToast(t('smartPlaylists.loadFailed'), 3500, 'warning'); + } finally { + setCreatingSmartBusy(false); + } +} diff --git a/src/utils/runPlaylistsSaveSmart.ts b/src/utils/runPlaylistsSaveSmart.ts new file mode 100644 index 00000000..ceadbc30 --- /dev/null +++ b/src/utils/runPlaylistsSaveSmart.ts @@ -0,0 +1,85 @@ +import type React from 'react'; +import type { TFunction } from 'i18next'; +import { ndCreateSmartPlaylist, ndUpdateSmartPlaylist } from '../api/navidromeSmart'; +import type { SubsonicPlaylist } from '../api/subsonicTypes'; +import { usePlaylistStore } from '../store/playlistStore'; +import { + buildSmartRulesPayload, defaultSmartFilters, SMART_PREFIX, + type PendingSmartPlaylist, type SmartFilters, +} from './playlistsSmart'; +import { showToast } from './toast'; + +export interface RunPlaylistsSaveSmartDeps { + isNavidromeServer: boolean; + smartFilters: SmartFilters; + editingSmartId: string | null; + playlists: SubsonicPlaylist[]; + fetchPlaylists: () => Promise; + t: TFunction; + setPendingSmart: React.Dispatch>; + setCreatingSmart: React.Dispatch>; + setEditingSmartId: React.Dispatch>; + setSmartFilters: React.Dispatch>; + setGenreQuery: React.Dispatch>; + setCreatingSmartBusy: React.Dispatch>; +} + +export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Promise { + const { + isNavidromeServer, smartFilters, editingSmartId, playlists, fetchPlaylists, t, + setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters, + setGenreQuery, setCreatingSmartBusy, + } = deps; + + if (!isNavidromeServer) { + showToast(t('smartPlaylists.navidromeOnly'), 3500, 'error'); + return; + } + setCreatingSmartBusy(true); + try { + let baseName = smartFilters.name.trim() || `mix-${new Date().toISOString().slice(0, 10)}`; + if (!editingSmartId) { + const existingNames = new Set(playlists.map((p) => (p.name ?? '').toLowerCase())); + const requestedBaseName = baseName; + let ordinal = 2; + while (existingNames.has(`${SMART_PREFIX}${baseName}`.toLowerCase())) { + baseName = `${requestedBaseName}-${ordinal}`; + ordinal += 1; + } + } + const rules = buildSmartRulesPayload(smartFilters); + const fullName = `${SMART_PREFIX}${baseName}`; + if (editingSmartId) { + await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true); + } else { + await ndCreateSmartPlaylist(fullName, rules, true); + } + await fetchPlaylists(); + const createdName = fullName; + const updatedId = editingSmartId; + setPendingSmart(prev => { + const existing = prev.find(p => p.id === updatedId || p.name === createdName); + if (existing) return prev; + const created = usePlaylistStore.getState().playlists.find((p) => p.id === updatedId || p.name === createdName); + return [ + ...prev, + { + name: createdName, + id: updatedId ?? created?.id, + firstSeenCoverArt: created?.coverArt, + attempts: 0, + }, + ]; + }); + setCreatingSmart(false); + setEditingSmartId(null); + setSmartFilters(defaultSmartFilters); + setGenreQuery(''); + if (updatedId) showToast(t('smartPlaylists.updated', { name: createdName }), 3500, 'success'); + else showToast(t('smartPlaylists.created', { name: createdName }), 3500, 'success'); + } catch { + showToast(editingSmartId ? t('smartPlaylists.updateFailed') : t('smartPlaylists.createFailed'), 3500, 'error'); + } finally { + setCreatingSmartBusy(false); + } +}