mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
perf(library): scope statistics and detail reads
This commit is contained in:
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,7 +13,7 @@ export const statistics = {
|
||||
statPlaytime: 'Общо време на слушане',
|
||||
genreInsights: 'Прозрения за жанрове',
|
||||
formatDistribution: 'Разпределение по формат',
|
||||
formatSample: 'Извадка от {{n}} песни',
|
||||
formatSample: 'Общо {{n}} песни',
|
||||
computing: 'Изчисляване…',
|
||||
genreSongs: '{{count}} песни',
|
||||
genreAlbums: '{{count}} албуми',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ export const statistics = {
|
||||
statPlaytime: '総再生時間',
|
||||
genreInsights: 'ジャンル分析',
|
||||
formatDistribution: '形式分布',
|
||||
formatSample: '{{n}} 曲のサンプル',
|
||||
formatSample: '{{n}} 曲全体',
|
||||
computing: '計算中…',
|
||||
genreSongs: '{{count}} 曲',
|
||||
genreAlbums: '{{count}} 枚のアルバム',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -13,7 +13,7 @@ export const statistics = {
|
||||
statPlaytime: 'Время звучания',
|
||||
genreInsights: 'По жанрам',
|
||||
formatDistribution: 'Форматы',
|
||||
formatSample: 'Выборка {{n}} треков',
|
||||
formatSample: 'Всего {{n}} треков',
|
||||
computing: 'Считаем…',
|
||||
genreSongs_one: '{{count}} композиция',
|
||||
genreSongs_few: '{{count}} композиции',
|
||||
|
||||
@@ -13,7 +13,7 @@ export const statistics = {
|
||||
statPlaytime: '音频总时长',
|
||||
genreInsights: '流派洞察',
|
||||
formatDistribution: '格式分布',
|
||||
formatSample: '{{n}} 首曲目的样本',
|
||||
formatSample: '共 {{n}} 首曲目',
|
||||
computing: '计算中…',
|
||||
genreSongs: '{{count}} 首歌曲',
|
||||
genreAlbums: '{{count}} 张专辑',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user