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:
cucadmuh
2026-06-11 01:02:35 +03:00
committed by GitHub
parent 8593858f3a
commit 5cd01c90ac
55 changed files with 1322 additions and 145 deletions
@@ -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;
+94 -11
View File
@@ -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()));
};
}, []);