mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(library): multi-genre local index with track_genre and backfill (#1059)
* fix(library): multi-genre local index with track_genre and backfill Restore atomic genre browse, filters, and counts via track_genre: OpenSubsonic genres[] first with Navidrome-default split fallback, sync write path, read-path query switches, blocking startup backfill with progress, and v12 repair migration for DBs that recorded legacy 002–011. TS fallback adds genreTagsFor and migration gate i18n across locales. * fix(library): address multi-genre review — robust TS genres and scope join genreTagsFor routes raw genres through parseItemGenres (single-object Subsonic quirk and bare strings). Library-scoped genre browse/counts join track for raw_json library_id fallback. Statistics keeps empty-genre bucket. * docs: CHANGELOG and credits for multi-genre local index (PR #1059) * docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
This commit is contained in:
@@ -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 2–11; 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 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
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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
|
/// 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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()));
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const migration = {
|
||||||
|
preparing: 'Datenaktualisierung wird vorbereitet…',
|
||||||
|
preparingBody: 'Bibliothek und Analyse-Cache werden geprüft…',
|
||||||
|
migrating: 'Daten werden migriert',
|
||||||
|
working: 'wird ausgeführt…',
|
||||||
|
skippedRows: 'Einträge entfernter Server wurden übersprungen; die alte Sicherungs-DB wird nach erfolgreichem Wechsel entfernt.',
|
||||||
|
failed: 'Migration fehlgeschlagen',
|
||||||
|
retry: 'Erneut versuchen',
|
||||||
|
copyDetails: 'Details kopieren',
|
||||||
|
complete: 'Aktualisierung abgeschlossen',
|
||||||
|
completeRows: '{{count}} Zeilen migriert',
|
||||||
|
genreTagsTitle: 'Genre-Index wird aktualisiert…',
|
||||||
|
genreTagsBody: 'Genres werden für Durchsuchen und Filter indexiert. Läuft einmal nach dem Update.',
|
||||||
|
genreTagsFailed: 'Genre-Index-Aktualisierung fehlgeschlagen',
|
||||||
|
};
|
||||||
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
|
|||||||
import { orbit } from './orbit';
|
import { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const migration = {
|
||||||
|
preparing: 'Preparing data update…',
|
||||||
|
preparingBody: 'Looking at your library and analysis cache…',
|
||||||
|
migrating: 'Migrating data',
|
||||||
|
working: 'working…',
|
||||||
|
skippedRows: 'Rows for removed servers were skipped and old backup DB will be removed after successful switch.',
|
||||||
|
failed: 'Migration failed',
|
||||||
|
retry: 'Retry',
|
||||||
|
copyDetails: 'Copy details',
|
||||||
|
complete: 'Update complete',
|
||||||
|
completeRows: '{{count}} rows migrated',
|
||||||
|
genreTagsTitle: 'Updating genre index…',
|
||||||
|
genreTagsBody: 'Indexing genres for browse and filters. This runs once after upgrade.',
|
||||||
|
genreTagsFailed: 'Genre index update failed',
|
||||||
|
};
|
||||||
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
|
|||||||
import { orbit } from './orbit';
|
import { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const migration = {
|
||||||
|
preparing: 'Preparando actualización de datos…',
|
||||||
|
preparingBody: 'Revisando la biblioteca local y la caché de análisis…',
|
||||||
|
migrating: 'Migrando datos',
|
||||||
|
working: 'en curso…',
|
||||||
|
skippedRows: 'Se omitieron filas de servidores eliminados; la copia de seguridad antigua se eliminará tras el cambio exitoso.',
|
||||||
|
failed: 'Error en la migración',
|
||||||
|
retry: 'Reintentar',
|
||||||
|
copyDetails: 'Copiar detalles',
|
||||||
|
complete: 'Actualización completada',
|
||||||
|
completeRows: '{{count}} filas migradas',
|
||||||
|
genreTagsTitle: 'Actualizando índice de géneros…',
|
||||||
|
genreTagsBody: 'Indexando géneros para exploración y filtros. Se ejecuta una vez tras la actualización.',
|
||||||
|
genreTagsFailed: 'Error al actualizar el índice de géneros',
|
||||||
|
};
|
||||||
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
|
|||||||
import { orbit } from './orbit';
|
import { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const migration = {
|
||||||
|
preparing: 'Préparation de la mise à jour des données…',
|
||||||
|
preparingBody: 'Analyse de la bibliothèque locale et du cache d\'analyse…',
|
||||||
|
migrating: 'Migration des données',
|
||||||
|
working: 'en cours…',
|
||||||
|
skippedRows: 'Les entrées des serveurs supprimés ont été ignorées ; l\'ancienne sauvegarde sera supprimée après le basculement.',
|
||||||
|
failed: 'Échec de la migration',
|
||||||
|
retry: 'Réessayer',
|
||||||
|
copyDetails: 'Copier les détails',
|
||||||
|
complete: 'Mise à jour terminée',
|
||||||
|
completeRows: '{{count}} lignes migrées',
|
||||||
|
genreTagsTitle: 'Mise à jour de l\'index des genres…',
|
||||||
|
genreTagsBody: 'Indexation des genres pour la navigation et les filtres. Exécuté une fois après la mise à jour.',
|
||||||
|
genreTagsFailed: 'Échec de la mise à jour de l\'index des genres',
|
||||||
|
};
|
||||||
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
|
|||||||
import { orbit } from './orbit';
|
import { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const migration = {
|
||||||
|
preparing: 'Forbereder dataoppdatering…',
|
||||||
|
preparingBody: 'Ser på biblioteket og analysebufferen…',
|
||||||
|
migrating: 'Migrerer data',
|
||||||
|
working: 'arbeider…',
|
||||||
|
skippedRows: 'Rader for fjernede servere ble hoppet over; gammel sikkerhetskopi fjernes etter vellykket bytte.',
|
||||||
|
failed: 'Migrering mislyktes',
|
||||||
|
retry: 'Prøv igjen',
|
||||||
|
copyDetails: 'Kopier detaljer',
|
||||||
|
complete: 'Oppdatering fullført',
|
||||||
|
completeRows: '{{count}} rader migrert',
|
||||||
|
genreTagsTitle: 'Oppdaterer sjangerindeks…',
|
||||||
|
genreTagsBody: 'Indekserer sjangre for blaing og filtre. Kjøres én gang etter oppgradering.',
|
||||||
|
genreTagsFailed: 'Oppdatering av sjangerindeks mislyktes',
|
||||||
|
};
|
||||||
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
|
|||||||
import { orbit } from './orbit';
|
import { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const migration = {
|
||||||
|
preparing: 'Gegevensupdate voorbereiden…',
|
||||||
|
preparingBody: 'Bibliotheek en analysecache controleren…',
|
||||||
|
migrating: 'Gegevens migreren',
|
||||||
|
working: 'bezig…',
|
||||||
|
skippedRows: 'Rijen van verwijderde servers zijn overgeslagen; oude back-up wordt na succesvolle switch verwijderd.',
|
||||||
|
failed: 'Migratie mislukt',
|
||||||
|
retry: 'Opnieuw',
|
||||||
|
copyDetails: 'Details kopiëren',
|
||||||
|
complete: 'Update voltooid',
|
||||||
|
completeRows: '{{count}} rijen gemigreerd',
|
||||||
|
genreTagsTitle: 'Genre-index bijwerken…',
|
||||||
|
genreTagsBody: 'Genres indexeren voor bladeren en filters. Eenmalig na de update.',
|
||||||
|
genreTagsFailed: 'Genre-index bijwerken mislukt',
|
||||||
|
};
|
||||||
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
|
|||||||
import { orbit } from './orbit';
|
import { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const migration = {
|
||||||
|
preparing: 'Se pregătește actualizarea datelor…',
|
||||||
|
preparingBody: 'Se verifică biblioteca locală și cache-ul de analiză…',
|
||||||
|
migrating: 'Migrare date',
|
||||||
|
working: 'în curs…',
|
||||||
|
skippedRows: 'Rândurile pentru serverele eliminate au fost omise; backup-ul vechi va fi șters după comutare.',
|
||||||
|
failed: 'Migrarea a eșuat',
|
||||||
|
retry: 'Reîncearcă',
|
||||||
|
copyDetails: 'Copiază detaliile',
|
||||||
|
complete: 'Actualizare finalizată',
|
||||||
|
completeRows: '{{count}} rânduri migrate',
|
||||||
|
genreTagsTitle: 'Se actualizează indexul de genuri…',
|
||||||
|
genreTagsBody: 'Se indexează genurile pentru navigare și filtre. Rulează o dată după actualizare.',
|
||||||
|
genreTagsFailed: 'Actualizarea indexului de genuri a eșuat',
|
||||||
|
};
|
||||||
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
|
|||||||
import { orbit } from './orbit';
|
import { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const migration = {
|
||||||
|
preparing: 'Подготовка обновления данных…',
|
||||||
|
preparingBody: 'Проверяем локальную библиотеку и кэш анализа…',
|
||||||
|
migrating: 'Миграция данных',
|
||||||
|
working: 'выполняется…',
|
||||||
|
skippedRows: 'Записи удалённых серверов пропущены; резервная БД будет удалена после успешного переключения.',
|
||||||
|
failed: 'Миграция не удалась',
|
||||||
|
retry: 'Повторить',
|
||||||
|
copyDetails: 'Копировать детали',
|
||||||
|
complete: 'Обновление завершено',
|
||||||
|
completeRows: 'Перенесено строк: {{count}}',
|
||||||
|
genreTagsTitle: 'Обновление индекса жанров…',
|
||||||
|
genreTagsBody: 'Индексируем жанры для просмотра и фильтров. Выполняется один раз после обновления.',
|
||||||
|
genreTagsFailed: 'Не удалось обновить индекс жанров',
|
||||||
|
};
|
||||||
@@ -42,6 +42,7 @@ import { deviceSync } from './deviceSync';
|
|||||||
import { orbit } from './orbit';
|
import { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const migration = {
|
||||||
|
preparing: '正在准备数据更新…',
|
||||||
|
preparingBody: '正在检查本地曲库和分析缓存…',
|
||||||
|
migrating: '正在迁移数据',
|
||||||
|
working: '处理中…',
|
||||||
|
skippedRows: '已跳过已移除服务器的记录;切换成功后将删除旧备份数据库。',
|
||||||
|
failed: '迁移失败',
|
||||||
|
retry: '重试',
|
||||||
|
copyDetails: '复制详情',
|
||||||
|
complete: '更新完成',
|
||||||
|
completeRows: '已迁移 {{count}} 行',
|
||||||
|
genreTagsTitle: '正在更新流派索引…',
|
||||||
|
genreTagsBody: '正在为浏览和筛选建立流派索引。升级后仅运行一次。',
|
||||||
|
genreTagsFailed: '流派索引更新失败',
|
||||||
|
};
|
||||||
@@ -1,30 +1,49 @@
|
|||||||
import { create } from 'zustand';
|
import { 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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { genreTagsFor, parseItemGenres, splitGenreTags } from './genreTags';
|
||||||
|
|
||||||
|
describe('splitGenreTags', () => {
|
||||||
|
it('splits Navidrome-default separators and dedupes case-insensitively', () => {
|
||||||
|
expect(splitGenreTags('Rock/Jazz')).toEqual(['Rock', 'Jazz']);
|
||||||
|
expect(splitGenreTags('Rock; Jazz, Electronic')).toEqual(['Rock', 'Jazz', 'Electronic']);
|
||||||
|
expect(splitGenreTags('Rock/rock/ROCK')).toEqual(['Rock']);
|
||||||
|
expect(splitGenreTags('')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseItemGenres', () => {
|
||||||
|
it('accepts ItemGenre objects and bare strings', () => {
|
||||||
|
expect(parseItemGenres([{ name: 'A' }, { name: 'B' }])).toEqual([{ name: 'A' }, { name: 'B' }]);
|
||||||
|
expect(parseItemGenres(['A', 'B'])).toEqual([{ name: 'A' }, { name: 'B' }]);
|
||||||
|
expect(parseItemGenres([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a single genre object (Subsonic one-element quirk)', () => {
|
||||||
|
expect(parseItemGenres({ name: 'Jazz' })).toEqual([{ name: 'Jazz' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('genreTagsFor', () => {
|
||||||
|
it('prefers genres[] over the compound genre string', () => {
|
||||||
|
expect(genreTagsFor({
|
||||||
|
genre: 'Noise Metal/Dark Ambient/Experimental Black Metal',
|
||||||
|
genres: [{ name: 'Dark Ambient' }, { name: 'Noise Metal' }],
|
||||||
|
})).toEqual(['Dark Ambient', 'Noise Metal']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates raw genres shapes from getAlbumList2 passthrough', () => {
|
||||||
|
expect(genreTagsFor({
|
||||||
|
genre: 'Ignored/Compound',
|
||||||
|
genres: { name: 'Rock' },
|
||||||
|
})).toEqual(['Rock']);
|
||||||
|
expect(genreTagsFor({
|
||||||
|
genre: 'Ignored',
|
||||||
|
genres: ['Jazz', 'Blues'],
|
||||||
|
})).toEqual(['Jazz', 'Blues']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to splitGenreTags when genres[] is absent', () => {
|
||||||
|
expect(genreTagsFor({
|
||||||
|
genre: 'Noise Metal/Dark Ambient/Experimental Black Metal',
|
||||||
|
})).toEqual([
|
||||||
|
'Noise Metal',
|
||||||
|
'Dark Ambient',
|
||||||
|
'Experimental Black Metal',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { SubsonicAlbum, SubsonicItemGenre, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
|
|
||||||
|
const GENRE_SEPARATORS = [';', '/', ','] as const;
|
||||||
|
|
||||||
|
function dedupeGenres(genres: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const g of genres) {
|
||||||
|
const t = g.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
const key = t.toLocaleLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(t);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse OpenSubsonic `genres` from a raw API payload fragment. */
|
||||||
|
export function parseItemGenres(raw: unknown): SubsonicItemGenre[] | undefined {
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
const items = Array.isArray(raw) ? raw : [raw];
|
||||||
|
if (items.length === 0) return undefined;
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||||
|
const name = (item as { name?: unknown }).name;
|
||||||
|
if (typeof name === 'string' && name.trim()) names.push(name.trim());
|
||||||
|
} else if (typeof item === 'string' && item.trim()) {
|
||||||
|
names.push(item.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deduped = dedupeGenres(names);
|
||||||
|
return deduped.length > 0 ? deduped.map(name => ({ name })) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navidrome-default split when the server sent no `genres[]` array. */
|
||||||
|
export function splitGenreTags(raw: string): string[] {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
let parts = [trimmed];
|
||||||
|
for (const sep of GENRE_SEPARATORS) {
|
||||||
|
const next: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
for (const sub of part.split(sep)) next.push(sub);
|
||||||
|
}
|
||||||
|
parts = next;
|
||||||
|
}
|
||||||
|
return dedupeGenres(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenreTagSource = Pick<SubsonicSong | SubsonicAlbum, 'genre'> & {
|
||||||
|
/** Runtime shape may be ItemGenre[], a single object, or bare strings (Subsonic JSON). */
|
||||||
|
genres?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Server-authoritative genres when present; otherwise split the legacy `genre` string. */
|
||||||
|
export function genreTagsFor(item: GenreTagSource): string[] {
|
||||||
|
const parsed = parseItemGenres(item.genres);
|
||||||
|
if (parsed && parsed.length > 0) {
|
||||||
|
return dedupeGenres(parsed.map(g => g.name));
|
||||||
|
}
|
||||||
|
const g = item.genre?.trim();
|
||||||
|
return g ? splitGenreTags(g) : [];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user