mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(library): multi-genre local index with track_genre and backfill (#1059)
* fix(library): multi-genre local index with track_genre and backfill Restore atomic genre browse, filters, and counts via track_genre: OpenSubsonic genres[] first with Navidrome-default split fallback, sync write path, read-path query switches, blocking startup backfill with progress, and v12 repair migration for DBs that recorded legacy 002–011. TS fallback adds genreTagsFor and migration gate i18n across locales. * fix(library): address multi-genre review — robust TS genres and scope join genreTagsFor routes raw genres through parseItemGenres (single-object Subsonic quirk and bare strings). Library-scoped genre browse/counts join track for raw_json library_id fallback. Statistics keeps empty-genre bucket. * docs: CHANGELOG and credits for multi-genre local index (PR #1059) * docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
This commit is contained in:
@@ -5,6 +5,8 @@ import { useMigrationStore } from '../store/migrationStore';
|
||||
|
||||
const migrationInspectMock = vi.fn();
|
||||
const migrationRunMock = vi.fn();
|
||||
const libraryGenreTagsInspectMock = vi.fn();
|
||||
const libraryGenreTagsRunMock = vi.fn();
|
||||
const rewriteFrontendStoreKeysMock = vi.fn(async (_servers: unknown) => undefined);
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
@@ -16,6 +18,11 @@ vi.mock('../api/migration', () => ({
|
||||
migrationRun: (mappings: unknown) => migrationRunMock(mappings),
|
||||
}));
|
||||
|
||||
vi.mock('../api/library', () => ({
|
||||
libraryGenreTagsInspect: () => libraryGenreTagsInspectMock(),
|
||||
libraryGenreTagsRun: () => libraryGenreTagsRunMock(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/server/rewriteFrontendStoreKeys', () => ({
|
||||
rewriteFrontendStoreKeys: (servers: unknown) => rewriteFrontendStoreKeysMock(servers),
|
||||
}));
|
||||
@@ -29,6 +36,10 @@ describe('useMigrationOrchestrator', () => {
|
||||
beforeEach(() => {
|
||||
migrationInspectMock.mockReset();
|
||||
migrationRunMock.mockReset();
|
||||
libraryGenreTagsInspectMock.mockReset();
|
||||
libraryGenreTagsRunMock.mockReset();
|
||||
libraryGenreTagsInspectMock.mockResolvedValue({ needed: false, totalTracks: 0, doneTracks: 0 });
|
||||
libraryGenreTagsRunMock.mockResolvedValue(undefined);
|
||||
rewriteFrontendStoreKeysMock.mockClear();
|
||||
localStorage.clear();
|
||||
useAuthStore.setState({
|
||||
@@ -40,9 +51,12 @@ describe('useMigrationOrchestrator', () => {
|
||||
});
|
||||
useMigrationStore.setState({
|
||||
phase: 'inspecting',
|
||||
step: null,
|
||||
needsMigration: false,
|
||||
inspect: null,
|
||||
progress: null,
|
||||
genreTagsInspect: null,
|
||||
genreTagsProgress: null,
|
||||
lastError: null,
|
||||
});
|
||||
(globalThis as Record<string, unknown>)[REAL_MIGRATION_TEST_OVERRIDE] = true;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { libraryGenreTagsInspect, libraryGenreTagsRun } from '../api/library';
|
||||
import { migrationInspect, migrationRun, type ServerIndexMapping } from '../api/migration';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useMigrationStore } from '../store/migrationStore';
|
||||
@@ -30,6 +31,40 @@ function buildMappings(): ServerIndexMapping[] {
|
||||
.filter(mapping => mapping.legacyId.trim().length > 0 && mapping.indexKey.trim().length > 0);
|
||||
}
|
||||
|
||||
async function runGenreTagsPhase(): Promise<void> {
|
||||
const state = useMigrationStore.getState();
|
||||
state.setStep('genreTags');
|
||||
state.setGenreTagsProgress(null);
|
||||
state.setError(null);
|
||||
state.setPhase('inspecting');
|
||||
|
||||
const inspect = await libraryGenreTagsInspect();
|
||||
state.setGenreTagsInspect(inspect);
|
||||
if (!inspect.needed) {
|
||||
state.setStep(null);
|
||||
return;
|
||||
}
|
||||
|
||||
state.setPhase('running');
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
await libraryGenreTagsRun();
|
||||
const after = await libraryGenreTagsInspect();
|
||||
state.setGenreTagsInspect(after);
|
||||
if (!after.needed) {
|
||||
state.setStep(null);
|
||||
state.setGenreTagsProgress(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const after = await libraryGenreTagsInspect();
|
||||
if (after.needed) {
|
||||
state.setError('Genre index update incomplete. Retry after restart.');
|
||||
state.setPhase('error');
|
||||
throw new Error('genre_tags_incomplete');
|
||||
}
|
||||
}
|
||||
|
||||
async function runOrchestrator(force = false): Promise<void> {
|
||||
if (migrationInFlight) {
|
||||
await migrationInFlight;
|
||||
@@ -53,6 +88,8 @@ async function runOrchestrator(force = false): Promise<void> {
|
||||
const hasDoneFlag = localStorage.getItem(MIGRATION_DONE_FLAG) === '1';
|
||||
state.setError(null);
|
||||
state.setProgress(null);
|
||||
state.setGenreTagsProgress(null);
|
||||
state.setStep('serverIndex');
|
||||
state.setPhase(force ? 'inspecting' : 'idle');
|
||||
let inspect = null as Awaited<ReturnType<typeof migrationInspect>> | null;
|
||||
if (!force && hasDoneFlag) {
|
||||
@@ -61,6 +98,7 @@ async function runOrchestrator(force = false): Promise<void> {
|
||||
state.setNeedsMigration(inspect.needsMigration);
|
||||
skippedLogged = logSkippedUnknownRowsOnce(inspect, skippedLogged);
|
||||
if (!inspect.needsMigration) {
|
||||
await runGenreTagsPhase();
|
||||
state.setPhase('completed');
|
||||
return;
|
||||
}
|
||||
@@ -74,6 +112,7 @@ async function runOrchestrator(force = false): Promise<void> {
|
||||
if (!inspect.needsMigration) {
|
||||
await rewriteFrontendStoreKeys(servers);
|
||||
localStorage.setItem(MIGRATION_DONE_FLAG, '1');
|
||||
await runGenreTagsPhase();
|
||||
state.setPhase('completed');
|
||||
return;
|
||||
}
|
||||
@@ -88,6 +127,7 @@ async function runOrchestrator(force = false): Promise<void> {
|
||||
skippedLogged = logSkippedUnknownRowsOnce(after, skippedLogged);
|
||||
if (!after.needsMigration) {
|
||||
localStorage.setItem(MIGRATION_DONE_FLAG, '1');
|
||||
await runGenreTagsPhase();
|
||||
state.setPhase('completed');
|
||||
return;
|
||||
}
|
||||
@@ -95,7 +135,9 @@ async function runOrchestrator(force = false): Promise<void> {
|
||||
state.setPhase('error');
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
useMigrationStore.getState().setError(String(error));
|
||||
if (!(error instanceof Error && error.message === 'genre_tags_incomplete')) {
|
||||
useMigrationStore.getState().setError(String(error));
|
||||
}
|
||||
useMigrationStore.getState().setPhase('error');
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -108,23 +150,64 @@ export function retryServerIndexMigration(): void {
|
||||
void runOrchestrator(true);
|
||||
}
|
||||
|
||||
export function retryGenreTagsMigration(): void {
|
||||
if (migrationInFlight) {
|
||||
void migrationInFlight.then(() => retryGenreTagsMigration());
|
||||
return;
|
||||
}
|
||||
migrationInFlight = (async () => {
|
||||
const state = useMigrationStore.getState();
|
||||
state.setError(null);
|
||||
state.setGenreTagsProgress(null);
|
||||
try {
|
||||
await runGenreTagsPhase();
|
||||
state.setPhase('completed');
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error && error.message === 'genre_tags_incomplete')) {
|
||||
state.setError(String(error));
|
||||
}
|
||||
state.setPhase('error');
|
||||
}
|
||||
})().finally(() => {
|
||||
migrationInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function retryBlockingMigration(): void {
|
||||
const step = useMigrationStore.getState().step;
|
||||
if (step === 'genreTags') {
|
||||
retryGenreTagsMigration();
|
||||
return;
|
||||
}
|
||||
retryServerIndexMigration();
|
||||
}
|
||||
|
||||
export function useMigrationOrchestrator(): void {
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const sub = listen('migration:progress', (event) => {
|
||||
if (disposed) return;
|
||||
useMigrationStore.getState().setProgress(event.payload as {
|
||||
stage: string;
|
||||
table: string;
|
||||
done: number;
|
||||
total: number;
|
||||
});
|
||||
});
|
||||
const subs = [
|
||||
listen('migration:progress', (event) => {
|
||||
if (disposed) return;
|
||||
useMigrationStore.getState().setProgress(event.payload as {
|
||||
stage: string;
|
||||
table: string;
|
||||
done: number;
|
||||
total: number;
|
||||
});
|
||||
}),
|
||||
listen('genre_tags:progress', (event) => {
|
||||
if (disposed) return;
|
||||
useMigrationStore.getState().setGenreTagsProgress(event.payload as {
|
||||
done: number;
|
||||
total: number;
|
||||
});
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
disposed = true;
|
||||
void sub.then(unlisten => unlisten());
|
||||
void Promise.all(subs).then(unlisteners => unlisteners.forEach(unlisten => unlisten()));
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user