fix(library): multi-genre local index with track_genre and backfill (#1059)

* fix(library): multi-genre local index with track_genre and backfill

Restore atomic genre browse, filters, and counts via track_genre:
OpenSubsonic genres[] first with Navidrome-default split fallback, sync
write path, read-path query switches, blocking startup backfill with
progress, and v12 repair migration for DBs that recorded legacy 002–011.
TS fallback adds genreTagsFor and migration gate i18n across locales.

* fix(library): address multi-genre review — robust TS genres and scope join

genreTagsFor routes raw genres through parseItemGenres (single-object
Subsonic quirk and bare strings). Library-scoped genre browse/counts join
track for raw_json library_id fallback. Statistics keeps empty-genre bucket.

* docs: CHANGELOG and credits for multi-genre local index (PR #1059)

* docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
This commit is contained in:
cucadmuh
2026-06-11 01:02:35 +03:00
committed by GitHub
parent 8593858f3a
commit 5cd01c90ac
55 changed files with 1322 additions and 145 deletions
+10
View File
@@ -182,6 +182,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Fixed ## 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 211; TS network fallback uses robust `genreTagsFor` parsing.
### Navidrome Now Playing and scrobble with local playback ### Navidrome Now Playing and scrobble with local playback
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1055](https://github.com/Psychotoxical/psysonic/pull/1055)** **By [@cucadmuh](https://github.com/cucadmuh), PR [#1055](https://github.com/Psychotoxical/psysonic/pull/1055)**
@@ -248,6 +248,23 @@ CREATE TABLE play_session (
CHECK (completion IN ('partial', 'full')) 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_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_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; 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 CREATE INDEX idx_track_fact_mood_tag
ON track_fact(server_id, fact_kind, value_text, track_id) ON track_fact(server_id, fact_kind, value_text, track_id)
WHERE fact_kind = 'mood_tag'; 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 != '';
@@ -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;
@@ -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 != '';
@@ -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;
@@ -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;
@@ -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);
@@ -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;
@@ -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';
@@ -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');
@@ -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 != '';
@@ -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 != '';
@@ -0,0 +1,22 @@
-- Repair for DBs that recorded legacy migrations 002011 (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
);
@@ -900,8 +900,25 @@ fn resolve_clause(
if c.field == "genre" { if c.field == "genre" {
let v = json_to_text(&c.field, c.value.as_ref())?; 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 { return Ok(Some(SqlFragment {
sql: format!("{col} = ? COLLATE NOCASE"), sql,
params: vec![v], params: vec![v],
})); }));
} }
@@ -113,13 +113,26 @@ pub(crate) fn genre_album_counts_for_server(
) -> Result<Vec<GenreAlbumCountDto>, String> { ) -> Result<Vec<GenreAlbumCountDto>, String> {
store store
.with_read_conn(|conn| { .with_read_conn(|conn| {
let mut sql = String::from( let scoped = library_scope.is_some_and(|s| !s.trim().is_empty());
"SELECT t.genre, COUNT(DISTINCT t.album_id) AS album_count, COUNT(*) AS song_count \ let mut sql = if scoped {
FROM track t \ String::from(
WHERE t.server_id = ?1 AND t.deleted = 0 \ "SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
AND t.genre IS NOT NULL AND TRIM(t.genre) != '' \ COUNT(DISTINCT tg.track_id) AS song_count \
AND t.album_id IS NOT NULL AND t.album_id != ''", 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<rusqlite::types::Value> = let mut params: Vec<rusqlite::types::Value> =
vec![rusqlite::types::Value::Text(server_id.to_string())]; vec![rusqlite::types::Value::Text(server_id.to_string())];
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) { 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())); params.push(rusqlite::types::Value::Text(scope.to_string()));
} }
sql.push_str( sql.push_str(
" GROUP BY t.genre COLLATE NOCASE \ " GROUP BY tg.genre COLLATE NOCASE \
ORDER BY album_count DESC, t.genre COLLATE NOCASE ASC", ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC",
); );
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
let rows = stmt let rows = stmt
@@ -337,6 +350,26 @@ mod tests {
assert_eq!(counts[0].song_count, 1); 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] #[test]
fn reconcile_album_stars_clears_all_when_server_list_empty() { fn reconcile_album_stars_clears_all_when_server_list_empty() {
let store = Arc::new(LibraryStore::open_in_memory()); let store = Arc::new(LibraryStore::open_in_memory());
@@ -526,6 +526,23 @@ pub async fn library_list_albums_by_genre(
.await .await
} }
#[tauri::command]
pub fn library_genre_tags_inspect(
runtime: State<'_, LibraryRuntime>,
) -> Result<crate::genre_tags_backfill::GenreTagsInspectDto, String> {
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] #[tauri::command]
pub async fn library_get_artist_lossless_browse( pub async fn library_get_artist_lossless_browse(
runtime: State<'_, LibraryRuntime>, runtime: State<'_, LibraryRuntime>,
@@ -45,10 +45,16 @@ fn count_genre_albums(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
where_sql: &str, where_sql: &str,
params: &[SqlValue], params: &[SqlValue],
library_scoped: bool,
) -> Result<u32, rusqlite::Error> { ) -> Result<u32, rusqlite::Error> {
let count_sql = format!( let from = if library_scoped {
"SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}" "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( let n: i64 = conn.query_row(
&count_sql, &count_sql,
rusqlite::params_from_iter(params.iter()), 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 order_sql = genre_album_order_sql(&req.sort);
let mut where_clauses = vec![ let mut where_clauses = vec![
"t.deleted = 0".to_string(), "tg.server_id = ?1".to_string(),
"t.server_id = ?1".to_string(), "tg.album_id IS NOT NULL AND tg.album_id != ''".to_string(),
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(), "tg.genre = ?2 COLLATE NOCASE".to_string(),
"t.genre = ?2 COLLATE NOCASE".to_string(),
]; ];
let mut params: Vec<SqlValue> = vec![ let mut params: Vec<SqlValue> = vec![
SqlValue::Text(req.server_id.clone()), SqlValue::Text(req.server_id.clone()),
SqlValue::Text(genre.to_string()), 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()) { if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
where_clauses.push(library_scope_equals_sql("t")); where_clauses.push(library_scope_equals_sql("t"));
params.push(SqlValue::Text(scope)); params.push(SqlValue::Text(scope));
@@ -142,8 +148,8 @@ pub fn list_albums_by_genre(
a.raw_json \ a.raw_json \
FROM ( \ FROM ( \
SELECT \ SELECT \
t.server_id, \ tg.server_id, \
t.album_id, \ tg.album_id, \
MAX(t.album) AS album_name, \ MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \ MAX(t.artist) AS artist, \
MAX(t.album_artist) AS album_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, \ MAX(t.synced_at) AS synced_at, \
COUNT(*) AS track_count, \ COUNT(*) AS track_count, \
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \ 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} \ WHERE {where_sql} \
GROUP BY t.server_id, t.album_id \ GROUP BY tg.server_id, tg.album_id \
) la \ ) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \ LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
{order_sql} \ {order_sql} \
@@ -170,7 +178,7 @@ pub fn list_albums_by_genre(
store.with_read_conn(|conn| { store.with_read_conn(|conn| {
let total = if req.include_total { 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 { } else {
None None
}; };
@@ -285,4 +293,49 @@ mod tests {
assert_eq!(all.total, Some(3)); assert_eq!(all.total, Some(3));
assert!(all.has_more); 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));
}
} }
@@ -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<String> {
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<String>) -> Vec<String> {
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<Vec<String>> {
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<Vec<String>> {
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<String> {
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<String> {
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<String> {
if let Ok(value) = serde_json::from_str::<Value>(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()]
);
}
}
@@ -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<String>,
Option<String>,
Option<String>,
Option<String>,
);
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<bool, rusqlite::Error> {
let completed: Option<Option<i64>> = 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<u64, rusqlite::Error> {
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<i64, rusqlite::Error> {
let rowid: Option<i64> = 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<GenreTagsInspectDto, String> {
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<BackfillTrackRow> =
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::<rusqlite::Result<Vec<_>>>()?;
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<TrackRow> = (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");
}
}
@@ -25,6 +25,8 @@ pub mod dto;
pub mod enrichment; pub mod enrichment;
pub mod filter; pub mod filter;
pub mod genre_album_browse; pub mod genre_album_browse;
pub mod genre_tags;
pub mod genre_tags_backfill;
pub mod mood_groups; pub mod mood_groups;
pub mod live_search; pub mod live_search;
pub mod lossless_albums; pub mod lossless_albums;
@@ -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}; 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 /// 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 /// `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 /// expected to project ingested payloads into this shape, not to talk SQL
@@ -181,6 +197,7 @@ impl<'a> TrackRepository<'a> {
r.raw_json, r.raw_json,
])?; ])?;
} }
sync_track_genre_row(&tx, r)?;
} }
drop(upsert); drop(upsert);
tx.commit()?; tx.commit()?;
@@ -204,6 +221,14 @@ impl<'a> TrackRepository<'a> {
pub fn sweep_resync_orphans(&self, server_id: &str, resync_gen: i64) -> Result<u32, String> { pub fn sweep_resync_orphans(&self, server_id: &str, resync_gen: i64) -> Result<u32, String> {
let now = now_unix_ms(); let now = now_unix_ms();
let changed = self.store.with_conn_mut("track.sweep_resync_orphans", |c| { 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( c.execute(
"UPDATE track SET deleted = 1, synced_at = ?3 \ "UPDATE track SET deleted = 1, synced_at = ?3 \
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2", WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2",
@@ -477,6 +502,7 @@ impl<'a> TrackRepository<'a> {
r.synced_at, r.synced_at,
r.raw_json, r.raw_json,
])?; ])?;
sync_track_genre_row(&tx, r)?;
if let Some(old_id) = detected_old { if let Some(old_id) = detected_old {
remap_existing_to_new( remap_existing_to_new(
@@ -1043,8 +1069,9 @@ mod tests {
repo.upsert_batch_initial_ingest(&rows).unwrap(); repo.upsert_batch_initial_ingest(&rows).unwrap();
let elapsed = start.elapsed(); let elapsed = start.elapsed();
assert!( assert!(
elapsed < std::time::Duration::from_millis(500), elapsed < std::time::Duration::from_millis(1000),
"initial ingest batch(500) took {elapsed:?}" "initial ingest batch(500) took {elapsed:?}; includes per-row track_genre \
maintenance and large raw_json payloads"
); );
} }
+59 -7
View File
@@ -9,7 +9,7 @@ use tauri::Manager;
/// Current head of the embedded migrations. Bump each time a new /// Current head of the embedded migrations. Bump each time a new
/// `migrations/NNN_*.sql` is added. /// `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 /// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook /// 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 const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql"); pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql");
/// Version 12 is above the removed legacy migrations 002011 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 /// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable. /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationOutcome { pub(crate) enum MigrationOutcome {
@@ -67,6 +77,7 @@ impl LibraryStore {
let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?; let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?;
configure_write_connection(&write_conn).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())?; 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())?; 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) let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -84,6 +95,7 @@ impl LibraryStore {
let write_conn = Connection::open(&uri).expect("in-memory write connection"); let write_conn = Connection::open(&uri).expect("in-memory write connection");
configure_write_connection(&write_conn).expect("write pragmas"); configure_write_connection(&write_conn).expect("write pragmas");
run_migrations(&write_conn).expect("schema migration"); 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"); let read_conn = Connection::open(&uri).expect("in-memory read connection");
configure_read_connection(&read_conn).expect("read pragmas"); configure_read_connection(&read_conn).expect("read pragmas");
Self { Self {
@@ -556,8 +568,7 @@ mod tests {
rows rows
}) })
.unwrap(); .unwrap();
// Embedded migrations are numbered 1..=head, all applied on a fresh DB. let expected: Vec<i64> = MIGRATIONS.iter().map(|(version, _)| *version).collect();
let expected: Vec<i64> = (1..=LIBRARY_DB_SCHEMA_VERSION).collect();
assert_eq!(versions, expected); assert_eq!(versions, expected);
} }
@@ -574,11 +585,53 @@ mod tests {
}) })
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
count, LIBRARY_DB_SCHEMA_VERSION, count,
MIGRATIONS.len() as i64,
"one schema_migrations row per embedded migration, no duplicates" "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] #[test]
fn fts_virtual_table_exists() { fn fts_virtual_table_exists() {
let store = LibraryStore::open_in_memory(); let store = LibraryStore::open_in_memory();
@@ -656,8 +709,7 @@ mod tests {
rows rows
}) })
.unwrap(); .unwrap();
// Real embedded migrations (1..=head) plus the additive fixture. let mut expected: Vec<i64> = MIGRATIONS.iter().map(|(version, _)| *version).collect();
let mut expected: Vec<i64> = (1..=LIBRARY_DB_SCHEMA_VERSION).collect();
expected.push(FIXTURE_ADD_BIO_VERSION); expected.push(FIXTURE_ADD_BIO_VERSION);
assert_eq!(versions, expected); assert_eq!(versions, expected);
} }
@@ -139,6 +139,10 @@ impl<'a> TombstoneReconciler<'a> {
WHERE server_id = ?1 AND id = ?2", WHERE server_id = ?1 AND id = ?2",
rusqlite::params![self.server_id, id, now_unix_ms()], 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(()) Ok(())
}) })
.map_err(SyncError::Storage) .map_err(SyncError::Storage)
+2
View File
@@ -768,6 +768,8 @@ pub fn run() {
psysonic_library::commands::library_advanced_search, psysonic_library::commands::library_advanced_search,
psysonic_library::commands::library_list_lossless_albums, psysonic_library::commands::library_list_lossless_albums,
psysonic_library::commands::library_list_albums_by_genre, 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_get_artist_lossless_browse,
psysonic_library::commands::library_search_cross_server, psysonic_library::commands::library_search_cross_server,
psysonic_library::commands::library_get_track, psysonic_library::commands::library_get_track,
+21
View File
@@ -892,3 +892,24 @@ export function subscribeLibrarySyncIdle(
handler(payload), handler(payload),
); );
} }
// ── Genre tags startup backfill (multi-genre local index) ───────────────
export interface GenreTagsInspectDto {
needed: boolean;
totalTracks: number;
doneTracks: number;
}
export interface GenreTagsProgressEvent {
done: number;
total: number;
}
export function libraryGenreTagsInspect(): Promise<GenreTagsInspectDto> {
return invoke<GenreTagsInspectDto>('library_genre_tags_inspect');
}
export function libraryGenreTagsRun(): Promise<void> {
return invoke<void>('library_genre_tags_run');
}
+4
View File
@@ -1,4 +1,5 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
import { parseItemGenres } from '../utils/library/genreTags';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { ndLogin } from './navidromeAdmin'; import { ndLogin } from './navidromeAdmin';
@@ -54,6 +55,7 @@ function mapNdSong(o: Record<string, unknown>): SubsonicSong {
userRating: asNumber(o.rating), userRating: asNumber(o.rating),
starred: o.starred ? asString(o.starredAt) || 'true' : undefined, starred: o.starred ? asString(o.starredAt) || 'true' : undefined,
genre: typeof o.genre === 'string' ? o.genre : undefined, genre: typeof o.genre === 'string' ? o.genre : undefined,
genres: parseItemGenres(o.genres),
bitRate: asNumber(o.bitRate), bitRate: asNumber(o.bitRate),
suffix: typeof o.suffix === 'string' ? o.suffix : undefined, suffix: typeof o.suffix === 'string' ? o.suffix : undefined,
contentType: typeof o.contentType === 'string' ? o.contentType : 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, duration: asNumber(o.duration) ?? 0,
year: asNumber(o.maxYear) ?? asNumber(o.year), year: asNumber(o.maxYear) ?? asNumber(o.year),
genre: typeof o.genre === 'string' ? o.genre : undefined, genre: typeof o.genre === 'string' ? o.genre : undefined,
genres: parseItemGenres(o.genres),
starred: starredFlag ? (starredAt ?? 'true') : undefined, starred: starredFlag ? (starredAt ?? 'true') : undefined,
userRating: asNumber(o.rating), userRating: asNumber(o.rating),
isCompilation: o.compilation === true, isCompilation: o.compilation === true,
@@ -383,6 +386,7 @@ export async function ndListLosslessAlbumsPage(req: NdLosslessPageRequest): Prom
duration: 0, duration: 0,
year: asNumber(o.year), year: asNumber(o.year),
genre: typeof o.genre === 'string' ? o.genre : undefined, genre: typeof o.genre === 'string' ? o.genre : undefined,
genres: parseItemGenres(o.genres),
}; };
pageEntries.push({ album, bitDepth, sampleRate: asNumber(o.sampleRate) ?? 0 }); pageEntries.push({ album, bitDepth, sampleRate: asNumber(o.sampleRate) ?? 0 });
} }
+11 -7
View File
@@ -1,4 +1,5 @@
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { genreTagsFor } from '../utils/library/genreTags';
import { getArtists } from './subsonicArtists'; import { getArtists } from './subsonicArtists';
import { getAlbumList, getRandomSongs } from './subsonicLibrary'; import { getAlbumList, getRandomSongs } from './subsonicLibrary';
import type { import type {
@@ -54,14 +55,17 @@ export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibr
albumsCounted += 1; albumsCounted += 1;
const sc = a.songCount ?? 0; const sc = a.songCount ?? 0;
songsCounted += sc; songsCounted += sc;
const label = (a.genre?.trim()) ? a.genre.trim() : ''; const tags = genreTagsFor(a);
let g = genreAgg.get(label); const labels = tags.length > 0 ? tags : [''];
if (!g) { for (const label of labels) {
g = { songCount: 0, albumCount: 0 }; let g = genreAgg.get(label);
genreAgg.set(label, g); 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; if (albums.length < pageSize) break;
offset += pageSize; offset += pageSize;
+9
View File
@@ -1,5 +1,10 @@
import type { SubsonicServerIdentity } from '../utils/server/subsonicServerIdentity'; 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 { export interface SubsonicAlbum {
id: string; id: string;
name: string; name: string;
@@ -11,6 +16,8 @@ export interface SubsonicAlbum {
playCount?: number; playCount?: number;
year?: number; year?: number;
genre?: string; genre?: string;
/** OpenSubsonic atomic genres — preferred over splitting `genre`. */
genres?: SubsonicItemGenre[];
starred?: string; starred?: string;
recordLabel?: string; recordLabel?: string;
created?: string; created?: string;
@@ -72,6 +79,8 @@ export interface SubsonicSong {
channelCount?: number; channelCount?: number;
starred?: string; starred?: string;
genre?: string; genre?: string;
/** OpenSubsonic atomic genres — preferred over splitting `genre`. */
genres?: SubsonicItemGenre[];
path?: string; path?: string;
albumArtist?: string; albumArtist?: string;
/** OpenSubsonic: single-string album-artist for display (mirrors `albumArtists` joined). */ /** OpenSubsonic: single-string album-artist for display (mirrors `albumArtists` joined). */
+26 -13
View File
@@ -1,12 +1,17 @@
import type { ReactNode } from 'react'; 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'; import { useMigrationStore } from '../store/migrationStore';
function MigrationModal() { function MigrationModal() {
const { t } = useTranslation();
const phase = useMigrationStore(s => s.phase); const phase = useMigrationStore(s => s.phase);
const step = useMigrationStore(s => s.step);
const progress = useMigrationStore(s => s.progress); const progress = useMigrationStore(s => s.progress);
const genreTagsProgress = useMigrationStore(s => s.genreTagsProgress);
const inspect = useMigrationStore(s => s.inspect); const inspect = useMigrationStore(s => s.inspect);
const error = useMigrationStore(s => s.lastError); const error = useMigrationStore(s => s.lastError);
const isGenreTags = step === 'genreTags';
const migratedRows = (inspect?.library.totalLegacyRows ?? 0) + (inspect?.analysis.totalLegacyRows ?? 0); const migratedRows = (inspect?.library.totalLegacyRows ?? 0) + (inspect?.analysis.totalLegacyRows ?? 0);
return ( return (
@@ -30,42 +35,50 @@ function MigrationModal() {
> >
{phase === 'inspecting' && ( {phase === 'inspecting' && (
<> <>
<h3>Preparing data update</h3> <h3>{isGenreTags ? t('migration.genreTagsTitle') : t('migration.preparing')}</h3>
<p style={{ color: 'var(--text-muted)' }}>Looking at your library and analysis cache</p> <p style={{ color: 'var(--text-muted)' }}>
{isGenreTags ? t('migration.genreTagsBody') : t('migration.preparingBody')}
</p>
</> </>
)} )}
{phase === 'running' && ( {phase === 'running' && (
<> <>
<h3>Migrating data</h3> <h3>{isGenreTags ? t('migration.genreTagsTitle') : t('migration.migrating')}</h3>
<p style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}> <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>
<p style={{ color: 'var(--text-muted)' }}> <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> </p>
{inspect?.hasSkippedUnknownServerRows ? ( {!isGenreTags && inspect?.hasSkippedUnknownServerRows ? (
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}> <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> </p>
) : null} ) : null}
</> </>
)} )}
{phase === 'error' && ( {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> <p style={{ color: 'var(--text-muted)' }}>{String(error ?? '').slice(0, 200)}</p>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}> <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 ?? ''))}> <button className="btn-surface" onClick={() => navigator.clipboard.writeText(String(error ?? ''))}>
Copy details {t('migration.copyDetails')}
</button> </button>
</div> </div>
</> </>
)} )}
{phase === 'completed' && ( {phase === 'completed' && (
<> <>
<h3>Update complete</h3> <h3>{t('migration.complete')}</h3>
<p style={{ color: 'var(--text-muted)' }}>{migratedRows} rows migrated</p> <p style={{ color: 'var(--text-muted)' }}>{t('migration.completeRows', { count: migratedRows })}</p>
</> </>
)} )}
</div> </div>
+1
View File
@@ -161,6 +161,7 @@ const CONTRIBUTOR_ENTRIES = [
'Settings → Servers: compact two-line cards, capability header badges, unified use/active action, delete in edit form, click-pinned version tooltip (PR #1054)', '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)', '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)', '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 migrationInspectMock = vi.fn();
const migrationRunMock = vi.fn(); const migrationRunMock = vi.fn();
const libraryGenreTagsInspectMock = vi.fn();
const libraryGenreTagsRunMock = vi.fn();
const rewriteFrontendStoreKeysMock = vi.fn(async (_servers: unknown) => undefined); const rewriteFrontendStoreKeysMock = vi.fn(async (_servers: unknown) => undefined);
vi.mock('@tauri-apps/api/event', () => ({ vi.mock('@tauri-apps/api/event', () => ({
@@ -16,6 +18,11 @@ vi.mock('../api/migration', () => ({
migrationRun: (mappings: unknown) => migrationRunMock(mappings), migrationRun: (mappings: unknown) => migrationRunMock(mappings),
})); }));
vi.mock('../api/library', () => ({
libraryGenreTagsInspect: () => libraryGenreTagsInspectMock(),
libraryGenreTagsRun: () => libraryGenreTagsRunMock(),
}));
vi.mock('../utils/server/rewriteFrontendStoreKeys', () => ({ vi.mock('../utils/server/rewriteFrontendStoreKeys', () => ({
rewriteFrontendStoreKeys: (servers: unknown) => rewriteFrontendStoreKeysMock(servers), rewriteFrontendStoreKeys: (servers: unknown) => rewriteFrontendStoreKeysMock(servers),
})); }));
@@ -29,6 +36,10 @@ describe('useMigrationOrchestrator', () => {
beforeEach(() => { beforeEach(() => {
migrationInspectMock.mockReset(); migrationInspectMock.mockReset();
migrationRunMock.mockReset(); migrationRunMock.mockReset();
libraryGenreTagsInspectMock.mockReset();
libraryGenreTagsRunMock.mockReset();
libraryGenreTagsInspectMock.mockResolvedValue({ needed: false, totalTracks: 0, doneTracks: 0 });
libraryGenreTagsRunMock.mockResolvedValue(undefined);
rewriteFrontendStoreKeysMock.mockClear(); rewriteFrontendStoreKeysMock.mockClear();
localStorage.clear(); localStorage.clear();
useAuthStore.setState({ useAuthStore.setState({
@@ -40,9 +51,12 @@ describe('useMigrationOrchestrator', () => {
}); });
useMigrationStore.setState({ useMigrationStore.setState({
phase: 'inspecting', phase: 'inspecting',
step: null,
needsMigration: false, needsMigration: false,
inspect: null, inspect: null,
progress: null, progress: null,
genreTagsInspect: null,
genreTagsProgress: null,
lastError: null, lastError: null,
}); });
(globalThis as Record<string, unknown>)[REAL_MIGRATION_TEST_OVERRIDE] = true; (globalThis as Record<string, unknown>)[REAL_MIGRATION_TEST_OVERRIDE] = true;
+94 -11
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { libraryGenreTagsInspect, libraryGenreTagsRun } from '../api/library';
import { migrationInspect, migrationRun, type ServerIndexMapping } from '../api/migration'; import { migrationInspect, migrationRun, type ServerIndexMapping } from '../api/migration';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useMigrationStore } from '../store/migrationStore'; import { useMigrationStore } from '../store/migrationStore';
@@ -30,6 +31,40 @@ function buildMappings(): ServerIndexMapping[] {
.filter(mapping => mapping.legacyId.trim().length > 0 && mapping.indexKey.trim().length > 0); .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> { async function runOrchestrator(force = false): Promise<void> {
if (migrationInFlight) { if (migrationInFlight) {
await migrationInFlight; await migrationInFlight;
@@ -53,6 +88,8 @@ async function runOrchestrator(force = false): Promise<void> {
const hasDoneFlag = localStorage.getItem(MIGRATION_DONE_FLAG) === '1'; const hasDoneFlag = localStorage.getItem(MIGRATION_DONE_FLAG) === '1';
state.setError(null); state.setError(null);
state.setProgress(null); state.setProgress(null);
state.setGenreTagsProgress(null);
state.setStep('serverIndex');
state.setPhase(force ? 'inspecting' : 'idle'); state.setPhase(force ? 'inspecting' : 'idle');
let inspect = null as Awaited<ReturnType<typeof migrationInspect>> | null; let inspect = null as Awaited<ReturnType<typeof migrationInspect>> | null;
if (!force && hasDoneFlag) { if (!force && hasDoneFlag) {
@@ -61,6 +98,7 @@ async function runOrchestrator(force = false): Promise<void> {
state.setNeedsMigration(inspect.needsMigration); state.setNeedsMigration(inspect.needsMigration);
skippedLogged = logSkippedUnknownRowsOnce(inspect, skippedLogged); skippedLogged = logSkippedUnknownRowsOnce(inspect, skippedLogged);
if (!inspect.needsMigration) { if (!inspect.needsMigration) {
await runGenreTagsPhase();
state.setPhase('completed'); state.setPhase('completed');
return; return;
} }
@@ -74,6 +112,7 @@ async function runOrchestrator(force = false): Promise<void> {
if (!inspect.needsMigration) { if (!inspect.needsMigration) {
await rewriteFrontendStoreKeys(servers); await rewriteFrontendStoreKeys(servers);
localStorage.setItem(MIGRATION_DONE_FLAG, '1'); localStorage.setItem(MIGRATION_DONE_FLAG, '1');
await runGenreTagsPhase();
state.setPhase('completed'); state.setPhase('completed');
return; return;
} }
@@ -88,6 +127,7 @@ async function runOrchestrator(force = false): Promise<void> {
skippedLogged = logSkippedUnknownRowsOnce(after, skippedLogged); skippedLogged = logSkippedUnknownRowsOnce(after, skippedLogged);
if (!after.needsMigration) { if (!after.needsMigration) {
localStorage.setItem(MIGRATION_DONE_FLAG, '1'); localStorage.setItem(MIGRATION_DONE_FLAG, '1');
await runGenreTagsPhase();
state.setPhase('completed'); state.setPhase('completed');
return; return;
} }
@@ -95,7 +135,9 @@ async function runOrchestrator(force = false): Promise<void> {
state.setPhase('error'); state.setPhase('error');
})() })()
.catch((error: unknown) => { .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'); useMigrationStore.getState().setPhase('error');
}) })
.finally(() => { .finally(() => {
@@ -108,23 +150,64 @@ export function retryServerIndexMigration(): void {
void runOrchestrator(true); 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 { export function useMigrationOrchestrator(): void {
const servers = useAuthStore(s => s.servers); const servers = useAuthStore(s => s.servers);
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
const sub = listen('migration:progress', (event) => { const subs = [
if (disposed) return; listen('migration:progress', (event) => {
useMigrationStore.getState().setProgress(event.payload as { if (disposed) return;
stage: string; useMigrationStore.getState().setProgress(event.payload as {
table: string; stage: string;
done: number; table: string;
total: number; done: number;
}); total: number;
}); });
}),
listen('genre_tags:progress', (event) => {
if (disposed) return;
useMigrationStore.getState().setGenreTagsProgress(event.payload as {
done: number;
total: number;
});
}),
];
return () => { return () => {
disposed = true; disposed = true;
void sub.then(unlisten => unlisten()); void Promise.all(subs).then(unlisteners => unlisteners.forEach(unlisten => unlisten()));
}; };
}, []); }, []);
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit'; import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration';
export const deTranslation = { export const deTranslation = {
sidebar, sidebar,
@@ -88,4 +89,5 @@ export const deTranslation = {
orbit, orbit,
tray, tray,
licenses, licenses,
migration,
}; };
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Datenaktualisierung wird vorbereitet…',
preparingBody: 'Bibliothek und Analyse-Cache werden geprüft…',
migrating: 'Daten werden migriert',
working: 'wird ausgeführt…',
skippedRows: 'Einträge entfernter Server wurden übersprungen; die alte Sicherungs-DB wird nach erfolgreichem Wechsel entfernt.',
failed: 'Migration fehlgeschlagen',
retry: 'Erneut versuchen',
copyDetails: 'Details kopieren',
complete: 'Aktualisierung abgeschlossen',
completeRows: '{{count}} Zeilen migriert',
genreTagsTitle: 'Genre-Index wird aktualisiert…',
genreTagsBody: 'Genres werden für Durchsuchen und Filter indexiert. Läuft einmal nach dem Update.',
genreTagsFailed: 'Genre-Index-Aktualisierung fehlgeschlagen',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit'; import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration';
export const enTranslation = { export const enTranslation = {
sidebar, sidebar,
@@ -88,4 +89,5 @@ export const enTranslation = {
orbit, orbit,
tray, tray,
licenses, licenses,
migration,
}; };
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Preparing data update…',
preparingBody: 'Looking at your library and analysis cache…',
migrating: 'Migrating data',
working: 'working…',
skippedRows: 'Rows for removed servers were skipped and old backup DB will be removed after successful switch.',
failed: 'Migration failed',
retry: 'Retry',
copyDetails: 'Copy details',
complete: 'Update complete',
completeRows: '{{count}} rows migrated',
genreTagsTitle: 'Updating genre index…',
genreTagsBody: 'Indexing genres for browse and filters. This runs once after upgrade.',
genreTagsFailed: 'Genre index update failed',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit'; import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration';
export const esTranslation = { export const esTranslation = {
sidebar, sidebar,
@@ -88,4 +89,5 @@ export const esTranslation = {
orbit, orbit,
tray, tray,
licenses, licenses,
migration,
}; };
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Preparando actualización de datos…',
preparingBody: 'Revisando la biblioteca local y la caché de análisis…',
migrating: 'Migrando datos',
working: 'en curso…',
skippedRows: 'Se omitieron filas de servidores eliminados; la copia de seguridad antigua se eliminará tras el cambio exitoso.',
failed: 'Error en la migración',
retry: 'Reintentar',
copyDetails: 'Copiar detalles',
complete: 'Actualización completada',
completeRows: '{{count}} filas migradas',
genreTagsTitle: 'Actualizando índice de géneros…',
genreTagsBody: 'Indexando géneros para exploración y filtros. Se ejecuta una vez tras la actualización.',
genreTagsFailed: 'Error al actualizar el índice de géneros',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit'; import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration';
export const frTranslation = { export const frTranslation = {
sidebar, sidebar,
@@ -88,4 +89,5 @@ export const frTranslation = {
orbit, orbit,
tray, tray,
licenses, licenses,
migration,
}; };
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Préparation de la mise à jour des données…',
preparingBody: 'Analyse de la bibliothèque locale et du cache d\'analyse…',
migrating: 'Migration des données',
working: 'en cours…',
skippedRows: 'Les entrées des serveurs supprimés ont été ignorées ; l\'ancienne sauvegarde sera supprimée après le basculement.',
failed: 'Échec de la migration',
retry: 'Réessayer',
copyDetails: 'Copier les détails',
complete: 'Mise à jour terminée',
completeRows: '{{count}} lignes migrées',
genreTagsTitle: 'Mise à jour de l\'index des genres…',
genreTagsBody: 'Indexation des genres pour la navigation et les filtres. Exécuté une fois après la mise à jour.',
genreTagsFailed: 'Échec de la mise à jour de l\'index des genres',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit'; import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration';
export const nbTranslation = { export const nbTranslation = {
sidebar, sidebar,
@@ -88,4 +89,5 @@ export const nbTranslation = {
orbit, orbit,
tray, tray,
licenses, licenses,
migration,
}; };
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Forbereder dataoppdatering…',
preparingBody: 'Ser på biblioteket og analysebufferen…',
migrating: 'Migrerer data',
working: 'arbeider…',
skippedRows: 'Rader for fjernede servere ble hoppet over; gammel sikkerhetskopi fjernes etter vellykket bytte.',
failed: 'Migrering mislyktes',
retry: 'Prøv igjen',
copyDetails: 'Kopier detaljer',
complete: 'Oppdatering fullført',
completeRows: '{{count}} rader migrert',
genreTagsTitle: 'Oppdaterer sjangerindeks…',
genreTagsBody: 'Indekserer sjangre for blaing og filtre. Kjøres én gang etter oppgradering.',
genreTagsFailed: 'Oppdatering av sjangerindeks mislyktes',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit'; import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration';
export const nlTranslation = { export const nlTranslation = {
sidebar, sidebar,
@@ -88,4 +89,5 @@ export const nlTranslation = {
orbit, orbit,
tray, tray,
licenses, licenses,
migration,
}; };
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Gegevensupdate voorbereiden…',
preparingBody: 'Bibliotheek en analysecache controleren…',
migrating: 'Gegevens migreren',
working: 'bezig…',
skippedRows: 'Rijen van verwijderde servers zijn overgeslagen; oude back-up wordt na succesvolle switch verwijderd.',
failed: 'Migratie mislukt',
retry: 'Opnieuw',
copyDetails: 'Details kopiëren',
complete: 'Update voltooid',
completeRows: '{{count}} rijen gemigreerd',
genreTagsTitle: 'Genre-index bijwerken…',
genreTagsBody: 'Genres indexeren voor bladeren en filters. Eenmalig na de update.',
genreTagsFailed: 'Genre-index bijwerken mislukt',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit'; import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration';
export const roTranslation = { export const roTranslation = {
sidebar, sidebar,
@@ -88,4 +89,5 @@ export const roTranslation = {
orbit, orbit,
tray, tray,
licenses, licenses,
migration,
}; };
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Se pregătește actualizarea datelor…',
preparingBody: 'Se verifică biblioteca locală și cache-ul de analiză…',
migrating: 'Migrare date',
working: 'în curs…',
skippedRows: 'Rândurile pentru serverele eliminate au fost omise; backup-ul vechi va fi șters după comutare.',
failed: 'Migrarea a eșuat',
retry: 'Reîncearcă',
copyDetails: 'Copiază detaliile',
complete: 'Actualizare finalizată',
completeRows: '{{count}} rânduri migrate',
genreTagsTitle: 'Se actualizează indexul de genuri…',
genreTagsBody: 'Se indexează genurile pentru navigare și filtre. Rulează o dată după actualizare.',
genreTagsFailed: 'Actualizarea indexului de genuri a eșuat',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit'; import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration';
export const ruTranslation = { export const ruTranslation = {
sidebar, sidebar,
@@ -88,4 +89,5 @@ export const ruTranslation = {
orbit, orbit,
tray, tray,
licenses, licenses,
migration,
}; };
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: 'Подготовка обновления данных…',
preparingBody: 'Проверяем локальную библиотеку и кэш анализа…',
migrating: 'Миграция данных',
working: 'выполняется…',
skippedRows: 'Записи удалённых серверов пропущены; резервная БД будет удалена после успешного переключения.',
failed: 'Миграция не удалась',
retry: 'Повторить',
copyDetails: 'Копировать детали',
complete: 'Обновление завершено',
completeRows: 'Перенесено строк: {{count}}',
genreTagsTitle: 'Обновление индекса жанров…',
genreTagsBody: 'Индексируем жанры для просмотра и фильтров. Выполняется один раз после обновления.',
genreTagsFailed: 'Не удалось обновить индекс жанров',
};
+2
View File
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
import { orbit } from './orbit'; import { orbit } from './orbit';
import { tray } from './tray'; import { tray } from './tray';
import { licenses } from './licenses'; import { licenses } from './licenses';
import { migration } from './migration';
export const zhTranslation = { export const zhTranslation = {
sidebar, sidebar,
@@ -88,4 +89,5 @@ export const zhTranslation = {
orbit, orbit,
tray, tray,
licenses, licenses,
migration,
}; };
+15
View File
@@ -0,0 +1,15 @@
export const migration = {
preparing: '正在准备数据更新…',
preparingBody: '正在检查本地曲库和分析缓存…',
migrating: '正在迁移数据',
working: '处理中…',
skippedRows: '已跳过已移除服务器的记录;切换成功后将删除旧备份数据库。',
failed: '迁移失败',
retry: '重试',
copyDetails: '复制详情',
complete: '更新完成',
completeRows: '已迁移 {{count}} 行',
genreTagsTitle: '正在更新流派索引…',
genreTagsBody: '正在为浏览和筛选建立流派索引。升级后仅运行一次。',
genreTagsFailed: '流派索引更新失败',
};
+19
View File
@@ -1,30 +1,49 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { GenreTagsInspectDto } from '../api/library';
import type { MigrationInspectReport, MigrationProgressEvent } from '../api/migration'; import type { MigrationInspectReport, MigrationProgressEvent } from '../api/migration';
export type MigrationPhase = 'idle' | 'inspecting' | 'running' | 'completed' | 'error'; export type MigrationPhase = 'idle' | 'inspecting' | 'running' | 'completed' | 'error';
export type MigrationStep = 'serverIndex' | 'genreTags';
export interface GenreTagsProgressEvent {
done: number;
total: number;
}
interface MigrationState { interface MigrationState {
phase: MigrationPhase; phase: MigrationPhase;
step: MigrationStep | null;
needsMigration: boolean; needsMigration: boolean;
inspect: MigrationInspectReport | null; inspect: MigrationInspectReport | null;
progress: MigrationProgressEvent | null; progress: MigrationProgressEvent | null;
genreTagsInspect: GenreTagsInspectDto | null;
genreTagsProgress: GenreTagsProgressEvent | null;
lastError: string | null; lastError: string | null;
setPhase: (phase: MigrationPhase) => void; setPhase: (phase: MigrationPhase) => void;
setStep: (step: MigrationStep | null) => void;
setNeedsMigration: (needsMigration: boolean) => void; setNeedsMigration: (needsMigration: boolean) => void;
setInspect: (report: MigrationInspectReport | null) => void; setInspect: (report: MigrationInspectReport | null) => void;
setProgress: (event: MigrationProgressEvent | null) => void; setProgress: (event: MigrationProgressEvent | null) => void;
setGenreTagsInspect: (report: GenreTagsInspectDto | null) => void;
setGenreTagsProgress: (event: GenreTagsProgressEvent | null) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
} }
export const useMigrationStore = create<MigrationState>(set => ({ export const useMigrationStore = create<MigrationState>(set => ({
phase: 'idle', phase: 'idle',
step: null,
needsMigration: false, needsMigration: false,
inspect: null, inspect: null,
progress: null, progress: null,
genreTagsInspect: null,
genreTagsProgress: null,
lastError: null, lastError: null,
setPhase: phase => set({ phase }), setPhase: phase => set({ phase }),
setStep: step => set({ step }),
setNeedsMigration: needsMigration => set({ needsMigration }), setNeedsMigration: needsMigration => set({ needsMigration }),
setInspect: inspect => set({ inspect }), setInspect: inspect => set({ inspect }),
setProgress: progress => set({ progress }), setProgress: progress => set({ progress }),
setGenreTagsInspect: genreTagsInspect => set({ genreTagsInspect }),
setGenreTagsProgress: genreTagsProgress => set({ genreTagsProgress }),
setError: lastError => set({ lastError }), setError: lastError => set({ lastError }),
})); }));
+6 -5
View File
@@ -3,6 +3,7 @@ import type { LibraryFilterClause } from '../../api/library';
import { albumIsCompilation, type AlbumCompFilter } from './albumCompilation'; import { albumIsCompilation, type AlbumCompFilter } from './albumCompilation';
import { albumYearFilterClauses, type AlbumYearBounds } from './albumYearFilter'; import { albumYearFilterClauses, type AlbumYearBounds } from './albumYearFilter';
import type { AlbumBrowseQuery, GenreFilterOption } from './albumBrowseTypes'; import type { AlbumBrowseQuery, GenreFilterOption } from './albumBrowseTypes';
import { genreTagsFor } from './genreTags';
export function albumBrowseHasGenreFilter(query: AlbumBrowseQuery): boolean { export function albumBrowseHasGenreFilter(query: AlbumBrowseQuery): boolean {
return query.genres.length > 0; return query.genres.length > 0;
@@ -89,8 +90,8 @@ export function filterAlbumsByGenres(
if (genres.length === 0) return albums; if (genres.length === 0) return albums;
const wanted = new Set(genres.map(g => g.toLowerCase())); const wanted = new Set(genres.map(g => g.toLowerCase()));
return albums.filter(a => { return albums.filter(a => {
const g = (a.genre ?? '').trim().toLowerCase(); const tags = genreTagsFor(a);
return g !== '' && wanted.has(g); return tags.some(tag => wanted.has(tag.toLowerCase()));
}); });
} }
@@ -107,9 +108,9 @@ export function filterAlbumsByNameTextQuery(
export function countGenresFromAlbums(albums: SubsonicAlbum[]): GenreFilterOption[] { export function countGenresFromAlbums(albums: SubsonicAlbum[]): GenreFilterOption[] {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const a of albums) { for (const a of albums) {
const g = (a.genre ?? '').trim(); for (const g of genreTagsFor(a)) {
if (!g) continue; counts.set(g, (counts.get(g) ?? 0) + 1);
counts.set(g, (counts.get(g) ?? 0) + 1); }
} }
return [...counts.entries()] return [...counts.entries()]
.map(([genre, count]) => ({ genre, count })) .map(([genre, count]) => ({ genre, count }))
+10
View File
@@ -117,6 +117,16 @@ describe('countGenresFromAlbums', () => {
{ genre: 'Jazz', count: 1 }, { 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', () => { describe('filterAlbumsByNameTextQuery', () => {
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { genreTagsFor, parseItemGenres, splitGenreTags } from './genreTags';
describe('splitGenreTags', () => {
it('splits Navidrome-default separators and dedupes case-insensitively', () => {
expect(splitGenreTags('Rock/Jazz')).toEqual(['Rock', 'Jazz']);
expect(splitGenreTags('Rock; Jazz, Electronic')).toEqual(['Rock', 'Jazz', 'Electronic']);
expect(splitGenreTags('Rock/rock/ROCK')).toEqual(['Rock']);
expect(splitGenreTags('')).toEqual([]);
});
});
describe('parseItemGenres', () => {
it('accepts ItemGenre objects and bare strings', () => {
expect(parseItemGenres([{ name: 'A' }, { name: 'B' }])).toEqual([{ name: 'A' }, { name: 'B' }]);
expect(parseItemGenres(['A', 'B'])).toEqual([{ name: 'A' }, { name: 'B' }]);
expect(parseItemGenres([])).toBeUndefined();
});
it('accepts a single genre object (Subsonic one-element quirk)', () => {
expect(parseItemGenres({ name: 'Jazz' })).toEqual([{ name: 'Jazz' }]);
});
});
describe('genreTagsFor', () => {
it('prefers genres[] over the compound genre string', () => {
expect(genreTagsFor({
genre: 'Noise Metal/Dark Ambient/Experimental Black Metal',
genres: [{ name: 'Dark Ambient' }, { name: 'Noise Metal' }],
})).toEqual(['Dark Ambient', 'Noise Metal']);
});
it('tolerates raw genres shapes from getAlbumList2 passthrough', () => {
expect(genreTagsFor({
genre: 'Ignored/Compound',
genres: { name: 'Rock' },
})).toEqual(['Rock']);
expect(genreTagsFor({
genre: 'Ignored',
genres: ['Jazz', 'Blues'],
})).toEqual(['Jazz', 'Blues']);
});
it('falls back to splitGenreTags when genres[] is absent', () => {
expect(genreTagsFor({
genre: 'Noise Metal/Dark Ambient/Experimental Black Metal',
})).toEqual([
'Noise Metal',
'Dark Ambient',
'Experimental Black Metal',
]);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { SubsonicAlbum, SubsonicItemGenre, SubsonicSong } from '../../api/subsonicTypes';
const GENRE_SEPARATORS = [';', '/', ','] as const;
function dedupeGenres(genres: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const g of genres) {
const t = g.trim();
if (!t) continue;
const key = t.toLocaleLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(t);
}
return out;
}
/** Parse OpenSubsonic `genres` from a raw API payload fragment. */
export function parseItemGenres(raw: unknown): SubsonicItemGenre[] | undefined {
if (raw == null) return undefined;
const items = Array.isArray(raw) ? raw : [raw];
if (items.length === 0) return undefined;
const names: string[] = [];
for (const item of items) {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const name = (item as { name?: unknown }).name;
if (typeof name === 'string' && name.trim()) names.push(name.trim());
} else if (typeof item === 'string' && item.trim()) {
names.push(item.trim());
}
}
const deduped = dedupeGenres(names);
return deduped.length > 0 ? deduped.map(name => ({ name })) : undefined;
}
/** Navidrome-default split when the server sent no `genres[]` array. */
export function splitGenreTags(raw: string): string[] {
const trimmed = raw.trim();
if (!trimmed) return [];
let parts = [trimmed];
for (const sep of GENRE_SEPARATORS) {
const next: string[] = [];
for (const part of parts) {
for (const sub of part.split(sep)) next.push(sub);
}
parts = next;
}
return dedupeGenres(parts);
}
type GenreTagSource = Pick<SubsonicSong | SubsonicAlbum, 'genre'> & {
/** Runtime shape may be ItemGenre[], a single object, or bare strings (Subsonic JSON). */
genres?: unknown;
};
/** Server-authoritative genres when present; otherwise split the legacy `genre` string. */
export function genreTagsFor(item: GenreTagSource): string[] {
const parsed = parseItemGenres(item.genres);
if (parsed && parsed.length > 0) {
return dedupeGenres(parsed.map(g => g.name));
}
const g = item.genre?.trim();
return g ? splitGenreTags(g) : [];
}