mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(playlists): G.80 — extract smart editor open/save orchestrators + polling hook + editor component (cluster) (#647)
Four-cut cluster pulling the Smart-playlist machinery out of Playlists.tsx. 763 → 480 LOC (−283). runPlaylistsOpenSmartEditor — open-existing flow: tries ndGetSmartPlaylist first (freshest rules), falls back to ndListSmartPlaylists if that fails or doesn't return the playlist; populates the editor with parsed filters or a name-only seed for shared / migrated edge cases; degrades gracefully with a warning toast if everything fails. runPlaylistsSaveSmart — create / update flow: dedupes the base name against existing playlists by appending `-2`, `-3` … on creation (skipped on edit); builds rules via buildSmartRulesPayload; calls ndCreate or ndUpdate; tracks the result in pendingSmart so the polling hook can observe rules processing on the server. usePendingSmartPolling — every 10 s polls fetchPlaylists + getPlaylist for each pending item; rehydrates the playlist store when the detail endpoint reports fresh metadata before the list endpoint catches up; stops polling an item when it has songs + its cover changed (or after ~3 minutes hard timeout). PlaylistsSmartEditor — the full smart-editor card (three sections: Basic / Genres / Years + Filters). Owns no state of its own; every input is a controlled component against smartFilters via setSmartFilters. The cancel button still resets through the page's setters. Playlists drops the inline definitions plus its direct '../api/navidromeSmart' import (now consumed inside the two orchestrators). Pure code move otherwise.
This commit is contained in:
committed by
GitHub
parent
2380543d59
commit
6e4ebca938
@@ -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<React.SetStateAction<PendingSmartPlaylist[]>>,
|
||||
fetchPlaylists: () => Promise<void>,
|
||||
): 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user