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
+21
View File
@@ -892,3 +892,24 @@ export function subscribeLibrarySyncIdle(
handler(payload),
);
}
// ── Genre tags startup backfill (multi-genre local index) ───────────────
export interface GenreTagsInspectDto {
needed: boolean;
totalTracks: number;
doneTracks: number;
}
export interface GenreTagsProgressEvent {
done: number;
total: number;
}
export function libraryGenreTagsInspect(): Promise<GenreTagsInspectDto> {
return invoke<GenreTagsInspectDto>('library_genre_tags_inspect');
}
export function libraryGenreTagsRun(): Promise<void> {
return invoke<void>('library_genre_tags_run');
}
+4
View File
@@ -1,4 +1,5 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
import { parseItemGenres } from '../utils/library/genreTags';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { ndLogin } from './navidromeAdmin';
@@ -54,6 +55,7 @@ function mapNdSong(o: Record<string, unknown>): SubsonicSong {
userRating: asNumber(o.rating),
starred: o.starred ? asString(o.starredAt) || 'true' : undefined,
genre: typeof o.genre === 'string' ? o.genre : undefined,
genres: parseItemGenres(o.genres),
bitRate: asNumber(o.bitRate),
suffix: typeof o.suffix === 'string' ? o.suffix : undefined,
contentType: typeof o.contentType === 'string' ? o.contentType : undefined,
@@ -166,6 +168,7 @@ function mapNdAlbum(o: Record<string, unknown>): SubsonicAlbum {
duration: asNumber(o.duration) ?? 0,
year: asNumber(o.maxYear) ?? asNumber(o.year),
genre: typeof o.genre === 'string' ? o.genre : undefined,
genres: parseItemGenres(o.genres),
starred: starredFlag ? (starredAt ?? 'true') : undefined,
userRating: asNumber(o.rating),
isCompilation: o.compilation === true,
@@ -383,6 +386,7 @@ export async function ndListLosslessAlbumsPage(req: NdLosslessPageRequest): Prom
duration: 0,
year: asNumber(o.year),
genre: typeof o.genre === 'string' ? o.genre : undefined,
genres: parseItemGenres(o.genres),
};
pageEntries.push({ album, bitDepth, sampleRate: asNumber(o.sampleRate) ?? 0 });
}
+11 -7
View File
@@ -1,4 +1,5 @@
import { useAuthStore } from '../store/authStore';
import { genreTagsFor } from '../utils/library/genreTags';
import { getArtists } from './subsonicArtists';
import { getAlbumList, getRandomSongs } from './subsonicLibrary';
import type {
@@ -54,14 +55,17 @@ export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibr
albumsCounted += 1;
const sc = a.songCount ?? 0;
songsCounted += sc;
const label = (a.genre?.trim()) ? a.genre.trim() : '';
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
const tags = genreTagsFor(a);
const labels = tags.length > 0 ? tags : [''];
for (const label of labels) {
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
}
g.songCount += sc;
g.albumCount += 1;
}
g.songCount += sc;
g.albumCount += 1;
}
if (albums.length < pageSize) break;
offset += pageSize;
+9
View File
@@ -1,5 +1,10 @@
import type { SubsonicServerIdentity } from '../utils/server/subsonicServerIdentity';
/** OpenSubsonic `ItemGenre` on songs/albums (atomic genres from the server). */
export interface SubsonicItemGenre {
name: string;
}
export interface SubsonicAlbum {
id: string;
name: string;
@@ -11,6 +16,8 @@ export interface SubsonicAlbum {
playCount?: number;
year?: number;
genre?: string;
/** OpenSubsonic atomic genres — preferred over splitting `genre`. */
genres?: SubsonicItemGenre[];
starred?: string;
recordLabel?: string;
created?: string;
@@ -72,6 +79,8 @@ export interface SubsonicSong {
channelCount?: number;
starred?: string;
genre?: string;
/** OpenSubsonic atomic genres — preferred over splitting `genre`. */
genres?: SubsonicItemGenre[];
path?: string;
albumArtist?: string;
/** OpenSubsonic: single-string album-artist for display (mirrors `albumArtists` joined). */
+26 -13
View File
@@ -1,12 +1,17 @@
import type { ReactNode } from 'react';
import { retryServerIndexMigration } from '../hooks/useMigrationOrchestrator';
import { useTranslation } from 'react-i18next';
import { retryBlockingMigration } from '../hooks/useMigrationOrchestrator';
import { useMigrationStore } from '../store/migrationStore';
function MigrationModal() {
const { t } = useTranslation();
const phase = useMigrationStore(s => s.phase);
const step = useMigrationStore(s => s.step);
const progress = useMigrationStore(s => s.progress);
const genreTagsProgress = useMigrationStore(s => s.genreTagsProgress);
const inspect = useMigrationStore(s => s.inspect);
const error = useMigrationStore(s => s.lastError);
const isGenreTags = step === 'genreTags';
const migratedRows = (inspect?.library.totalLegacyRows ?? 0) + (inspect?.analysis.totalLegacyRows ?? 0);
return (
@@ -30,42 +35,50 @@ function MigrationModal() {
>
{phase === 'inspecting' && (
<>
<h3>Preparing data update</h3>
<p style={{ color: 'var(--text-muted)' }}>Looking at your library and analysis cache</p>
<h3>{isGenreTags ? t('migration.genreTagsTitle') : t('migration.preparing')}</h3>
<p style={{ color: 'var(--text-muted)' }}>
{isGenreTags ? t('migration.genreTagsBody') : t('migration.preparingBody')}
</p>
</>
)}
{phase === 'running' && (
<>
<h3>Migrating data</h3>
<h3>{isGenreTags ? t('migration.genreTagsTitle') : t('migration.migrating')}</h3>
<p style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{progress ? `${progress.stage} - ${progress.table}` : 'running'}
{isGenreTags
? t('migration.genreTagsBody')
: (progress ? `${progress.stage} - ${progress.table}` : t('migration.working'))}
</p>
<p style={{ color: 'var(--text-muted)' }}>
{progress ? `${progress.done} / ${progress.total}` : 'working…'}
{isGenreTags
? (genreTagsProgress
? `${genreTagsProgress.done} / ${genreTagsProgress.total}`
: t('migration.working'))
: (progress ? `${progress.done} / ${progress.total}` : t('migration.working'))}
</p>
{inspect?.hasSkippedUnknownServerRows ? (
{!isGenreTags && inspect?.hasSkippedUnknownServerRows ? (
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>
Rows for removed servers were skipped and old backup DB will be removed after successful switch.
{t('migration.skippedRows')}
</p>
) : null}
</>
)}
{phase === 'error' && (
<>
<h3>Migration failed</h3>
<h3>{isGenreTags ? t('migration.genreTagsFailed') : t('migration.failed')}</h3>
<p style={{ color: 'var(--text-muted)' }}>{String(error ?? '').slice(0, 200)}</p>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button className="btn-primary" onClick={() => retryServerIndexMigration()}>Retry</button>
<button className="btn-primary" onClick={() => retryBlockingMigration()}>{t('migration.retry')}</button>
<button className="btn-surface" onClick={() => navigator.clipboard.writeText(String(error ?? ''))}>
Copy details
{t('migration.copyDetails')}
</button>
</div>
</>
)}
{phase === 'completed' && (
<>
<h3>Update complete</h3>
<p style={{ color: 'var(--text-muted)' }}>{migratedRows} rows migrated</p>
<h3>{t('migration.complete')}</h3>
<p style={{ color: 'var(--text-muted)' }}>{t('migration.completeRows', { count: migratedRows })}</p>
</>
)}
</div>
+1
View File
@@ -161,6 +161,7 @@ const CONTRIBUTOR_ENTRIES = [
'Settings → Servers: compact two-line cards, capability header badges, unified use/active action, delete in edit form, click-pinned version tooltip (PR #1054)',
'Navidrome Now Playing and scrobble with hot cache, offline pins, and mixed-server playback reachability (PR #1055)',
'What\'s New: remote WHATS_NEW.md from release assets, dev workspace mode, Highlights vs changelog tabs (PR #1058)',
'Local library index: multi-genre browse, filters, and counts via track_genre table and blocking backfill (PR #1059)',
],
},
{
@@ -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()));
};
}, []);
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
export const deTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const deTranslation = {
orbit,
tray,
licenses,
migration,
};
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Datenaktualisierung wird vorbereitet…',
preparingBody: 'Bibliothek und Analyse-Cache werden geprüft…',
migrating: 'Daten werden migriert',
working: 'wird ausgeführt…',
skippedRows: 'Einträge entfernter Server wurden übersprungen; die alte Sicherungs-DB wird nach erfolgreichem Wechsel entfernt.',
failed: 'Migration fehlgeschlagen',
retry: 'Erneut versuchen',
copyDetails: 'Details kopieren',
complete: 'Aktualisierung abgeschlossen',
completeRows: '{{count}} Zeilen migriert',
genreTagsTitle: 'Genre-Index wird aktualisiert…',
genreTagsBody: 'Genres werden für Durchsuchen und Filter indexiert. Läuft einmal nach dem Update.',
genreTagsFailed: 'Genre-Index-Aktualisierung fehlgeschlagen',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
export const enTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const enTranslation = {
orbit,
tray,
licenses,
migration,
};
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Preparing data update…',
preparingBody: 'Looking at your library and analysis cache…',
migrating: 'Migrating data',
working: 'working…',
skippedRows: 'Rows for removed servers were skipped and old backup DB will be removed after successful switch.',
failed: 'Migration failed',
retry: 'Retry',
copyDetails: 'Copy details',
complete: 'Update complete',
completeRows: '{{count}} rows migrated',
genreTagsTitle: 'Updating genre index…',
genreTagsBody: 'Indexing genres for browse and filters. This runs once after upgrade.',
genreTagsFailed: 'Genre index update failed',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
export const esTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const esTranslation = {
orbit,
tray,
licenses,
migration,
};
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Preparando actualización de datos…',
preparingBody: 'Revisando la biblioteca local y la caché de análisis…',
migrating: 'Migrando datos',
working: 'en curso…',
skippedRows: 'Se omitieron filas de servidores eliminados; la copia de seguridad antigua se eliminará tras el cambio exitoso.',
failed: 'Error en la migración',
retry: 'Reintentar',
copyDetails: 'Copiar detalles',
complete: 'Actualización completada',
completeRows: '{{count}} filas migradas',
genreTagsTitle: 'Actualizando índice de géneros…',
genreTagsBody: 'Indexando géneros para exploración y filtros. Se ejecuta una vez tras la actualización.',
genreTagsFailed: 'Error al actualizar el índice de géneros',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
export const frTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const frTranslation = {
orbit,
tray,
licenses,
migration,
};
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Préparation de la mise à jour des données…',
preparingBody: 'Analyse de la bibliothèque locale et du cache d\'analyse…',
migrating: 'Migration des données',
working: 'en cours…',
skippedRows: 'Les entrées des serveurs supprimés ont été ignorées ; l\'ancienne sauvegarde sera supprimée après le basculement.',
failed: 'Échec de la migration',
retry: 'Réessayer',
copyDetails: 'Copier les détails',
complete: 'Mise à jour terminée',
completeRows: '{{count}} lignes migrées',
genreTagsTitle: 'Mise à jour de l\'index des genres…',
genreTagsBody: 'Indexation des genres pour la navigation et les filtres. Exécuté une fois après la mise à jour.',
genreTagsFailed: 'Échec de la mise à jour de l\'index des genres',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
export const nbTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const nbTranslation = {
orbit,
tray,
licenses,
migration,
};
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Forbereder dataoppdatering…',
preparingBody: 'Ser på biblioteket og analysebufferen…',
migrating: 'Migrerer data',
working: 'arbeider…',
skippedRows: 'Rader for fjernede servere ble hoppet over; gammel sikkerhetskopi fjernes etter vellykket bytte.',
failed: 'Migrering mislyktes',
retry: 'Prøv igjen',
copyDetails: 'Kopier detaljer',
complete: 'Oppdatering fullført',
completeRows: '{{count}} rader migrert',
genreTagsTitle: 'Oppdaterer sjangerindeks…',
genreTagsBody: 'Indekserer sjangre for blaing og filtre. Kjøres én gang etter oppgradering.',
genreTagsFailed: 'Oppdatering av sjangerindeks mislyktes',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
export const nlTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const nlTranslation = {
orbit,
tray,
licenses,
migration,
};
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Gegevensupdate voorbereiden…',
preparingBody: 'Bibliotheek en analysecache controleren…',
migrating: 'Gegevens migreren',
working: 'bezig…',
skippedRows: 'Rijen van verwijderde servers zijn overgeslagen; oude back-up wordt na succesvolle switch verwijderd.',
failed: 'Migratie mislukt',
retry: 'Opnieuw',
copyDetails: 'Details kopiëren',
complete: 'Update voltooid',
completeRows: '{{count}} rijen gemigreerd',
genreTagsTitle: 'Genre-index bijwerken…',
genreTagsBody: 'Genres indexeren voor bladeren en filters. Eenmalig na de update.',
genreTagsFailed: 'Genre-index bijwerken mislukt',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
export const roTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const roTranslation = {
orbit,
tray,
licenses,
migration,
};
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Se pregătește actualizarea datelor…',
preparingBody: 'Se verifică biblioteca locală și cache-ul de analiză…',
migrating: 'Migrare date',
working: 'în curs…',
skippedRows: 'Rândurile pentru serverele eliminate au fost omise; backup-ul vechi va fi șters după comutare.',
failed: 'Migrarea a eșuat',
retry: 'Reîncearcă',
copyDetails: 'Copiază detaliile',
complete: 'Actualizare finalizată',
completeRows: '{{count}} rânduri migrate',
genreTagsTitle: 'Se actualizează indexul de genuri…',
genreTagsBody: 'Se indexează genurile pentru navigare și filtre. Rulează o dată după actualizare.',
genreTagsFailed: 'Actualizarea indexului de genuri a eșuat',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
export const ruTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const ruTranslation = {
orbit,
tray,
licenses,
migration,
};
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Подготовка обновления данных…',
preparingBody: 'Проверяем локальную библиотеку и кэш анализа…',
migrating: 'Миграция данных',
working: 'выполняется…',
skippedRows: 'Записи удалённых серверов пропущены; резервная БД будет удалена после успешного переключения.',
failed: 'Миграция не удалась',
retry: 'Повторить',
copyDetails: 'Копировать детали',
complete: 'Обновление завершено',
completeRows: 'Перенесено строк: {{count}}',
genreTagsTitle: 'Обновление индекса жанров…',
genreTagsBody: 'Индексируем жанры для просмотра и фильтров. Выполняется один раз после обновления.',
genreTagsFailed: 'Не удалось обновить индекс жанров',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
export const zhTranslation = {
sidebar,
@@ -88,4 +89,5 @@ export const zhTranslation = {
orbit,
tray,
licenses,
migration,
};
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: '正在准备数据更新…',
preparingBody: '正在检查本地曲库和分析缓存…',
migrating: '正在迁移数据',
working: '处理中…',
skippedRows: '已跳过已移除服务器的记录;切换成功后将删除旧备份数据库。',
failed: '迁移失败',
retry: '重试',
copyDetails: '复制详情',
complete: '更新完成',
completeRows: '已迁移 {{count}} 行',
genreTagsTitle: '正在更新流派索引…',
genreTagsBody: '正在为浏览和筛选建立流派索引。升级后仅运行一次。',
genreTagsFailed: '流派索引更新失败',
};
+19
View File
@@ -1,30 +1,49 @@
import { create } from 'zustand';
import type { GenreTagsInspectDto } from '../api/library';
import type { MigrationInspectReport, MigrationProgressEvent } from '../api/migration';
export type MigrationPhase = 'idle' | 'inspecting' | 'running' | 'completed' | 'error';
export type MigrationStep = 'serverIndex' | 'genreTags';
export interface GenreTagsProgressEvent {
done: number;
total: number;
}
interface MigrationState {
phase: MigrationPhase;
step: MigrationStep | null;
needsMigration: boolean;
inspect: MigrationInspectReport | null;
progress: MigrationProgressEvent | null;
genreTagsInspect: GenreTagsInspectDto | null;
genreTagsProgress: GenreTagsProgressEvent | null;
lastError: string | null;
setPhase: (phase: MigrationPhase) => void;
setStep: (step: MigrationStep | null) => void;
setNeedsMigration: (needsMigration: boolean) => void;
setInspect: (report: MigrationInspectReport | null) => void;
setProgress: (event: MigrationProgressEvent | null) => void;
setGenreTagsInspect: (report: GenreTagsInspectDto | null) => void;
setGenreTagsProgress: (event: GenreTagsProgressEvent | null) => void;
setError: (error: string | null) => void;
}
export const useMigrationStore = create<MigrationState>(set => ({
phase: 'idle',
step: null,
needsMigration: false,
inspect: null,
progress: null,
genreTagsInspect: null,
genreTagsProgress: null,
lastError: null,
setPhase: phase => set({ phase }),
setStep: step => set({ step }),
setNeedsMigration: needsMigration => set({ needsMigration }),
setInspect: inspect => set({ inspect }),
setProgress: progress => set({ progress }),
setGenreTagsInspect: genreTagsInspect => set({ genreTagsInspect }),
setGenreTagsProgress: genreTagsProgress => set({ genreTagsProgress }),
setError: lastError => set({ lastError }),
}));
+6 -5
View File
@@ -3,6 +3,7 @@ import type { LibraryFilterClause } from '../../api/library';
import { albumIsCompilation, type AlbumCompFilter } from './albumCompilation';
import { albumYearFilterClauses, type AlbumYearBounds } from './albumYearFilter';
import type { AlbumBrowseQuery, GenreFilterOption } from './albumBrowseTypes';
import { genreTagsFor } from './genreTags';
export function albumBrowseHasGenreFilter(query: AlbumBrowseQuery): boolean {
return query.genres.length > 0;
@@ -89,8 +90,8 @@ export function filterAlbumsByGenres(
if (genres.length === 0) return albums;
const wanted = new Set(genres.map(g => g.toLowerCase()));
return albums.filter(a => {
const g = (a.genre ?? '').trim().toLowerCase();
return g !== '' && wanted.has(g);
const tags = genreTagsFor(a);
return tags.some(tag => wanted.has(tag.toLowerCase()));
});
}
@@ -107,9 +108,9 @@ export function filterAlbumsByNameTextQuery(
export function countGenresFromAlbums(albums: SubsonicAlbum[]): GenreFilterOption[] {
const counts = new Map<string, number>();
for (const a of albums) {
const g = (a.genre ?? '').trim();
if (!g) continue;
counts.set(g, (counts.get(g) ?? 0) + 1);
for (const g of genreTagsFor(a)) {
counts.set(g, (counts.get(g) ?? 0) + 1);
}
}
return [...counts.entries()]
.map(([genre, count]) => ({ genre, count }))
+10
View File
@@ -117,6 +117,16 @@ describe('countGenresFromAlbums', () => {
{ genre: 'Jazz', count: 1 },
]);
});
it('counts atomic genres from compound genre strings', () => {
expect(countGenresFromAlbums([
album('1', 'Rock/Jazz'),
album('2', 'Rock'),
])).toEqual([
{ genre: 'Rock', count: 2 },
{ genre: 'Jazz', count: 1 },
]);
});
});
describe('filterAlbumsByNameTextQuery', () => {
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { genreTagsFor, parseItemGenres, splitGenreTags } from './genreTags';
describe('splitGenreTags', () => {
it('splits Navidrome-default separators and dedupes case-insensitively', () => {
expect(splitGenreTags('Rock/Jazz')).toEqual(['Rock', 'Jazz']);
expect(splitGenreTags('Rock; Jazz, Electronic')).toEqual(['Rock', 'Jazz', 'Electronic']);
expect(splitGenreTags('Rock/rock/ROCK')).toEqual(['Rock']);
expect(splitGenreTags('')).toEqual([]);
});
});
describe('parseItemGenres', () => {
it('accepts ItemGenre objects and bare strings', () => {
expect(parseItemGenres([{ name: 'A' }, { name: 'B' }])).toEqual([{ name: 'A' }, { name: 'B' }]);
expect(parseItemGenres(['A', 'B'])).toEqual([{ name: 'A' }, { name: 'B' }]);
expect(parseItemGenres([])).toBeUndefined();
});
it('accepts a single genre object (Subsonic one-element quirk)', () => {
expect(parseItemGenres({ name: 'Jazz' })).toEqual([{ name: 'Jazz' }]);
});
});
describe('genreTagsFor', () => {
it('prefers genres[] over the compound genre string', () => {
expect(genreTagsFor({
genre: 'Noise Metal/Dark Ambient/Experimental Black Metal',
genres: [{ name: 'Dark Ambient' }, { name: 'Noise Metal' }],
})).toEqual(['Dark Ambient', 'Noise Metal']);
});
it('tolerates raw genres shapes from getAlbumList2 passthrough', () => {
expect(genreTagsFor({
genre: 'Ignored/Compound',
genres: { name: 'Rock' },
})).toEqual(['Rock']);
expect(genreTagsFor({
genre: 'Ignored',
genres: ['Jazz', 'Blues'],
})).toEqual(['Jazz', 'Blues']);
});
it('falls back to splitGenreTags when genres[] is absent', () => {
expect(genreTagsFor({
genre: 'Noise Metal/Dark Ambient/Experimental Black Metal',
})).toEqual([
'Noise Metal',
'Dark Ambient',
'Experimental Black Metal',
]);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { SubsonicAlbum, SubsonicItemGenre, SubsonicSong } from '../../api/subsonicTypes';
const GENRE_SEPARATORS = [';', '/', ','] as const;
function dedupeGenres(genres: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const g of genres) {
const t = g.trim();
if (!t) continue;
const key = t.toLocaleLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(t);
}
return out;
}
/** Parse OpenSubsonic `genres` from a raw API payload fragment. */
export function parseItemGenres(raw: unknown): SubsonicItemGenre[] | undefined {
if (raw == null) return undefined;
const items = Array.isArray(raw) ? raw : [raw];
if (items.length === 0) return undefined;
const names: string[] = [];
for (const item of items) {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const name = (item as { name?: unknown }).name;
if (typeof name === 'string' && name.trim()) names.push(name.trim());
} else if (typeof item === 'string' && item.trim()) {
names.push(item.trim());
}
}
const deduped = dedupeGenres(names);
return deduped.length > 0 ? deduped.map(name => ({ name })) : undefined;
}
/** Navidrome-default split when the server sent no `genres[]` array. */
export function splitGenreTags(raw: string): string[] {
const trimmed = raw.trim();
if (!trimmed) return [];
let parts = [trimmed];
for (const sep of GENRE_SEPARATORS) {
const next: string[] = [];
for (const part of parts) {
for (const sub of part.split(sep)) next.push(sub);
}
parts = next;
}
return dedupeGenres(parts);
}
type GenreTagSource = Pick<SubsonicSong | SubsonicAlbum, 'genre'> & {
/** Runtime shape may be ItemGenre[], a single object, or bare strings (Subsonic JSON). */
genres?: unknown;
};
/** Server-authoritative genres when present; otherwise split the legacy `genre` string. */
export function genreTagsFor(item: GenreTagSource): string[] {
const parsed = parseItemGenres(item.genres);
if (parsed && parsed.length > 0) {
return dedupeGenres(parsed.map(g => g.name));
}
const g = item.genre?.trim();
return g ? splitGenreTags(g) : [];
}