Files
Psychotoxical-psysonic/src/utils/runPlaylistsSaveSmart.ts
T
Frank Stellmacher 6e4ebca938 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.
2026-05-13 16:04:47 +02:00

86 lines
3.3 KiB
TypeScript

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<void>;
t: TFunction;
setPendingSmart: React.Dispatch<React.SetStateAction<PendingSmartPlaylist[]>>;
setCreatingSmart: React.Dispatch<React.SetStateAction<boolean>>;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
setCreatingSmartBusy: React.Dispatch<React.SetStateAction<boolean>>;
}
export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Promise<void> {
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);
}
}