From 5cd01c90ac028012bc84f32323153a035736bd61 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:02:35 +0300 Subject: [PATCH] fix(library): multi-genre local index with track_genre and backfill (#1059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- CHANGELOG.md | 10 + .../migrations/001_initial.sql | 21 ++ .../migrations/002_n1_bulk_unreliable.sql | 6 - .../migrations/003_track_remap_indexes.sql | 10 - .../migrations/004_track_title_index.sql | 4 - .../005_track_genre_year_indexes.sql | 8 - .../migrations/006_play_session.sql | 22 -- .../migrations/007_resync_gen.sql | 4 - .../migrations/008_mood_tag_index.sql | 4 - .../migrations/009_purge_mood_facts.sql | 3 - .../010_genre_album_browse_index.sql | 8 - .../011_genre_album_name_browse_index.sql | 8 - .../012_track_genre_legacy_repair.sql | 22 ++ .../psysonic-library/src/advanced_search.rs | 19 +- .../psysonic-library/src/browse_support.rs | 51 ++- .../crates/psysonic-library/src/commands.rs | 17 + .../src/genre_album_browse.rs | 77 ++++- .../crates/psysonic-library/src/genre_tags.rs | 226 +++++++++++++ .../src/genre_tags_backfill.rs | 318 ++++++++++++++++++ src-tauri/crates/psysonic-library/src/lib.rs | 2 + .../psysonic-library/src/repos/track.rs | 33 +- .../crates/psysonic-library/src/store.rs | 66 +++- .../psysonic-library/src/sync/tombstone.rs | 4 + src-tauri/src/lib.rs | 2 + src/api/library.ts | 21 ++ src/api/navidromeBrowse.ts | 4 + src/api/subsonicStatistics.ts | 18 +- src/api/subsonicTypes.ts | 9 + src/app/BlockingMigrationGate.tsx | 39 ++- src/config/settingsCredits.ts | 1 + src/hooks/useMigrationOrchestrator.test.ts | 14 + src/hooks/useMigrationOrchestrator.ts | 105 +++++- src/locales/de/index.ts | 2 + src/locales/de/migration.ts | 15 + src/locales/en/index.ts | 2 + src/locales/en/migration.ts | 15 + src/locales/es/index.ts | 2 + src/locales/es/migration.ts | 15 + src/locales/fr/index.ts | 2 + src/locales/fr/migration.ts | 15 + src/locales/nb/index.ts | 2 + src/locales/nb/migration.ts | 15 + src/locales/nl/index.ts | 2 + src/locales/nl/migration.ts | 15 + src/locales/ro/index.ts | 2 + src/locales/ro/migration.ts | 15 + src/locales/ru/index.ts | 2 + src/locales/ru/migration.ts | 15 + src/locales/zh/index.ts | 2 + src/locales/zh/migration.ts | 15 + src/store/migrationStore.ts | 19 ++ src/utils/library/albumBrowseFilters.ts | 11 +- src/utils/library/albumBrowseLoad.test.ts | 10 + src/utils/library/genreTags.test.ts | 53 +++ src/utils/library/genreTags.ts | 65 ++++ 55 files changed, 1322 insertions(+), 145 deletions(-) delete mode 100644 src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql delete mode 100644 src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql delete mode 100644 src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql delete mode 100644 src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql delete mode 100644 src-tauri/crates/psysonic-library/migrations/006_play_session.sql delete mode 100644 src-tauri/crates/psysonic-library/migrations/007_resync_gen.sql delete mode 100644 src-tauri/crates/psysonic-library/migrations/008_mood_tag_index.sql delete mode 100644 src-tauri/crates/psysonic-library/migrations/009_purge_mood_facts.sql delete mode 100644 src-tauri/crates/psysonic-library/migrations/010_genre_album_browse_index.sql delete mode 100644 src-tauri/crates/psysonic-library/migrations/011_genre_album_name_browse_index.sql create mode 100644 src-tauri/crates/psysonic-library/migrations/012_track_genre_legacy_repair.sql create mode 100644 src-tauri/crates/psysonic-library/src/genre_tags.rs create mode 100644 src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs create mode 100644 src/locales/de/migration.ts create mode 100644 src/locales/en/migration.ts create mode 100644 src/locales/es/migration.ts create mode 100644 src/locales/fr/migration.ts create mode 100644 src/locales/nb/migration.ts create mode 100644 src/locales/nl/migration.ts create mode 100644 src/locales/ro/migration.ts create mode 100644 src/locales/ru/migration.ts create mode 100644 src/locales/zh/migration.ts create mode 100644 src/utils/library/genreTags.test.ts create mode 100644 src/utils/library/genreTags.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3519f321..21cde3be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -182,6 +182,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Fixed +### Local index — multi-genre browse, filters, and counts + +**By [@cucadmuh](https://github.com/cucadmuh), reported by HiveMind on the Psysonic Discord, PR [#1059](https://github.com/Psychotoxical/psysonic/pull/1059)** + +* Tracks tagged with several genres in one metadata field (e.g. `Noise Metal/Dark Ambient/Experimental Black Metal`) again match **each atomic genre** in Genres browse, All Albums filters, genre detail, and Advanced Search — not only the first segment. +* New `track_genre` index (OpenSubsonic `genres[]` when present, Navidrome-default split fallback), maintained on sync; one-time blocking startup backfill for existing libraries with progress. +* Migration v12 repairs databases that recorded legacy schema versions 2–11; TS network fallback uses robust `genreTagsFor` parsing. + + + ### Navidrome Now Playing and scrobble with local playback **By [@cucadmuh](https://github.com/cucadmuh), PR [#1055](https://github.com/Psychotoxical/psysonic/pull/1055)** diff --git a/src-tauri/crates/psysonic-library/migrations/001_initial.sql b/src-tauri/crates/psysonic-library/migrations/001_initial.sql index 0ef06d66..b398bcf9 100644 --- a/src-tauri/crates/psysonic-library/migrations/001_initial.sql +++ b/src-tauri/crates/psysonic-library/migrations/001_initial.sql @@ -248,6 +248,23 @@ CREATE TABLE play_session ( CHECK (completion IN ('partial', 'full')) ); +CREATE TABLE track_genre ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + genre TEXT NOT NULL, + album_id TEXT, + library_id TEXT, + PRIMARY KEY (server_id, track_id, genre COLLATE NOCASE), + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) ON DELETE CASCADE +); + +CREATE TABLE library_data_migration ( + id TEXT PRIMARY KEY, + cursor_rowid INTEGER NOT NULL DEFAULT 0, + completed_at INTEGER, + started_at INTEGER +); + CREATE INDEX idx_track_album ON track(server_id, album_id) WHERE deleted = 0; CREATE INDEX idx_track_artist ON track(server_id, artist_id) WHERE deleted = 0; CREATE INDEX idx_track_updated ON track(server_id, server_updated_at DESC) WHERE deleted = 0; @@ -293,3 +310,7 @@ CREATE INDEX idx_play_session_started CREATE INDEX idx_track_fact_mood_tag ON track_fact(server_id, fact_kind, value_text, track_id) WHERE fact_kind = 'mood_tag'; + +CREATE INDEX idx_track_genre_browse + ON track_genre(server_id, genre COLLATE NOCASE, album_id, track_id) + WHERE album_id IS NOT NULL AND album_id != ''; diff --git a/src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql b/src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql deleted file mode 100644 index 995f4f45..00000000 --- a/src-tauri/crates/psysonic-library/migrations/002_n1_bulk_unreliable.sql +++ /dev/null @@ -1,6 +0,0 @@ --- psysonic-library schema v2 — large-library ingest policy (R7-15). --- Per-server learned flag: when N1 (`/api/song`) returns HTTP 500 beyond a --- deep offset on a large catalog, the strategy selector stops choosing N1 for --- that server on future initial syncs (spec §6.3 / R7-15 Q1/Q5). Additive --- column, DEFAULT 0 → existing rows keep N1 eligible until they hit the wall. -ALTER TABLE sync_state ADD COLUMN n1_bulk_unreliable INTEGER NOT NULL DEFAULT 0; diff --git a/src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql b/src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql deleted file mode 100644 index bd2f2e43..00000000 --- a/src-tauri/crates/psysonic-library/migrations/003_track_remap_indexes.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Remap detection (§6.9) and unstable-id servers: without these indexes --- each upsert in a 500-row batch can scan the whole track table. - -CREATE INDEX IF NOT EXISTS idx_track_remap_path - ON track(server_id, server_path) - WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != ''; - -CREATE INDEX IF NOT EXISTS idx_track_remap_hash - ON track(server_id, content_hash) - WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != ''; diff --git a/src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql b/src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql deleted file mode 100644 index d94f9575..00000000 --- a/src-tauri/crates/psysonic-library/migrations/004_track_title_index.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Browse / sort-by-title without sorting the full server slice on every page. -CREATE INDEX IF NOT EXISTS idx_track_title - ON track(server_id, title COLLATE NOCASE) - WHERE deleted = 0; diff --git a/src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql b/src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql deleted file mode 100644 index b0fc60c3..00000000 --- a/src-tauri/crates/psysonic-library/migrations/005_track_genre_year_indexes.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Advanced search filters on genre and year (partial indexes — only non-null rows). -CREATE INDEX IF NOT EXISTS idx_track_genre - ON track(server_id, genre COLLATE NOCASE) - WHERE deleted = 0 AND genre IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_track_year - ON track(server_id, year) - WHERE deleted = 0 AND year IS NOT NULL; diff --git a/src-tauri/crates/psysonic-library/migrations/006_play_session.sql b/src-tauri/crates/psysonic-library/migrations/006_play_session.sql deleted file mode 100644 index e676851b..00000000 --- a/src-tauri/crates/psysonic-library/migrations/006_play_session.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Player listening history — see workdocs player-stats spec §3.1 -CREATE TABLE play_session ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_id TEXT NOT NULL, - track_id TEXT NOT NULL, - started_at_ms INTEGER NOT NULL, - listened_sec REAL NOT NULL, - position_max_sec REAL NOT NULL, - completion TEXT NOT NULL, - end_reason TEXT NOT NULL, - FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id), - CHECK (completion IN ('partial', 'full')) -); - -CREATE INDEX idx_play_session_server_time - ON play_session(server_id, started_at_ms DESC); - -CREATE INDEX idx_play_session_track - ON play_session(server_id, track_id, started_at_ms DESC); - -CREATE INDEX idx_play_session_started - ON play_session(started_at_ms DESC); diff --git a/src-tauri/crates/psysonic-library/migrations/007_resync_gen.sql b/src-tauri/crates/psysonic-library/migrations/007_resync_gen.sql deleted file mode 100644 index ecac46dc..00000000 --- a/src-tauri/crates/psysonic-library/migrations/007_resync_gen.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Full-resync orphan sweep (mark-and-sweep via generation stamp). --- Rows ingested during a resync pass carry the active `resync_gen`; after --- IS-6 succeeds, live rows with a stale generation are soft-deleted. -ALTER TABLE track ADD COLUMN resync_gen INTEGER NOT NULL DEFAULT 0; diff --git a/src-tauri/crates/psysonic-library/migrations/008_mood_tag_index.sql b/src-tauri/crates/psysonic-library/migrations/008_mood_tag_index.sql deleted file mode 100644 index ba003672..00000000 --- a/src-tauri/crates/psysonic-library/migrations/008_mood_tag_index.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Atomic mood tags for Advanced Search (EXISTS on track_fact). -CREATE INDEX IF NOT EXISTS idx_track_fact_mood_tag - ON track_fact(server_id, fact_kind, value_text, track_id) - WHERE fact_kind = 'mood_tag'; diff --git a/src-tauri/crates/psysonic-library/migrations/009_purge_mood_facts.sql b/src-tauri/crates/psysonic-library/migrations/009_purge_mood_facts.sql deleted file mode 100644 index de6078fb..00000000 --- a/src-tauri/crates/psysonic-library/migrations/009_purge_mood_facts.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Oximedia mood heuristics were misleading; drop accumulated mood facts. -DELETE FROM track_fact - WHERE fact_kind IN ('mood_tag', 'moods', 'valence', 'arousal', 'mood_labels'); diff --git a/src-tauri/crates/psysonic-library/migrations/010_genre_album_browse_index.sql b/src-tauri/crates/psysonic-library/migrations/010_genre_album_browse_index.sql deleted file mode 100644 index 3230146d..00000000 --- a/src-tauri/crates/psysonic-library/migrations/010_genre_album_browse_index.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Genre album browse: filter by (server, genre) then group by album_id. -CREATE INDEX IF NOT EXISTS idx_track_genre_album_browse - ON track(server_id, genre COLLATE NOCASE, album_id) - WHERE deleted = 0 - AND genre IS NOT NULL - AND TRIM(genre) != '' - AND album_id IS NOT NULL - AND album_id != ''; diff --git a/src-tauri/crates/psysonic-library/migrations/011_genre_album_name_browse_index.sql b/src-tauri/crates/psysonic-library/migrations/011_genre_album_name_browse_index.sql deleted file mode 100644 index 76e4099b..00000000 --- a/src-tauri/crates/psysonic-library/migrations/011_genre_album_name_browse_index.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Genre album browse sort: (server, genre, album name, album_id) covering walk. -CREATE INDEX IF NOT EXISTS idx_track_genre_album_name_browse - ON track(server_id, genre COLLATE NOCASE, album COLLATE NOCASE, album_id) - WHERE deleted = 0 - AND genre IS NOT NULL - AND TRIM(genre) != '' - AND album_id IS NOT NULL - AND album_id != ''; diff --git a/src-tauri/crates/psysonic-library/migrations/012_track_genre_legacy_repair.sql b/src-tauri/crates/psysonic-library/migrations/012_track_genre_legacy_repair.sql new file mode 100644 index 00000000..5913cc6e --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/012_track_genre_legacy_repair.sql @@ -0,0 +1,22 @@ +-- Repair for DBs that recorded legacy migrations 002–011 (removed) before +-- multi-genre tables shipped. Safe on fresh installs (IF NOT EXISTS). +CREATE TABLE IF NOT EXISTS track_genre ( + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + genre TEXT NOT NULL, + album_id TEXT, + library_id TEXT, + PRIMARY KEY (server_id, track_id, genre COLLATE NOCASE), + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_track_genre_browse + ON track_genre(server_id, genre COLLATE NOCASE, album_id, track_id) + WHERE album_id IS NOT NULL AND album_id != ''; + +CREATE TABLE IF NOT EXISTS library_data_migration ( + id TEXT PRIMARY KEY, + cursor_rowid INTEGER NOT NULL DEFAULT 0, + completed_at INTEGER, + started_at INTEGER +); diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index db9e9ce5..9edb54b4 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -900,8 +900,25 @@ fn resolve_clause( if c.field == "genre" { let v = json_to_text(&c.field, c.value.as_ref())?; + let sql = match entity { + EntityKind::Track => { + "EXISTS (SELECT 1 FROM track_genre tg \ + WHERE tg.server_id = t.server_id AND tg.track_id = t.id \ + AND tg.genre = ? COLLATE NOCASE)" + .to_string() + } + EntityKind::Album => { + "EXISTS (SELECT 1 FROM track_genre tg \ + WHERE tg.server_id = a.server_id AND tg.album_id = a.id \ + AND tg.genre = ? COLLATE NOCASE)" + .to_string() + } + _ => { + return Err(filter::FilterError::NotQueryable(c.field.clone()).to_string()); + } + }; return Ok(Some(SqlFragment { - sql: format!("{col} = ? COLLATE NOCASE"), + sql, params: vec![v], })); } diff --git a/src-tauri/crates/psysonic-library/src/browse_support.rs b/src-tauri/crates/psysonic-library/src/browse_support.rs index 70d34d29..442762c1 100644 --- a/src-tauri/crates/psysonic-library/src/browse_support.rs +++ b/src-tauri/crates/psysonic-library/src/browse_support.rs @@ -113,13 +113,26 @@ pub(crate) fn genre_album_counts_for_server( ) -> Result, String> { store .with_read_conn(|conn| { - let mut sql = String::from( - "SELECT t.genre, COUNT(DISTINCT t.album_id) AS album_count, COUNT(*) AS song_count \ - FROM track t \ - WHERE t.server_id = ?1 AND t.deleted = 0 \ - AND t.genre IS NOT NULL AND TRIM(t.genre) != '' \ - AND t.album_id IS NOT NULL AND t.album_id != ''", - ); + let scoped = library_scope.is_some_and(|s| !s.trim().is_empty()); + let mut sql = if scoped { + String::from( + "SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \ + COUNT(DISTINCT tg.track_id) AS song_count \ + FROM track_genre tg \ + INNER JOIN track t \ + ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \ + WHERE tg.server_id = ?1 \ + AND tg.album_id IS NOT NULL AND tg.album_id != ''", + ) + } else { + String::from( + "SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \ + COUNT(DISTINCT tg.track_id) AS song_count \ + FROM track_genre tg \ + WHERE tg.server_id = ?1 \ + AND tg.album_id IS NOT NULL AND tg.album_id != ''", + ) + }; let mut params: Vec = vec![rusqlite::types::Value::Text(server_id.to_string())]; if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) { @@ -127,8 +140,8 @@ pub(crate) fn genre_album_counts_for_server( params.push(rusqlite::types::Value::Text(scope.to_string())); } sql.push_str( - " GROUP BY t.genre COLLATE NOCASE \ - ORDER BY album_count DESC, t.genre COLLATE NOCASE ASC", + " GROUP BY tg.genre COLLATE NOCASE \ + ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC", ); let mut stmt = conn.prepare(&sql)?; let rows = stmt @@ -337,6 +350,26 @@ mod tests { assert_eq!(counts[0].song_count, 1); } + #[test] + fn genre_album_counts_scope_reads_library_id_from_track_raw_json() { + let store = Arc::new(LibraryStore::open_in_memory()); + let mut scoped = make_row("s1", "r1", "al_a", 1); + scoped.genre = Some("Rock".into()); + scoped.library_id = None; + scoped.raw_json = r#"{"libraryId":"lib1"}"#.into(); + let mut other = make_row("s1", "r2", "al_b", 1); + other.genre = Some("Rock".into()); + other.library_id = None; + other.raw_json = r#"{"libraryId":"lib2"}"#.into(); + TrackRepository::new(&store) + .upsert_batch(&[scoped, other]) + .unwrap(); + + let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap(); + assert_eq!(counts.len(), 1); + assert_eq!(counts[0].album_count, 1); + } + #[test] fn reconcile_album_stars_clears_all_when_server_list_empty() { let store = Arc::new(LibraryStore::open_in_memory()); diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 0b955d61..07e77ab2 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -526,6 +526,23 @@ pub async fn library_list_albums_by_genre( .await } +#[tauri::command] +pub fn library_genre_tags_inspect( + runtime: State<'_, LibraryRuntime>, +) -> Result { + crate::genre_tags_backfill::inspect_genre_tags_backfill(&runtime.store) +} + +#[tauri::command] +pub async fn library_genre_tags_run( + app: tauri::AppHandle, + runtime: State<'_, LibraryRuntime>, +) -> Result<(), String> { + let store = Arc::clone(&runtime.store); + library_spawn_blocking(move || crate::genre_tags_backfill::run_genre_tags_backfill(&store, &app)) + .await +} + #[tauri::command] pub async fn library_get_artist_lossless_browse( runtime: State<'_, LibraryRuntime>, diff --git a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs index 0251ffc3..4fc02626 100644 --- a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs +++ b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs @@ -45,10 +45,16 @@ fn count_genre_albums( conn: &rusqlite::Connection, where_sql: &str, params: &[SqlValue], + library_scoped: bool, ) -> Result { - let count_sql = format!( - "SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}" - ); + let from = if library_scoped { + "FROM track_genre tg \ + INNER JOIN track t \ + ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0" + } else { + "FROM track_genre tg" + }; + let count_sql = format!("SELECT COUNT(DISTINCT tg.album_id) {from} WHERE {where_sql}"); let n: i64 = conn.query_row( &count_sql, rusqlite::params_from_iter(params.iter()), @@ -108,16 +114,16 @@ pub fn list_albums_by_genre( let order_sql = genre_album_order_sql(&req.sort); let mut where_clauses = vec![ - "t.deleted = 0".to_string(), - "t.server_id = ?1".to_string(), - "t.album_id IS NOT NULL AND t.album_id != ''".to_string(), - "t.genre = ?2 COLLATE NOCASE".to_string(), + "tg.server_id = ?1".to_string(), + "tg.album_id IS NOT NULL AND tg.album_id != ''".to_string(), + "tg.genre = ?2 COLLATE NOCASE".to_string(), ]; let mut params: Vec = vec![ SqlValue::Text(req.server_id.clone()), SqlValue::Text(genre.to_string()), ]; + let library_scoped = trimmed_nonempty(req.library_scope.as_deref()).is_some(); if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { where_clauses.push(library_scope_equals_sql("t")); params.push(SqlValue::Text(scope)); @@ -142,8 +148,8 @@ pub fn list_albums_by_genre( a.raw_json \ FROM ( \ SELECT \ - t.server_id, \ - t.album_id, \ + tg.server_id, \ + tg.album_id, \ MAX(t.album) AS album_name, \ MAX(t.artist) AS artist, \ MAX(t.album_artist) AS album_artist, \ @@ -155,9 +161,11 @@ pub fn list_albums_by_genre( MAX(t.synced_at) AS synced_at, \ COUNT(*) AS track_count, \ COALESCE(SUM(t.duration_sec), 0) AS duration_sec \ - FROM track t \ + FROM track_genre tg \ + INNER JOIN track t \ + ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \ WHERE {where_sql} \ - GROUP BY t.server_id, t.album_id \ + GROUP BY tg.server_id, tg.album_id \ ) la \ LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \ {order_sql} \ @@ -170,7 +178,7 @@ pub fn list_albums_by_genre( store.with_read_conn(|conn| { let total = if req.include_total { - Some(count_genre_albums(conn, &where_sql, &count_params)?) + Some(count_genre_albums(conn, &where_sql, &count_params, library_scoped)?) } else { None }; @@ -285,4 +293,49 @@ mod tests { assert_eq!(all.total, Some(3)); assert!(all.has_more); } + + #[test] + fn list_albums_by_atomic_genre_from_compound_tag() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track( + "s1", + "t1", + "al_a", + "Noise Metal/Dark Ambient/Experimental Black Metal", + )]) + .unwrap(); + + let dark = list_albums_by_genre( + &store, + &LibraryGenreAlbumsRequest { + server_id: "s1".into(), + genre: "Dark Ambient".into(), + library_scope: None, + sort: vec![], + limit: 10, + offset: 0, + include_total: true, + }, + ) + .unwrap(); + assert_eq!(dark.total, Some(1)); + assert_eq!(dark.albums.len(), 1); + assert_eq!(dark.albums[0].id, "al_a"); + + let noise = list_albums_by_genre( + &store, + &LibraryGenreAlbumsRequest { + server_id: "s1".into(), + genre: "Noise Metal".into(), + library_scope: None, + sort: vec![], + limit: 10, + offset: 0, + include_total: true, + }, + ) + .unwrap(); + assert_eq!(noise.total, Some(1)); + } } diff --git a/src-tauri/crates/psysonic-library/src/genre_tags.rs b/src-tauri/crates/psysonic-library/src/genre_tags.rs new file mode 100644 index 00000000..ad378a19 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/genre_tags.rs @@ -0,0 +1,226 @@ +//! Atomic genre resolution for multi-value tags (OpenSubsonic `genres[]` first, +//! Navidrome-default string split as fallback). + +use std::collections::HashSet; + +use rusqlite::{params, Transaction}; +use serde_json::Value; + +const GENRE_SEPARATORS: [&str; 3] = [";", "/", ","]; + +/// Fallback split when the server sent no `genres[]` array (legacy Subsonic). +pub fn split_genre_tags(raw: &str) -> Vec { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + let mut parts = vec![trimmed.to_string()]; + for sep in GENRE_SEPARATORS { + let mut next = Vec::new(); + for part in parts { + for sub in part.split(sep) { + next.push(sub.to_string()); + } + } + parts = next; + } + dedupe_genres(parts) +} + +fn dedupe_genres(genres: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for g in genres { + let t = g.trim(); + if t.is_empty() { + continue; + } + let key = t.to_ascii_lowercase(); + if seen.insert(key) { + out.push(t.to_string()); + } + } + out +} + +fn parse_genres_array_value(value: &Value) -> Option> { + let arr = value.as_array()?; + if arr.is_empty() { + return None; + } + let mut out = Vec::new(); + for item in arr { + if let Some(name) = item.get("name").and_then(|v| v.as_str()) { + let t = name.trim(); + if !t.is_empty() { + out.push(t.to_string()); + } + } else if let Some(s) = item.as_str() { + let t = s.trim(); + if !t.is_empty() { + out.push(t.to_string()); + } + } + } + if out.is_empty() { + None + } else { + Some(dedupe_genres(out)) + } +} + +fn parse_genres_json_str(genres_json: &str) -> Option> { + let trimmed = genres_json.trim(); + if trimmed.is_empty() { + return None; + } + let value: Value = serde_json::from_str(trimmed).ok()?; + parse_genres_array_value(&value) +} + +/// Source-priority resolver (§2.0): `genres[]` from parsed payload, else split `genre`. +pub fn genres_for_track_value(raw_json: &Value, genre: Option<&str>) -> Vec { + if let Some(genres) = raw_json.get("genres").and_then(parse_genres_array_value) { + return genres; + } + genre + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(split_genre_tags) + .unwrap_or_default() +} + +/// Backfill path: `genres_json` from `json_extract(raw_json, '$.genres')`. +pub fn genres_for_track_extracted(genres_json: Option<&str>, genre: Option<&str>) -> Vec { + if let Some(json) = genres_json { + if let Some(genres) = parse_genres_json_str(json) { + return genres; + } + } + genre + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(split_genre_tags) + .unwrap_or_default() +} + +pub fn genres_for_track_raw_json(raw_json: &str, genre: Option<&str>) -> Vec { + if let Ok(value) = serde_json::from_str::(raw_json) { + return genres_for_track_value(&value, genre); + } + genre + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(split_genre_tags) + .unwrap_or_default() +} + +pub fn replace_track_genre_rows( + tx: &Transaction<'_>, + server_id: &str, + track_id: &str, + album_id: Option<&str>, + library_id: Option<&str>, + genres: &[String], +) -> rusqlite::Result<()> { + tx.execute( + "DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2", + params![server_id, track_id], + )?; + if genres.is_empty() { + return Ok(()); + } + let mut insert = tx.prepare_cached( + "INSERT OR IGNORE INTO track_genre (server_id, track_id, genre, album_id, library_id) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + for genre in genres { + insert.execute(params![server_id, track_id, genre, album_id, library_id])?; + } + Ok(()) +} + +pub fn delete_track_genre_for_track( + conn: &rusqlite::Connection, + server_id: &str, + track_id: &str, +) -> rusqlite::Result<()> { + conn.execute( + "DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2", + params![server_id, track_id], + )?; + Ok(()) +} + +pub fn delete_track_genre_for_server_tracks( + conn: &rusqlite::Connection, + server_id: &str, + track_ids: &[String], +) -> rusqlite::Result<()> { + if track_ids.is_empty() { + return Ok(()); + } + for id in track_ids { + delete_track_genre_for_track(conn, server_id, id)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn split_separators_and_dedupe() { + assert_eq!( + split_genre_tags("Rock/Jazz"), + vec!["Rock".to_string(), "Jazz".to_string()] + ); + assert_eq!( + split_genre_tags("Rock; Jazz, Electronic"), + vec![ + "Rock".to_string(), + "Jazz".to_string(), + "Electronic".to_string() + ] + ); + assert_eq!(split_genre_tags("Rock/rock/ROCK"), vec!["Rock".to_string()]); + assert!(split_genre_tags("").is_empty()); + } + + #[test] + fn array_wins_over_genre_string() { + let raw = json!({ + "genres": [{"name": "A"}, {"name": "B"}], + "genre": "A/B/C" + }); + assert_eq!( + genres_for_track_value(&raw, Some("A/B/C")), + vec!["A".to_string(), "B".to_string()] + ); + } + + #[test] + fn bare_string_array_and_empty_array_fallback() { + let bare = json!({ "genres": ["A", "B"] }); + assert_eq!( + genres_for_track_value(&bare, None), + vec!["A".to_string(), "B".to_string()] + ); + let empty = json!({ "genres": [], "genre": "A/B" }); + assert_eq!( + genres_for_track_value(&empty, Some("A/B")), + vec!["A".to_string(), "B".to_string()] + ); + } + + #[test] + fn extracted_json_matches_value_path() { + let genres_json = r#"[{"name":"Jazz"},{"name":"Rock"}]"#; + assert_eq!( + genres_for_track_extracted(Some(genres_json), Some("Noise/Metal")), + vec!["Jazz".to_string(), "Rock".to_string()] + ); + } +} diff --git a/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs b/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs new file mode 100644 index 00000000..4b549187 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs @@ -0,0 +1,318 @@ +//! One-time blocking backfill: populate `track_genre` from existing `track` rows. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use rusqlite::{params, Connection, OptionalExtension}; +use tauri::{AppHandle, Emitter}; + +use crate::genre_tags::{genres_for_track_extracted, replace_track_genre_rows}; +use crate::store::LibraryStore; + +pub const GENRE_TAGS_MIGRATION_ID: &str = "genre_tags_v1"; + +const BATCH_SIZE: i64 = 10_000; + +type BackfillTrackRow = ( + i64, + String, + String, + Option, + Option, + Option, + Option, +); + +fn ensure_genre_tags_tables(conn: &mut Connection) -> rusqlite::Result<()> { + crate::store::ensure_genre_tags_schema(conn) +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GenreTagsInspectDto { + pub needed: bool, + pub total_tracks: u64, + pub done_tracks: u64, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GenreTagsProgressEvent { + pub done: u64, + pub total: u64, +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn migration_completed(conn: &Connection) -> Result { + let completed: Option> = conn + .query_row( + "SELECT completed_at FROM library_data_migration WHERE id = ?1", + params![GENRE_TAGS_MIGRATION_ID], + |r| r.get(0), + ) + .optional()?; + Ok(completed.flatten().is_some()) +} + +fn count_live_tracks(conn: &Connection) -> Result { + let n: i64 = conn.query_row( + "SELECT COUNT(*) FROM track WHERE deleted = 0", + [], + |r| r.get(0), + )?; + Ok(n.max(0) as u64) +} + +fn cursor_rowid(conn: &Connection) -> Result { + let rowid: Option = conn + .query_row( + "SELECT cursor_rowid FROM library_data_migration WHERE id = ?1", + params![GENRE_TAGS_MIGRATION_ID], + |r| r.get(0), + ) + .optional()?; + Ok(rowid.unwrap_or(0)) +} + +pub fn inspect_genre_tags_backfill(store: &LibraryStore) -> Result { + store.with_conn_mut("genre_tags.ensure_schema", ensure_genre_tags_tables)?; + store.with_read_conn(|conn| { + let total_tracks = count_live_tracks(conn)?; + if total_tracks == 0 { + return Ok(GenreTagsInspectDto { + needed: false, + total_tracks: 0, + done_tracks: 0, + }); + } + if migration_completed(conn)? { + return Ok(GenreTagsInspectDto { + needed: false, + total_tracks, + done_tracks: total_tracks, + }); + } + let cursor = cursor_rowid(conn)?; + let done: i64 = conn.query_row( + "SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1", + params![cursor], + |r| r.get(0), + )?; + Ok(GenreTagsInspectDto { + needed: true, + total_tracks, + done_tracks: done.max(0) as u64, + }) + }) +} + +fn emit_progress(app: &AppHandle, done: u64, total: u64) -> Result<(), String> { + app.emit( + "genre_tags:progress", + GenreTagsProgressEvent { done, total }, + ) + .map_err(|e| e.to_string()) +} + +pub fn run_genre_tags_backfill(store: &LibraryStore, app: &AppHandle) -> Result<(), String> { + run_genre_tags_backfill_impl(store, Some(app)) +} + +fn run_genre_tags_backfill_impl( + store: &LibraryStore, + app: Option<&AppHandle>, +) -> Result<(), String> { + let inspect = inspect_genre_tags_backfill(store)?; + if !inspect.needed { + return Ok(()); + } + let total = inspect.total_tracks; + + loop { + let (batch_done, finished) = store.with_conn_mut("genre_tags.backfill", |conn| { + if migration_completed(conn)? { + return Ok::<(i64, bool), rusqlite::Error>((total as i64, true)); + } + + conn.execute( + "INSERT INTO library_data_migration (id, cursor_rowid, started_at) \ + VALUES (?1, 0, ?2) \ + ON CONFLICT(id) DO UPDATE SET \ + started_at = COALESCE(library_data_migration.started_at, excluded.started_at)", + params![GENRE_TAGS_MIGRATION_ID, now_unix()], + )?; + + let cursor = cursor_rowid(conn)?; + + let mut stmt = conn.prepare( + "SELECT rowid, server_id, id, genre, \ + CASE WHEN json_valid(raw_json) THEN json_extract(raw_json, '$.genres') END, \ + album_id, library_id \ + FROM track \ + WHERE deleted = 0 AND rowid > ?1 \ + ORDER BY rowid \ + LIMIT ?2", + )?; + + let rows: Vec = + stmt + .query_map(params![cursor, BATCH_SIZE], |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + )) + })? + .collect::>>()?; + + if rows.is_empty() { + conn.execute( + "UPDATE library_data_migration SET completed_at = ?2, cursor_rowid = \ + (SELECT COALESCE(MAX(rowid), 0) FROM track WHERE deleted = 0) \ + WHERE id = ?1", + params![GENRE_TAGS_MIGRATION_ID, now_unix()], + )?; + return Ok((total as i64, true)); + } + + let tx = conn.unchecked_transaction()?; + let mut last_rowid = cursor; + for (rowid, server_id, track_id, genre, genres_json, album_id, library_id) in rows { + let genres = genres_for_track_extracted( + genres_json.as_deref(), + genre.as_deref(), + ); + replace_track_genre_rows( + &tx, + &server_id, + &track_id, + album_id.as_deref(), + library_id.as_deref(), + &genres, + )?; + last_rowid = rowid; + } + tx.commit()?; + + conn.execute( + "UPDATE library_data_migration SET cursor_rowid = ?2 WHERE id = ?1", + params![GENRE_TAGS_MIGRATION_ID, last_rowid], + )?; + + let done: i64 = conn.query_row( + "SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1", + params![last_rowid], + |r| r.get(0), + )?; + Ok((done, false)) + })?; + + if let Some(app) = app { + emit_progress(app, batch_done.max(0) as u64, total)?; + } + + if finished { + break; + } + } + + // Belt-and-suspenders: all live tracks processed but `completed_at` not set + // (can happen when rowid gaps from soft-deletes make done == total early). + store.with_conn_mut("genre_tags.backfill.finalize", |conn| { + if migration_completed(conn)? { + return Ok(()); + } + let cursor = cursor_rowid(conn)?; + let pending: i64 = conn.query_row( + "SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid > ?1", + params![cursor], + |r| r.get(0), + )?; + if pending == 0 { + conn.execute( + "UPDATE library_data_migration SET completed_at = ?2, cursor_rowid = \ + (SELECT COALESCE(MAX(rowid), 0) FROM track WHERE deleted = 0) \ + WHERE id = ?1", + params![GENRE_TAGS_MIGRATION_ID, now_unix()], + )?; + } + Ok(()) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::track::{TrackRepository, TrackRow}; + use crate::store::LibraryStore; + + fn track(server_id: &str, id: &str, genre: &str, deleted: bool) -> TrackRow { + TrackRow { + server_id: server_id.into(), + id: id.into(), + title: id.into(), + title_sort: None, + artist: Some("Artist".into()), + artist_id: None, + album: "Album".into(), + album_id: Some("al1".into()), + album_artist: None, + duration_sec: 100, + track_number: Some(1), + disc_number: Some(1), + year: None, + genre: Some(genre.into()), + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: Some("lib1".into()), + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted, + synced_at: 1, + raw_json: "{}".into(), + } + } + + #[test] + fn backfill_marks_complete_when_rowid_gaps_leave_pending_rows() { + let store = LibraryStore::open_in_memory(); + let live: Vec = (1..=5) + .map(|n| track("s1", &format!("t{n}"), "Rock", false)) + .collect(); + let mut batch = live; + for n in 6..=20 { + batch.push(track("s1", &format!("del{n}"), "Rock", true)); + } + batch.push(track("s1", "t6", "Jazz", false)); + TrackRepository::new(&store).upsert_batch(&batch).unwrap(); + + run_genre_tags_backfill_impl(&store, None).unwrap(); + + let inspect = inspect_genre_tags_backfill(&store).unwrap(); + assert!(!inspect.needed, "backfill should complete despite rowid gaps"); + } +} diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index 35be3a02..3f04a177 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -25,6 +25,8 @@ pub mod dto; pub mod enrichment; pub mod filter; pub mod genre_album_browse; +pub mod genre_tags; +pub mod genre_tags_backfill; pub mod mood_groups; pub mod live_search; pub mod lossless_albums; diff --git a/src-tauri/crates/psysonic-library/src/repos/track.rs b/src-tauri/crates/psysonic-library/src/repos/track.rs index b9056acc..924750cd 100644 --- a/src-tauri/crates/psysonic-library/src/repos/track.rs +++ b/src-tauri/crates/psysonic-library/src/repos/track.rs @@ -1,7 +1,23 @@ -use rusqlite::{params, OptionalExtension}; +use rusqlite::{params, OptionalExtension, Transaction}; +use crate::genre_tags::{self, genres_for_track_raw_json}; use crate::store::{LibraryStore, WriteOpTiming}; +fn sync_track_genre_row(tx: &Transaction<'_>, row: &TrackRow) -> rusqlite::Result<()> { + if row.deleted { + return genre_tags::delete_track_genre_for_track(tx, &row.server_id, &row.id); + } + let genres = genres_for_track_raw_json(&row.raw_json, row.genre.as_deref()); + genre_tags::replace_track_genre_rows( + tx, + &row.server_id, + &row.id, + row.album_id.as_deref(), + row.library_id.as_deref(), + &genres, + ) +} + /// One row of the `track` table — every hot column from spec §5.1 plus /// `raw_json` (the full normalized SubsonicSong). Sync code (PR-2/PR-3) is /// expected to project ingested payloads into this shape, not to talk SQL @@ -181,6 +197,7 @@ impl<'a> TrackRepository<'a> { r.raw_json, ])?; } + sync_track_genre_row(&tx, r)?; } drop(upsert); tx.commit()?; @@ -204,6 +221,14 @@ impl<'a> TrackRepository<'a> { pub fn sweep_resync_orphans(&self, server_id: &str, resync_gen: i64) -> Result { let now = now_unix_ms(); let changed = self.store.with_conn_mut("track.sweep_resync_orphans", |c| { + c.execute( + "DELETE FROM track_genre \ + WHERE server_id = ?1 AND track_id IN ( \ + SELECT id FROM track \ + WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2 \ + )", + params![server_id, resync_gen], + )?; c.execute( "UPDATE track SET deleted = 1, synced_at = ?3 \ WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2", @@ -477,6 +502,7 @@ impl<'a> TrackRepository<'a> { r.synced_at, r.raw_json, ])?; + sync_track_genre_row(&tx, r)?; if let Some(old_id) = detected_old { remap_existing_to_new( @@ -1043,8 +1069,9 @@ mod tests { repo.upsert_batch_initial_ingest(&rows).unwrap(); let elapsed = start.elapsed(); assert!( - elapsed < std::time::Duration::from_millis(500), - "initial ingest batch(500) took {elapsed:?}" + elapsed < std::time::Duration::from_millis(1000), + "initial ingest batch(500) took {elapsed:?}; includes per-row track_genre \ + maintenance and large raw_json payloads" ); } diff --git a/src-tauri/crates/psysonic-library/src/store.rs b/src-tauri/crates/psysonic-library/src/store.rs index 35c798eb..aef88aab 100644 --- a/src-tauri/crates/psysonic-library/src/store.rs +++ b/src-tauri/crates/psysonic-library/src/store.rs @@ -9,7 +9,7 @@ use tauri::Manager; /// Current head of the embedded migrations. Bump each time a new /// `migrations/NNN_*.sql` is added. -pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1; +pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 12; /// Lowest applied schema version the current code can advance from purely /// additively. If a DB carries a version below this, the breaking-bump hook @@ -22,10 +22,20 @@ pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1; pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1; pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql"); +/// Version 12 is above the removed legacy migrations 002–011 so existing DBs +/// still pick up `track_genre` + `library_data_migration`. +pub(crate) const MIGRATION_012_TRACK_GENRE_LEGACY: &str = + include_str!("../migrations/012_track_genre_legacy_repair.sql"); /// Embedded migrations. Ordered ascending by `version`; the runner sorts /// defensively before applying so the source order can stay readable. -const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL)]; +const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL), (12, MIGRATION_012_TRACK_GENRE_LEGACY)]; + +/// Idempotent repair — also runs after the migration runner on every open so +/// DBs that recorded the wrong version numbers still get the tables. +pub(crate) fn ensure_genre_tags_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch(MIGRATION_012_TRACK_GENRE_LEGACY) +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum MigrationOutcome { @@ -67,6 +77,7 @@ impl LibraryStore { let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?; configure_write_connection(&write_conn).map_err(|e| e.to_string())?; run_migrations(&write_conn).map_err(|e| e.to_string())?; + ensure_genre_tags_schema(&write_conn).map_err(|e| e.to_string())?; checkpoint_wal_conn(&write_conn, "open").map_err(|e| e.to_string())?; let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) .map_err(|e| e.to_string())?; @@ -84,6 +95,7 @@ impl LibraryStore { let write_conn = Connection::open(&uri).expect("in-memory write connection"); configure_write_connection(&write_conn).expect("write pragmas"); run_migrations(&write_conn).expect("schema migration"); + ensure_genre_tags_schema(&write_conn).expect("genre tags schema"); let read_conn = Connection::open(&uri).expect("in-memory read connection"); configure_read_connection(&read_conn).expect("read pragmas"); Self { @@ -556,8 +568,7 @@ mod tests { rows }) .unwrap(); - // Embedded migrations are numbered 1..=head, all applied on a fresh DB. - let expected: Vec = (1..=LIBRARY_DB_SCHEMA_VERSION).collect(); + let expected: Vec = MIGRATIONS.iter().map(|(version, _)| *version).collect(); assert_eq!(versions, expected); } @@ -574,11 +585,53 @@ mod tests { }) .unwrap(); assert_eq!( - count, LIBRARY_DB_SCHEMA_VERSION, + count, + MIGRATIONS.len() as i64, "one schema_migrations row per embedded migration, no duplicates" ); } + #[test] + fn migration_012_repairs_db_that_recorded_legacy_versions_without_genre_tables() { + let uri = in_memory_uri(); + let conn = Connection::open(&uri).expect("connection"); + configure_write_connection(&conn).expect("pragmas"); + conn.execute_batch(INITIAL_SQL).expect("initial"); + conn.execute("DROP TABLE IF EXISTS track_genre", []) + .expect("drop track_genre"); + conn.execute("DROP TABLE IF EXISTS library_data_migration", []) + .expect("drop cursor table"); + for version in 1..=11_i64 { + conn.execute( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, ?1)", + params![version], + ) + .expect("seed legacy versions"); + } + + let outcome = run_migrations_with( + &conn, + MIGRATIONS, + LIBRARY_DB_MIN_COMPATIBLE_VERSION, + no_op_hook, + ) + .expect("apply v12 repair"); + assert_eq!(outcome, MigrationOutcome::Applied); + ensure_genre_tags_schema(&conn).expect("ensure"); + + for table in ["track_genre", "library_data_migration"] { + let exists: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master \ + WHERE type = 'table' AND name = ?1", + params![table], + |r| r.get(0), + ) + .expect("table probe"); + assert_eq!(exists, 1, "missing table {table}"); + } + } + #[test] fn fts_virtual_table_exists() { let store = LibraryStore::open_in_memory(); @@ -656,8 +709,7 @@ mod tests { rows }) .unwrap(); - // Real embedded migrations (1..=head) plus the additive fixture. - let mut expected: Vec = (1..=LIBRARY_DB_SCHEMA_VERSION).collect(); + let mut expected: Vec = MIGRATIONS.iter().map(|(version, _)| *version).collect(); expected.push(FIXTURE_ADD_BIO_VERSION); assert_eq!(versions, expected); } diff --git a/src-tauri/crates/psysonic-library/src/sync/tombstone.rs b/src-tauri/crates/psysonic-library/src/sync/tombstone.rs index 7ae8ba8e..19835188 100644 --- a/src-tauri/crates/psysonic-library/src/sync/tombstone.rs +++ b/src-tauri/crates/psysonic-library/src/sync/tombstone.rs @@ -139,6 +139,10 @@ impl<'a> TombstoneReconciler<'a> { WHERE server_id = ?1 AND id = ?2", rusqlite::params![self.server_id, id, now_unix_ms()], )?; + c.execute( + "DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2", + rusqlite::params![self.server_id, id], + )?; Ok(()) }) .map_err(SyncError::Storage) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a137ca0a..2741ba88 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -768,6 +768,8 @@ pub fn run() { psysonic_library::commands::library_advanced_search, psysonic_library::commands::library_list_lossless_albums, psysonic_library::commands::library_list_albums_by_genre, + psysonic_library::commands::library_genre_tags_inspect, + psysonic_library::commands::library_genre_tags_run, psysonic_library::commands::library_get_artist_lossless_browse, psysonic_library::commands::library_search_cross_server, psysonic_library::commands::library_get_track, diff --git a/src/api/library.ts b/src/api/library.ts index 7f67825f..0be172d2 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -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 { + return invoke('library_genre_tags_inspect'); +} + +export function libraryGenreTagsRun(): Promise { + return invoke('library_genre_tags_run'); +} diff --git a/src/api/navidromeBrowse.ts b/src/api/navidromeBrowse.ts index aecbe360..c504f28b 100644 --- a/src/api/navidromeBrowse.ts +++ b/src/api/navidromeBrowse.ts @@ -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): 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): 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 }); } diff --git a/src/api/subsonicStatistics.ts b/src/api/subsonicStatistics.ts index e2b2f62e..66cecb2f 100644 --- a/src/api/subsonicStatistics.ts +++ b/src/api/subsonicStatistics.ts @@ -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 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; diff --git a/src/api/subsonicTypes.ts b/src/api/subsonicTypes.ts index 011424f4..5ccdd332 100644 --- a/src/api/subsonicTypes.ts +++ b/src/api/subsonicTypes.ts @@ -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). */ diff --git a/src/app/BlockingMigrationGate.tsx b/src/app/BlockingMigrationGate.tsx index 57cd72ef..6f65a93e 100644 --- a/src/app/BlockingMigrationGate.tsx +++ b/src/app/BlockingMigrationGate.tsx @@ -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' && ( <> -

Preparing data update…

-

Looking at your library and analysis cache…

+

{isGenreTags ? t('migration.genreTagsTitle') : t('migration.preparing')}

+

+ {isGenreTags ? t('migration.genreTagsBody') : t('migration.preparingBody')} +

)} {phase === 'running' && ( <> -

Migrating data

+

{isGenreTags ? t('migration.genreTagsTitle') : t('migration.migrating')}

- {progress ? `${progress.stage} - ${progress.table}` : 'running'} + {isGenreTags + ? t('migration.genreTagsBody') + : (progress ? `${progress.stage} - ${progress.table}` : t('migration.working'))}

- {progress ? `${progress.done} / ${progress.total}` : 'working…'} + {isGenreTags + ? (genreTagsProgress + ? `${genreTagsProgress.done} / ${genreTagsProgress.total}` + : t('migration.working')) + : (progress ? `${progress.done} / ${progress.total}` : t('migration.working'))}

- {inspect?.hasSkippedUnknownServerRows ? ( + {!isGenreTags && inspect?.hasSkippedUnknownServerRows ? (

- Rows for removed servers were skipped and old backup DB will be removed after successful switch. + {t('migration.skippedRows')}

) : null} )} {phase === 'error' && ( <> -

Migration failed

+

{isGenreTags ? t('migration.genreTagsFailed') : t('migration.failed')}

{String(error ?? '').slice(0, 200)}

- +
)} {phase === 'completed' && ( <> -

Update complete

-

{migratedRows} rows migrated

+

{t('migration.complete')}

+

{t('migration.completeRows', { count: migratedRows })}

)} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index d89becd8..f2ecefd3 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/hooks/useMigrationOrchestrator.test.ts b/src/hooks/useMigrationOrchestrator.test.ts index fc766065..05b8a981 100644 --- a/src/hooks/useMigrationOrchestrator.test.ts +++ b/src/hooks/useMigrationOrchestrator.test.ts @@ -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)[REAL_MIGRATION_TEST_OVERRIDE] = true; diff --git a/src/hooks/useMigrationOrchestrator.ts b/src/hooks/useMigrationOrchestrator.ts index 9d7dcfb8..d1bf1931 100644 --- a/src/hooks/useMigrationOrchestrator.ts +++ b/src/hooks/useMigrationOrchestrator.ts @@ -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 { + 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 { if (migrationInFlight) { await migrationInFlight; @@ -53,6 +88,8 @@ async function runOrchestrator(force = false): Promise { 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> | null; if (!force && hasDoneFlag) { @@ -61,6 +98,7 @@ async function runOrchestrator(force = false): Promise { 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 { 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 { 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 { 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())); }; }, []); diff --git a/src/locales/de/index.ts b/src/locales/de/index.ts index ea6622d4..7eec1952 100644 --- a/src/locales/de/index.ts +++ b/src/locales/de/index.ts @@ -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, }; diff --git a/src/locales/de/migration.ts b/src/locales/de/migration.ts new file mode 100644 index 00000000..acdc1f16 --- /dev/null +++ b/src/locales/de/migration.ts @@ -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', +}; diff --git a/src/locales/en/index.ts b/src/locales/en/index.ts index 08ddc704..3ab87a07 100644 --- a/src/locales/en/index.ts +++ b/src/locales/en/index.ts @@ -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, }; diff --git a/src/locales/en/migration.ts b/src/locales/en/migration.ts new file mode 100644 index 00000000..25119a13 --- /dev/null +++ b/src/locales/en/migration.ts @@ -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', +}; diff --git a/src/locales/es/index.ts b/src/locales/es/index.ts index ff27ade2..92ff53b2 100644 --- a/src/locales/es/index.ts +++ b/src/locales/es/index.ts @@ -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, }; diff --git a/src/locales/es/migration.ts b/src/locales/es/migration.ts new file mode 100644 index 00000000..01f26c75 --- /dev/null +++ b/src/locales/es/migration.ts @@ -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', +}; diff --git a/src/locales/fr/index.ts b/src/locales/fr/index.ts index d56e50ba..d382e991 100644 --- a/src/locales/fr/index.ts +++ b/src/locales/fr/index.ts @@ -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, }; diff --git a/src/locales/fr/migration.ts b/src/locales/fr/migration.ts new file mode 100644 index 00000000..642daa05 --- /dev/null +++ b/src/locales/fr/migration.ts @@ -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', +}; diff --git a/src/locales/nb/index.ts b/src/locales/nb/index.ts index 007c317f..6f56bb4c 100644 --- a/src/locales/nb/index.ts +++ b/src/locales/nb/index.ts @@ -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, }; diff --git a/src/locales/nb/migration.ts b/src/locales/nb/migration.ts new file mode 100644 index 00000000..483c9236 --- /dev/null +++ b/src/locales/nb/migration.ts @@ -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', +}; diff --git a/src/locales/nl/index.ts b/src/locales/nl/index.ts index 9c48f817..72bb3466 100644 --- a/src/locales/nl/index.ts +++ b/src/locales/nl/index.ts @@ -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, }; diff --git a/src/locales/nl/migration.ts b/src/locales/nl/migration.ts new file mode 100644 index 00000000..b0f31a69 --- /dev/null +++ b/src/locales/nl/migration.ts @@ -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', +}; diff --git a/src/locales/ro/index.ts b/src/locales/ro/index.ts index a4fd1487..87d655ba 100644 --- a/src/locales/ro/index.ts +++ b/src/locales/ro/index.ts @@ -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, }; diff --git a/src/locales/ro/migration.ts b/src/locales/ro/migration.ts new file mode 100644 index 00000000..0c037211 --- /dev/null +++ b/src/locales/ro/migration.ts @@ -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', +}; diff --git a/src/locales/ru/index.ts b/src/locales/ru/index.ts index 3d7059ac..b05de2bf 100644 --- a/src/locales/ru/index.ts +++ b/src/locales/ru/index.ts @@ -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, }; diff --git a/src/locales/ru/migration.ts b/src/locales/ru/migration.ts new file mode 100644 index 00000000..f55f89bc --- /dev/null +++ b/src/locales/ru/migration.ts @@ -0,0 +1,15 @@ +export const migration = { + preparing: 'Подготовка обновления данных…', + preparingBody: 'Проверяем локальную библиотеку и кэш анализа…', + migrating: 'Миграция данных', + working: 'выполняется…', + skippedRows: 'Записи удалённых серверов пропущены; резервная БД будет удалена после успешного переключения.', + failed: 'Миграция не удалась', + retry: 'Повторить', + copyDetails: 'Копировать детали', + complete: 'Обновление завершено', + completeRows: 'Перенесено строк: {{count}}', + genreTagsTitle: 'Обновление индекса жанров…', + genreTagsBody: 'Индексируем жанры для просмотра и фильтров. Выполняется один раз после обновления.', + genreTagsFailed: 'Не удалось обновить индекс жанров', +}; diff --git a/src/locales/zh/index.ts b/src/locales/zh/index.ts index 48115911..1b62e2d7 100644 --- a/src/locales/zh/index.ts +++ b/src/locales/zh/index.ts @@ -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, }; diff --git a/src/locales/zh/migration.ts b/src/locales/zh/migration.ts new file mode 100644 index 00000000..4cd2653b --- /dev/null +++ b/src/locales/zh/migration.ts @@ -0,0 +1,15 @@ +export const migration = { + preparing: '正在准备数据更新…', + preparingBody: '正在检查本地曲库和分析缓存…', + migrating: '正在迁移数据', + working: '处理中…', + skippedRows: '已跳过已移除服务器的记录;切换成功后将删除旧备份数据库。', + failed: '迁移失败', + retry: '重试', + copyDetails: '复制详情', + complete: '更新完成', + completeRows: '已迁移 {{count}} 行', + genreTagsTitle: '正在更新流派索引…', + genreTagsBody: '正在为浏览和筛选建立流派索引。升级后仅运行一次。', + genreTagsFailed: '流派索引更新失败', +}; diff --git a/src/store/migrationStore.ts b/src/store/migrationStore.ts index 5109b420..02674acd 100644 --- a/src/store/migrationStore.ts +++ b/src/store/migrationStore.ts @@ -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(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 }), })); diff --git a/src/utils/library/albumBrowseFilters.ts b/src/utils/library/albumBrowseFilters.ts index 302c2390..7fe5505f 100644 --- a/src/utils/library/albumBrowseFilters.ts +++ b/src/utils/library/albumBrowseFilters.ts @@ -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(); 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 })) diff --git a/src/utils/library/albumBrowseLoad.test.ts b/src/utils/library/albumBrowseLoad.test.ts index bf32e1f0..cb23945d 100644 --- a/src/utils/library/albumBrowseLoad.test.ts +++ b/src/utils/library/albumBrowseLoad.test.ts @@ -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', () => { diff --git a/src/utils/library/genreTags.test.ts b/src/utils/library/genreTags.test.ts new file mode 100644 index 00000000..eb5fc728 --- /dev/null +++ b/src/utils/library/genreTags.test.ts @@ -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', + ]); + }); +}); diff --git a/src/utils/library/genreTags.ts b/src/utils/library/genreTags.ts new file mode 100644 index 00000000..b1a2a6f6 --- /dev/null +++ b/src/utils/library/genreTags.ts @@ -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(); + 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 & { + /** 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) : []; +}