mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const migration = {
|
||||
preparing: 'Подготовка обновления данных…',
|
||||
preparingBody: 'Проверяем локальную библиотеку и кэш анализа…',
|
||||
migrating: 'Миграция данных',
|
||||
working: 'выполняется…',
|
||||
skippedRows: 'Записи удалённых серверов пропущены; резервная БД будет удалена после успешного переключения.',
|
||||
failed: 'Миграция не удалась',
|
||||
retry: 'Повторить',
|
||||
copyDetails: 'Копировать детали',
|
||||
complete: 'Обновление завершено',
|
||||
completeRows: 'Перенесено строк: {{count}}',
|
||||
genreTagsTitle: 'Обновление индекса жанров…',
|
||||
genreTagsBody: 'Индексируем жанры для просмотра и фильтров. Выполняется один раз после обновления.',
|
||||
genreTagsFailed: 'Не удалось обновить индекс жанров',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const migration = {
|
||||
preparing: '正在准备数据更新…',
|
||||
preparingBody: '正在检查本地曲库和分析缓存…',
|
||||
migrating: '正在迁移数据',
|
||||
working: '处理中…',
|
||||
skippedRows: '已跳过已移除服务器的记录;切换成功后将删除旧备份数据库。',
|
||||
failed: '迁移失败',
|
||||
retry: '重试',
|
||||
copyDetails: '复制详情',
|
||||
complete: '更新完成',
|
||||
completeRows: '已迁移 {{count}} 行',
|
||||
genreTagsTitle: '正在更新流派索引…',
|
||||
genreTagsBody: '正在为浏览和筛选建立流派索引。升级后仅运行一次。',
|
||||
genreTagsFailed: '流派索引更新失败',
|
||||
};
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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) : [];
|
||||
}
|
||||
Reference in New Issue
Block a user