mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
5cd01c90ac
* 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)
399 lines
14 KiB
Rust
399 lines
14 KiB
Rust
//! Album browse helpers: favorites reconcile and catalog year bounds.
|
|
|
|
use rusqlite::params;
|
|
use tauri::State;
|
|
|
|
use crate::dto::CatalogYearBoundsDto;
|
|
use crate::dto::GenreAlbumCountDto;
|
|
use crate::runtime::LibraryRuntime;
|
|
use crate::search::library_scope_equals_sql;
|
|
use crate::store::LibraryStore;
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct StarredAlbumReconcileItem {
|
|
pub id: String,
|
|
pub starred_at: i64,
|
|
}
|
|
|
|
/// Align `album.starred_at` with server favorites: UPDATE existing rows only
|
|
/// (no INSERT / stub rows). Clears local stars absent from `starred_albums`.
|
|
#[tauri::command]
|
|
pub fn library_reconcile_album_stars(
|
|
runtime: State<'_, LibraryRuntime>,
|
|
server_id: String,
|
|
starred_albums: Vec<StarredAlbumReconcileItem>,
|
|
) -> Result<(), String> {
|
|
reconcile_album_stars(&runtime, &server_id, &starred_albums)
|
|
}
|
|
|
|
pub(crate) fn reconcile_album_stars(
|
|
runtime: &LibraryRuntime,
|
|
server_id: &str,
|
|
starred: &[StarredAlbumReconcileItem],
|
|
) -> Result<(), String> {
|
|
runtime
|
|
.store
|
|
.with_conn("browse.reconcile_album_stars", |conn| {
|
|
if starred.is_empty() {
|
|
conn.execute(
|
|
"UPDATE album SET starred_at = NULL \
|
|
WHERE server_id = ?1 AND starred_at IS NOT NULL",
|
|
params![server_id],
|
|
)?;
|
|
return Ok(());
|
|
}
|
|
let placeholders = std::iter::repeat_n("?", starred.len())
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let clear_sql = format!(
|
|
"UPDATE album SET starred_at = NULL \
|
|
WHERE server_id = ?1 AND starred_at IS NOT NULL \
|
|
AND id NOT IN ({placeholders})"
|
|
);
|
|
let mut clear_params: Vec<rusqlite::types::Value> =
|
|
vec![rusqlite::types::Value::Text(server_id.to_string())];
|
|
for item in starred {
|
|
clear_params.push(rusqlite::types::Value::Text(item.id.clone()));
|
|
}
|
|
conn.execute(
|
|
&clear_sql,
|
|
rusqlite::params_from_iter(clear_params.iter()),
|
|
)?;
|
|
for item in starred {
|
|
conn.execute(
|
|
"UPDATE album SET starred_at = ?3 \
|
|
WHERE server_id = ?1 AND id = ?2",
|
|
params![server_id, item.id, item.starred_at],
|
|
)?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
pub(crate) fn catalog_year_bounds_for_server(
|
|
store: &LibraryStore,
|
|
server_id: &str,
|
|
) -> Result<CatalogYearBoundsDto, String> {
|
|
store
|
|
.with_read_conn(|conn| {
|
|
let min_year: Option<i64> = conn.query_row(
|
|
"SELECT MIN(year) FROM track \
|
|
WHERE server_id = ?1 AND deleted = 0 AND year IS NOT NULL AND year > 0",
|
|
params![server_id],
|
|
|r| r.get(0),
|
|
)?;
|
|
let max_year: Option<i64> = conn.query_row(
|
|
"SELECT MAX(year) FROM track \
|
|
WHERE server_id = ?1 AND deleted = 0 AND year IS NOT NULL AND year > 0",
|
|
params![server_id],
|
|
|r| r.get(0),
|
|
)?;
|
|
let min_year = min_year.map(|y| y as i32);
|
|
let max_year = max_year.map(|y| y as i32);
|
|
Ok(CatalogYearBoundsDto { min_year, max_year })
|
|
})
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Min/max album years from the local track catalog (for Albums browse filter spinners).
|
|
#[tauri::command]
|
|
pub fn library_get_catalog_year_bounds(
|
|
runtime: State<'_, LibraryRuntime>,
|
|
server_id: String,
|
|
) -> Result<CatalogYearBoundsDto, String> {
|
|
catalog_year_bounds_for_server(&runtime.store, &server_id)
|
|
}
|
|
|
|
pub(crate) fn genre_album_counts_for_server(
|
|
store: &LibraryStore,
|
|
server_id: &str,
|
|
library_scope: Option<&str>,
|
|
) -> Result<Vec<GenreAlbumCountDto>, String> {
|
|
store
|
|
.with_read_conn(|conn| {
|
|
let scoped = library_scope.is_some_and(|s| !s.trim().is_empty());
|
|
let mut sql = if scoped {
|
|
String::from(
|
|
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
|
COUNT(DISTINCT tg.track_id) AS song_count \
|
|
FROM track_genre tg \
|
|
INNER JOIN track t \
|
|
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
|
|
WHERE tg.server_id = ?1 \
|
|
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
|
)
|
|
} else {
|
|
String::from(
|
|
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
|
COUNT(DISTINCT tg.track_id) AS song_count \
|
|
FROM track_genre tg \
|
|
WHERE tg.server_id = ?1 \
|
|
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
|
)
|
|
};
|
|
let mut params: Vec<rusqlite::types::Value> =
|
|
vec![rusqlite::types::Value::Text(server_id.to_string())];
|
|
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
|
|
sql.push_str(&format!(" AND {}", library_scope_equals_sql("t")));
|
|
params.push(rusqlite::types::Value::Text(scope.to_string()));
|
|
}
|
|
sql.push_str(
|
|
" GROUP BY tg.genre COLLATE NOCASE \
|
|
ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC",
|
|
);
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let rows = stmt
|
|
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
|
Ok(GenreAlbumCountDto {
|
|
value: r.get::<_, String>(0)?,
|
|
album_count: r.get::<_, i64>(1)?.max(0) as u32,
|
|
song_count: r.get::<_, i64>(2)?.max(0) as u32,
|
|
})
|
|
})?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
Ok(rows)
|
|
})
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Distinct album counts per track genre — same grouping as genre album browse.
|
|
#[tauri::command]
|
|
pub fn library_get_genre_album_counts(
|
|
runtime: State<'_, LibraryRuntime>,
|
|
server_id: String,
|
|
library_scope: Option<String>,
|
|
) -> Result<Vec<GenreAlbumCountDto>, String> {
|
|
genre_album_counts_for_server(
|
|
&runtime.store,
|
|
&server_id,
|
|
library_scope.as_deref(),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use crate::repos::TrackRepository;
|
|
use crate::runtime::LibraryRuntime;
|
|
use crate::store::LibraryStore;
|
|
|
|
use super::{
|
|
catalog_year_bounds_for_server, genre_album_counts_for_server, reconcile_album_stars,
|
|
StarredAlbumReconcileItem,
|
|
};
|
|
|
|
fn make_row(server: &str, id: &str, album_id: &str, track: i64) -> crate::repos::TrackRow {
|
|
crate::repos::TrackRow {
|
|
server_id: server.into(),
|
|
id: id.into(),
|
|
title: format!("T{id}"),
|
|
title_sort: None,
|
|
artist: Some("A".into()),
|
|
artist_id: Some("ar".into()),
|
|
album: album_id.into(),
|
|
album_id: Some(album_id.into()),
|
|
album_artist: None,
|
|
duration_sec: 200,
|
|
track_number: Some(track),
|
|
disc_number: Some(1),
|
|
year: None,
|
|
genre: None,
|
|
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: None,
|
|
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: false,
|
|
synced_at: 1,
|
|
raw_json: "{}".into(),
|
|
}
|
|
}
|
|
|
|
fn runtime(store: Arc<LibraryStore>) -> LibraryRuntime {
|
|
LibraryRuntime::new(store)
|
|
}
|
|
|
|
#[test]
|
|
fn reconcile_album_stars_clears_stale_and_sets_existing_rows() {
|
|
let store = Arc::new(LibraryStore::open_in_memory());
|
|
store
|
|
.with_conn("misc", |c| {
|
|
c.execute(
|
|
"INSERT INTO album (server_id, id, name, starred_at, synced_at, raw_json) \
|
|
VALUES ('s1', 'al_old', 'Old', 1, 1, '{}'), \
|
|
('s1', 'al_keep', 'Keep', 1, 1, '{}'), \
|
|
('s1', 'al_new', 'New', NULL, 1, '{}')",
|
|
[],
|
|
)
|
|
})
|
|
.unwrap();
|
|
let rt = runtime(store.clone());
|
|
reconcile_album_stars(
|
|
&rt,
|
|
"s1",
|
|
&[StarredAlbumReconcileItem {
|
|
id: "al_keep".into(),
|
|
starred_at: 99,
|
|
}],
|
|
)
|
|
.unwrap();
|
|
let old: Option<i64> = store
|
|
.with_conn("misc", |c| {
|
|
c.query_row(
|
|
"SELECT starred_at FROM album WHERE server_id = 's1' AND id = 'al_old'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
})
|
|
.unwrap();
|
|
let keep: Option<i64> = store
|
|
.with_conn("misc", |c| {
|
|
c.query_row(
|
|
"SELECT starred_at FROM album WHERE server_id = 's1' AND id = 'al_keep'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
})
|
|
.unwrap();
|
|
let new: Option<i64> = store
|
|
.with_conn("misc", |c| {
|
|
c.query_row(
|
|
"SELECT starred_at FROM album WHERE server_id = 's1' AND id = 'al_new'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
})
|
|
.unwrap();
|
|
assert!(old.is_none());
|
|
assert_eq!(keep, Some(99));
|
|
assert!(new.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_year_bounds_from_indexed_tracks() {
|
|
let store = Arc::new(LibraryStore::open_in_memory());
|
|
let mut old = make_row("s1", "t1", "al1", 1);
|
|
old.year = Some(1985);
|
|
let mut recent = make_row("s1", "t2", "al2", 1);
|
|
recent.year = Some(2018);
|
|
TrackRepository::new(&store)
|
|
.upsert_batch(&[old, recent])
|
|
.unwrap();
|
|
let bounds = catalog_year_bounds_for_server(&store, "s1").unwrap();
|
|
assert_eq!(bounds.min_year, Some(1985));
|
|
assert_eq!(bounds.max_year, Some(2018));
|
|
}
|
|
|
|
#[test]
|
|
fn genre_album_counts_group_distinct_albums_per_genre() {
|
|
let store = Arc::new(LibraryStore::open_in_memory());
|
|
let mut rock_one: Vec<_> = (0..3)
|
|
.map(|i| {
|
|
let mut t = make_row("s1", &format!("r{i}"), "al_rock_one", i + 1);
|
|
t.genre = Some("Rock".into());
|
|
t
|
|
})
|
|
.collect();
|
|
let mut rock_two = make_row("s1", "r3", "al_rock_two", 1);
|
|
rock_two.genre = Some("Rock".into());
|
|
let mut jazz = make_row("s1", "j1", "al_jazz", 1);
|
|
jazz.genre = Some("Jazz".into());
|
|
rock_one.push(rock_two);
|
|
rock_one.push(jazz);
|
|
TrackRepository::new(&store)
|
|
.upsert_batch(&rock_one)
|
|
.unwrap();
|
|
|
|
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
|
|
assert_eq!(counts.len(), 2);
|
|
assert_eq!(counts[0].value, "Rock");
|
|
assert_eq!(counts[0].album_count, 2);
|
|
assert_eq!(counts[0].song_count, 4);
|
|
assert_eq!(counts[1].value, "Jazz");
|
|
assert_eq!(counts[1].album_count, 1);
|
|
assert_eq!(counts[1].song_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn genre_album_counts_respect_library_scope() {
|
|
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 = Some("lib1".into());
|
|
let mut other = make_row("s1", "r2", "al_b", 1);
|
|
other.genre = Some("Rock".into());
|
|
other.library_id = Some("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].value, "Rock");
|
|
assert_eq!(counts[0].album_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]
|
|
fn reconcile_album_stars_clears_all_when_server_list_empty() {
|
|
let store = Arc::new(LibraryStore::open_in_memory());
|
|
store
|
|
.with_conn("misc", |c| {
|
|
c.execute(
|
|
"INSERT INTO album (server_id, id, name, starred_at, synced_at, raw_json) \
|
|
VALUES ('s1', 'al1', 'A', 5, 1, '{}')",
|
|
[],
|
|
)
|
|
})
|
|
.unwrap();
|
|
let rt = runtime(store.clone());
|
|
reconcile_album_stars(&rt, "s1", &[]).unwrap();
|
|
let starred_at: Option<i64> = store
|
|
.with_conn("misc", |c| {
|
|
c.query_row(
|
|
"SELECT starred_at FROM album WHERE server_id = 's1' AND id = 'al1'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
})
|
|
.unwrap();
|
|
assert!(starred_at.is_none());
|
|
}
|
|
}
|