diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index 247e7557..a2aebeef 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -2021,7 +2021,7 @@ where F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result, { let where_sql = w.where_sql(); - store.with_read_conn(|conn| { + store.with_scope_detail_read_conn(|conn| { let total = if skip_totals { 0u32 } else { diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 8d2628a9..d659e291 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -27,7 +27,7 @@ use crate::dto::{ LibraryMainstageAlbumsRequest, LibraryMainstageAlbumsResponse, LibraryScopeAlbumDetailRequest, LibraryScopeAlbumDetailResponse, LibraryScopeArtistDetailRequest, LibraryScopeArtistDetailResponse, LibraryScopeBrowseRequest, LibraryScopeBrowseResponse, - LibraryScopeListRequest, LibraryScopeSearchRequest, + LibraryScopeListRequest, LibraryScopeSearchRequest, LibraryStatisticsDto, LibraryStatisticsRequest, LibraryTrackDto, LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto, PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto, @@ -182,6 +182,18 @@ pub fn library_count_live_tracks( repo.count_live_tracks(&server_id) } +/// Index-backed Statistics aggregates for one or more selected servers/folders. +/// Deliberately does not merge equivalent albums/artists between scopes. +#[tauri::command] +#[specta::specta] +pub async fn library_scope_statistics( + runtime: State<'_, LibraryRuntime>, + request: LibraryStatisticsRequest, +) -> Result { + let store = Arc::clone(&runtime.store); + library_spawn_blocking(move || crate::statistics::query_statistics(&store, &request)).await +} + #[tauri::command] #[specta::specta] pub async fn library_get_status( @@ -596,9 +608,29 @@ pub async fn library_advanced_search( .as_ref() .map(|scopes| scopes.len()) .unwrap_or(if request.library_scope.is_some() { 1 } else { 0 }); + let trace_advanced_search = psysonic_core::logging::should_log_debug(); + let trace_entity_types = format!("{:?}", request.entity_types); + let trace_filters = request + .filters + .iter() + .map(|filter| format!("{}:{}", filter.field, filter.op.as_str())) + .collect::>(); + let trace_skip_totals = request.skip_totals; library_spawn_blocking(move || { let t0 = std::time::Instant::now(); let result = advanced_search::run_advanced_search(&store, &request); + if trace_advanced_search { + crate::app_deprintln!( + "[library-db][advanced-search] entity_types={} scope_count={} filters={:?} limit={} offset={} skip_totals={} elapsed_ms={}", + trace_entity_types, + trace_scope_count, + trace_filters, + trace_limit, + trace_offset, + trace_skip_totals, + t0.elapsed().as_millis(), + ); + } if trace_album_browse { let step_ms = t0.elapsed().as_millis(); let album_count = result.as_ref().map(|r| r.albums.len()).unwrap_or(0); diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index b55587b8..1d6ead69 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -720,6 +720,48 @@ pub struct LibraryScopePair { pub library_id: String, } +/// One selected server and its optional music-folder filter for aggregate index reads. +/// An empty `library_ids` list includes every indexed folder on that server. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct LibraryStatisticsScope { + pub server_id: String, + #[serde(default)] + pub library_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct LibraryStatisticsRequest { + pub scopes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct LibraryStatisticsGenreDto { + pub value: String, + pub song_count: i64, + pub album_count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct LibraryStatisticsFormatDto { + pub value: String, + pub song_count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct LibraryStatisticsDto { + pub artist_count: i64, + pub album_count: i64, + pub song_count: i64, + pub playtime_sec: i64, + pub genres: Vec, + pub formats: Vec, +} + /// Entity surface served by the cursor-based scoped browse engine. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -872,6 +914,10 @@ pub struct LibraryScopeArtistDetailRequest { pub scopes: Vec, pub artist_id: String, pub server_id: String, + /// Detail views need tracks for top songs; discography-only callers can skip + /// the extra scoped track query. + #[serde(default = "default_true")] + pub include_tracks: bool, } /// `library_scope_album_detail` response. diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index 3f3b1f79..5df68d5f 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -46,6 +46,7 @@ pub mod scope_merge; pub mod search; pub mod store; pub mod starred_browse; +pub mod statistics; pub mod sync; pub(crate) mod track_fts; diff --git a/src-tauri/crates/psysonic-library/src/scope_merge.rs b/src-tauri/crates/psysonic-library/src/scope_merge.rs index 828da35a..6cc09470 100644 --- a/src-tauri/crates/psysonic-library/src/scope_merge.rs +++ b/src-tauri/crates/psysonic-library/src/scope_merge.rs @@ -1877,7 +1877,7 @@ pub fn artist_detail( return Err("server_id and artist_id are required".into()); } - store.with_read_conn(|conn| { + store.with_scope_detail_read_conn(|conn| { let artist_key = lookup_artist_key(conn, server_id, artist_id)?; let mut candidates = fetch_artist_candidates( conn, @@ -1900,13 +1900,17 @@ pub fn artist_detail( server_id, artist_id, )?; - let tracks = fetch_scope_deduped_tracks_for_artist_key( - conn, - scopes, - artist_key.as_deref(), - server_id, - artist_id, - )?; + let tracks = if request.include_tracks { + fetch_scope_deduped_tracks_for_artist_key( + conn, + scopes, + artist_key.as_deref(), + server_id, + artist_id, + )? + } else { + Vec::new() + }; Ok(LibraryScopeArtistDetailResponse { artist, albums, @@ -1988,6 +1992,43 @@ mod tests { rebuild_cluster_keys(store, None).unwrap(); } + #[test] + fn artist_detail_can_skip_tracks_for_discography_only_callers() { + let store = LibraryStore::open_in_memory(); + seed_and_rebuild( + &store, + &[track( + "s1", + "t1", + "Song", + Some("Artist"), + "Album", + "alb1", + Some("art1"), + 200, + "lib-a", + Some(2024), + Some("Rock"), + None, + )], + ); + + let response = artist_detail( + &store, + &LibraryScopeArtistDetailRequest { + scopes: vec![scope_pair("s1", "lib-a")], + artist_id: "art1".into(), + server_id: "s1".into(), + include_tracks: false, + }, + ) + .unwrap(); + + assert_eq!(response.artist.id, "art1"); + assert_eq!(response.albums.len(), 1); + assert!(response.tracks.is_empty()); + } + #[test] fn dedup_collapses_same_album_and_priority_winner_flips() { let store = LibraryStore::open_in_memory(); diff --git a/src-tauri/crates/psysonic-library/src/statistics.rs b/src-tauri/crates/psysonic-library/src/statistics.rs new file mode 100644 index 00000000..d8f81753 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/statistics.rs @@ -0,0 +1,198 @@ +use std::collections::BTreeMap; + +use rusqlite::types::Value as SqlValue; + +use crate::dto::{ + LibraryStatisticsDto, LibraryStatisticsFormatDto, LibraryStatisticsGenreDto, + LibraryStatisticsRequest, LibraryStatisticsScope, +}; +use crate::store::LibraryStore; + +fn scope_where(scope: &LibraryStatisticsScope, alias: &str) -> (String, Vec) { + let mut clauses = vec![format!("{alias}.server_id = ?")]; + let mut params = vec![SqlValue::Text(scope.server_id.clone())]; + let library_ids: Vec<&str> = scope + .library_ids + .iter() + .map(String::as_str) + .filter(|id| !id.is_empty()) + .collect(); + if !library_ids.is_empty() { + clauses.push(format!( + "{alias}.library_id IN ({})", + std::iter::repeat_n("?", library_ids.len()).collect::>().join(", ") + )); + params.extend(library_ids.into_iter().map(|id| SqlValue::Text(id.to_string()))); + } + (clauses.join(" AND "), params) +} + +/// Aggregate the selected index rows without merging equivalent entities across +/// servers or music folders. This keeps multi-server Statistics bounded to SQL +/// aggregate reads instead of walking each server's REST album catalogue. +pub fn query_statistics( + store: &LibraryStore, + request: &LibraryStatisticsRequest, +) -> Result { + store.with_scope_detail_read_conn(|conn| { + let mut artist_count = 0_i64; + let mut album_count = 0_i64; + let mut song_count = 0_i64; + let mut playtime_sec = 0_i64; + let mut genres = BTreeMap::::new(); + let mut formats = BTreeMap::::new(); + + for scope in &request.scopes { + if scope.server_id.trim().is_empty() { + continue; + } + + let (track_where, track_params) = scope_where(scope, "t"); + let track_where = format!("{track_where} AND t.deleted = 0"); + let (tracks, duration): (i64, i64) = conn.query_row( + &format!( + "SELECT COUNT(*), COALESCE(SUM(t.duration_sec), 0) FROM track t WHERE {track_where}" + ), + rusqlite::params_from_iter(track_params.iter()), + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + song_count += tracks; + playtime_sec += duration; + + let format_sql = format!( + "SELECT COALESCE(NULLIF(UPPER(TRIM(t.suffix)), ''), 'Unknown'), COUNT(*) \ + FROM track t WHERE {track_where} \ + GROUP BY COALESCE(NULLIF(UPPER(TRIM(t.suffix)), ''), 'Unknown')" + ); + let mut format_stmt = conn.prepare(&format_sql)?; + let format_rows = format_stmt.query_map( + rusqlite::params_from_iter(track_params.iter()), + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + )?; + for format in format_rows { + let (value, songs) = format?; + *formats.entry(value).or_default() += songs; + } + + let (projection_where, projection_params) = scope_where(scope, "p"); + album_count += conn.query_row( + &format!("SELECT COUNT(*) FROM album_browse_projection p WHERE {projection_where}"), + rusqlite::params_from_iter(projection_params.iter()), + |row| row.get::<_, i64>(0), + )?; + + // Keep duplicate artists from separately selected folders/servers. + // The DISTINCT is only within one folder, where it defines an artist count. + let artist_sql = format!( + "SELECT COUNT(DISTINCT COALESCE(NULLIF(t.artist_id, ''), NULLIF(t.artist, ''))) \ + FROM track t WHERE {track_where} GROUP BY COALESCE(t.library_id, '')" + ); + let mut artist_stmt = conn.prepare(&artist_sql)?; + let artist_rows = artist_stmt.query_map( + rusqlite::params_from_iter(track_params.iter()), + |row| row.get::<_, i64>(0), + )?; + for count in artist_rows { + artist_count += count?; + } + + // `track_genre` keeps every indexed tag instead of the projection's + // representative album genre, so multi-tag albums are not discarded. + let genre_sql = format!( + "SELECT COALESCE(NULLIF(TRIM(g.genre), ''), ''), COUNT(*), \ + COUNT(DISTINCT COALESCE(NULLIF(g.album_id, ''), g.track_id)) \ + FROM track_genre g \ + INNER JOIN track t ON t.server_id = g.server_id AND t.id = g.track_id \ + WHERE {track_where} \ + GROUP BY COALESCE(NULLIF(TRIM(g.genre), ''), '')" + ); + let mut genre_stmt = conn.prepare(&genre_sql)?; + let genre_rows = genre_stmt.query_map( + rusqlite::params_from_iter(track_params.iter()), + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?)), + )?; + for genre in genre_rows { + let (value, songs, albums) = genre?; + let entry = genres.entry(value).or_default(); + entry.0 += songs; + entry.1 += albums; + } + } + + let mut genres: Vec = genres + .into_iter() + .map(|(value, (song_count, album_count))| LibraryStatisticsGenreDto { + value, + song_count, + album_count, + }) + .collect(); + genres.sort_by(|a, b| b.song_count.cmp(&a.song_count).then_with(|| a.value.cmp(&b.value))); + let mut formats: Vec = formats + .into_iter() + .map(|(value, song_count)| LibraryStatisticsFormatDto { value, song_count }) + .collect(); + formats.sort_by(|a, b| b.song_count.cmp(&a.song_count).then_with(|| a.value.cmp(&b.value))); + + Ok(LibraryStatisticsDto { + artist_count, + album_count, + song_count, + playtime_sec, + genres, + formats, + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sums_selected_servers_without_cross_server_deduplication() { + let store = LibraryStore::open_in_memory(); + store.with_conn("statistics.test", |conn| { + for (server, library, album, track, artist, duration, suffix, genre) in [ + ("s1", "one", "a1", "t1", "shared", 120, "flac", "Rock"), + ("s1", "two", "a2", "t2", "shared", 180, "mp3", "Jazz"), + ("s2", "one", "a1", "t3", "shared", 240, "flac", "Rock"), + ] { + conn.execute( + "INSERT INTO track (server_id, id, title, artist, album, album_id, library_id, duration_sec, suffix, synced_at, raw_json) \ + VALUES (?1, ?2, ?2, ?3, ?4, ?4, ?5, ?6, ?7, 1, '{}')", + rusqlite::params![server, track, artist, album, library, duration, suffix], + )?; + conn.execute( + "INSERT INTO track_genre (server_id, track_id, genre, album_id, library_id) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![server, track, genre, album, library], + )?; + conn.execute( + "INSERT INTO album_browse_projection \ + (server_id, library_id, album_id, name, song_count, duration_sec, genre, synced_at, representative_track_id) \ + VALUES (?1, ?2, ?3, ?3, 1, ?4, ?5, 1, ?6)", + rusqlite::params![server, library, album, duration, genre, track], + )?; + } + Ok(()) + }).unwrap(); + + let result = query_statistics(&store, &LibraryStatisticsRequest { + scopes: vec![ + LibraryStatisticsScope { server_id: "s1".into(), library_ids: vec!["one".into(), "two".into()] }, + LibraryStatisticsScope { server_id: "s2".into(), library_ids: vec![] }, + ], + }).unwrap(); + + assert_eq!(result.song_count, 3); + assert_eq!(result.album_count, 3); + assert_eq!(result.playtime_sec, 540); + assert_eq!(result.artist_count, 3, "each selected folder/server keeps its own artist row"); + assert_eq!(result.genres[0].value, "Rock"); + assert_eq!(result.genres[0].song_count, 2); + assert_eq!(result.formats[0].value, "FLAC"); + assert_eq!(result.formats[0].song_count, 2); + + } +} diff --git a/src-tauri/crates/psysonic-library/src/store.rs b/src-tauri/crates/psysonic-library/src/store.rs index cc628774..4741e4d8 100644 --- a/src-tauri/crates/psysonic-library/src/store.rs +++ b/src-tauri/crates/psysonic-library/src/store.rs @@ -177,6 +177,10 @@ pub struct LibraryStore { /// Dedicated read-only handle for Mainstage's wide chronological scans so /// genre counts cannot queue short browse and Favorites reads behind them. mainstage_read_conn: Mutex, + /// Dedicated reader for heavy derived reads. Scoped artist detail and grouped + /// album/artist queries can scan large track sets, so they must not stall + /// startup browse requests. + scope_detail_read_conn: Mutex, /// Current holder of `read_conn`, used only to attribute contention in /// targeted diagnostics such as the Favorites initial snapshot. read_op_owner: Mutex>, @@ -197,12 +201,13 @@ impl LibraryStore { } fn open_file(db_path: &Path) -> Result { - let (write_conn, read_conn, mainstage_read_conn) = + let (write_conn, read_conn, mainstage_read_conn, scope_detail_read_conn) = open_database_connections(db_path).map_err(|e| e.to_string())?; Ok(Self { write_conn: Mutex::new(write_conn), read_conn: Mutex::new(read_conn), mainstage_read_conn: Mutex::new(mainstage_read_conn), + scope_detail_read_conn: Mutex::new(scope_detail_read_conn), read_op_owner: Mutex::new(None), bulk_ingest_active: AtomicBool::new(false), swap_in_progress: AtomicBool::new(false), @@ -233,10 +238,15 @@ impl LibraryStore { configure_read_connection(&mainstage_read_conn).expect("mainstage read pragmas"); crate::identity::attach_cluster_read_memory(&mainstage_read_conn, &cluster_uri) .expect("cluster attach mainstage read"); + let scope_detail_read_conn = Connection::open(&uri).expect("in-memory scope detail read connection"); + configure_read_connection(&scope_detail_read_conn).expect("scope detail read pragmas"); + crate::identity::attach_cluster_read_memory(&scope_detail_read_conn, &cluster_uri) + .expect("cluster attach scope detail read"); Self { write_conn: Mutex::new(write_conn), read_conn: Mutex::new(read_conn), mainstage_read_conn: Mutex::new(mainstage_read_conn), + scope_detail_read_conn: Mutex::new(scope_detail_read_conn), read_op_owner: Mutex::new(None), bulk_ingest_active: AtomicBool::new(false), swap_in_progress: AtomicBool::new(false), @@ -295,6 +305,19 @@ impl LibraryStore { } } + fn lock_scope_detail_read_conn(&self) -> Result, String> { + if self.swap_in_progress() { + return Err("library database swap in progress".to_string()); + } + match self.scope_detail_read_conn.lock() { + Ok(guard) => Ok(guard), + Err(poisoned) => { + crate::app_eprintln!("[library-db] scope detail read lock was poisoned — recovering"); + Ok(poisoned.into_inner()) + } + } + } + /// Writer connection — sync ingest, migrations, mutations. /// /// `op` is logged on slow writes (`[library-db] SLOW write op=…`) — use a @@ -357,6 +380,16 @@ impl LibraryStore { run_conn_closure(&conn, f) } + /// Isolated reader for heavy derived reads, which can be much wider than + /// ordinary browse reads even when their result page is small. + pub(crate) fn with_scope_detail_read_conn( + &self, + f: impl FnOnce(&Connection) -> rusqlite::Result, + ) -> Result { + let conn = self.lock_scope_detail_read_conn()?; + run_conn_closure(&conn, f) + } + fn read_op_owner(&self) -> Option { match self.read_op_owner.lock() { Ok(owner) => *owner, @@ -425,16 +458,23 @@ impl LibraryStore { let mut mainstage_read_conn = self.mainstage_read_conn.lock().map_err(|_| { "library store mainstage read lock poisoned during database swap".to_string() })?; + let mut scope_detail_read_conn = self.scope_detail_read_conn.lock().map_err(|_| { + "library store scope detail read lock poisoned during database swap".to_string() + })?; let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; let mainstage_read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; + let scope_detail_read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; let old_write = std::mem::replace(&mut *write_conn, write_tmp); let old_read = std::mem::replace(&mut *read_conn, read_tmp); let old_mainstage_read = std::mem::replace(&mut *mainstage_read_conn, mainstage_read_tmp); + let old_scope_detail_read = + std::mem::replace(&mut *scope_detail_read_conn, scope_detail_read_tmp); drop(old_write); drop(old_read); drop(old_mainstage_read); + drop(old_scope_detail_read); let backup = active_path.with_file_name(format!( "{}.backup-pre-indexkey", @@ -457,8 +497,9 @@ impl LibraryStore { } drop(read_conn); drop(mainstage_read_conn); + drop(scope_detail_read_conn); drop(write_conn); - let (reopened_write, reopened_read, reopened_mainstage_read) = open_database_connections(active_path) + let (reopened_write, reopened_read, reopened_mainstage_read, reopened_scope_detail_read) = open_database_connections(active_path) .map_err(|e| format!("library swap reopen failed after rename error: {e}"))?; let mut write_conn = self.write_conn.lock().map_err(|_| { "library store write lock poisoned during database swap".to_string() @@ -469,15 +510,20 @@ impl LibraryStore { let mut mainstage_read_conn = self.mainstage_read_conn.lock().map_err(|_| { "library store mainstage read lock poisoned during database swap".to_string() })?; + let mut scope_detail_read_conn = self.scope_detail_read_conn.lock().map_err(|_| { + "library store scope detail read lock poisoned during database swap".to_string() + })?; *write_conn = reopened_write; *read_conn = reopened_read; *mainstage_read_conn = reopened_mainstage_read; + *scope_detail_read_conn = reopened_scope_detail_read; swap_guard.release(); return Err(err.to_string()); } drop(read_conn); drop(mainstage_read_conn); + drop(scope_detail_read_conn); drop(write_conn); // The freshly-installed library file has different track ids; the @@ -498,12 +544,16 @@ impl LibraryStore { let mut mainstage_read_conn = self.mainstage_read_conn.lock().map_err(|_| { "library store mainstage read lock poisoned during database swap".to_string() })?; + let mut scope_detail_read_conn = self.scope_detail_read_conn.lock().map_err(|_| { + "library store scope detail read lock poisoned during database swap".to_string() + })?; match reopen { - Ok((reopened_write, reopened_read, reopened_mainstage_read)) => { + Ok((reopened_write, reopened_read, reopened_mainstage_read, reopened_scope_detail_read)) => { *write_conn = reopened_write; *read_conn = reopened_read; *mainstage_read_conn = reopened_mainstage_read; + *scope_detail_read_conn = reopened_scope_detail_read; swap_guard.release(); Ok(Some(backup)) } @@ -516,11 +566,12 @@ impl LibraryStore { let _ = move_sidecar(&backup, active_path, "-wal"); let _ = move_sidecar(&backup, active_path, "-shm"); } - let (reopened_write, reopened_read, reopened_mainstage_read) = open_database_connections(active_path) + let (reopened_write, reopened_read, reopened_mainstage_read, reopened_scope_detail_read) = open_database_connections(active_path) .map_err(|e| format!("library swap reopen failed after revert: {e}"))?; *write_conn = reopened_write; *read_conn = reopened_read; *mainstage_read_conn = reopened_mainstage_read; + *scope_detail_read_conn = reopened_scope_detail_read; swap_guard.release(); Err(format!("library swap failed: {open_err}")) } @@ -538,16 +589,23 @@ impl LibraryStore { let mut mainstage_read_conn = self.mainstage_read_conn.lock().map_err(|_| { "library store mainstage read lock poisoned during database restore".to_string() })?; + let mut scope_detail_read_conn = self.scope_detail_read_conn.lock().map_err(|_| { + "library store scope detail read lock poisoned during database restore".to_string() + })?; let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; let mainstage_read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; + let scope_detail_read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?; let old_write = std::mem::replace(&mut *write_conn, write_tmp); let old_read = std::mem::replace(&mut *read_conn, read_tmp); let old_mainstage_read = std::mem::replace(&mut *mainstage_read_conn, mainstage_read_tmp); + let old_scope_detail_read = + std::mem::replace(&mut *scope_detail_read_conn, scope_detail_read_tmp); drop(old_write); drop(old_read); drop(old_mainstage_read); + drop(old_scope_detail_read); if active_path.exists() { remove_db_with_sidecars(active_path)?; @@ -560,13 +618,14 @@ impl LibraryStore { drop(read_conn); drop(mainstage_read_conn); + drop(scope_detail_read_conn); drop(write_conn); // Restored library file → the fixed-name identity sidecar is stale; drop // it so keys rebuild lazily against the restored content (see swap). crate::identity::remove_cluster_files_for_library(active_path); - let (reopened_write, reopened_read, reopened_mainstage_read) = + let (reopened_write, reopened_read, reopened_mainstage_read, reopened_scope_detail_read) = open_database_connections(active_path).map_err(|e| e.to_string())?; let mut write_conn = self.write_conn.lock().map_err(|_| { @@ -578,9 +637,13 @@ impl LibraryStore { let mut mainstage_read_conn = self.mainstage_read_conn.lock().map_err(|_| { "library store mainstage read lock poisoned during database restore".to_string() })?; + let mut scope_detail_read_conn = self.scope_detail_read_conn.lock().map_err(|_| { + "library store scope detail read lock poisoned during database restore".to_string() + })?; *write_conn = reopened_write; *read_conn = reopened_read; *mainstage_read_conn = reopened_mainstage_read; + *scope_detail_read_conn = reopened_scope_detail_read; swap_guard.release(); Ok(()) } @@ -814,7 +877,9 @@ fn checkpoint_wal_conn(conn: &Connection, op: &str) -> rusqlite::Result<()> { /// Open write + read handles after migrations, one-time repairs, WAL checkpoint, /// and cluster identity DB attach. -fn open_database_connections(db_path: &Path) -> rusqlite::Result<(Connection, Connection, Connection)> { +fn open_database_connections( + db_path: &Path, +) -> rusqlite::Result<(Connection, Connection, Connection, Connection)> { let write_conn = Connection::open(db_path)?; configure_write_connection(&write_conn)?; prepare_write_connection_for_open(&write_conn)?; @@ -823,6 +888,8 @@ fn open_database_connections(db_path: &Path) -> rusqlite::Result<(Connection, Co configure_read_connection(&read_conn)?; let mainstage_read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; configure_read_connection(&mainstage_read_conn)?; + let scope_detail_read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + configure_read_connection(&scope_detail_read_conn)?; // The identity sidecar is fully rebuildable; a corrupt/unwritable // `library-cluster.db` must never prevent the library itself from opening. @@ -839,7 +906,12 @@ fn open_database_connections(db_path: &Path) -> rusqlite::Result<(Connection, Co "[library-db] mainstage identity sidecar unavailable, multi-library dedup disabled: {e}" ); } - Ok((write_conn, read_conn, mainstage_read_conn)) + if let Err(e) = crate::identity::attach_cluster_read_file(&scope_detail_read_conn, db_path) { + crate::app_eprintln!( + "[library-db] scope detail identity sidecar unavailable, multi-library dedup disabled: {e}" + ); + } + Ok((write_conn, read_conn, mainstage_read_conn, scope_detail_read_conn)) } fn prepare_write_connection_for_open(conn: &Connection) -> rusqlite::Result<()> { @@ -1321,6 +1393,35 @@ mod tests { mainstage.join().expect("mainstage reader thread"); } + #[test] + fn scope_detail_reader_does_not_block_the_shared_browse_reader() { + let store = std::sync::Arc::new(LibraryStore::open_in_memory()); + let (started_tx, started_rx) = mpsc::channel(); + let detail_store = std::sync::Arc::clone(&store); + let detail = std::thread::spawn(move || { + detail_store + .with_scope_detail_read_conn(|_| { + started_tx.send(()).expect("signal scope detail read start"); + std::thread::sleep(Duration::from_millis(100)); + Ok(()) + }) + .unwrap(); + }); + started_rx.recv().expect("wait for scope detail read"); + + let started_at = std::time::Instant::now(); + let value: i64 = store + .with_read_conn(|conn| conn.query_row("SELECT 1", [], |row| row.get(0))) + .unwrap(); + + assert_eq!(value, 1); + assert!( + started_at.elapsed() < Duration::from_millis(50), + "shared read was blocked by the scope detail reader" + ); + detail.join().expect("scope detail reader thread"); + } + #[test] fn open_in_memory_creates_all_expected_tables() { let store = LibraryStore::open_in_memory(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 98acd629..cc98d547 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -117,6 +117,7 @@ fn specta_builder() -> tauri_specta::Builder { psysonic_library::commands::library_analysis_progress, psysonic_library::commands::library_count_live_tracks, psysonic_library::commands::library_get_status, + psysonic_library::commands::library_scope_statistics, psysonic_library::commands::library_get_artifact, psysonic_library::commands::library_get_entity_user_ratings, psysonic_library::commands::library_get_facts, @@ -1037,6 +1038,7 @@ pub fn run() { psysonic_analysis::commands::analysis_get_backfill_queue_stats, psysonic_analysis::commands::analysis_prune_pending_to_track_ids, psysonic_library::commands::library_get_status, + psysonic_library::commands::library_scope_statistics, psysonic_library::commands::library_search, psysonic_library::commands::library_live_search, psysonic_library::commands::library_advanced_search, diff --git a/src/features/nowPlaying/utils/nowPlayingMetadataResolve.ts b/src/features/nowPlaying/utils/nowPlayingMetadataResolve.ts index 9918a975..52d0e882 100644 --- a/src/features/nowPlaying/utils/nowPlayingMetadataResolve.ts +++ b/src/features/nowPlaying/utils/nowPlayingMetadataResolve.ts @@ -22,7 +22,11 @@ import { getAlbumForServer } from '@/lib/api/subsonicLibrary'; import { resolveSongMetaIndexFirst } from '@/lib/library/resolveSongMetaIndexFirst'; import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes'; import { shouldAttemptSubsonicForServer } from '@/lib/network/subsonicNetworkGuard'; -import { loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex } from '@/features/offline'; +import { + loadAlbumFromLibraryIndex, + loadArtistFromLibraryIndex, + loadArtistTracksFromLibraryIndex, +} from '@/features/offline'; import { trackToSong } from '@/lib/library/advancedSearchLocal'; import { libraryIsReady } from '@/lib/library/libraryReady'; @@ -62,10 +66,10 @@ export async function resolveNpDiscography( } /** - * Most played — derive from the artist's own discography albums (same bucket the - * discography card uses), sorted by play_count. This is deterministic: it can't - * pull the wrong artist's tracks the way an FTS-on-name query could. Network - * `getTopSongsForServer` is the fallback on index miss / off / unreachable. + * Most played — load the artist's own scoped tracks in one local query and sort + * by play_count. The all-library fallback keeps the older album-by-album path. + * Both are deterministic by artist id; network `getTopSongsForServer` is the + * fallback on index miss / off / unreachable. */ export async function resolveNpTopSongs( serverId: string, @@ -74,17 +78,25 @@ export async function resolveNpTopSongs( ): Promise { if (artistId && await libraryIsReady(serverId)) { try { - const hit = await loadArtistFromLibraryIndex(serverId, artistId); - if (hit && hit.albums.length > 0) { - const perAlbum = await Promise.all( - hit.albums.map(a => libraryGetTracksByAlbum(serverId, a.id).catch(() => [])), - ); - const songs = perAlbum - .flat() - .map(trackToSong) + const scopedTracks = await loadArtistTracksFromLibraryIndex(serverId, artistId); + if (scopedTracks !== null) { + const songs = scopedTracks .sort((a, b) => (b.playCount ?? 0) - (a.playCount ?? 0)) .slice(0, TOP_SONGS_LIMIT); if (songs.length > 0) return songs; + } else { + const hit = await loadArtistFromLibraryIndex(serverId, artistId); + if (hit && hit.albums.length > 0) { + const perAlbum = await Promise.all( + hit.albums.map(a => libraryGetTracksByAlbum(serverId, a.id).catch(() => [])), + ); + const songs = perAlbum + .flat() + .map(trackToSong) + .sort((a, b) => (b.playCount ?? 0) - (a.playCount ?? 0)) + .slice(0, TOP_SONGS_LIMIT); + if (songs.length > 0) return songs; + } } } catch { /* index error → network fallback */ } } diff --git a/src/features/offline/utils/offlineLibraryIndexLoad.test.ts b/src/features/offline/utils/offlineLibraryIndexLoad.test.ts new file mode 100644 index 00000000..d9bf89f5 --- /dev/null +++ b/src/features/offline/utils/offlineLibraryIndexLoad.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + libraryAdvancedSearchMock, + libraryGetTracksByAlbumMock, + libraryScopeArtistDetailMock, + libraryScopeForServerMock, + libraryScopePairsForServerMock, +} = vi.hoisted(() => ({ + libraryAdvancedSearchMock: vi.fn(), + libraryGetTracksByAlbumMock: vi.fn(), + libraryScopeArtistDetailMock: vi.fn(), + libraryScopeForServerMock: vi.fn(), + libraryScopePairsForServerMock: vi.fn(), +})); + +vi.mock('@/lib/api/library', () => ({ + libraryAdvancedSearch: libraryAdvancedSearchMock, + libraryGetTracksByAlbum: libraryGetTracksByAlbumMock, + libraryScopeArtistDetail: libraryScopeArtistDetailMock, +})); + +vi.mock('@/lib/api/subsonicClient', () => ({ + libraryScopeForServer: libraryScopeForServerMock, + libraryScopePairsForServer: libraryScopePairsForServerMock, +})); + +import { + loadAlbumFromLibraryIndex, + loadArtistFromLibraryIndex, + loadArtistTracksFromLibraryIndex, +} from './offlineLibraryIndexLoad'; + +describe('loadArtistFromLibraryIndex', () => { + beforeEach(() => { + libraryAdvancedSearchMock.mockReset(); + libraryGetTracksByAlbumMock.mockReset(); + libraryScopeArtistDetailMock.mockReset(); + libraryScopeForServerMock.mockReset(); + libraryScopePairsForServerMock.mockReset(); + }); + + it('uses one scoped artist-detail request for concurrent loads', async () => { + const scopes = [{ serverId: 'srv-1', libraryId: 'lib-1' }]; + libraryScopePairsForServerMock.mockReturnValue(scopes); + libraryScopeArtistDetailMock.mockResolvedValue({ + artist: { id: 'artist-1', name: 'Artist', albumCount: 1, serverId: 'srv-1' }, + albums: [{ id: 'album-1', name: 'Album', artist: 'Artist', artistId: 'artist-1', serverId: 'srv-1' }], + tracks: [], + }); + + const [first, second] = await Promise.all([ + loadArtistFromLibraryIndex('srv-1', 'artist-1'), + loadArtistFromLibraryIndex('srv-1', 'artist-1'), + ]); + + expect(libraryScopeArtistDetailMock).toHaveBeenCalledOnce(); + expect(libraryScopeArtistDetailMock).toHaveBeenCalledWith('srv-1', { + scopes, + artistId: 'artist-1', + serverId: 'srv-1', + includeTracks: false, + }); + expect(libraryAdvancedSearchMock).not.toHaveBeenCalled(); + expect(first).toEqual(second); + expect(first?.albums).toHaveLength(1); + }); + + it('deduplicates album lookup and skips its unused total', async () => { + libraryGetTracksByAlbumMock.mockResolvedValue([ + { id: 'track-1', title: 'Track', album: 'Album', artistId: 'artist-1', serverId: 'srv-1' }, + ]); + libraryAdvancedSearchMock.mockResolvedValue({ + albums: [{ id: 'album-1', name: 'Album', artistId: 'artist-1', serverId: 'srv-1' }], + }); + + const [first, second] = await Promise.all([ + loadAlbumFromLibraryIndex('srv-1', 'album-1'), + loadAlbumFromLibraryIndex('srv-1', 'album-1'), + ]); + + expect(libraryGetTracksByAlbumMock).toHaveBeenCalledOnce(); + expect(libraryAdvancedSearchMock).toHaveBeenCalledWith({ + serverId: 'srv-1', + entityTypes: ['album'], + restrictAlbumIds: ['album-1'], + limit: 1, + skipTotals: true, + }); + expect(first).toEqual(second); + }); + + it('skips totals on the legacy all-library fallback', async () => { + libraryScopePairsForServerMock.mockReturnValue([]); + libraryScopeForServerMock.mockReturnValue(null); + libraryAdvancedSearchMock.mockResolvedValue({ artists: [], albums: [] }); + + await expect(loadArtistFromLibraryIndex('srv-1', 'artist-1')).resolves.toBeNull(); + + expect(libraryAdvancedSearchMock).toHaveBeenCalledWith({ + serverId: 'srv-1', + entityTypes: ['album', 'artist'], + limit: 10_000, + skipTotals: true, + }); + }); + + it('loads scoped artist tracks once instead of querying each album', async () => { + const scopes = [{ serverId: 'srv-1', libraryId: 'lib-1' }]; + libraryScopePairsForServerMock.mockReturnValue(scopes); + libraryScopeArtistDetailMock.mockResolvedValue({ + artist: { id: 'artist-1', name: 'Artist', albumCount: 1, serverId: 'srv-1' }, + albums: [], + tracks: [{ id: 'track-1', title: 'Track', serverId: 'srv-1', syncedAt: 0, rawJson: {} }], + }); + + const [first, second] = await Promise.all([ + loadArtistTracksFromLibraryIndex('srv-1', 'artist-1'), + loadArtistTracksFromLibraryIndex('srv-1', 'artist-1'), + ]); + + expect(libraryScopeArtistDetailMock).toHaveBeenCalledOnce(); + expect(libraryScopeArtistDetailMock).toHaveBeenCalledWith('srv-1', { + scopes, + artistId: 'artist-1', + serverId: 'srv-1', + includeTracks: true, + }); + expect(first).toEqual(second); + expect(first?.map(track => track.id)).toEqual(['track-1']); + }); +}); diff --git a/src/features/offline/utils/offlineLibraryIndexLoad.ts b/src/features/offline/utils/offlineLibraryIndexLoad.ts index c734269c..285b00aa 100644 --- a/src/features/offline/utils/offlineLibraryIndexLoad.ts +++ b/src/features/offline/utils/offlineLibraryIndexLoad.ts @@ -1,4 +1,8 @@ -import { libraryAdvancedSearch, libraryGetTracksByAlbum } from '@/lib/api/library'; +import { + libraryAdvancedSearch, + libraryGetTracksByAlbum, + libraryScopeArtistDetail, +} from '@/lib/api/library'; import { libraryScopeForServer, libraryScopePairsForServer } from '@/lib/api/subsonicClient'; import type { SubsonicAlbum, @@ -11,10 +15,39 @@ import { trackToSong, } from '@/lib/library/advancedSearchLocal'; -export async function loadAlbumFromLibraryIndex( +type ArtistIndexLoad = { artist: SubsonicArtist; albums: SubsonicAlbum[] } | null; +type AlbumIndexLoad = { album: SubsonicAlbum; songs: SubsonicSong[] } | null; + +const albumIndexLoads = new Map>(); +const artistIndexLoads = new Map>(); +const artistTrackLoads = new Map>(); + +function artistScopes(serverId: string) { + const selectedScopes = libraryScopePairsForServer(serverId); + const fallbackScope = libraryScopeForServer(serverId); + return selectedScopes.length > 0 + ? selectedScopes + : fallbackScope ? [{ serverId, libraryId: fallbackScope }] : []; +} + +export function loadAlbumFromLibraryIndex( serverId: string, albumId: string, -): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> { +): Promise { + const key = `${serverId}\u0000${albumId}`; + const existing = albumIndexLoads.get(key); + if (existing) return existing; + + const load = loadAlbumFromLibraryIndexImpl(serverId, albumId) + .finally(() => albumIndexLoads.delete(key)); + albumIndexLoads.set(key, load); + return load; +} + +async function loadAlbumFromLibraryIndexImpl( + serverId: string, + albumId: string, +): Promise { const tracks = await libraryGetTracksByAlbum(serverId, albumId); if (tracks.length === 0) return null; @@ -24,6 +57,7 @@ export async function loadAlbumFromLibraryIndex( entityTypes: ['album'], restrictAlbumIds: [albumId], limit: 1, + skipTotals: true, }); const albumDto = albumSearch.albums[0]; if (albumDto) { @@ -61,13 +95,34 @@ export async function loadAlbumFromLibraryIndex( export async function loadArtistFromLibraryIndex( serverId: string, artistId: string, -): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> { +): Promise { + const scopes = artistScopes(serverId); + + if (scopes.length > 0) { + const key = `${serverId}\u0000${artistId}\u0000${scopes.map(scope => `${scope.serverId}:${scope.libraryId}`).join(',')}`; + const existing = artistIndexLoads.get(key); + if (existing) return existing; + + const load = libraryScopeArtistDetail(serverId, { + scopes, + artistId, + serverId, + includeTracks: false, + }) + .then(response => response.artist.id ? { + artist: artistToArtist(response.artist), + albums: response.albums.map(albumToAlbum).map(album => ({ ...album, serverId })), + } : null) + .finally(() => artistIndexLoads.delete(key)); + artistIndexLoads.set(key, load); + return load; + } + const response = await libraryAdvancedSearch({ serverId, - libraryScope: libraryScopeForServer(serverId) ?? undefined, - libraryScopes: libraryScopePairsForServer(serverId), entityTypes: ['album', 'artist'], limit: 10_000, + skipTotals: true, }); const albums = response.albums .filter(a => a.artistId === artistId) @@ -93,3 +148,27 @@ export async function loadArtistFromLibraryIndex( albums, }; } + +/** Scoped artist tracks for Now Playing. Avoids one indexed album read per discography entry. */ +export async function loadArtistTracksFromLibraryIndex( + serverId: string, + artistId: string, +): Promise { + const scopes = artistScopes(serverId); + if (scopes.length === 0) return null; + + const key = `${serverId}\u0000${artistId}\u0000${scopes.map(scope => `${scope.serverId}:${scope.libraryId}`).join(',')}`; + const existing = artistTrackLoads.get(key); + if (existing) return existing; + + const load = libraryScopeArtistDetail(serverId, { + scopes, + artistId, + serverId, + includeTracks: true, + }) + .then(response => response.artist.id ? response.tracks.map(trackToSong) : null) + .finally(() => artistTrackLoads.delete(key)); + artistTrackLoads.set(key, load); + return load; +} diff --git a/src/features/offline/utils/offlineLocalBrowse.test.ts b/src/features/offline/utils/offlineLocalBrowse.test.ts index 75d9c7ac..fb7889c4 100644 --- a/src/features/offline/utils/offlineLocalBrowse.test.ts +++ b/src/features/offline/utils/offlineLocalBrowse.test.ts @@ -13,6 +13,7 @@ import { fetchOfflineLocalBrowsableSongPage, fetchOfflineLocalStarredArtists, invalidateBrowsableLocalTrackCache, + loadAlbumFromLocalPlayback, loadArtistFromLocalPlayback, offlineLocalBrowseEnabled, resetBrowsableLocalTrackCacheForTests, @@ -22,8 +23,13 @@ import { } from '@/features/offline/utils/offlineLocalBrowse'; import { resetOfflineLocalLibrarySyncRevisionForTests, bumpOfflineLocalLibrarySyncRevisionForTests } from '@/store/offlineLocalLibrarySyncRevision'; -const { libraryGetTracksBatchChunkedMock, libraryAdvancedSearchMock } = vi.hoisted(() => ({ +const { + libraryGetTracksBatchChunkedMock, + libraryGetTracksByAlbumMock, + libraryAdvancedSearchMock, +} = vi.hoisted(() => ({ libraryGetTracksBatchChunkedMock: vi.fn(async (): Promise => []), + libraryGetTracksByAlbumMock: vi.fn(async (): Promise => []), libraryAdvancedSearchMock: vi.fn(async () => ({ source: 'local' as const, albums: [], @@ -38,7 +44,7 @@ const { libraryGetTracksBatchChunkedMock, libraryAdvancedSearchMock } = vi.hoist vi.mock('@/lib/api/library', () => ({ libraryGetTracksBatchChunked: libraryGetTracksBatchChunkedMock, - libraryGetTracksByAlbum: vi.fn(async () => []), + libraryGetTracksByAlbum: libraryGetTracksByAlbumMock, libraryAdvancedSearch: libraryAdvancedSearchMock, subscribeLibrarySyncIdle: vi.fn(async () => () => {}), })); @@ -55,6 +61,8 @@ describe('offlineLocalBrowse', () => { resetOfflineLocalLibrarySyncRevisionForTests(); libraryGetTracksBatchChunkedMock.mockReset(); libraryGetTracksBatchChunkedMock.mockResolvedValue([]); + libraryGetTracksByAlbumMock.mockReset(); + libraryGetTracksByAlbumMock.mockResolvedValue([]); libraryAdvancedSearchMock.mockClear(); }); @@ -97,6 +105,42 @@ describe('offlineLocalBrowse', () => { expect(offlineLocalBrowseEnabled('srv-a')).toBe(true); }); + it('skips the unused total when loading a local album by id', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/library/a.test/a/al/t1.mp3', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'library', + cachedAt: 1, + suffix: 'mp3', + }, + }, + }); + libraryGetTracksByAlbumMock.mockResolvedValue([{ + id: 't1', title: 'Track', album: 'Album', artist: 'Artist', artistId: 'ar1', + durationSec: 1, serverId: 'srv-a', syncedAt: 0, rawJson: {}, + }]); + libraryAdvancedSearchMock.mockResolvedValue({ + source: 'local', + albums: [{ id: 'al1', name: 'Album', artist: 'Artist', artistId: 'ar1', serverId: 'srv-a', syncedAt: 0, rawJson: {} }], + artists: [], tracks: [], totals: { tracks: 0, albums: 1, artists: 0 }, appliedFilters: [], + } as never); + + await loadAlbumFromLocalPlayback('srv-a', 'al1'); + + expect(libraryAdvancedSearchMock).toHaveBeenCalledWith({ + serverId: 'srv-a', + entityTypes: ['album'], + restrictAlbumIds: ['al1'], + limit: 1, + skipTotals: true, + }); + }); + it('fetchOfflineLocalBrowsableSongPage pages local bytes alphabetically', async () => { useLocalPlaybackStore.setState({ entries: { diff --git a/src/features/offline/utils/offlineLocalBrowse.ts b/src/features/offline/utils/offlineLocalBrowse.ts index 8c983778..7b5ce399 100644 --- a/src/features/offline/utils/offlineLocalBrowse.ts +++ b/src/features/offline/utils/offlineLocalBrowse.ts @@ -509,6 +509,7 @@ export async function loadAlbumFromLocalPlayback( entityTypes: ['album'], restrictAlbumIds: [albumId], limit: 1, + skipTotals: true, }).catch(() => null); const albumDto = albumSearch?.albums[0]; const album = albumDto diff --git a/src/features/stats/components/StatsExportModal.tsx b/src/features/stats/components/StatsExportModal.tsx index 63de3348..8477961b 100644 --- a/src/features/stats/components/StatsExportModal.tsx +++ b/src/features/stats/components/StatsExportModal.tsx @@ -1,4 +1,3 @@ -import { getAlbumList } from '@/lib/api/subsonicLibrary'; import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -16,16 +15,13 @@ import { interface Props { open: boolean; - /** Pre-loaded albums (e.g. from the statistics page). The modal will fetch - * more on open if this list is shorter than 25 (max grid 5×5). */ + /** Pre-loaded albums from the active Statistics scope. */ albums: SubsonicAlbum[]; /** Footer-right meta string, e.g. "Most Played" or a date. */ meta?: string; onClose: () => void; } -const MAX_NEEDED = 25; // 5 × 5 grid - const FORMATS: { key: ExportFormat; ratioBox: { w: number; h: number } }[] = [ { key: 'story', ratioBox: { w: 36, h: 64 } }, { key: 'square', ratioBox: { w: 50, h: 50 } }, @@ -39,39 +35,13 @@ export default function StatsExportModal({ open, albums, meta, onClose }: Props) const [format, setFormat] = useState('square'); const [gridSize, setGridSize] = useState(3); const [saving, setSaving] = useState(false); - const [topUpAlbums, setTopUpAlbums] = useState(null); const previewRef = useRef(null); const previewSeqRef = useRef(0); - const effectiveAlbums = topUpAlbums ?? albums; + const effectiveAlbums = albums; const required = gridSize * gridSize; const enoughAlbums = effectiveAlbums.length >= required; - // On open: if the caller-provided list is shorter than the largest grid, - // fetch up to 25 in the background so the user can pick 4×4 / 5×5 even - // when the entry surface only loaded a few albums. - useEffect(() => { - if (!open) return; - if (albums.length >= MAX_NEEDED) { - // React Compiler set-state-in-effect rule: local state synced with the already-available `albums` prop when the modal opens (the async top-up below is skipped). - // eslint-disable-next-line react-hooks/set-state-in-effect - setTopUpAlbums(albums); - return; - } - setTopUpAlbums(null); - let cancelled = false; - (async () => { - try { - const more = await getAlbumList('frequent', MAX_NEEDED, 0); - if (cancelled) return; - setTopUpAlbums(more.length > albums.length ? more : albums); - } catch { - if (!cancelled) setTopUpAlbums(albums); - } - })(); - return () => { cancelled = true; }; - }, [open, albums]); - const title = t('statistics.exportFooterLabel'); // Live preview: re-renders on format / gridSize / albums changes. diff --git a/src/features/stats/pages/Statistics.tsx b/src/features/stats/pages/Statistics.tsx index 7e6b2fe8..a8084407 100644 --- a/src/features/stats/pages/Statistics.tsx +++ b/src/features/stats/pages/Statistics.tsx @@ -1,7 +1,6 @@ -import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchStatisticsOverview } from '@/lib/api/subsonicStatistics'; -import { getAlbumList } from '@/lib/api/subsonicLibrary'; +import { fetchStatisticsLibraryAggregates, fetchStatisticsOverview } from '@/lib/api/subsonicStatistics'; import type { SubsonicAlbum, SubsonicGenre } from '@/lib/api/subsonicTypes'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Share2 } from 'lucide-react'; import { formatHumanHoursMinutes } from '@/lib/format/formatHumanDuration'; @@ -35,6 +34,16 @@ const PERIODS: { key: StatsPeriod; label: string }[] = [ { key: 'overall', label: 'lfmPeriodOverall' }, ]; +function StatisticsLoadingDots({ label }: { label: string }) { + return ( + + + + + + ); +} + export default function Statistics() { const { t } = useTranslation(); const location = useLocation(); @@ -52,12 +61,13 @@ export default function Statistics() { const [totalSongs, setTotalSongs] = useState(null); const [totalAlbums, setTotalAlbums] = useState(null); const [genres, setGenres] = useState([]); - const [loading, setLoading] = useState(true); + const [overviewLoading, setOverviewLoading] = useState(true); + const [aggregateLoading, setAggregateLoading] = useState(true); const [totalPlaytime, setTotalPlaytime] = useState(null); const [playtimeCapped, setPlaytimeCapped] = useState(false); const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null); - const [formatSampleSize, setFormatSampleSize] = useState(0); + const [formatTrackCount, setFormatTrackCount] = useState(0); const [exportOpen, setExportOpen] = useState(false); @@ -84,18 +94,25 @@ export default function Statistics() { if (offlineBrowseActive || isPlayerStats) { // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. // eslint-disable-next-line react-hooks/set-state-in-effect - setLoading(false); + setOverviewLoading(false); return; } + setOverviewLoading(true); + const startedAt = performance.now(); fetchStatisticsOverview() .then(d => { setRecent(d.recent); setFrequent(d.frequent); setHighest(d.highest); - setArtistCount(d.artistCount); - setLoading(false); + setOverviewLoading(false); + console.info('[statistics] overview loaded', { + elapsedMs: Math.round(performance.now() - startedAt), + recent: d.recent.length, + frequent: d.frequent.length, + highest: d.highest.length, + }); }) - .catch(() => setLoading(false)); + .catch(() => setOverviewLoading(false)); }, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]); // Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch) @@ -104,51 +121,54 @@ export default function Statistics() { let cancelled = false; // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. // eslint-disable-next-line react-hooks/set-state-in-effect + setArtistCount(null); setTotalPlaytime(null); setTotalAlbums(null); setTotalSongs(null); setPlaytimeCapped(false); setGenres([]); + setFormatData(null); + setFormatTrackCount(0); + setAggregateLoading(true); + const startedAt = performance.now(); (async () => { try { const agg = await fetchStatisticsLibraryAggregates(); if (cancelled) return; + setArtistCount(agg.artistCount); setTotalPlaytime(agg.playtimeSec); setTotalAlbums(agg.albumsCounted); setTotalSongs(agg.songsCounted); setPlaytimeCapped(agg.capped); setGenres(agg.genres); + setFormatData(agg.formats); + setFormatTrackCount(agg.songsCounted); + setAggregateLoading(false); + console.info('[statistics] index aggregates loaded', { + elapsedMs: Math.round(performance.now() - startedAt), + artists: agg.artistCount, + albums: agg.albumsCounted, + songs: agg.songsCounted, + genres: agg.genres.length, + formats: agg.formats.length, + }); } catch { if (!cancelled) { + setArtistCount(0); setTotalPlaytime(0); setTotalAlbums(0); setTotalSongs(0); setPlaytimeCapped(false); setGenres([]); + setFormatData([]); + setFormatTrackCount(0); + setAggregateLoading(false); } } })(); return () => { cancelled = true; }; }, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]); - // Background: format distribution (cached random sample, same TTL as other Statistics fetches) - useEffect(() => { - if (offlineBrowseActive || isPlayerStats) return; - let cancelled = false; - // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. - // eslint-disable-next-line react-hooks/set-state-in-effect - setFormatData(null); - setFormatSampleSize(0); - fetchStatisticsFormatSample() - .then(s => { - if (cancelled) return; - setFormatData(s.rows); - setFormatSampleSize(s.sampleSize); - }) - .catch(() => {}); - return () => { cancelled = true; }; - }, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]); - useEffect(() => { if (offlineBrowseActive || isPlayerStats) return; if (enrichmentPrimaryId === null) return; @@ -178,20 +198,6 @@ export default function Statistics() { }).catch(() => setLfmLoading(false)); }, [lfmPeriod, enrichmentPrimaryId, offlineBrowseActive, isPlayerStats]); - const loadMore = async ( - type: 'frequent' | 'highest', - currentList: SubsonicAlbum[], - setter: React.Dispatch> - ) => { - try { - const more = await getAlbumList(type, 12, currentList.length); - const newItems = more.filter(m => !currentList.find(c => c.id === m.id)); - if (newItems.length > 0) setter(prev => [...prev, ...newItems]); - } catch (e) { - console.error('Failed to load more', e); - } - }; - const playtimeDisplay = totalPlaytime === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + formatHumanHoursMinutes(totalPlaytime); @@ -200,7 +206,7 @@ export default function Statistics() { n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString(); const stats = [ - { label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—', tooltip: t('statistics.statArtistsTooltip') }, + { label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? t('statistics.computing'), tooltip: t('statistics.statArtistsTooltip') }, { label: t('statistics.statAlbums'), value: countDisplay(totalAlbums) }, { label: t('statistics.statSongs'), value: countDisplay(totalSongs) }, { label: t('statistics.statPlaytime'), value: playtimeDisplay }, @@ -216,31 +222,29 @@ export default function Statistics() { {isPlayerStats ? ( - ) : loading ? ( -
) : (
{stats.map(s => (
- {s.value} + {aggregateLoading ? : s.value} {s.label}
))}
{/* Genre Insights + Format Distribution */} - {(topGenres.length > 0 || formatData) && ( + {(topGenres.length > 0 || formatData || aggregateLoading) && (
- {topGenres.length > 0 && ( + {(topGenres.length > 0 || aggregateLoading) && (

{t('statistics.genreInsights')}

- {topGenres.map(g => ( + {aggregateLoading ? : topGenres.map(g => (
@@ -266,17 +270,17 @@ export default function Statistics() {
)} - {formatData && ( + {(formatData || aggregateLoading) && (

{t('statistics.formatDistribution')}

- {t('statistics.formatSample', { n: formatSampleSize.toLocaleString() })} + {aggregateLoading ? : t('statistics.formatSample', { n: formatTrackCount.toLocaleString() })}

- {formatData.map(f => { - const pct = formatSampleSize > 0 ? Math.round((f.count / formatSampleSize) * 100) : 0; + {!aggregateLoading && formatData?.map(f => { + const pct = formatTrackCount > 0 ? Math.round((f.count / formatTrackCount) * 100) : 0; return (
@@ -303,15 +307,13 @@ export default function Statistics() {
)} - {recent.length > 0 && ( + {overviewLoading ?
: recent.length > 0 && ( )} -
: loadMore('frequent', frequent, setFrequent)} - moreText={t('statistics.loadMore')} headerExtra={frequent.length >= 9 ? ( ) : undefined} - /> + />} -
: loadMore('highest', highest, setHighest)} - moreText={t('statistics.loadMore')} showRating - /> + />} {/* Music Network Stats */} {enrichmentPrimaryId !== null && ( diff --git a/src/generated/bindings.ts b/src/generated/bindings.ts index 324f891c..9c8bcb58 100644 --- a/src/generated/bindings.ts +++ b/src/generated/bindings.ts @@ -24,6 +24,11 @@ export const commands = { libraryAnalysisProgress: (serverId: string) => typedError(__TAURI_INVOKE("library_analysis_progress", { serverId })), libraryCountLiveTracks: (serverId: string) => typedError(__TAURI_INVOKE("library_count_live_tracks", { serverId })), libraryGetStatus: (serverId: string, libraryScope: string | null) => typedError(__TAURI_INVOKE("library_get_status", { serverId, libraryScope })), + /** + * Index-backed Statistics aggregates for one or more selected servers/folders. + * Deliberately does not merge equivalent albums/artists between scopes. + */ + libraryScopeStatistics: (request: LibraryStatisticsRequest) => typedError(__TAURI_INVOKE("library_scope_statistics", { request })), libraryGetArtifact: (serverId: string, trackId: string, artifactKind: string, sourceKind: string | null, sourceId: string | null, format: string | null) => typedError<{ serverId: string, trackId: string, @@ -1059,6 +1064,39 @@ export type LibraryServerKeyMigrationDto = { indexKey: string, }; +export type LibraryStatisticsDto = { + artistCount: number, + albumCount: number, + songCount: number, + playtimeSec: number, + genres: LibraryStatisticsGenreDto[], + formats: LibraryStatisticsFormatDto[], +}; + +export type LibraryStatisticsFormatDto = { + value: string, + songCount: number, +}; + +export type LibraryStatisticsGenreDto = { + value: string, + songCount: number, + albumCount: number, +}; + +export type LibraryStatisticsRequest = { + scopes: LibraryStatisticsScope[], +}; + +/** + * One selected server and its optional music-folder filter for aggregate index reads. + * An empty `library_ids` list includes every indexed folder on that server. + */ +export type LibraryStatisticsScope = { + serverId: string, + libraryIds?: string[], +}; + export type LibraryTierDiskHit = { trackId: string, path: string, diff --git a/src/lib/api/library/scopeReads.test.ts b/src/lib/api/library/scopeReads.test.ts index 7c94e2c0..18392547 100644 --- a/src/lib/api/library/scopeReads.test.ts +++ b/src/lib/api/library/scopeReads.test.ts @@ -7,6 +7,7 @@ import { libraryScopeListArtists, libraryScopeListMainstageAlbums, libraryScopeSearchTracks, + libraryScopeStatistics, type LibraryScopePair, } from './scopeReads'; import { useAuthStore } from '@/store/authStore'; @@ -26,6 +27,13 @@ beforeEach(() => { username: 'u', password: 'p', }, + { + id: 'profile-s2', + name: 'S2', + url: 'https://s2.example', + username: 'u', + password: 'p', + }, ], activeServerId: 'profile-s1', }); @@ -70,6 +78,30 @@ describe('libraryScopeListArtists', () => { }); }); +describe('libraryScopeStatistics', () => { + it('maps every selected profile server to its index key', async () => { + let captured: unknown; + onInvoke('library_scope_statistics', (args) => { + captured = args; + return { artistCount: 0, albumCount: 0, songCount: 0, playtimeSec: 0, genres: [] }; + }); + + await libraryScopeStatistics([ + { serverId: 'profile-s1', libraryIds: ['lib-a'] }, + { serverId: 'profile-s2', libraryIds: [] }, + ]); + + expect(captured).toEqual({ + request: { + scopes: [ + { serverId: 's1.example', libraryIds: ['lib-a'] }, + { serverId: 's2.example', libraryIds: [] }, + ], + }, + }); + }); +}); + describe('libraryScopeListMainstageAlbums', () => { it('maps scope and response server ids without changing album order', async () => { let captured: unknown; diff --git a/src/lib/api/library/scopeReads.ts b/src/lib/api/library/scopeReads.ts index 4d5a5b3e..4b353930 100644 --- a/src/lib/api/library/scopeReads.ts +++ b/src/lib/api/library/scopeReads.ts @@ -28,6 +28,21 @@ export interface LibraryScopeListRequest { offset?: number; } +export interface LibraryStatisticsScope { + serverId: string; + /** Empty means every indexed folder on this server. */ + libraryIds: string[]; +} + +export interface LibraryScopeStatisticsResponse { + artistCount: number; + albumCount: number; + songCount: number; + playtimeSec: number; + genres: GenreAlbumCountRow[]; + formats: { value: string; songCount: number }[]; +} + export interface LibraryScopeSearchRequest { scopes: LibraryScopePair[]; query: string; @@ -59,6 +74,8 @@ export interface LibraryScopeArtistDetailRequest { scopes: LibraryScopePair[]; artistId: string; serverId: string; + /** Skip tracks when the caller needs only artist metadata and discography. */ + includeTracks?: boolean; } export interface LibraryScopeAlbumDetailResponse { @@ -113,6 +130,20 @@ export function scopePairsFromLibrarySelection(serverId: string): LibraryScopePa })); } +/** Aggregate selected index scopes without cross-server entity merging. */ +export function libraryScopeStatistics( + scopes: LibraryStatisticsScope[], +): Promise { + return invoke('library_scope_statistics', { + request: { + scopes: scopes.map(scope => ({ + ...scope, + serverId: serverIndexKeyForId(scope.serverId), + })), + }, + }); +} + export function libraryScopeListAlbums( serverId: string, request: LibraryScopeListRequest, diff --git a/src/lib/api/subsonicStatistics.test.ts b/src/lib/api/subsonicStatistics.test.ts index fb39cf2c..eb88bdc3 100644 --- a/src/lib/api/subsonicStatistics.test.ts +++ b/src/lib/api/subsonicStatistics.test.ts @@ -1,9 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useAuthStore } from '@/store/authStore'; -import { statisticsPageCacheKey } from '@/lib/api/subsonicStatistics'; +import { fetchStatisticsLibraryAggregates, fetchStatisticsOverview, statisticsPageCacheKey } from '@/lib/api/subsonicStatistics'; import { getArtistsAcrossLibraries } from '@/lib/api/subsonicArtists'; const apiMock = vi.fn(); +const indexStatisticsMock = vi.fn(); +const getAlbumListForServerMock = vi.fn(); + +vi.mock('@/lib/api/subsonicLibrary', () => ({ + getAlbumListForServer: (...args: unknown[]) => getAlbumListForServerMock(...args), +})); vi.mock('@/lib/api/subsonicClient', async importOriginal => { const actual = await importOriginal(); @@ -14,6 +20,14 @@ vi.mock('@/lib/api/subsonicClient', async importOriginal => { }; }); +vi.mock('@/lib/api/library/scopeReads', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + libraryScopeStatistics: (...args: unknown[]) => indexStatisticsMock(...args), + }; +}); + describe('statisticsPageCacheKey', () => { beforeEach(() => { useAuthStore.setState({ @@ -57,3 +71,72 @@ describe('getArtistsAcrossLibraries', () => { expect(apiMock.mock.calls[1]?.[1]).toEqual({ musicFolderId: '2' }); }); }); + +describe('fetchStatisticsLibraryAggregates', () => { + beforeEach(() => { + indexStatisticsMock.mockReset(); + getAlbumListForServerMock.mockReset(); + useAuthStore.setState({ + activeServerId: 'stats-a', + libraryBrowseServerIds: ['stats-a', 'stats-b'], + libraryBrowseSelectionByServer: { 'stats-a': ['rock'], 'stats-b': [] }, + }); + }); + + it('uses selected index scopes and reuses the seven-minute aggregate cache', async () => { + indexStatisticsMock.mockResolvedValue({ + artistCount: 9, + albumCount: 20, + songCount: 80, + playtimeSec: 12_345, + genres: [{ value: 'Rock', songCount: 40, albumCount: 10 }], + formats: [{ value: 'FLAC', songCount: 60 }], + }); + + const first = await fetchStatisticsLibraryAggregates(); + const second = await fetchStatisticsLibraryAggregates(); + + expect(first).toEqual({ + artistCount: 9, + albumsCounted: 20, + songsCounted: 80, + playtimeSec: 12_345, + capped: false, + genres: [{ value: 'Rock', songCount: 40, albumCount: 10 }], + formats: [{ format: 'FLAC', count: 60 }], + }); + expect(second).toBe(first); + expect(indexStatisticsMock).toHaveBeenCalledTimes(1); + expect(indexStatisticsMock).toHaveBeenCalledWith([ + { serverId: 'stats-a', libraryIds: ['rock'] }, + { serverId: 'stats-b', libraryIds: [] }, + ]); + }); +}); + +describe('fetchStatisticsOverview', () => { + beforeEach(() => { + getAlbumListForServerMock.mockReset(); + useAuthStore.setState({ + activeServerId: 'stats-a', + libraryBrowseServerIds: ['stats-a', 'stats-b'], + libraryBrowseSelectionByServer: { 'stats-a': ['rock'], 'stats-b': [] }, + }); + }); + + it('reads ranked strips from every selected index scope', async () => { + getAlbumListForServerMock.mockImplementation(async (serverId: string, type: string) => [{ + serverId, + id: `${type}-${serverId}`, + name: `${type}-${serverId}`, + }]); + + const overview = await fetchStatisticsOverview(); + + expect(getAlbumListForServerMock).toHaveBeenCalledTimes(6); + expect(getAlbumListForServerMock).toHaveBeenCalledWith('stats-a', 'recent', 20); + expect(getAlbumListForServerMock).toHaveBeenCalledWith('stats-b', 'frequent', 12); + expect(overview.recent.map(album => album.serverId)).toEqual(['stats-a', 'stats-b']); + expect(overview.frequent.map(album => album.serverId)).toEqual(['stats-a', 'stats-b']); + }); +}); diff --git a/src/lib/api/subsonicStatistics.ts b/src/lib/api/subsonicStatistics.ts index 3619e5d2..a313c384 100644 --- a/src/lib/api/subsonicStatistics.ts +++ b/src/lib/api/subsonicStatistics.ts @@ -1,15 +1,14 @@ import { useAuthStore } from '@/store/authStore'; -import { genreTagsFor } from '@/lib/library/genreTags'; -import { getArtists, getArtistsAcrossLibraries } from '@/lib/api/subsonicArtists'; -import { getAlbumList, getRandomSongs } from '@/lib/api/subsonicLibrary'; -import { libraryScopeCacheKeyForServer, librarySelectionForServer } from '@/lib/api/subsonicClient'; +import { getAlbumListForServer } from '@/lib/api/subsonicLibrary'; +import { libraryScopeCacheKeyForServer } from '@/lib/api/subsonicClient'; +import { + libraryScopeStatistics, + type LibraryStatisticsScope, +} from '@/lib/api/library/scopeReads'; import type { - StatisticsFormatSample, StatisticsLibraryAggregates, StatisticsOverviewData, SubsonicAlbum, - SubsonicGenre, - SubsonicSong, } from '@/lib/api/subsonicTypes'; /** Cache TTL for statistics page aggregates — same 7-minute window as @@ -23,74 +22,45 @@ export function statisticsPageCacheKey(prefix: string): string | null { return `${prefix}:${activeServerId}:${libraryScopeCacheKeyForServer(activeServerId)}`; } +function statisticsIndexScopes(): LibraryStatisticsScope[] { + const state = useAuthStore.getState(); + const selectedServerIds = state.libraryBrowseServerIds.length > 0 + ? state.libraryBrowseServerIds + : (state.activeServerId ? [state.activeServerId] : []); + return selectedServerIds.map(serverId => ({ + serverId, + libraryIds: state.libraryBrowseSelectionByServer[serverId] ?? [], + })); +} + +function statisticsAggregateCacheKey(scopes: LibraryStatisticsScope[]): string | null { + if (scopes.length === 0) return null; + return `statsAgg:${scopes.map(scope => `${scope.serverId}:${scope.libraryIds.join(',') || 'all'}`).join('|')}`; +} + const statisticsAggregatesCache = new Map(); /** - * Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for - * 7 minutes. - * Unknown/missing album genre is stored as `value: ''`; UI should map to i18n. + * Reads aggregate counts from the local index. Cache keys include every selected + * server/folder, and intentionally preserve duplicate entities across scopes. */ export async function fetchStatisticsLibraryAggregates(): Promise { - const key = statisticsPageCacheKey('statsAgg'); + const scopes = statisticsIndexScopes(); + const key = statisticsAggregateCacheKey(scopes); if (key) { const hit = statisticsAggregatesCache.get(key); if (hit && Date.now() < hit.expiresAt) return hit.value; } - let playtimeSec = 0; - let albumsCounted = 0; - let songsCounted = 0; - const genreAgg = new Map(); - const pageSize = 500; - const capped = false; - let offset = 0; - const activeServerId = useAuthStore.getState().activeServerId; - const dedupeAlbumIds = - activeServerId != null && librarySelectionForServer(activeServerId).length > 1; - const seenAlbumIds = dedupeAlbumIds ? new Set() : null; - let nextPage = getAlbumList('alphabeticalByName', pageSize, 0); - for (;;) { - try { - const albums = await nextPage; - for (const a of albums) { - if (seenAlbumIds) { - if (seenAlbumIds.has(a.id)) continue; - seenAlbumIds.add(a.id); - } - playtimeSec += a.duration ?? 0; - albumsCounted += 1; - const sc = a.songCount ?? 0; - songsCounted += sc; - const tags = genreTagsFor(a); - const labels = tags.length > 0 ? tags : ['']; - for (const label of labels) { - let g = genreAgg.get(label); - if (!g) { - g = { songCount: 0, albumCount: 0 }; - genreAgg.set(label, g); - } - g.songCount += sc; - g.albumCount += 1; - } - } - if (albums.length < pageSize) break; - offset += pageSize; - nextPage = getAlbumList('alphabeticalByName', pageSize, offset); - } catch { - break; - } - } - - const genres: SubsonicGenre[] = [...genreAgg.entries()] - .map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount })) - .sort((a, b) => b.songCount - a.songCount); - + const aggregate = await libraryScopeStatistics(scopes); const result: StatisticsLibraryAggregates = { - playtimeSec, - albumsCounted, - songsCounted, - capped, - genres, + artistCount: aggregate.artistCount, + playtimeSec: aggregate.playtimeSec, + albumsCounted: aggregate.albumCount, + songsCounted: aggregate.songCount, + capped: false, + genres: aggregate.genres, + formats: aggregate.formats.map(format => ({ format: format.value, count: format.songCount })), }; if (key) { statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL }); @@ -98,64 +68,34 @@ export async function fetchStatisticsLibraryAggregates(): Promise(); export async function fetchStatisticsOverview(): Promise { - const key = statisticsPageCacheKey('statsOverview'); + const scopes = statisticsIndexScopes(); + const scopeKey = statisticsAggregateCacheKey(scopes); + const key = scopeKey ? `statsOverview:${scopeKey}` : null; if (key) { const hit = statisticsOverviewCache.get(key); if (hit && Date.now() < hit.expiresAt) return hit.value; } - const [recent, frequent, highest, artists] = await Promise.all([ - getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]), - getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]), - getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]), - fetchStatisticsArtistCount().catch(() => 0), + const serverIds = scopes.map(scope => scope.serverId); + const fetchType = (type: 'recent' | 'frequent' | 'highest', size: number) => + Promise.all(serverIds.map(serverId => + getAlbumListForServer(serverId, type, size).catch(() => [] as SubsonicAlbum[]), + )).then(results => results.flat()); + const [recent, frequent, highest] = await Promise.all([ + fetchType('recent', 20), + fetchType('frequent', 12), + fetchType('highest', 12), ]); const result: StatisticsOverviewData = { recent, frequent, highest, - artistCount: artists, }; if (key) { statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL }); } return result; } - -async function fetchStatisticsArtistCount(): Promise { - const { activeServerId } = useAuthStore.getState(); - if (!activeServerId) return 0; - const selection = librarySelectionForServer(activeServerId); - if (selection.length <= 1) { - return (await getArtists()).length; - } - return (await getArtistsAcrossLibraries(selection)).length; -} - -/** Format (suffix) histogram from a random sample for Statistics. */ -const statisticsFormatCache = new Map(); - -export async function fetchStatisticsFormatSample(): Promise { - const key = statisticsPageCacheKey('statsFormat'); - if (key) { - const hit = statisticsFormatCache.get(key); - if (hit && Date.now() < hit.expiresAt) return hit.value; - } - const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]); - const counts: Record = {}; - for (const song of songs) { - const fmt = song.suffix?.toUpperCase() ?? 'Unknown'; - counts[fmt] = (counts[fmt] ?? 0) + 1; - } - const rows = Object.entries(counts) - .map(([format, count]) => ({ format, count })) - .sort((a, b) => b.count - a.count); - const result: StatisticsFormatSample = { rows, sampleSize: songs.length }; - if (key) { - statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL }); - } - return result; -} diff --git a/src/lib/api/subsonicTypes.ts b/src/lib/api/subsonicTypes.ts index 21180783..82dcaf60 100644 --- a/src/lib/api/subsonicTypes.ts +++ b/src/lib/api/subsonicTypes.ts @@ -235,23 +235,19 @@ export interface RandomSongsFilters { } export interface StatisticsLibraryAggregates { + artistCount: number; playtimeSec: number; albumsCounted: number; songsCounted: number; capped: boolean; genres: SubsonicGenre[]; + formats: { format: string; count: number }[]; } export interface StatisticsOverviewData { recent: SubsonicAlbum[]; frequent: SubsonicAlbum[]; highest: SubsonicAlbum[]; - artistCount: number; -} - -export interface StatisticsFormatSample { - rows: { format: string; count: number }[]; - sampleSize: number; } export interface SearchResults { diff --git a/src/locales/bg/statistics.ts b/src/locales/bg/statistics.ts index f95f784c..ba0d55ff 100644 --- a/src/locales/bg/statistics.ts +++ b/src/locales/bg/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Общо време на слушане', genreInsights: 'Прозрения за жанрове', formatDistribution: 'Разпределение по формат', - formatSample: 'Извадка от {{n}} песни', + formatSample: 'Общо {{n}} песни', computing: 'Изчисляване…', genreSongs: '{{count}} песни', genreAlbums: '{{count}} албуми', diff --git a/src/locales/de/statistics.ts b/src/locales/de/statistics.ts index 416766f6..769aa01a 100644 --- a/src/locales/de/statistics.ts +++ b/src/locales/de/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Gesamtspielzeit', genreInsights: 'Genre-Einblicke', formatDistribution: 'Format-Verteilung', - formatSample: 'Stichprobe von {{n}} Titeln', + formatSample: 'Über {{n}} Titel', computing: 'Wird berechnet…', genreSongs: '{{count}} Songs', genreAlbums: '{{count}} Alben', diff --git a/src/locales/en/statistics.ts b/src/locales/en/statistics.ts index c782749e..1419275d 100644 --- a/src/locales/en/statistics.ts +++ b/src/locales/en/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Total Playtime', genreInsights: 'Genre Insights', formatDistribution: 'Format Distribution', - formatSample: 'Sample of {{n}} tracks', + formatSample: 'Across {{n}} tracks', computing: 'Computing…', genreSongs: '{{count}} Songs', genreAlbums: '{{count}} Albums', diff --git a/src/locales/es/statistics.ts b/src/locales/es/statistics.ts index 5be1877f..ea636aa4 100644 --- a/src/locales/es/statistics.ts +++ b/src/locales/es/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Tiempo Total de Reproducción', genreInsights: 'Información de Géneros', formatDistribution: 'Distribución de Formatos', - formatSample: 'Muestra de {{n}} pistas', + formatSample: 'En {{n}} pistas', computing: 'Calculando…', genreSongs: '{{count}} Canciones', genreAlbums: '{{count}} Álbumes', diff --git a/src/locales/fr/statistics.ts b/src/locales/fr/statistics.ts index ce19a04f..a8c531f1 100644 --- a/src/locales/fr/statistics.ts +++ b/src/locales/fr/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Durée totale', genreInsights: 'Aperçu des genres', formatDistribution: 'Distribution des formats', - formatSample: 'Échantillon de {{n}} pistes', + formatSample: 'Sur {{n}} pistes', computing: 'Calcul en cours…', genreSongs: '{{count}} morceaux', genreAlbums: '{{count}} albums', diff --git a/src/locales/hu/statistics.ts b/src/locales/hu/statistics.ts index cdab1027..8ef4ee7c 100644 --- a/src/locales/hu/statistics.ts +++ b/src/locales/hu/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Teljes lejátszási idő', genreInsights: 'Műfaj-betekintés', formatDistribution: 'Formátumeloszlás', - formatSample: '{{n}} szám mintája', + formatSample: '{{n}} szám alapján', computing: 'Számítás…', genreSongs: '{{count}} dal', genreAlbums: '{{count}} album', diff --git a/src/locales/it/statistics.ts b/src/locales/it/statistics.ts index 75a6ad4e..62d53e2d 100644 --- a/src/locales/it/statistics.ts +++ b/src/locales/it/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Tempo totale di ascolto', genreInsights: 'Approfondimenti sui generi', formatDistribution: 'Distribuzione per formato', - formatSample: 'Campione di {{n}} brani', + formatSample: 'Su {{n}} brani', computing: 'Calcolo in corso…', genreSongs: '{{count}} brani', genreAlbums: '{{count}} album', @@ -90,4 +90,4 @@ export const statistics = { playerListenedMinDecimal: '{{minutes}} min', completionFull: 'Ascolto completo', completionPartial: 'Parziale', -}; \ No newline at end of file +}; diff --git a/src/locales/ja/statistics.ts b/src/locales/ja/statistics.ts index 66efbe0b..6e9fcff0 100644 --- a/src/locales/ja/statistics.ts +++ b/src/locales/ja/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: '総再生時間', genreInsights: 'ジャンル分析', formatDistribution: '形式分布', - formatSample: '{{n}} 曲のサンプル', + formatSample: '{{n}} 曲全体', computing: '計算中…', genreSongs: '{{count}} 曲', genreAlbums: '{{count}} 枚のアルバム', diff --git a/src/locales/nb/statistics.ts b/src/locales/nb/statistics.ts index 0e0c29b0..7f473693 100644 --- a/src/locales/nb/statistics.ts +++ b/src/locales/nb/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Total spilletid', genreInsights: 'Sjangerinnsikt', formatDistribution: 'Formatdistribusjon', - formatSample: 'Utvalg av {{n}} spor', + formatSample: 'På tvers av {{n}} spor', computing: 'Utregner…', genreSongs: '{{count}} Sanger', genreAlbums: '{{count}} Album', diff --git a/src/locales/nl/statistics.ts b/src/locales/nl/statistics.ts index 3a6092f6..095e3fd5 100644 --- a/src/locales/nl/statistics.ts +++ b/src/locales/nl/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Totale speelduur', genreInsights: 'Genre-inzichten', formatDistribution: 'Formaatdistributie', - formatSample: 'Steekproef van {{n}} nummers', + formatSample: 'Over {{n}} nummers', computing: 'Berekenen…', genreSongs: '{{count}} nummers', genreAlbums: '{{count}} albums', diff --git a/src/locales/pl/statistics.ts b/src/locales/pl/statistics.ts index 2469083a..ac79021c 100644 --- a/src/locales/pl/statistics.ts +++ b/src/locales/pl/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Całkowity czas odtwarzania', genreInsights: 'Wgląd w gatunek', formatDistribution: 'Dystrybucja formatów', - formatSample: 'Próbka z {{n}} utworów', + formatSample: 'Wśród {{n}} utworów', computing: 'Obliczanie…', genreSongs: '{{count}} utworów', genreAlbums: '{{count}} albumów', diff --git a/src/locales/ro/statistics.ts b/src/locales/ro/statistics.ts index 81f2ad26..1e07f1af 100644 --- a/src/locales/ro/statistics.ts +++ b/src/locales/ro/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Timpul total de redare', genreInsights: 'Perspectivele Genurilor', formatDistribution: 'Distribuția Formatului', - formatSample: 'Eșantion de {{n}} piese', + formatSample: 'Din {{n}} piese', computing: 'Se calculează…', genreSongs: '{{count}} Piese', genreAlbums: '{{count}} Albume', diff --git a/src/locales/ru/statistics.ts b/src/locales/ru/statistics.ts index 47a89185..24216471 100644 --- a/src/locales/ru/statistics.ts +++ b/src/locales/ru/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: 'Время звучания', genreInsights: 'По жанрам', formatDistribution: 'Форматы', - formatSample: 'Выборка {{n}} треков', + formatSample: 'Всего {{n}} треков', computing: 'Считаем…', genreSongs_one: '{{count}} композиция', genreSongs_few: '{{count}} композиции', diff --git a/src/locales/zh/statistics.ts b/src/locales/zh/statistics.ts index 8fa3b873..1a22e88e 100644 --- a/src/locales/zh/statistics.ts +++ b/src/locales/zh/statistics.ts @@ -13,7 +13,7 @@ export const statistics = { statPlaytime: '音频总时长', genreInsights: '流派洞察', formatDistribution: '格式分布', - formatSample: '{{n}} 首曲目的样本', + formatSample: '共 {{n}} 首曲目', computing: '计算中…', genreSongs: '{{count}} 首歌曲', genreAlbums: '{{count}} 张专辑', diff --git a/src/styles/components/statistics-page.css b/src/styles/components/statistics-page.css index 4ef1ae87..553955cd 100644 --- a/src/styles/components/statistics-page.css +++ b/src/styles/components/statistics-page.css @@ -56,6 +56,45 @@ text-underline-offset: 3px; } +.statistics-loading-dots { + display: inline-flex; + align-items: center; + gap: 0.2em; + min-height: 1em; + color: var(--text-muted); +} + +.statistics-loading-dots span { + width: 0.28em; + height: 0.28em; + border-radius: 50%; + background: currentColor; + animation: statistics-loading-pulse 1s ease-in-out infinite; +} + +.statistics-loading-dots span:nth-child(2) { + animation-delay: 0.14s; +} + +.statistics-loading-dots span:nth-child(3) { + animation-delay: 0.28s; +} + +.statistics-row-loading { + min-height: 124px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + background: var(--bg-card); +} + +@keyframes statistics-loading-pulse { + 0%, 100% { opacity: 0.28; transform: scale(0.8); } + 50% { opacity: 1; transform: scale(1); } +} + /* Genre chart */ .genre-chart { background: var(--bg-card);