soulsync/webui/src/routes/import/-import.store.ts
Antti Kettunen 9da8251e43
feat(webui): clarify import source metadata
- thread primary_source through album and track search payloads while keeping per-result source on the returned rows
- reuse the shared Notice primitive for fallback and error messaging in the import pages
- update the import route tests and shell route smoke coverage for the new behavior
2026-05-24 21:17:22 +03:00

267 lines
9.4 KiB
TypeScript

import { create } from 'zustand';
import { combine } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';
import type {
ImportAlbumMatchPayload,
ImportAlbumResult,
ImportQueueEntry,
ImportQueueJob,
ImportStagingFile,
ImportTrackResult,
} from './-import.types';
import { getStagingFileKey } from './-import.helpers';
export type SingleSearchState = {
query: string;
loading: boolean;
error: string | null;
results: ImportTrackResult[];
};
type StateUpdater<T> = T | ((current: T) => T);
function resolveState<T>(current: T, updater: StateUpdater<T>) {
return typeof updater === 'function' ? (updater as (value: T) => T)(current) : updater;
}
function createInitialWorkflowState() {
return {
queue: [] as ImportQueueEntry[],
nextQueueId: 0,
albumQuery: '',
albumResults: null as ImportAlbumResult[] | null,
albumSearchError: null as string | null,
albumSearchLoading: false,
// Lookup source used to seed the album search. Result rows still carry their own `source`.
albumSearchLookupSource: null as string | null,
autoGroupFilePaths: null as string[] | null,
selectedAlbum: null as ImportAlbumResult | null,
albumMatch: null as ImportAlbumMatchPayload | null,
albumMatchError: null as string | null,
albumMatchLoading: false,
matchOverrides: {} as Record<number, number>,
selectedSingles: new Set<string>(),
singlesManualMatches: {} as Record<string, ImportTrackResult>,
openSingleSearch: null as string | null,
singleSearches: {} as Record<string, SingleSearchState>,
};
}
export const useImportWorkflowStore = create(
combine(createInitialWorkflowState(), (set, get) => ({
clearFinishedJobs: () => {
set((state) => ({ queue: state.queue.filter((entry) => entry.status === 'running') }));
},
enqueueQueueJob: (job: ImportQueueJob) => {
const id = get().nextQueueId + 1;
const entry: ImportQueueEntry = {
id,
type: job.type,
label: job.label,
sublabel: job.sublabel,
imageUrl: job.imageUrl,
status: 'running',
processed: 0,
total: job.items.length,
errors: [],
};
set((state) => ({
nextQueueId: id,
queue: [...state.queue, entry],
}));
return id;
},
updateQueueEntry: (entryId: number, patch: Partial<ImportQueueEntry>) => {
set((state) => ({
queue: state.queue.map((entry) => (entry.id === entryId ? { ...entry, ...patch } : entry)),
}));
},
resetAlbumSearch: () => {
set({
albumQuery: '',
albumResults: null,
albumSearchError: null,
albumSearchLoading: false,
albumSearchLookupSource: null,
autoGroupFilePaths: null,
selectedAlbum: null,
albumMatch: null,
albumMatchError: null,
albumMatchLoading: false,
matchOverrides: {},
});
},
setAlbumQuery: (albumQuery: string) => set({ albumQuery }),
setAlbumResults: (albumResults: ImportAlbumResult[] | null) => set({ albumResults }),
setAlbumSearchError: (albumSearchError: string | null) => set({ albumSearchError }),
setAlbumSearchLoading: (albumSearchLoading: boolean) => set({ albumSearchLoading }),
setAlbumSearchLookupSource: (albumSearchLookupSource: string | null) =>
set({ albumSearchLookupSource }),
setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => {
set({
albumQuery,
albumSearchLoading: true,
albumSearchError: null,
albumResults: null,
albumSearchLookupSource: null,
selectedAlbum: null,
albumMatch: null,
autoGroupFilePaths,
});
},
setSelectedAlbum: (selectedAlbum: ImportAlbumResult | null) => set({ selectedAlbum }),
setAlbumMatch: (albumMatch: ImportAlbumMatchPayload | null) => set({ albumMatch }),
setAlbumMatchError: (albumMatchError: string | null) => set({ albumMatchError }),
setAlbumMatchLoading: (albumMatchLoading: boolean) => set({ albumMatchLoading }),
clearAutoGroupFilePaths: () => set({ autoGroupFilePaths: null }),
setMatchOverrides: (updater: StateUpdater<Record<number, number>>) => {
set((state) => ({ matchOverrides: resolveState(state.matchOverrides, updater) }));
},
toggleSingle: (fileKey: string) => {
set((state) => {
const selectedSingles = new Set(state.selectedSingles);
if (selectedSingles.has(fileKey)) selectedSingles.delete(fileKey);
else selectedSingles.add(fileKey);
return { selectedSingles };
});
},
toggleAllSingles: (stagingFiles: ImportStagingFile[]) => {
set((state) => ({
selectedSingles: (() => {
const fileKeys = stagingFiles.map(getStagingFileKey);
return state.selectedSingles.size === fileKeys.length &&
fileKeys.every((key) => state.selectedSingles.has(key))
? new Set<string>()
: new Set(fileKeys);
})(),
}));
},
clearSinglesSelection: () => {
set({
selectedSingles: new Set<string>(),
singlesManualMatches: {},
});
},
syncSinglesWorkflow: (stagingFiles: ImportStagingFile[]) => {
const validKeys = new Set(stagingFiles.map(getStagingFileKey));
set((state) => ({
selectedSingles: new Set([...state.selectedSingles].filter((key) => validKeys.has(key))),
singlesManualMatches: Object.fromEntries(
Object.entries(state.singlesManualMatches).filter(([key]) => validKeys.has(key)),
),
openSingleSearch:
state.openSingleSearch && validKeys.has(state.openSingleSearch)
? state.openSingleSearch
: null,
singleSearches: Object.fromEntries(
Object.entries(state.singleSearches).filter(([key]) => validKeys.has(key)),
),
}));
},
setOpenSingleSearch: (openSingleSearch: string | null) => set({ openSingleSearch }),
ensureSingleSearch: (fileKey: string, query: string) => {
set((state) => ({
singleSearches: {
...state.singleSearches,
[fileKey]: state.singleSearches[fileKey] ?? {
query,
loading: false,
error: null,
results: [],
},
},
}));
},
setSingleSearch: (fileKey: string, updater: StateUpdater<SingleSearchState>) => {
set((state) => {
const current = state.singleSearches[fileKey] ?? {
query: '',
loading: false,
error: null,
results: [],
};
return {
singleSearches: {
...state.singleSearches,
[fileKey]: resolveState(current, updater),
},
};
});
},
selectSingleMatch: (fileKey: string, track: ImportTrackResult) => {
set((state) => ({
singlesManualMatches: { ...state.singlesManualMatches, [fileKey]: track },
selectedSingles: new Set(state.selectedSingles).add(fileKey),
openSingleSearch: null,
}));
},
})),
);
export function resetImportWorkflowStore() {
useImportWorkflowStore.setState(createInitialWorkflowState());
}
export function useImportQueueWorkflow() {
return useImportWorkflowStore(
useShallow((state) => ({
clearFinishedJobs: state.clearFinishedJobs,
enqueueQueueJob: state.enqueueQueueJob,
queue: state.queue,
updateQueueEntry: state.updateQueueEntry,
})),
);
}
export function useAlbumImportWorkflow() {
return useImportWorkflowStore(
useShallow((state) => ({
albumMatch: state.albumMatch,
albumMatchError: state.albumMatchError,
albumMatchLoading: state.albumMatchLoading,
albumQuery: state.albumQuery,
albumResults: state.albumResults,
albumSearchError: state.albumSearchError,
albumSearchLoading: state.albumSearchLoading,
albumSearchLookupSource: state.albumSearchLookupSource,
autoGroupFilePaths: state.autoGroupFilePaths,
clearAutoGroupFilePaths: state.clearAutoGroupFilePaths,
matchOverrides: state.matchOverrides,
resetAlbumWorkflow: state.resetAlbumSearch,
selectedAlbum: state.selectedAlbum,
setAlbumMatch: state.setAlbumMatch,
setAlbumMatchError: state.setAlbumMatchError,
setAlbumMatchLoading: state.setAlbumMatchLoading,
setAlbumQuery: state.setAlbumQuery,
setAlbumResults: state.setAlbumResults,
setAlbumSearchContext: state.setAlbumSearchContext,
setAlbumSearchError: state.setAlbumSearchError,
setAlbumSearchLoading: state.setAlbumSearchLoading,
setAlbumSearchLookupSource: state.setAlbumSearchLookupSource,
setMatchOverrides: state.setMatchOverrides,
setSelectedAlbum: state.setSelectedAlbum,
})),
);
}
export function useSinglesImportWorkflow() {
return useImportWorkflowStore(
useShallow((state) => ({
clearSinglesSelection: state.clearSinglesSelection,
ensureSingleSearch: state.ensureSingleSearch,
openSingleSearch: state.openSingleSearch,
selectedSingles: state.selectedSingles,
selectSingleMatchInStore: state.selectSingleMatch,
setOpenSingleSearch: state.setOpenSingleSearch,
setSingleSearch: state.setSingleSearch,
singleSearches: state.singleSearches,
singlesManualMatches: state.singlesManualMatches,
syncSinglesWorkflow: state.syncSinglesWorkflow,
toggleAllSingles: state.toggleAllSingles,
toggleSingleInStore: state.toggleSingle,
})),
);
}