perf(library): scope statistics and detail reads

This commit is contained in:
cucadmuh
2026-07-17 17:12:59 +03:00
parent 215d6783ad
commit deb15f52df
36 changed files with 1078 additions and 260 deletions
@@ -2021,7 +2021,7 @@ where
F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
{
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 {
@@ -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<LibraryStatisticsDto, String> {
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::<Vec<_>>();
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);
@@ -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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryStatisticsRequest {
pub scopes: Vec<LibraryStatisticsScope>,
}
#[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<LibraryStatisticsGenreDto>,
pub formats: Vec<LibraryStatisticsFormatDto>,
}
/// 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<LibraryScopePair>,
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.
@@ -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;
@@ -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();
@@ -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<SqlValue>) {
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::<Vec<_>>().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<LibraryStatisticsDto, String> {
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::<String, (i64, i64)>::new();
let mut formats = BTreeMap::<String, i64>::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<LibraryStatisticsGenreDto> = 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<LibraryStatisticsFormatDto> = 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);
}
}
+108 -7
View File
@@ -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<Connection>,
/// 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<Connection>,
/// Current holder of `read_conn`, used only to attribute contention in
/// targeted diagnostics such as the Favorites initial snapshot.
read_op_owner: Mutex<Option<ReadOpOwner>>,
@@ -197,12 +201,13 @@ impl LibraryStore {
}
fn open_file(db_path: &Path) -> Result<Self, String> {
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<std::sync::MutexGuard<'_, Connection>, 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<R>(
&self,
f: impl FnOnce(&Connection) -> rusqlite::Result<R>,
) -> Result<R, String> {
let conn = self.lock_scope_detail_read_conn()?;
run_conn_closure(&conn, f)
}
fn read_op_owner(&self) -> Option<ReadOpOwner> {
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();
+2
View File
@@ -117,6 +117,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
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,
@@ -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<SubsonicSong[]> {
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 */ }
}
@@ -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']);
});
});
@@ -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<string, Promise<AlbumIndexLoad>>();
const artistIndexLoads = new Map<string, Promise<ArtistIndexLoad>>();
const artistTrackLoads = new Map<string, Promise<SubsonicSong[] | null>>();
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<AlbumIndexLoad> {
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<AlbumIndexLoad> {
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<ArtistIndexLoad> {
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<SubsonicSong[] | null> {
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;
}
@@ -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<LibraryTrackDto[]> => []),
libraryGetTracksByAlbumMock: vi.fn(async (): Promise<LibraryTrackDto[]> => []),
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: {
@@ -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
@@ -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<ExportFormat>('square');
const [gridSize, setGridSize] = useState<ExportGridSize>(3);
const [saving, setSaving] = useState(false);
const [topUpAlbums, setTopUpAlbums] = useState<SubsonicAlbum[] | null>(null);
const previewRef = useRef<HTMLDivElement | null>(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.
+61 -61
View File
@@ -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 (
<span className="statistics-loading-dots" aria-label={label} role="status">
<span />
<span />
<span />
</span>
);
}
export default function Statistics() {
const { t } = useTranslation();
const location = useLocation();
@@ -52,12 +61,13 @@ export default function Statistics() {
const [totalSongs, setTotalSongs] = useState<number | null>(null);
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [loading, setLoading] = useState(true);
const [overviewLoading, setOverviewLoading] = useState(true);
const [aggregateLoading, setAggregateLoading] = useState(true);
const [totalPlaytime, setTotalPlaytime] = useState<number | null>(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<React.SetStateAction<SubsonicAlbum[]>>
) => {
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 ? (
<PlayerStatisticsPanel />
) : loading ? (
<div className="loading-center"><div className="spinner" /></div>
) : (
<div className="stats-page">
<div className="stats-overview">
{stats.map(s => (
<div key={s.label} className="stats-card">
<span className="stats-card-value">{s.value}</span>
<span className="stats-card-value">{aggregateLoading ? <StatisticsLoadingDots label={t('statistics.computing')} /> : s.value}</span>
<span className="stats-card-label" data-tooltip={s.tooltip} data-tooltip-wrap="true">{s.label}</span>
</div>
))}
</div>
{/* Genre Insights + Format Distribution */}
{(topGenres.length > 0 || formatData) && (
{(topGenres.length > 0 || formatData || aggregateLoading) && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '0.5rem' }}>
{topGenres.length > 0 && (
{(topGenres.length > 0 || aggregateLoading) && (
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
{t('statistics.genreInsights')}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{topGenres.map(g => (
{aggregateLoading ? <StatisticsLoadingDots label={t('statistics.computing')} /> : topGenres.map(g => (
<div key={g.value || '__genre_unknown__'}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
@@ -266,17 +270,17 @@ export default function Statistics() {
</div>
)}
{formatData && (
{(formatData || aggregateLoading) && (
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '0.25rem' }}>
{t('statistics.formatDistribution')}
</h3>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{t('statistics.formatSample', { n: formatSampleSize.toLocaleString() })}
{aggregateLoading ? <StatisticsLoadingDots label={t('statistics.computing')} /> : t('statistics.formatSample', { n: formatTrackCount.toLocaleString() })}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{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 (
<div key={f.format}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
@@ -303,15 +307,13 @@ export default function Statistics() {
</div>
)}
{recent.length > 0 && (
{overviewLoading ? <div className="statistics-row-loading"><StatisticsLoadingDots label={t('statistics.computing')} /></div> : recent.length > 0 && (
<AlbumRow title={t('statistics.recentlyPlayed')} albums={recent} />
)}
<AlbumRow
{overviewLoading ? <div className="statistics-row-loading"><StatisticsLoadingDots label={t('statistics.computing')} /></div> : <AlbumRow
title={t('statistics.mostPlayed')}
albums={frequent}
onLoadMore={() => loadMore('frequent', frequent, setFrequent)}
moreText={t('statistics.loadMore')}
headerExtra={frequent.length >= 9 ? (
<button
type="button"
@@ -323,15 +325,13 @@ export default function Statistics() {
<Share2 size={18} />
</button>
) : undefined}
/>
/>}
<AlbumRow
{overviewLoading ? <div className="statistics-row-loading"><StatisticsLoadingDots label={t('statistics.computing')} /></div> : <AlbumRow
title={t('statistics.highestRated')}
albums={highest}
onLoadMore={() => loadMore('highest', highest, setHighest)}
moreText={t('statistics.loadMore')}
showRating
/>
/>}
{/* Music Network Stats */}
{enrichmentPrimaryId !== null && (
+38
View File
@@ -24,6 +24,11 @@ export const commands = {
libraryAnalysisProgress: (serverId: string) => typedError<LibraryAnalysisProgressDto, string>(__TAURI_INVOKE("library_analysis_progress", { serverId })),
libraryCountLiveTracks: (serverId: string) => typedError<number, string>(__TAURI_INVOKE("library_count_live_tracks", { serverId })),
libraryGetStatus: (serverId: string, libraryScope: string | null) => typedError<SyncStateDto, string>(__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<LibraryStatisticsDto, string>(__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,
+32
View File
@@ -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;
+31
View File
@@ -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<LibraryScopeStatisticsResponse> {
return invoke<LibraryScopeStatisticsResponse>('library_scope_statistics', {
request: {
scopes: scopes.map(scope => ({
...scope,
serverId: serverIndexKeyForId(scope.serverId),
})),
},
});
}
export function libraryScopeListAlbums(
serverId: string,
request: LibraryScopeListRequest,
+84 -1
View File
@@ -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<typeof import('@/lib/api/subsonicClient')>();
@@ -14,6 +20,14 @@ vi.mock('@/lib/api/subsonicClient', async importOriginal => {
};
});
vi.mock('@/lib/api/library/scopeReads', async importOriginal => {
const actual = await importOriginal<typeof import('@/lib/api/library/scopeReads')>();
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']);
});
});
+47 -107
View File
@@ -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<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
/**
* 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<StatisticsLibraryAggregates> {
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<string, { songCount: number; albumCount: number }>();
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<string>() : 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<StatisticsLibr
return result;
}
/** Recent / frequent / highest album strips + artist count for Statistics. */
/** Recent / frequent / highest album strips for Statistics. */
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
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<number> {
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<string, { value: StatisticsFormatSample; expiresAt: number }>();
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
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<string, number> = {};
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;
}
+2 -6
View File
@@ -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 {
+1 -1
View File
@@ -13,7 +13,7 @@ export const statistics = {
statPlaytime: 'Общо време на слушане',
genreInsights: 'Прозрения за жанрове',
formatDistribution: 'Разпределение по формат',
formatSample: 'Извадка от {{n}} песни',
formatSample: 'Общо {{n}} песни',
computing: 'Изчисляване…',
genreSongs: '{{count}} песни',
genreAlbums: '{{count}} албуми',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+2 -2
View File
@@ -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',
};
};
+1 -1
View File
@@ -13,7 +13,7 @@ export const statistics = {
statPlaytime: '総再生時間',
genreInsights: 'ジャンル分析',
formatDistribution: '形式分布',
formatSample: '{{n}} 曲のサンプル',
formatSample: '{{n}} 曲全体',
computing: '計算中…',
genreSongs: '{{count}} 曲',
genreAlbums: '{{count}} 枚のアルバム',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -13,7 +13,7 @@ export const statistics = {
statPlaytime: 'Время звучания',
genreInsights: 'По жанрам',
formatDistribution: 'Форматы',
formatSample: 'Выборка {{n}} треков',
formatSample: 'Всего {{n}} треков',
computing: 'Считаем…',
genreSongs_one: '{{count}} композиция',
genreSongs_few: '{{count}} композиции',
+1 -1
View File
@@ -13,7 +13,7 @@ export const statistics = {
statPlaytime: '音频总时长',
genreInsights: '流派洞察',
formatDistribution: '格式分布',
formatSample: '{{n}} 首曲目的样本',
formatSample: '{{n}} 首曲目',
computing: '计算中…',
genreSongs: '{{count}} 首歌曲',
genreAlbums: '{{count}} 张专辑',
+39
View File
@@ -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);