Compare commits

...

19 Commits

Author SHA1 Message Date
Maxim Isaev 0feb54ac33 fix(browse): album text search union, scope, and local-first index
Merge album/FTS/track sources for title search (any-word LIKE + prefix FTS),
dedupe cluster text hits by album id instead of album_key, route scoped
cluster browse through per-server advanced search, and prefer the local
index over network race when the library index is enabled.
2026-06-05 22:20:41 +03:00
Maxim Isaev e640d14a0e fix(cluster): dedup merged album lists by album_key at album grain
Roll up track candidates to one row per (server_id, album_id) before
partitioning by album_key so the same album on multiple servers (or mixed
keyed/unkeyed tracks) appears once with priority winner per spec §4.
2026-06-05 21:31:25 +03:00
Maxim Isaev 38ec62a639 fix(browse): cluster multi-library album search without network race
When two or more sidebar libraries are selected in cluster mode, All Albums
text search uses the merged local index only so an empty search3 response from
one member cannot win the local-vs-network race.
2026-06-05 21:26:14 +03:00
Maxim Isaev 6c64c5aa41 perf(browse): album-table search and single-library scoped list
Route plain album title search through the synced album table (LIKE)
instead of FTS over all tracks. Use album+EXISTS for single-library
scope; keep track GROUP BY only for multi-library union.
2026-06-05 21:06:47 +03:00
Maxim Isaev 478c049d0a perf(browse): fast local All Albums path and scoped multi-library union
Add library_list_albums (album table when unscoped, track GROUP BY when
scoped), cluster single-member bypass, ready-member caching, and UI load
deferrals. Remove REST scope allowlists from local hot paths; fix multi-
library scope union and invalidate cluster scope cache on picker toggles.
2026-06-05 21:04:43 +03:00
Maxim Isaev 26716e2fb0 fix(browse): cluster and lossless library scope allowlists
Apply cached getAlbumList2 allowlists per server in SQL and post-filter
so narrowed sidebar scope does not leak albums from other libraries or
cluster members. Wire restrictAlbumScopes into cluster advanced search
and dedicated lossless browse; detect cluster scope in All Albums paging.
2026-06-05 20:29:57 +03:00
Maxim Isaev 496b39a4da fix(browse): layered album filters and multi-library scope
Introduce albumBrowseExecution with explicit filter layers (library scope,
AND attributes, OR genre union, finalize) so multi-genre and cluster paths
cannot leak albums from other libraries. Wire multi-select sidebar picker
scope through local index, cluster merge reads, and REST allowlist fallback.
2026-06-05 19:54:23 +03:00
Maxim Isaev 558f42dba6 feat(cluster): per-member library scope in sidebar picker and merge reads
Cluster mode lists music folders from every member (cached for instant
picker), applies per-server musicLibraryFilter scopes in merged browse,
and passes libraryScopes through Rust list/advanced-search SQL filters.
Dropdown width sizes to content; single-server picker unchanged.
2026-06-05 19:11:40 +03:00
Maxim Isaev 0a09d29b06 fix(preview): Symphonia format sniff and cluster member stream URLs
Resolve preview container hints from HTTP headers, Subsonic suffix, and
magic-byte sniff after Symphonia 0.6. Route preview streams through
clusterBrowseServerId like main playback; guard CoverArtImage when the
preview cover ref is still loading.
2026-06-05 18:48:13 +03:00
Maxim Isaev 1d65a8d339 fix(cluster): artist Play All/top tracks and member-scoped browse UX
Load artist album tracks via getAlbumForServer when clusterSeedServerId is set
so Play All, shuffle, and top-track clicks resolve on the correct member server.
Also improve connection indicator tooltips, Song Info server labels, artist browse
covers/detail fallbacks, and faster merged artist list SQL.
2026-06-05 18:38:10 +03:00
Maxim Isaev 884951fbc2 feat(cluster): playback, browse, discovery, Tier 2/3, and infra
Enqueue-time cluster resolve with mixed-server queue refs; play_session
and scrobble target resolved server; mid-session cascade monitor.
Queue share filters to representative with export count. Excluded-member
banner and cluster settings diagnostics. Cluster advanced search, random
tracks, native favorite albums/artists, extended player stats, sync
rebuild hook. Discovery merge helper, filtered Tier 1, Tier 3 by-source
UI, i18n, and tauri test mocks.
2026-06-05 17:36:53 +03:00
Maxim Isaev 3447cf9fdd feat(cluster): virtual aggregate album/artist detail pages
Add Rust library_cluster_album_detail and library_cluster_artist_detail
with cluster_key dedup, owner metadata fallback, and merged tracklists.
Wire cluster-mode hooks and navigation seedServerId so detail routes
never fall back to single-server getAlbum/getArtist.
2026-06-05 17:02:25 +03:00
Maxim Isaev e601b5888a feat(cluster): playback resolve, writes fan-out, Orbit gating, random browse
Resolve tracks to cluster candidates at play time with cascade fallback;
fan-out scrobble/star/rating to all members; block Orbit host in cluster
mode; warn on mixed-server queue share; serve random picks from merged index.
2026-06-05 16:40:37 +03:00
Maxim Isaev 2ee601683c feat(cluster): Tier 2 favorites and player statistics aggregation
Add merged favorites listing (starred on any member) and cluster-scoped
play_session stats with cluster_key dedup; wire Favorites page and player
statistics panel to cluster APIs when a cluster is active.
2026-06-05 16:36:18 +03:00
Maxim Isaev 4f37fb8752 feat(cluster): Tier 1 browse — merged albums, artists, tracks, search
Add Rust list_merged_albums/artists commands and wire cluster-mode reads
into tracks, albums, artists catalog, and text search browse paths via
clusterBrowse helpers.
2026-06-05 16:32:30 +03:00
Maxim Isaev a7effbc271 feat(cluster): server cluster builder UI and scope switcher
Add cluster management in Settings (create, reorder, rename, sync toggle),
connection indicator dropdown for servers vs clusters, delete guard when a
server is in a cluster, and i18n across all locales.
2026-06-05 16:27:52 +03:00
Maxim Isaev 9813c79c91 feat(cluster): auth store cluster model and library API wrappers (step 3)
Add persisted clusters/activeClusterId, cluster CRUD actions, scope helpers,
representative recompute hook, library_cluster_* API wrappers, and vitest
coverage for cluster store rules and delete guard.
2026-06-05 16:23:38 +03:00
Maxim Isaev f3d4a06726 feat(library): cluster merge, resolve, and search commands (server cluster step 2)
Add library_cluster_list_tracks, library_cluster_resolve_candidates, and
library_search_cluster with priority-ordered dedup by cluster_key and
duration guard. Register Tauri commands; no frontend changes in this step.
2026-06-05 16:22:10 +03:00
Maxim Isaev dbccbea6cc feat(library): attached cluster identity DB for server cluster v1 (PR-1)
Add library-cluster.db (separate from library.sqlite) with track_cluster_key
normalization, batch rebuild, and ATTACH on both library store connections.
No Tauri commands or frontend changes in this PR.
2026-06-05 16:11:59 +03:00
256 changed files with 13293 additions and 878 deletions
+20
View File
@@ -4252,6 +4252,8 @@ dependencies = [
"serde_json",
"tauri",
"tokio",
"twox-hash",
"unicode-normalization",
"wiremock",
]
@@ -6601,6 +6603,15 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
dependencies = [
"rand 0.9.4",
]
[[package]]
name = "typed-path"
version = "0.12.3"
@@ -6683,6 +6694,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
+104 -8
View File
@@ -9,7 +9,11 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::MASTER_HEADROOM;
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, resolve_playback_format_hint,
MASTER_HEADROOM,
};
use super::play_input::url_format_hint;
use super::sources::PriorityBoostSource;
// ────────────────────────────────────────────────────────────────────────────
@@ -90,19 +94,44 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
}
}
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
/// a `format=flac` query param for `.opus` files (server transcodes); for
/// everything else we guess from the URL's `format=` value or fall back to None.
/// `format=` query param on Subsonic stream URLs (transcode targets).
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
url.split('?')
.nth(1)?
.split('&')
.find_map(|kv| {
let (k, v) = kv.split_once('=')?;
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
if k.eq_ignore_ascii_case("format") {
Some(v.to_string())
} else {
None
}
})
}
/// Symphonia container hint for preview downloads — mirrors main playback:
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
pub(crate) fn resolve_preview_format_hint(
url: &str,
content_type: Option<&str>,
content_disposition: Option<&str>,
stream_suffix: Option<&str>,
bytes: &[u8],
) -> Option<String> {
let media_hint = content_type
.and_then(content_type_to_hint)
.or_else(|| {
content_disposition.and_then(format_hint_from_content_disposition)
});
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
resolve_playback_format_hint(
url_hint.as_deref(),
stream_suffix,
media_hint.as_deref(),
Some(bytes),
)
}
#[tauri::command]
pub async fn audio_preview_play(
id: String,
@@ -110,6 +139,7 @@ pub async fn audio_preview_play(
start_sec: f64,
duration_sec: f64,
volume: f32,
format_suffix: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -147,13 +177,24 @@ pub async fn audio_preview_play(
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(&state));
let bytes = preview_http
let response = preview_http
.get(&url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?
.map_err(|e| format!("preview: HTTP {e}"))?;
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
@@ -165,7 +206,13 @@ pub async fn audio_preview_play(
}
// ── Decode ───────────────────────────────────────────────────────────────
let hint = preview_format_hint_from_url(&url);
let hint = resolve_preview_format_hint(
&url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix.as_deref(),
&bytes,
);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
@@ -271,6 +318,55 @@ pub async fn audio_preview_play(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
Some("audio/mpeg"),
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("mp3"));
}
#[test]
fn resolve_preview_format_hint_uses_subsonic_suffix() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
Some("flac"),
&[0x00, 0x01, 0x02, 0x03],
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn preview_format_hint_from_url_reads_format_query_param() {
assert_eq!(
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
Some("opus".into())
);
}
}
#[tauri::command]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true);
@@ -15,6 +15,8 @@ serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "brotli"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] }
twox-hash = "2"
unicode-normalization = "0.1"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "test-util"] }
@@ -0,0 +1,10 @@
-- Plain All Albums browse: read from `album` with ORDER BY name (not track GROUP BY).
CREATE INDEX IF NOT EXISTS idx_album_server_name_browse
ON album(server_id, name COLLATE NOCASE);
-- Scoped album EXISTS probes: (server, album, library) on live tracks.
CREATE INDEX IF NOT EXISTS idx_track_server_album_library_browse
ON track(server_id, album_id, library_id)
WHERE deleted = 0
AND album_id IS NOT NULL
AND album_id != '';
@@ -22,11 +22,68 @@ use crate::filter::{self, EntityKind, FilterOp, SqlFragment};
use crate::repos;
use crate::search::{
aliased_track_columns, aliased_track_columns_resolved_bpm, bpm_resolved_expr,
fts_album_prefix_match_query, fts_album_title_prefix_match_query, fts_column_prefix_query, fts_query_meets_min_len,
fts_track_prefix_match_query, library_scope_equals_sql, like_contains, PAGE_LIMIT_MAX,
fts_album_prefix_any_token_match_query, fts_album_title_prefix_any_token_match_query,
fts_column_prefix_query, fts_query_meets_min_len, fts_track_prefix_match_query,
library_scope_filter_sql, like_any_token_contains_clause, like_contains, like_name_tokens,
PAGE_LIMIT_MAX,
};
use crate::store::LibraryStore;
fn effective_library_scope_ids(req: &LibraryAdvancedSearchRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s.to_string()])
.unwrap_or_default()
}
fn push_library_scope_where(w: &mut WhereBuilder, table: &str, scope_ids: &[String]) {
if scope_ids.is_empty() {
return;
}
let (sql, params) = library_scope_filter_sql(table, scope_ids);
if let Some(clause) = sql {
if params.len() == 1 {
w.push_param(&clause, params[0].clone());
} else {
w.push_params(&clause, params);
}
}
}
/// `album` rows have no `library_id`; scope is enforced via matching tracks.
fn push_album_table_library_scope(
w: &mut WhereBuilder,
album_alias: &str,
scope_ids: &[String],
) {
if scope_ids.is_empty() {
return;
}
let (clause, params) = library_scope_filter_sql("t_scope", scope_ids);
let Some(scope_clause) = clause else {
return;
};
w.push_params(
&format!(
"EXISTS (SELECT 1 FROM track t_scope \
WHERE t_scope.server_id = {album_alias}.server_id \
AND t_scope.album_id = {album_alias}.id \
AND t_scope.deleted = 0 \
AND {scope_clause})"
),
params,
);
}
/// `bpm` dual-storage resolution (§5.13.4): prefer analysis `track_fact(bpm)`,
/// then hot `track.bpm` tag, then other fact sources.
fn bpm_resolved_sql() -> String {
@@ -61,8 +118,8 @@ fn fts_candidate_pool_size(limit: u32, offset: u32) -> i64 {
need.saturating_mul(20).clamp(256, 10_000)
}
/// FTS rowid pick scoped to the active server (and optional library folder).
fn scoped_fts_rowid_subquery_sql(pool: i64, library_scope: Option<&str>) -> String {
/// FTS rowid pick scoped to the active server (and optional library folders).
fn scoped_fts_rowid_subquery_sql(pool: i64, scope_ids: &[String]) -> String {
let alias = "t_fts";
let mut sql = format!(
"SELECT f.rowid FROM track_fts f \
@@ -71,20 +128,21 @@ fn scoped_fts_rowid_subquery_sql(pool: i64, library_scope: Option<&str>) -> Stri
AND {alias}.server_id = ? \
AND {alias}.deleted = 0"
);
if library_scope.is_some() {
if let (Some(clause), _) = library_scope_filter_sql(alias, scope_ids) {
sql.push_str(" AND ");
sql.push_str(&library_scope_equals_sql(alias));
sql.push_str(&clause);
}
sql.push_str(&format!(" ORDER BY bm25(track_fts) LIMIT {pool}"));
sql
}
fn scoped_fts_pick_join_sql(pool: i64, library_scope: Option<&str>) -> String {
fn scoped_fts_pick_join_sql(pool: i64, scope_ids: &[String]) -> String {
let alias = "t_fts";
let mut scope_sql = String::new();
if library_scope.is_some() {
scope_sql = format!(" AND {}", library_scope_equals_sql(alias));
}
let scope_sql = if let (Some(clause), _) = library_scope_filter_sql(alias, scope_ids) {
format!(" AND {clause}")
} else {
String::new()
};
format!(
"track t INNER JOIN (\
SELECT f.rowid, bm25(track_fts) AS fts_rank \
@@ -99,14 +157,10 @@ fn scoped_fts_pick_join_sql(pool: i64, library_scope: Option<&str>) -> String {
)
}
fn scoped_fts_subquery_bind(
server_id: &str,
library_scope: Option<&str>,
) -> Vec<SqlValue> {
fn scoped_fts_subquery_bind(server_id: &str, scope_ids: &[String]) -> Vec<SqlValue> {
let mut params = vec![SqlValue::Text(server_id.to_string())];
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
params.push(SqlValue::Text(scope.to_string()));
}
let (_, scope_params) = library_scope_filter_sql("t_fts", scope_ids);
params.extend(scope_params);
params
}
@@ -217,10 +271,7 @@ fn build_track(
let mut w = WhereBuilder::new();
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
@@ -246,8 +297,8 @@ fn build_track(
if let Some(q) = text.and_then(fts_track_prefix_match_query) {
applied.insert("text".to_string());
let pool = fts_candidate_pool_size(limit, offset);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let from = scoped_fts_pick_join_sql(pool, scope.as_deref());
let scope_ids = effective_library_scope_ids(req);
let from = scoped_fts_pick_join_sql(pool, &scope_ids);
let order = order_clause(&req.sort, EntityKind::Track)
.unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string());
return query_rows_fts(
@@ -255,7 +306,7 @@ fn build_track(
&cols,
&from,
&q,
&scoped_fts_subquery_bind(&req.server_id, scope.as_deref()),
&scoped_fts_subquery_bind(&req.server_id, &scope_ids),
&w,
&order,
limit,
@@ -307,12 +358,208 @@ fn server_has_indexed_tracks(store: &LibraryStore, server_id: &str) -> Result<bo
fn fts_album_text_match_query(req: &LibraryAdvancedSearchRequest, text: &str) -> Option<String> {
if req.query_album_title_only == Some(true) {
fts_album_title_prefix_match_query(text)
fts_album_title_prefix_any_token_match_query(text)
} else {
fts_album_prefix_match_query(text)
fts_album_prefix_any_token_match_query(text)
}
}
fn push_album_name_like_any_token(
w: &mut WhereBuilder,
column: &str,
text: &str,
applied: &mut BTreeSet<String>,
) {
let Some((sql, params)) = like_any_token_contains_clause(column, text) else {
return;
};
w.push_params(
&sql,
params.into_iter().map(SqlValue::Text).collect(),
);
applied.insert("text".to_string());
}
/// `album` row or any child track tag may carry the searchable title.
fn push_album_table_text_match(
w: &mut WhereBuilder,
text: &str,
applied: &mut BTreeSet<String>,
) {
let tokens = like_name_tokens(text);
if tokens.is_empty() {
return;
}
let mut parts = Vec::new();
let mut params = Vec::new();
for token in tokens {
let pat = like_contains(&token);
parts.push(
"(a.name COLLATE NOCASE LIKE ? ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM track t_mt \
WHERE t_mt.server_id = a.server_id AND t_mt.album_id = a.id \
AND t_mt.deleted = 0 \
AND t_mt.album COLLATE NOCASE LIKE ? ESCAPE '\\'))".to_string(),
);
params.push(SqlValue::Text(pat.clone()));
params.push(SqlValue::Text(pat));
}
w.push_params(&format!("({})", parts.join(" OR ")), params);
applied.insert("text".to_string());
}
/// Track group match: hot `t.album` tag or synced `album.name`.
fn push_track_group_text_match(
w: &mut WhereBuilder,
text: &str,
applied: &mut BTreeSet<String>,
) {
let tokens = like_name_tokens(text);
if tokens.is_empty() {
return;
}
let mut parts = Vec::new();
let mut params = Vec::new();
for token in tokens {
let pat = like_contains(&token);
parts.push(
"(t.album COLLATE NOCASE LIKE ? ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM album a_mt \
WHERE a_mt.server_id = t.server_id AND a_mt.id = t.album_id \
AND a_mt.name COLLATE NOCASE LIKE ? ESCAPE '\\'))".to_string(),
);
params.push(SqlValue::Text(pat.clone()));
params.push(SqlValue::Text(pat));
}
w.push_params(&format!("({})", parts.join(" OR ")), params);
applied.insert("text".to_string());
}
/// Synced `album` rows + scope filters — plain browse only (no free-text query).
fn try_build_album_from_table(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<Option<(Vec<LibraryAlbumDto>, u32)>, String> {
if scalar_requires_lossless_track_grouping(scalar)
|| scalar_requires_track_derived_entities(scalar)
{
return Ok(None);
}
if !crate::album_browse::album_table_usable(store, &req.server_id)? {
return Ok(None);
}
let table = build_album_from_table(store, req, None, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 {
return Ok(Some(table));
}
Ok(None)
}
fn album_text_hit_key(a: &LibraryAlbumDto) -> (String, String) {
(a.server_id.clone(), a.id.clone())
}
/// Prefer synced `album` rows; fill gaps from FTS / track-derived groups.
fn merge_album_text_hits(
table: Vec<LibraryAlbumDto>,
fts: Vec<LibraryAlbumDto>,
tracks: Vec<LibraryAlbumDto>,
) -> Vec<LibraryAlbumDto> {
let mut by_key: std::collections::HashMap<(String, String), LibraryAlbumDto> =
std::collections::HashMap::new();
for a in tracks {
by_key.entry(album_text_hit_key(&a)).or_insert(a);
}
for a in fts {
by_key.entry(album_text_hit_key(&a)).or_insert(a);
}
for a in table {
by_key.insert(album_text_hit_key(&a), a);
}
let mut out: Vec<LibraryAlbumDto> = by_key.into_values().collect();
out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
out
}
/// All Albums text search — union `album` LIKE, track FTS, and track GROUP BY
/// (substring + prefix). The `album`-table fast path alone misses titles that
/// only appear on track rows (Navidrome parity / Live Search).
#[allow(clippy::too_many_arguments)]
fn build_album_text_search(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: &str,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
applied.insert("text".to_string());
let fetch = limit.saturating_add(offset).clamp(1, PAGE_LIMIT_MAX);
let scope_ids = effective_library_scope_ids(req);
let mut scratch = BTreeSet::new();
let mut table = Vec::new();
// Match `list_albums`: multi-folder scope uses track GROUP BY, not album+EXISTS.
if scope_ids.len() <= 1
&& !scalar_requires_lossless_track_grouping(scalar)
&& !scalar_requires_track_derived_entities(scalar)
&& crate::album_browse::album_table_usable(store, &req.server_id)?
{
table = build_album_from_table(
store,
req,
Some(text),
scalar,
fetch,
0,
true,
&mut scratch,
)?
.0;
}
let mut fts = Vec::new();
let mut tracks = Vec::new();
if server_has_indexed_tracks(store, &req.server_id)? {
if fts_query_meets_min_len(text) {
if let Some(q) = fts_album_text_match_query(req, text) {
fts = build_album_from_fts(
store, req, &q, scalar, fetch, 0, true, &mut scratch,
)?
.0;
}
}
tracks = build_album_from_tracks(
store,
req,
Some(text),
scalar,
fetch,
0,
true,
&mut scratch,
true,
)?
.0;
}
let merged = merge_album_text_hits(table, fts, tracks);
let total = merged.len() as u32;
let page = merged
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok((page, if skip_totals { 0 } else { total }))
}
#[allow(clippy::too_many_arguments)]
fn build_album(
store: &LibraryStore,
@@ -334,12 +581,19 @@ fn build_album(
if req.starred_only == Some(true) {
return build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied);
}
if let Some(t) = text {
return build_album_text_search(
store, req, t, scalar, limit, offset, skip_totals, applied,
);
}
if let Some(table) = try_build_album_from_table(
store, req, scalar, limit, offset, skip_totals, applied,
)? {
return Ok(table);
}
if server_has_indexed_tracks(store, &req.server_id)? {
if let Some(q) = text.and_then(|t| fts_album_text_match_query(req, t)) {
return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
}
return build_album_from_tracks(
store, req, text, scalar, limit, offset, skip_totals, applied, false,
store, req, None, scalar, limit, offset, skip_totals, applied, false,
);
}
if !scalar_requires_track_derived_entities(scalar) {
@@ -367,13 +621,12 @@ fn build_album_from_table(
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
// `album` has no `library_id` / `deleted` columns, so `libraryScope` is
// a track-only filter (P20) and does not narrow album-table results.
// `album` has no `library_id`; scope is enforced via EXISTS on `track`.
let mut w = WhereBuilder::new();
w.push_param("a.server_id = ?", SqlValue::Text(req.server_id.clone()));
push_album_table_library_scope(&mut w, "a", &effective_library_scope_ids(req));
if let Some(t) = text {
w.push_param("a.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
push_album_table_text_match(&mut w, t, applied);
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Album)? {
@@ -434,13 +687,9 @@ fn build_album_from_tracks(
AND a.id = t.album_id AND a.song_count IS NOT NULL)",
);
}
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
if let Some(t) = text {
w.push_param("t.album LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
push_track_group_text_match(&mut w, t, applied);
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
@@ -459,7 +708,9 @@ fn build_album_from_tracks(
applied,
);
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
let select = "t.server_id, t.album_id, \
MAX(COALESCE((SELECT a2.name FROM album a2 WHERE a2.server_id = t.server_id AND a2.id = t.album_id), t.album)), \
MAX(t.artist), MAX(t.artist_id), \
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
MAX(t.starred_at), MAX(t.synced_at)";
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
@@ -558,10 +809,7 @@ fn build_artist_from_tracks(
w.push_raw(
"NOT EXISTS (SELECT 1 FROM artist ar WHERE ar.server_id = t.server_id AND ar.id = t.artist_id)",
);
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
if let Some(t) = text {
w.push_param("t.artist LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
@@ -605,110 +853,94 @@ fn build_album_from_fts(
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
applied.insert("text".to_string());
let need = limit.saturating_add(offset) as i64;
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let fetch = limit.saturating_add(offset).clamp(1, PAGE_LIMIT_MAX);
let pool = fts_candidate_pool_size(fetch, 0);
let scope_ids = effective_library_scope_ids(req);
let mut w = WhereBuilder::new();
w.push_params(
&format!(
"t.rowid IN ({})",
scoped_fts_rowid_subquery_sql(pool, scope.as_deref())
),
{
let mut p = vec![SqlValue::Text(fts.to_string())];
p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref()));
p
},
);
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
if let Some(scope) = scope {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
let mut extra = WhereBuilder::new();
extra.push_raw("t.deleted = 0");
extra.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
extra.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
push_library_scope_where(&mut extra, "t", &scope_ids);
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
w.push(frag);
extra.push(frag);
}
}
if req.starred_only == Some(true) {
w.push_raw("t.starred_at IS NOT NULL");
extra.push_raw("t.starred_at IS NOT NULL");
applied.insert("starred".to_string());
}
push_album_id_allowlist(
&mut w,
&mut extra,
"t.album_id",
req.restrict_album_ids.as_deref(),
applied,
);
let extra_sql = extra.where_sql();
let where_sql = w.where_sql();
let scope_tail = if let (Some(clause), _) = library_scope_filter_sql("t_fts", &scope_ids) {
format!(" AND {clause}")
} else {
String::new()
};
store.with_read_conn(|conn| {
let sql = format!(
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
"SELECT t.server_id, t.album_id, \
COALESCE(a.name, t.album), t.artist, t.artist_id, t.year, \
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
FROM track t \
WHERE {where_sql}"
FROM (\
SELECT f.rowid, bm25(track_fts) AS fts_rank \
FROM track_fts f \
JOIN track t_fts ON t_fts.rowid = f.rowid \
WHERE track_fts MATCH ? \
AND t_fts.server_id = ? \
AND t_fts.deleted = 0{scope_tail} \
ORDER BY fts_rank \
LIMIT {pool}\
) fts_pick \
JOIN track t ON t.rowid = fts_pick.rowid \
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id \
WHERE {extra_sql} \
GROUP BY t.album_id \
ORDER BY MIN(fts_pick.fts_rank) \
LIMIT ? OFFSET ?",
);
let params = w.params.clone();
let mut params: Vec<SqlValue> = Vec::new();
params.push(SqlValue::Text(fts.to_string()));
params.extend(scoped_fts_subquery_bind(&req.server_id, &scope_ids));
params.extend(extra.params);
params.push(SqlValue::Integer(fetch as i64));
params.push(SqlValue::Integer(offset as i64));
let mut stmt = conn.prepare(&sql)?;
let rows: Vec<AlbumBrowseTrackRow> =
stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
r.get(8)?,
r.get(9)?,
))
let albums: Vec<LibraryAlbumDto> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: None,
duration_sec: None,
year: r.get(5)?,
genre: r.get(6)?,
cover_art_id: r.get(7)?,
starred_at: r.get(8)?,
synced_at: r.get(9)?,
raw_json: Value::Null,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut seen = HashSet::new();
let mut deduped: Vec<LibraryAlbumDto> = Vec::new();
for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows {
if !seen.insert(album_id.clone()) {
continue;
}
deduped.push(LibraryAlbumDto {
server_id,
id: album_id,
name: album,
artist,
artist_id,
song_count: None,
duration_sec: None,
year,
genre,
cover_art_id,
starred_at,
synced_at,
raw_json: Value::Null,
});
if deduped.len() >= need as usize {
break;
}
}
let total = if skip_totals {
0
} else {
deduped.len() as u32
albums.len() as u32
};
let page = deduped
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok((page, total))
Ok((albums, total))
})
}
@@ -727,27 +959,24 @@ fn build_artist_from_fts(
applied.insert("text".to_string());
let need = limit.saturating_add(offset) as i64;
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let scope_ids = effective_library_scope_ids(req);
let mut w = WhereBuilder::new();
w.push_params(
&format!(
"t.rowid IN ({})",
scoped_fts_rowid_subquery_sql(pool, scope.as_deref())
scoped_fts_rowid_subquery_sql(pool, &scope_ids)
),
{
let mut p = vec![SqlValue::Text(fts.to_string())];
p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref()));
p.extend(scoped_fts_subquery_bind(&req.server_id, &scope_ids));
p
},
);
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''");
if let Some(scope) = scope {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
push_library_scope_where(&mut w, "t", &scope_ids);
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
@@ -1395,6 +1624,17 @@ mod tests {
.unwrap();
}
fn mark_album_catalog_row(store: &LibraryStore, server: &str, id: &str) {
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 1 WHERE server_id = ?1 AND id = ?2",
rusqlite::params![server, id],
)
})
.unwrap();
}
fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) {
store
.with_conn("misc", |c| {
@@ -1411,6 +1651,7 @@ mod tests {
LibraryAdvancedSearchRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
query: None,
entity_types: entities.to_vec(),
filters: Vec::new(),
@@ -1475,6 +1716,8 @@ mod tests {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al1", "Aurora Nights", None, None);
insert_album(&store, "s1", "al2", "Other", None, None);
mark_album_catalog_row(&store, "s1", "al1");
mark_album_catalog_row(&store, "s1", "al2");
insert_artist(&store, "s1", "ar1", "Aurora Quartet");
let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]);
r.query = Some("aurora".into());
@@ -1485,6 +1728,169 @@ mod tests {
assert_eq!(resp.artists[0].id, "ar1");
}
#[test]
fn album_title_search_matches_any_query_word_via_like() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_moon", "The Dark Side of the Moon", None, None);
insert_album(&store, "s1", "al_other", "Wish You Were Here", None, None);
mark_album_catalog_row(&store, "s1", "al_moon");
mark_album_catalog_row(&store, "s1", "al_other");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("moon side".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_moon");
}
#[test]
fn album_title_search_matches_later_word_only() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_moon", "The Dark Side of the Moon", None, None);
mark_album_catalog_row(&store, "s1", "al_moon");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("moon".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_moon");
}
#[test]
fn album_title_search_matches_synced_name_when_track_tag_differs() {
let store = LibraryStore::open_in_memory();
insert_album(
&store,
"s1",
"al_kerrang",
"Kerrang! Metallica Master of Puppets Revisited",
None,
None,
);
mark_album_catalog_row(&store, "s1", "al_kerrang");
let mut tr = track("s1", "t1", "A", "Various", "Master of Puppets Revisited");
tr.album_id = Some("al_kerrang".into());
TrackRepository::new(&store).upsert_batch(&[tr]).unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_kerrang");
}
fn track_with_lib(
server: &str,
id: &str,
album_id: &str,
album: &str,
library_id: Option<&str>,
) -> TrackRow {
let mut t = track(server, id, "A", "art-1", album);
t.album_id = Some(album_id.into());
t.library_id = library_id.map(str::to_string);
t
}
#[test]
fn album_title_text_search_respects_single_library_scope() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_lib("s1", "t1", "al-in", "Metallica", Some("lib-a")),
track_with_lib("s1", "t2", "al-out", "Metallica Covers", Some("lib-b")),
])
.unwrap();
insert_album(&store, "s1", "al-in", "Metallica", None, None);
insert_album(&store, "s1", "al-out", "Metallica Covers", None, None);
mark_album_catalog_row(&store, "s1", "al-in");
mark_album_catalog_row(&store, "s1", "al-out");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
r.library_scope_ids = Some(vec!["lib-a".into()]);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al-in");
}
#[test]
fn album_title_text_search_unions_multi_library_scope() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_lib("s1", "t1", "al-a", "Alpha Metallica", Some("lib-a")),
track_with_lib("s1", "t2", "al-b", "Beta Metallica", Some("lib-b")),
track_with_lib("s1", "t3", "al-c", "Gamma Other", Some("lib-c")),
])
.unwrap();
insert_album(&store, "s1", "al-a", "Alpha Metallica", None, None);
insert_album(&store, "s1", "al-b", "Beta Metallica", None, None);
mark_album_catalog_row(&store, "s1", "al-a");
mark_album_catalog_row(&store, "s1", "al-b");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
r.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 2, "expected {ids:?}");
assert!(ids.contains(&"al-a"));
assert!(ids.contains(&"al-b"));
}
#[test]
fn album_title_text_search_unions_sparse_table_and_track_catalog() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_self", "Metallica", None, None);
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 10 WHERE server_id = 's1' AND id = 'al_self'",
[],
)
})
.unwrap();
let mut plays = track("s1", "t1", "A", "Apocalyptica", "Plays Metallica Vol. 2");
plays.album_id = Some("al_plays".into());
let mut blacklist = track("s1", "t2", "B", "Various", "The Metallica Blacklist");
blacklist.album_id = Some("al_black".into());
let mut other = track("s1", "t3", "C", "Pink Floyd", "Wish You Were Here");
other.album_id = Some("al_wish".into());
TrackRepository::new(&store)
.upsert_batch(&[plays, blacklist, other])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 3);
assert!(ids.contains(&"al_self"));
assert!(ids.contains(&"al_plays"));
assert!(ids.contains(&"al_black"));
}
#[test]
fn album_title_search_fts_matches_any_word_on_track_catalog() {
let store = LibraryStore::open_in_memory();
let mut moon = track("s1", "t1", "Breathe", "Pink Floyd", "The Dark Side of the Moon");
moon.album_id = Some("al_moon".into());
let mut other = track("s1", "t2", "Shine", "Pink Floyd", "Wish You Were Here");
other.album_id = Some("al_wish".into());
TrackRepository::new(&store)
.upsert_batch(&[moon, other])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("wish moon".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"al_moon"));
assert!(ids.contains(&"al_wish"));
}
#[test]
fn text_query_derives_album_and_artist_from_tracks_when_tables_empty() {
let store = LibraryStore::open_in_memory();
@@ -0,0 +1,499 @@
//! Paginated All Albums browse from the local index (plain, no filters).
//!
//! Prefers the synced `album` table (≈5k rows) with LIMIT/OFFSET. Falls back to
//! track `GROUP BY` only when the album catalog is not populated (N1 ingest).
use crate::dto::{
LibraryAlbumBrowseRequest, LibraryAlbumBrowseResponse, LibraryAlbumDto, LibrarySortClause,
SortDir,
};
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
s.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
}
fn effective_scope_ids(req: &LibraryAlbumBrowseRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn album_table_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "a.name COLLATE NOCASE",
"artist" => "a.artist COLLATE NOCASE",
"year" => "a.year",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
keys.push("a.name COLLATE NOCASE ASC".to_string());
}
keys.push("a.id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
fn track_group_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
"year" => "COALESCE(a.year, la.year)",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
}
keys.push("la.album_id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
fn push_album_id_allowlist(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
column: &str,
ids: Option<&[String]>,
) {
let Some(ids) = ids else {
return;
};
if ids.is_empty() {
where_clauses.push("1 = 0".to_string());
return;
}
let placeholders = (0..ids.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
where_clauses.push(format!("{column} IN ({placeholders})"));
for id in ids {
params.push(SqlValue::Text(id.clone()));
}
}
/// `album` rows have no `library_id`; scope is enforced via matching tracks.
fn push_album_table_library_scope(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
scope_ids: &[String],
) {
if scope_ids.is_empty() {
return;
}
let (clause, scope_params) = library_scope_filter_sql("t_scope", scope_ids);
let Some(scope_clause) = clause else {
return;
};
where_clauses.push(format!(
"EXISTS (SELECT 1 FROM track t_scope \
WHERE t_scope.server_id = a.server_id \
AND t_scope.album_id = a.id \
AND t_scope.deleted = 0 \
AND {scope_clause})"
));
params.extend(scope_params);
}
fn map_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null),
})
}
pub(crate) fn album_table_usable(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
store
.with_read_conn(|c| {
c.query_row(
"SELECT EXISTS(
SELECT 1 FROM album
WHERE server_id = ?1 AND song_count IS NOT NULL
LIMIT 1
)",
rusqlite::params![server_id],
|r| r.get(0),
)
})
.map_err(|e| e.to_string())
}
fn list_albums_from_table(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let limit = req.limit.max(1);
let offset = req.offset;
let order_sql = album_table_order_sql(&req.sort);
let mut where_clauses = vec!["a.server_id = ?1".to_string()];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_scope_ids(req);
push_album_table_library_scope(&mut where_clauses, &mut params, &scope_ids);
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"a.id",
req.restrict_album_ids.as_deref(),
);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
a.server_id, \
a.id, \
a.name, \
a.artist, \
a.artist_id, \
a.song_count, \
a.duration_sec, \
a.year, \
a.genre, \
a.cover_art_id, \
a.starred_at, \
a.synced_at, \
a.raw_json \
FROM album a \
WHERE {where_sql} \
{order_sql} \
LIMIT ? OFFSET ?"
);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryAlbumBrowseResponse {
albums,
has_more,
source: "local".to_string(),
})
}
fn list_albums_from_tracks(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let limit = req.limit.max(1);
let offset = req.offset;
let order_sql = track_group_order_sql(&req.sort);
let mut where_clauses = vec![
"t.deleted = 0".to_string(),
"t.server_id = ?1".to_string(),
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_scope_ids(req);
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
params.extend(scope_params);
}
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"t.album_id",
req.restrict_album_ids.as_deref(),
);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
COALESCE(a.year, la.year), \
COALESCE(a.genre, la.genre), \
COALESCE(a.cover_art_id, la.cover_art_id), \
COALESCE(a.starred_at, la.starred_at), \
COALESCE(a.synced_at, la.synced_at), \
a.raw_json \
FROM ( \
SELECT \
t.server_id, \
t.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
MAX(t.cover_art_id) AS cover_art_id, \
MAX(t.starred_at) AS starred_at, \
MAX(t.synced_at) AS synced_at, \
COUNT(*) AS track_count, \
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \
FROM track t \
WHERE {where_sql} \
GROUP BY t.server_id, t.album_id \
) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
{order_sql} \
LIMIT ? OFFSET ?"
);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryAlbumBrowseResponse {
albums,
has_more,
source: "local".to_string(),
})
}
fn browse_is_scoped(req: &LibraryAlbumBrowseRequest) -> bool {
!effective_scope_ids(req).is_empty()
|| req
.restrict_album_ids
.as_ref()
.is_some_and(|ids| !ids.is_empty())
}
pub fn list_albums(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let scope_ids = effective_scope_ids(req);
// Unscoped, or a single library: `album` table + EXISTS (fast on ~5k rows).
// Multi-library union: filter tracks by `library_id IN (...)` then GROUP BY.
if album_table_usable(store, &req.server_id)?
&& (!browse_is_scoped(req) || scope_ids.len() == 1)
{
return list_albums_from_table(store, req);
}
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(LibraryAlbumBrowseResponse {
albums: Vec::new(),
has_more: false,
source: "local".to_string(),
});
}
list_albums_from_tracks(store, req)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
fn track(server: &str, id: &str, album_id: &str, album: &str, library_id: Option<&str>) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: format!("{album} track"),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some("art-1".into()),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: Some("mp3".into()),
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: library_id.map(String::from),
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
fn req(server: &str, limit: u32, offset: u32) -> LibraryAlbumBrowseRequest {
LibraryAlbumBrowseRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
sort: Vec::new(),
restrict_album_ids: None,
limit,
offset,
}
}
fn seed_album(
store: &LibraryStore,
server_id: &str,
id: &str,
name: &str,
song_count: i64,
) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, artist, song_count, duration_sec, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 'Band', ?4, 400, 1, '{}')",
rusqlite::params![server_id, id, name, song_count],
)
})
.unwrap();
}
#[test]
fn lists_albums_grouped_from_tracks() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", None),
track("s1", "t2", "al-2", "Beta", None),
])
.unwrap();
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 2);
assert!(!resp.has_more);
}
#[test]
fn prefers_album_table_when_synced_catalog_exists() {
let store = LibraryStore::open_in_memory();
seed_album(&store, "s1", "al-1", "Alpha", 10);
seed_album(&store, "s1", "al-2", "Beta", 8);
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].name, "Alpha");
assert_eq!(resp.albums[1].name, "Beta");
}
#[test]
fn library_scope_narrows_results() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "In", Some("lib-a")),
track("s1", "t2", "al-2", "Out", Some("lib-b")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "In", 1);
seed_album(&store, "s1", "al-2", "Out", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al-1");
}
#[test]
fn multi_library_scope_unions_albums() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
track("s1", "t2", "al-2", "Beta", Some("lib-b")),
track("s1", "t3", "al-3", "Gamma", Some("lib-c")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "Alpha", 1);
seed_album(&store, "s1", "al-2", "Beta", 1);
seed_album(&store, "s1", "al-3", "Gamma", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al-1");
assert_eq!(resp.albums[1].id, "al-2");
}
#[test]
fn multi_library_scope_includes_track_only_albums() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
track("s1", "t2", "al-2", "Zulu", Some("lib-b")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "Alpha", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al-1");
assert_eq!(resp.albums[1].id, "al-2");
}
}
@@ -6,7 +6,7 @@ use tauri::State;
use crate::dto::CatalogYearBoundsDto;
use crate::dto::GenreAlbumCountDto;
use crate::runtime::LibraryRuntime;
use crate::search::library_scope_equals_sql;
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
#[derive(Debug, Clone, serde::Deserialize)]
@@ -106,11 +106,34 @@ pub fn library_get_catalog_year_bounds(
catalog_year_bounds_for_server(&runtime.store, &server_id)
}
fn effective_genre_count_scope_ids(
library_scope: Option<&str>,
library_scope_ids: Option<&[String]>,
) -> Vec<String> {
if let Some(ids) = library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
library_scope
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| vec![s.to_string()])
.unwrap_or_default()
}
pub(crate) fn genre_album_counts_for_server(
store: &LibraryStore,
server_id: &str,
library_scope: Option<&str>,
library_scope_ids: Option<&[String]>,
) -> Result<Vec<GenreAlbumCountDto>, String> {
let scope_ids = effective_genre_count_scope_ids(library_scope, library_scope_ids);
store
.with_read_conn(|conn| {
let mut sql = String::from(
@@ -122,9 +145,9 @@ pub(crate) fn genre_album_counts_for_server(
);
let mut params: Vec<rusqlite::types::Value> =
vec![rusqlite::types::Value::Text(server_id.to_string())];
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
sql.push_str(&format!(" AND {}", library_scope_equals_sql("t")));
params.push(rusqlite::types::Value::Text(scope.to_string()));
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
sql.push_str(&format!(" AND {clause}"));
params.extend(scope_params);
}
sql.push_str(
" GROUP BY t.genre COLLATE NOCASE \
@@ -151,11 +174,13 @@ pub fn library_get_genre_album_counts(
runtime: State<'_, LibraryRuntime>,
server_id: String,
library_scope: Option<String>,
library_scope_ids: Option<Vec<String>>,
) -> Result<Vec<GenreAlbumCountDto>, String> {
genre_album_counts_for_server(
&runtime.store,
&server_id,
library_scope.as_deref(),
library_scope_ids.as_deref(),
)
}
@@ -307,7 +332,7 @@ mod tests {
.upsert_batch(&rock_one)
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None, None).unwrap();
assert_eq!(counts.len(), 2);
assert_eq!(counts[0].value, "Rock");
assert_eq!(counts[0].album_count, 2);
@@ -330,7 +355,7 @@ mod tests {
.upsert_batch(&[scoped, other])
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1"), None).unwrap();
assert_eq!(counts.len(), 1);
assert_eq!(counts[0].value, "Rock");
assert_eq!(counts[0].album_count, 1);
@@ -20,10 +20,16 @@ use crate::cover_resolve::CoverEntryDto;
use crate::cross_server;
use crate::dto::{
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryClusterAdvancedSearchRequest, LibraryClusterListTracksRequest, LibraryClusterResolveRequest,
LibraryClusterResolveResponse, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse,
LibraryClusterScopeRequest, LibraryClusterPlayerStatsRequest, LibraryClusterPlayerStatsDayDetailRequest,
LibraryClusterEntityDetailRequest, LibraryClusterAlbumDetailResponse,
LibraryClusterArtistDetailResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
PlaySessionInputDto, PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto,
PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
TrackArtifactDto, TrackFactDto, TrackRefDto,
};
use crate::live_search;
@@ -473,6 +479,16 @@ pub async fn library_advanced_search(
library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await
}
#[tauri::command]
pub async fn library_cluster_advanced_search(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterAdvancedSearchRequest,
) -> Result<LibraryAdvancedSearchResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::server_cluster::run_cluster_advanced_search(&store, request))
.await
}
#[tauri::command]
pub async fn library_list_lossless_albums(
runtime: State<'_, LibraryRuntime>,
@@ -482,6 +498,15 @@ pub async fn library_list_lossless_albums(
library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await
}
#[tauri::command]
pub async fn library_list_albums(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryAlbumBrowseRequest,
) -> Result<crate::dto::LibraryAlbumBrowseResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::album_browse::list_albums(&store, &request)).await
}
#[tauri::command]
pub async fn library_list_albums_by_genre(
runtime: State<'_, LibraryRuntime>,
@@ -546,6 +571,265 @@ pub async fn library_search_cross_server(
cross_server::run_cross_server_search(&runtime.store, &query, limit, servers.as_deref())
}
#[tauri::command]
pub async fn library_cluster_list_tracks(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryTracksEnvelope, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_tracks(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_albums(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryClusterAlbumsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_albums(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_artists(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryClusterArtistsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_artists(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorites(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryTracksEnvelope, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_tracks(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorite_albums(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryClusterAlbumsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_albums(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorite_artists(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryClusterArtistsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_artists(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub fn library_cluster_player_stats_year_summary(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsRequest,
) -> Result<PlaySessionYearSummaryDto, String> {
crate::server_cluster::cluster_year_summary(
&runtime.store,
&request.servers_ordered,
request.year,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_heatmap(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsRequest,
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
crate::server_cluster::cluster_heatmap(
&runtime.store,
&request.servers_ordered,
request.year,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_day_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsDayDetailRequest,
) -> Result<PlaySessionDayDetailDto, String> {
crate::server_cluster::cluster_day_detail(
&runtime.store,
&request.servers_ordered,
&request.date_iso,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_recent_days(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
crate::server_cluster::cluster_recent_days(
&runtime.store,
&request.servers_ordered,
request.limit.unwrap_or(30),
)
}
#[tauri::command]
pub fn library_cluster_player_stats_most_played(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<Vec<PlaySessionMostPlayedDto>, String> {
crate::server_cluster::cluster_most_played(
&runtime.store,
&request.servers_ordered,
request.limit.unwrap_or(50),
)
}
#[tauri::command]
pub async fn library_cluster_resolve_candidates(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterResolveRequest,
) -> Result<LibraryClusterResolveResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || {
if let Some(key) = request.cluster_key.filter(|k| !k.is_empty()) {
let candidates = crate::server_cluster::resolve_candidates_by_cluster_key(
&store,
&request.servers_ordered,
&key,
)?;
return Ok(LibraryClusterResolveResponse {
candidates,
cluster_key: Some(key),
});
}
let server_id = request
.server_id
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| "cluster_key or (server_id, track_id) required".to_string())?;
let track_id = request
.track_id
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| "cluster_key or (server_id, track_id) required".to_string())?;
let cluster_key =
crate::server_cluster::cluster_key_for_track(&store, server_id, track_id)?;
let candidates = crate::server_cluster::resolve_candidates_for_track(
&store,
&request.servers_ordered,
server_id,
track_id,
)?;
Ok(LibraryClusterResolveResponse {
candidates,
cluster_key,
})
})
.await
}
#[tauri::command]
pub async fn library_cluster_album_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterEntityDetailRequest,
) -> Result<LibraryClusterAlbumDetailResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let server_id = request.server_id;
let entity_id = request.entity_id;
library_spawn_blocking(move || {
crate::server_cluster::cluster_album_detail(&store, &servers_ordered, &server_id, &entity_id)
})
.await
}
#[tauri::command]
pub async fn library_cluster_artist_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterEntityDetailRequest,
) -> Result<LibraryClusterArtistDetailResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let server_id = request.server_id;
let entity_id = request.entity_id;
library_spawn_blocking(move || {
crate::server_cluster::cluster_artist_detail(&store, &servers_ordered, &server_id, &entity_id)
})
.await
}
#[tauri::command]
pub async fn library_search_cluster(
runtime: State<'_, LibraryRuntime>,
query: String,
limit: Option<u32>,
offset: Option<u32>,
servers_ordered: Vec<String>,
) -> Result<LibraryCrossServerSearchResponse, String> {
let store = Arc::clone(&runtime.store);
let limit = limit.unwrap_or(100);
let offset = offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::run_cluster_search(&store, &query, limit, offset, &servers_ordered)
})
.await
}
// ── helpers ──────────────────────────────────────────────────────────
fn hydrate_refs(
@@ -913,6 +1197,12 @@ async fn library_sync_start_inner(
};
if let Some(runtime) = app_for_emit.try_state::<LibraryRuntime>() {
let _ = runtime.store.checkpoint_wal("sync.checkpoint");
if outcome.ok {
let _ = crate::server_cluster::rebuild_cluster_keys_for_server(
&runtime.store,
&server_id_for_emit,
);
}
}
let _ = app_for_emit.emit(LibrarySyncProgressPayload::IDLE_EVENT_NAME, &outcome);
@@ -4,6 +4,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use crate::filter::{EntityKind, FilterOp};
use crate::repos::TrackRow;
@@ -365,6 +366,14 @@ pub struct PlaySessionRecentDayDto {
pub partial_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionMostPlayedDto {
pub track: LibraryTrackDto,
pub track_play_count: u32,
pub total_listened_sec: f64,
}
/// Earliest/latest calendar years with at least one session (local TZ).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -390,6 +399,38 @@ pub struct GenreAlbumCountDto {
pub song_count: u32,
}
/// `library_list_albums` request — paginated plain All Albums browse (local index).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumBrowseRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default = "default_album_browse_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_album_browse_limit() -> u32 {
30
}
/// `library_list_albums` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumBrowseResponse {
pub albums: Vec<LibraryAlbumDto>,
pub has_more: bool,
pub source: String,
}
/// `library_list_albums_by_genre` request — paginated genre album browse (local index).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -399,6 +440,8 @@ pub struct LibraryGenreAlbumsRequest {
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
#[serde(default = "default_genre_album_limit")]
pub limit: u32,
@@ -520,6 +563,9 @@ pub struct LibraryAdvancedSearchRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Multiple music-folder ids (OR). Takes precedence over `library_scope` when non-empty.
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub query: Option<String>,
pub entity_types: Vec<EntityKind>,
@@ -605,6 +651,14 @@ pub struct LibraryLosslessAlbumsRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Multiple music-folder ids (OR). Preferred over `library_scope` when length > 1.
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
/// Navidrome-scoped album ids from getAlbumList2 (authoritative when track `library_id` is sparse).
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default = "default_lossless_limit")]
pub limit: u32,
#[serde(default)]
@@ -658,6 +712,158 @@ pub struct LibraryCrossServerSearchResponse {
pub servers_searched: Vec<String>,
}
/// Cluster candidate row for playback / write fan-out resolution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterCandidateDto {
pub server_id: String,
pub track_id: String,
pub duration_sec: i64,
pub priority_rank: u32,
pub is_winner: bool,
}
/// `library_cluster_list_tracks` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterListTracksRequest {
/// Ordered member server ids (index 0 = highest priority).
pub servers_ordered: Vec<String>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub offset: Option<u32>,
/// Per-member music-folder scopes (`server_id` → folder ids). Omitted members = all libraries.
#[serde(default)]
pub library_scopes: HashMap<String, Vec<String>>,
}
/// `library_cluster_advanced_search` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAdvancedSearchRequest {
/// Ordered member server ids (index 0 = highest priority).
pub servers_ordered: Vec<String>,
#[serde(default)]
pub query: Option<String>,
pub entity_types: Vec<EntityKind>,
#[serde(default)]
pub filters: Vec<LibraryFilterClause>,
#[serde(default)]
pub starred_only: Option<bool>,
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
/// Per-member album allowlists from getAlbumList2 (`server_id` → album ids).
#[serde(default)]
pub restrict_album_scopes: HashMap<String, Vec<String>>,
#[serde(default)]
pub query_album_title_only: Option<bool>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
pub limit: u32,
#[serde(default)]
pub offset: u32,
#[serde(default)]
pub skip_totals: bool,
/// Per-member music-folder scopes (`server_id` → folder ids). Omitted members = all libraries.
#[serde(default)]
pub library_scopes: HashMap<String, Vec<String>>,
}
/// Merged album browse response for cluster scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAlbumsResponse {
pub albums: Vec<LibraryAlbumDto>,
pub has_more: bool,
}
/// Merged artist browse response for cluster scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterArtistsResponse {
pub artists: Vec<LibraryArtistDto>,
pub has_more: bool,
}
/// Cluster player stats / favorites scope request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterScopeRequest {
pub servers_ordered: Vec<String>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterPlayerStatsRequest {
pub servers_ordered: Vec<String>,
pub year: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterPlayerStatsDayDetailRequest {
pub servers_ordered: Vec<String>,
pub date_iso: String,
}
/// `library_cluster_resolve_candidates` request — provide cluster_key OR seed track.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterResolveRequest {
pub servers_ordered: Vec<String>,
#[serde(default)]
pub cluster_key: Option<String>,
#[serde(default)]
pub server_id: Option<String>,
#[serde(default)]
pub track_id: Option<String>,
}
/// `library_cluster_resolve_candidates` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterResolveResponse {
pub candidates: Vec<LibraryClusterCandidateDto>,
#[serde(default)]
pub cluster_key: Option<String>,
}
/// `library_cluster_album_detail` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterEntityDetailRequest {
pub servers_ordered: Vec<String>,
pub server_id: String,
pub entity_id: String,
}
/// Virtual aggregate album detail (spec §4).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAlbumDetailResponse {
pub album: LibraryAlbumDto,
pub tracks: Vec<LibraryTrackDto>,
pub owner_server_id: String,
pub related_albums: Vec<LibraryAlbumDto>,
}
/// Virtual aggregate artist detail (spec §4).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterArtistDetailResponse {
pub artist: LibraryArtistDto,
pub albums: Vec<LibraryAlbumDto>,
pub top_tracks: Vec<LibraryTrackDto>,
pub owner_server_id: String,
#[serde(default)]
pub artist_key: Option<String>,
}
/// Read `MAX(server_updated_at)` for non-deleted tracks on this server
/// — used by `SyncStateDto` so callers can show "tracks watermark" in
/// Settings without a separate column.
@@ -7,7 +7,7 @@ use crate::dto::{
LibraryAlbumDto, LibraryGenreAlbumsRequest, LibraryGenreAlbumsResponse, LibrarySortClause,
SortDir,
};
use crate::search::library_scope_equals_sql;
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
@@ -18,6 +18,22 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
.map(String::from)
}
fn effective_genre_scope_ids(req: &LibraryGenreAlbumsRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
@@ -117,9 +133,10 @@ pub fn list_albums_by_genre(
SqlValue::Text(genre.to_string()),
];
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
where_clauses.push(library_scope_equals_sql("t"));
params.push(SqlValue::Text(scope));
let scope_ids = effective_genre_scope_ids(req);
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
params.extend(scope_params);
}
let where_sql = where_clauses.join(" AND ");
@@ -253,6 +270,7 @@ mod tests {
server_id: "s1".into(),
genre: "Rock".into(),
library_scope: Some("lib1".into()),
library_scope_ids: None,
sort: vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
@@ -272,6 +290,7 @@ mod tests {
server_id: "s1".into(),
genre: "Rock".into(),
library_scope: None,
library_scope_ids: None,
sort: vec![],
limit: 1,
offset: 0,
@@ -9,6 +9,7 @@
pub(crate) mod bulk_ingest;
pub mod advanced_search;
pub mod album_browse;
pub mod album_compilation_filter;
pub mod browse_support;
mod advanced_search_mood;
@@ -33,6 +34,7 @@ pub mod payload;
pub mod repos;
pub mod runtime;
pub mod search;
pub mod server_cluster;
pub mod store;
pub mod sync;
pub(crate) mod track_fts;
@@ -2,9 +2,12 @@
//!
//! Mirrors the frontend allowlist in `src/utils/library/losslessFormats.ts`.
use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse};
use crate::dto::{
LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse, LibrarySortClause,
SortDir,
};
use crate::lossless_formats::track_is_lossless_sql;
use crate::search::library_scope_equals_sql;
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
@@ -15,6 +18,45 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
.map(String::from)
}
fn effective_lossless_scope_ids(req: &LibraryLosslessAlbumsRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn lossless_album_order(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
return "ORDER BY la.max_bit_depth DESC, \
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, la.album_id ASC"
.to_string();
}
keys.push("la.album_id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
/// Paginated lossless albums for one server. Returns empty when the index has
/// no matching tracks — caller may fall back to the Navidrome song-stream walk.
pub fn list_lossless_albums(
@@ -37,12 +79,25 @@ pub fn list_lossless_albums(
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
where_clauses.push(clause);
params.push(SqlValue::Text(scope));
let scope_ids = effective_lossless_scope_ids(req);
if !scope_ids.is_empty() {
let match_expr = crate::search::library_scope_match_sql("t");
where_clauses.push(format!("({match_expr}) IS NOT NULL AND TRIM({match_expr}) != ''"));
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
for p in scope_params {
params.push(p);
}
}
}
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"t.album_id",
req.restrict_album_ids.as_deref(),
);
let order_sql = lossless_album_order(&req.sort);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
@@ -81,9 +136,7 @@ pub fn list_lossless_albums(
GROUP BY t.server_id, t.album_id \
) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
ORDER BY la.max_bit_depth DESC, \
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, \
la.album_id ASC \
{order_sql} \
LIMIT ? OFFSET ?"
);
@@ -106,6 +159,26 @@ pub fn list_lossless_albums(
})
}
fn push_album_id_allowlist(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
column: &str,
ids: Option<&[String]>,
) {
let Some(ids) = ids else {
return;
};
if ids.is_empty() {
where_clauses.push("1 = 0".to_string());
return;
}
let placeholders = (0..ids.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
where_clauses.push(format!("{column} IN ({placeholders})"));
for id in ids {
params.push(SqlValue::Text(id.clone()));
}
}
fn empty_response() -> LibraryLosslessAlbumsResponse {
LibraryLosslessAlbumsResponse {
albums: Vec::new(),
@@ -203,6 +276,9 @@ mod tests {
LibraryLosslessAlbumsRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
sort: Vec::new(),
restrict_album_ids: None,
limit,
offset,
}
@@ -271,6 +347,79 @@ mod tests {
assert_eq!(resp.albums[0].id, "al1");
}
#[test]
fn library_scope_ids_union_narrows_results() {
let store = LibraryStore::open_in_memory();
let mut a = track_with_suffix("s1", "t1", "al1", "A", "flac", 16);
a.library_id = Some("lib1".into());
let mut b = track_with_suffix("s1", "t2", "al2", "B", "flac", 16);
b.library_id = Some("lib2".into());
let mut c = track_with_suffix("s1", "t3", "al3", "C", "flac", 16);
c.library_id = Some("lib3".into());
TrackRepository::new(&store)
.upsert_batch(&[a, b, c])
.unwrap();
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib1".into(), "lib3".into()]);
let resp = list_lossless_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al1");
assert_eq!(resp.albums[1].id, "al3");
}
#[test]
fn name_sort_overrides_bit_depth_default() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_suffix("s1", "t1", "al_z", "Zulu", "flac", 24),
track_with_suffix("s1", "t2", "al_a", "Alpha", "flac", 16),
])
.unwrap();
let mut sorted = req("s1", 50, 0);
sorted.sort = vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
}];
let resp = list_lossless_albums(&store, &sorted).unwrap();
assert_eq!(resp.albums[0].id, "al_a");
assert_eq!(resp.albums[1].id, "al_z");
}
#[test]
fn matches_suffix_from_raw_json_when_column_null() {
let store = LibraryStore::open_in_memory();
let mut row = track_with_suffix("s1", "t1", "al_json", "Json", "mp3", 0);
row.suffix = None;
row.raw_json = r#"{"suffix":"flac","bitDepth":24}"#.into();
TrackRepository::new(&store)
.upsert_batch(&[row])
.unwrap();
let resp = list_lossless_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_json");
}
#[test]
fn restrict_album_ids_narrows_lossless_results() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_suffix("s1", "t1", "al_keep", "Keep", "flac", 24),
track_with_suffix("s1", "t2", "al_drop", "Drop", "flac", 24),
])
.unwrap();
let mut restricted = req("s1", 50, 0);
restricted.restrict_album_ids = Some(vec!["al_keep".into()]);
let resp = list_lossless_albums(&store, &restricted).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_keep");
}
#[test]
fn pagination_sets_has_more() {
let store = LibraryStore::open_in_memory();
@@ -7,14 +7,22 @@ pub const LOSSLESS_SUFFIXES: &[&str] = &[
"flac", "wav", "wave", "aiff", "aif", "dsf", "dff", "ape", "wv", "shn", "tta",
];
/// `LOWER(alias.suffix) IN ('flac', …)` for SQL WHERE clauses.
/// Effective suffix — hot `track.suffix`, then Navidrome `raw_json.suffix`.
pub fn track_suffix_expr(table_alias: &str) -> String {
format!(
"LOWER(COALESCE(NULLIF({table_alias}.suffix, ''), \
CAST(json_extract({table_alias}.raw_json, '$.suffix') AS TEXT), ''))"
)
}
/// `track_suffix_expr IN ('flac', …)` for SQL WHERE clauses.
pub fn track_is_lossless_sql(table_alias: &str) -> String {
let list = LOSSLESS_SUFFIXES
.iter()
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ");
format!("LOWER({table_alias}.suffix) IN ({list})")
format!("{} IN ({list})", track_suffix_expr(table_alias))
}
/// Album has at least one indexed lossless track (same allowlist as browse).
@@ -50,6 +58,6 @@ mod tests {
let sql = track_is_lossless_sql("t");
assert!(sql.contains("'flac'"));
assert!(sql.contains("'tta'"));
assert!(sql.starts_with("LOWER(t.suffix) IN ("));
assert!(sql.contains("json_extract(t.raw_json, '$.suffix')"));
}
}
@@ -171,6 +171,11 @@ pub(crate) fn fts_album_title_prefix_match_query(raw: &str) -> Option<String> {
fts_prefix_token_expr(raw).map(|tokens| format!("album : {tokens}"))
}
/// All Albums title search — any query word may prefix-match the album column.
pub(crate) fn fts_album_title_prefix_any_token_match_query(raw: &str) -> Option<String> {
fts_prefix_token_or_expr(raw).map(|tokens| format!("album : ({tokens})"))
}
/// Live Search album match — any query word may hit album or album_artist (Navidrome parity).
pub(crate) fn fts_album_prefix_any_token_match_query(raw: &str) -> Option<String> {
fts_prefix_token_or_expr(raw).map(|tokens| {
@@ -226,6 +231,35 @@ pub(crate) fn library_scope_equals_sql(table_alias: &str) -> String {
format!("{} = ?", library_scope_match_sql(table_alias))
}
/// `library_id` filter for one or more Navidrome music-folder scopes.
pub(crate) fn library_scope_filter_sql(
table_alias: &str,
scope_ids: &[String],
) -> (Option<String>, Vec<rusqlite::types::Value>) {
use rusqlite::types::Value as SqlValue;
if scope_ids.is_empty() {
return (None, Vec::new());
}
if scope_ids.len() == 1 {
return (
Some(library_scope_equals_sql(table_alias)),
vec![SqlValue::Text(scope_ids[0].clone())],
);
}
let match_sql = library_scope_match_sql(table_alias);
let placeholders = (0..scope_ids.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(", ");
(
Some(format!("{match_sql} IN ({placeholders})")),
scope_ids
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect(),
)
}
pub(crate) fn aliased_track_columns(alias: &str) -> String {
crate::repos::track_columns()
.split(',')
@@ -320,6 +354,36 @@ pub(crate) fn like_contains(raw: &str) -> String {
format!("%{escaped}%")
}
/// Whitespace-split tokens for substring LIKE (any non-empty segment).
pub(crate) fn like_name_tokens(raw: &str) -> Vec<String> {
raw.split_whitespace()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
/// `(col LIKE ? OR …)` — any query word may match as a case-insensitive substring.
pub(crate) fn like_any_token_contains_clause(column: &str, raw: &str) -> Option<(String, Vec<String>)> {
let tokens = like_name_tokens(raw);
if tokens.is_empty() {
return None;
}
let col = format!("{column} COLLATE NOCASE");
if tokens.len() == 1 {
return Some((
format!("{col} LIKE ? ESCAPE '\\'"),
vec![like_contains(&tokens[0])],
));
}
let parts: Vec<String> = tokens
.iter()
.map(|_| format!("{col} LIKE ? ESCAPE '\\'"))
.collect();
let params: Vec<String> = tokens.iter().map(|t| like_contains(t)).collect();
Some((format!("({})", parts.join(" OR ")), params))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -462,6 +526,23 @@ mod tests {
);
}
#[test]
fn fts_album_title_prefix_any_token_match_query_or_words() {
assert_eq!(
fts_album_title_prefix_any_token_match_query("dark side").as_deref(),
Some("album : (\"dark\"* OR \"side\"*)")
);
}
#[test]
fn like_any_token_contains_clause_ors_words() {
let (sql, params) = like_any_token_contains_clause("a.name", "dark side").unwrap();
assert!(sql.contains(" OR "));
assert_eq!(params.len(), 2);
assert_eq!(params[0], "%dark%");
assert_eq!(params[1], "%side%");
}
#[test]
fn fts_track_match_query_or_across_display_columns() {
let q = fts_track_match_query("manowar").unwrap();
@@ -0,0 +1,595 @@
//! Cluster-scope advanced search: run per-server advanced search, then merge
//! winners by cluster identity keys with server-priority precedence.
use std::collections::{BTreeSet, HashMap, HashSet};
use rusqlite::types::Value as SqlValue;
use crate::advanced_search::run_advanced_search;
use crate::dto::{
LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto,
LibraryClusterAdvancedSearchRequest, LibrarySearchTotals, LibraryTrackDto,
};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
pub fn run_cluster_advanced_search(
store: &LibraryStore,
req: LibraryClusterAdvancedSearchRequest,
) -> Result<LibraryAdvancedSearchResponse, String> {
if req.servers_ordered.is_empty() {
return Ok(empty_response(req.skip_totals));
}
let page_limit = req.limit.clamp(1, PAGE_LIMIT_MAX);
let page_offset = req.offset as usize;
let per_server_limit = req
.limit
.saturating_add(req.offset)
.clamp(1, PAGE_LIMIT_MAX);
let mut all_tracks: Vec<LibraryTrackDto> = Vec::new();
let mut all_albums: Vec<LibraryAlbumDto> = Vec::new();
let mut all_artists: Vec<LibraryArtistDto> = Vec::new();
let mut applied_filters = BTreeSet::new();
for server_id in &req.servers_ordered {
let server_req = LibraryAdvancedSearchRequest {
server_id: server_id.clone(),
library_scope: None,
library_scope_ids: req
.library_scopes
.get(server_id)
.filter(|ids| !ids.is_empty())
.cloned(),
query: req.query.clone(),
entity_types: req.entity_types.clone(),
filters: req.filters.clone(),
starred_only: req.starred_only,
restrict_album_ids: req
.restrict_album_scopes
.get(server_id)
.filter(|ids| !ids.is_empty())
.cloned()
.or_else(|| req.restrict_album_ids.clone()),
query_album_title_only: req.query_album_title_only,
sort: req.sort.clone(),
limit: per_server_limit,
offset: 0,
skip_totals: true,
};
let resp = run_advanced_search(store, &server_req)?;
all_tracks.extend(resp.tracks);
all_albums.extend(resp.albums);
all_artists.extend(resp.artists);
applied_filters.extend(resp.applied_filters);
}
let merged_tracks = merge_tracks_by_cluster_key(store, all_tracks)?;
let merged_albums = if req
.query
.as_ref()
.is_some_and(|q| !q.trim().is_empty())
{
dedupe_album_search_hits(all_albums)
} else {
merge_albums_by_album_key(store, all_albums)?
};
let merged_artists = merge_artists_by_artist_key(store, all_artists)?;
let totals = if req.skip_totals {
LibrarySearchTotals::default()
} else {
LibrarySearchTotals {
artists: merged_artists.len() as u32,
albums: merged_albums.len() as u32,
tracks: merged_tracks.len() as u32,
}
};
Ok(LibraryAdvancedSearchResponse {
artists: merged_artists
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
albums: merged_albums
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
tracks: merged_tracks
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
totals,
applied_filters: applied_filters.into_iter().collect(),
source: "local".to_string(),
})
}
fn empty_response(skip_totals: bool) -> LibraryAdvancedSearchResponse {
LibraryAdvancedSearchResponse {
artists: Vec::new(),
albums: Vec::new(),
tracks: Vec::new(),
totals: if skip_totals {
LibrarySearchTotals::default()
} else {
LibrarySearchTotals {
artists: 0,
albums: 0,
tracks: 0,
}
},
applied_filters: Vec::new(),
source: "local".to_string(),
}
}
fn merge_tracks_by_cluster_key(
store: &LibraryStore,
tracks: Vec<LibraryTrackDto>,
) -> Result<Vec<LibraryTrackDto>, String> {
let refs: Vec<(String, String)> = tracks
.iter()
.map(|t| (t.server_id.clone(), t.id.clone()))
.collect();
let key_map = lookup_track_cluster_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for track in tracks {
let key = key_map
.get(&(track.server_id.clone(), track.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", track.server_id, track.id));
if seen.insert(key) {
out.push(track);
}
}
Ok(out)
}
/// Text search — keep distinct `(server_id, album_id)` rows; do not collapse
/// same-server albums that share an `album_key` (tribute / variant titles).
fn dedupe_album_search_hits(albums: Vec<LibraryAlbumDto>) -> Vec<LibraryAlbumDto> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for album in albums {
if seen.insert((album.server_id.clone(), album.id.clone())) {
out.push(album);
}
}
out
}
fn merge_albums_by_album_key(
store: &LibraryStore,
albums: Vec<LibraryAlbumDto>,
) -> Result<Vec<LibraryAlbumDto>, String> {
let refs: Vec<(String, String)> = albums
.iter()
.map(|a| (a.server_id.clone(), a.id.clone()))
.collect();
let key_map = lookup_album_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for album in albums {
let key = key_map
.get(&(album.server_id.clone(), album.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", album.server_id, album.id));
if seen.insert(key) {
out.push(album);
}
}
Ok(out)
}
fn merge_artists_by_artist_key(
store: &LibraryStore,
artists: Vec<LibraryArtistDto>,
) -> Result<Vec<LibraryArtistDto>, String> {
let refs: Vec<(String, String)> = artists
.iter()
.map(|a| (a.server_id.clone(), a.id.clone()))
.collect();
let key_map = lookup_artist_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for artist in artists {
let key = key_map
.get(&(artist.server_id.clone(), artist.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", artist.server_id, artist.id));
if seen.insert(key) {
out.push(artist);
}
}
Ok(out)
}
fn lookup_track_cluster_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, k.cluster_key
FROM wanted w
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = w.server_id AND k.track_id = w.entity_id"
),
)
}
fn lookup_album_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, MIN(k.album_key)
FROM wanted w
LEFT JOIN track t
ON t.server_id = w.server_id AND t.album_id = w.entity_id AND t.deleted = 0
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
GROUP BY w.server_id, w.entity_id"
),
)
}
fn lookup_artist_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, MIN(k.artist_key)
FROM wanted w
LEFT JOIN track t
ON t.server_id = w.server_id
AND COALESCE(NULLIF(t.artist_id, ''), t.artist) = w.entity_id
AND t.deleted = 0
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
GROUP BY w.server_id, w.entity_id"
),
)
}
fn lookup_keys_with_values(
store: &LibraryStore,
refs: &[(String, String)],
query_sql: &str,
) -> Result<HashMap<(String, String), Option<String>>, String> {
if refs.is_empty() {
return Ok(HashMap::new());
}
let values_sql = std::iter::repeat_n("(?, ?)", refs.len()).collect::<Vec<_>>().join(", ");
let sql = format!("WITH wanted(server_id, entity_id) AS (VALUES {values_sql}) {query_sql}");
let mut bind: Vec<SqlValue> = Vec::with_capacity(refs.len() * 2);
for (server_id, entity_id) in refs {
bind.push(SqlValue::Text(server_id.clone()));
bind.push(SqlValue::Text(entity_id.clone()));
}
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(rusqlite::params_from_iter(bind.iter()))?;
let mut out = HashMap::new();
while let Some(row) = rows.next()? {
let server_id: String = row.get(0)?;
let entity_id: String = row.get(1)?;
let key: Option<String> = row.get(2)?;
out.insert((server_id, entity_id), key);
}
Ok(out)
})
}
#[cfg(test)]
mod tests {
use crate::filter::EntityKind;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
use super::*;
fn track(server: &str, id: &str, artist: &str, artist_id: &str, album: &str, album_id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(artist_id.into()),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2024),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merges_tracks_by_cluster_key_with_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "LP", "alb-1"),
track("s2", "t2", "Band", "art-2", "LP", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Track],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].server_id, "s1");
assert_eq!(resp.totals.tracks, 1);
assert_eq!(resp.totals.albums, 0);
assert_eq!(resp.totals.artists, 0);
}
fn insert_album(store: &LibraryStore, server: &str, id: &str, name: &str) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 1, '{}')",
rusqlite::params![server, id, name],
)
})
.unwrap();
}
#[test]
fn cluster_text_search_respects_per_member_library_scope() {
let store = LibraryStore::open_in_memory();
let mut in_scope = track("s1", "t1", "Band", "art-1", "Metallica", "alb-in");
in_scope.library_id = Some("lib-a".into());
let mut out_scope = track("s1", "t2", "Band", "art-2", "Metallica Tribute", "alb-out");
out_scope.library_id = Some("lib-b".into());
TrackRepository::new(&store)
.upsert_batch(&[in_scope, out_scope])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into()]);
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: scopes,
},
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "alb-in");
}
#[test]
fn cluster_metallica_text_search_unions_table_and_track_catalog() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_self", "Metallica");
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 10 WHERE server_id = 's1' AND id = 'al_self'",
[],
)
})
.unwrap();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Apocalyptica", "art-1", "Plays Metallica Vol. 2", "al_plays"),
track("s1", "t2", "Various", "art-2", "The Metallica Blacklist", "al_black"),
track("s1", "t3", "Pink Floyd", "art-3", "Wish You Were Here", "al_wish"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 3, "expected {ids:?}");
assert!(ids.contains(&"al_self"));
assert!(ids.contains(&"al_plays"));
assert!(ids.contains(&"al_black"));
}
#[test]
fn text_search_keeps_distinct_same_server_albums_with_shared_album_key() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "Metallica", "alb-1"),
track("s1", "t2", "Band", "art-2", "Plays Metallica Vol. 2", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.albums.len(), 2);
}
#[test]
fn merges_albums_by_album_key_with_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "LP", "alb-1"),
track("s2", "t2", "Band", "art-2", "LP", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn applies_offset_after_merge() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band A", "art-a1", "LP A", "alb-a1"),
track("s2", "t2", "Band A", "art-a2", "LP A", "alb-a2"),
track("s1", "t3", "Band B", "art-b1", "LP B", "alb-b1"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Track],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 1,
offset: 1,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.totals.tracks, 2);
}
}
@@ -0,0 +1,171 @@
//! Attached `library-cluster.db` schema and metadata.
use std::path::{Path, PathBuf};
use rusqlite::{params, Connection, OptionalExtension};
pub const CLUSTER_DB_FILENAME: &str = "library-cluster.db";
pub const ATTACH_ALIAS: &str = "cluster";
pub const NORM_VERSION: &str = "1";
pub fn cluster_db_path(library_db_path: &Path) -> PathBuf {
library_db_path.with_file_name(CLUSTER_DB_FILENAME)
}
fn escape_sqlite_path(path: &str) -> String {
path.replace('\'', "''")
}
fn init_cluster_on_attached(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {ATTACH_ALIAS}.track_cluster_key (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
cluster_key TEXT NOT NULL,
album_key TEXT,
artist_key TEXT,
duration_sec INTEGER,
PRIMARY KEY (server_id, track_id)
)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_cluster_key \
ON track_cluster_key(cluster_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_album_key \
ON track_cluster_key(album_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_artist_key \
ON track_cluster_key(artist_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {ATTACH_ALIAS}.cluster_meta (
key TEXT PRIMARY KEY,
value TEXT
)"
),
[],
)?;
init_cluster_meta(conn)?;
Ok(())
}
fn init_cluster_file(cluster_path: &Path) -> rusqlite::Result<()> {
let cluster_conn = Connection::open(cluster_path)?;
cluster_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS track_cluster_key (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
cluster_key TEXT NOT NULL,
album_key TEXT,
artist_key TEXT,
duration_sec INTEGER,
PRIMARY KEY (server_id, track_id)
);
CREATE INDEX IF NOT EXISTS idx_cluster_key ON track_cluster_key(cluster_key);
CREATE INDEX IF NOT EXISTS idx_album_key ON track_cluster_key(album_key);
CREATE INDEX IF NOT EXISTS idx_artist_key ON track_cluster_key(artist_key);
CREATE TABLE IF NOT EXISTS cluster_meta (
key TEXT PRIMARY KEY,
value TEXT
);",
)?;
cluster_conn.execute(
"INSERT OR IGNORE INTO cluster_meta (key, value) VALUES ('norm_version', ?1)",
params![NORM_VERSION],
)?;
Ok(())
}
/// Create or migrate the cluster file, then attach it to `conn`.
pub fn attach_cluster_database(conn: &Connection, cluster_path: &Path) -> rusqlite::Result<()> {
if let Some(parent) = cluster_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
Some(e.to_string()),
)
})?;
}
init_cluster_file(cluster_path)?;
let path_str = escape_sqlite_path(&cluster_path.to_string_lossy());
conn.execute_batch(&format!(
"ATTACH DATABASE '{path_str}' AS {ATTACH_ALIAS}"
))?;
Ok(())
}
/// In-memory cluster DB for tests — attach then init schema on the alias.
pub fn attach_cluster_database_uri(conn: &Connection, uri: &str) -> rusqlite::Result<()> {
let path_str = escape_sqlite_path(uri);
conn.execute_batch(&format!(
"ATTACH DATABASE '{path_str}' AS {ATTACH_ALIAS}"
))?;
init_cluster_on_attached(conn)
}
pub fn ensure_cluster_schema(conn: &Connection) -> rusqlite::Result<()> {
init_cluster_on_attached(conn)
}
pub fn init_cluster_meta(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
&format!(
"INSERT OR IGNORE INTO {ATTACH_ALIAS}.cluster_meta (key, value) VALUES ('norm_version', ?1)"
),
params![NORM_VERSION],
)?;
Ok(())
}
pub fn stored_norm_version(conn: &Connection) -> rusqlite::Result<Option<String>> {
conn.query_row(
&format!(
"SELECT value FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|row| row.get(0),
)
.optional()
}
pub fn needs_norm_rebuild(conn: &Connection) -> rusqlite::Result<bool> {
Ok(stored_norm_version(conn)?.as_deref() != Some(NORM_VERSION))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_creates_cluster_tables_on_attach() {
let conn = Connection::open_in_memory().unwrap();
attach_cluster_database_uri(&conn, "file:cluster_mem?mode=memory&cache=shared").unwrap();
let count: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.sqlite_master \
WHERE type='table' AND name='track_cluster_key'"
),
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
//! Derive `cluster_key`, `album_key`, and `artist_key` from track metadata.
use std::hash::{Hash, Hasher};
use twox_hash::XxHash64;
use super::norm::norm_field;
const FIELD_SEP: u8 = 0x1f;
/// Precomputed keys for one track. `None` when any required field normalizes empty.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackClusterKeys {
pub cluster_key: String,
pub album_key: String,
pub artist_key: String,
}
fn effective_artist<'a>(artist: Option<&'a str>, album_artist: Option<&'a str>) -> Option<&'a str> {
artist
.filter(|s| !s.is_empty())
.or_else(|| album_artist.filter(|s| !s.is_empty()))
}
fn effective_album_artist<'a>(
album_artist: Option<&'a str>,
artist: Option<&'a str>,
) -> Option<&'a str> {
album_artist
.filter(|s| !s.is_empty())
.or_else(|| artist.filter(|s| !s.is_empty()))
}
fn hash_parts(parts: &[&str], sep: Option<u8>) -> String {
let mut hasher = XxHash64::with_seed(0);
for (i, part) in parts.iter().enumerate() {
if i > 0 {
if let Some(sep) = sep {
sep.hash(&mut hasher);
}
}
part.hash(&mut hasher);
}
format!("{:016x}", hasher.finish())
}
/// Compute cluster identity keys from raw track fields (spec §2.12.5).
pub fn compute_track_cluster_keys(
artist: Option<&str>,
album_artist: Option<&str>,
title: &str,
album: &str,
) -> Option<TrackClusterKeys> {
let artist_src = effective_artist(artist, album_artist)?;
let norm_artist = norm_field(artist_src);
let norm_title = norm_field(title);
let norm_album = norm_field(album);
if norm_artist.is_empty() || norm_title.is_empty() || norm_album.is_empty() {
return None;
}
let cluster_key = hash_parts(&[&norm_artist, &norm_title, &norm_album], Some(FIELD_SEP));
let album_artist_src = effective_album_artist(album_artist, artist).unwrap_or(artist_src);
let norm_album_artist = norm_field(album_artist_src);
let album_key = hash_parts(&[&norm_album_artist, &norm_album], None);
let artist_key = hash_parts(&[&norm_artist], None);
Some(TrackClusterKeys {
cluster_key,
album_key,
artist_key,
})
}
/// Stable cross-server artist merge key from display name alone (spec §2.5).
pub fn artist_key_from_display_name(name: &str) -> Option<String> {
let norm = norm_field(name);
if norm.is_empty() {
return None;
}
Some(hash_parts(&[&norm], None))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_metadata_yields_same_keys() {
let a = compute_track_cluster_keys(
Some("Artist"),
None,
"Title",
"Album",
)
.unwrap();
let b = compute_track_cluster_keys(
Some("artist"),
None,
"title",
"album",
)
.unwrap();
assert_eq!(a, b);
}
#[test]
fn artist_falls_back_to_album_artist_for_cluster_key() {
let with = compute_track_cluster_keys(None, Some("Band"), "Song", "LP").unwrap();
let direct = compute_track_cluster_keys(Some("Band"), None, "Song", "LP").unwrap();
assert_eq!(with.cluster_key, direct.cluster_key);
}
#[test]
fn empty_title_or_album_yields_none() {
assert!(compute_track_cluster_keys(Some("A"), None, "", "Album").is_none());
assert!(compute_track_cluster_keys(Some("A"), None, "Title", "").is_none());
assert!(compute_track_cluster_keys(None, None, "Title", "Album").is_none());
}
#[test]
fn artist_key_from_display_name_matches_track_derived_key() {
let from_track = compute_track_cluster_keys(Some("Pink Floyd"), None, "x", "y").unwrap();
let from_name = artist_key_from_display_name("Pink Floyd").unwrap();
assert_eq!(from_track.artist_key, from_name);
}
#[test]
fn punctuation_insensitive_cluster_key() {
let a = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time", "Dark Side").unwrap();
let b = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time!", "Dark Side.").unwrap();
assert_eq!(a.cluster_key, b.cluster_key);
}
}
@@ -0,0 +1,89 @@
//! Per-member music-folder (`library_scope`) filters for merged cluster queries.
use std::collections::HashMap;
use rusqlite::types::Value as SqlValue;
use crate::search::{library_scope_equals_sql, library_scope_match_sql};
/// `(sql_suffix, bind_params)` — AND ( (server + optional scope) OR … ).
pub(crate) fn scope_filter_sql_and_params(
table_alias: &str,
servers_ordered: &[String],
scopes: &HashMap<String, Vec<String>>,
) -> (String, Vec<SqlValue>) {
if scopes.is_empty() {
return (String::new(), Vec::new());
}
let mut parts = Vec::with_capacity(servers_ordered.len());
let mut params = Vec::new();
for sid in servers_ordered {
if let Some(scope_ids) = scopes.get(sid).filter(|v| !v.is_empty()) {
if scope_ids.len() == 1 {
let eq = library_scope_equals_sql(table_alias);
parts.push(format!("({table_alias}.server_id = ? AND {eq})"));
params.push(SqlValue::Text(sid.clone()));
params.push(SqlValue::Text(scope_ids[0].clone()));
} else {
let match_sql = library_scope_match_sql(table_alias);
let ph = (0..scope_ids.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(", ");
parts.push(format!(
"({table_alias}.server_id = ? AND {match_sql} IN ({ph}))"
));
params.push(SqlValue::Text(sid.clone()));
for id in scope_ids {
params.push(SqlValue::Text(id.clone()));
}
}
} else {
parts.push(format!("({table_alias}.server_id = ?)"));
params.push(SqlValue::Text(sid.clone()));
}
}
(format!(" AND ({})", parts.join(" OR ")), params)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::search::library_scope_filter_sql;
#[test]
fn scope_filter_empty_when_no_scopes() {
let (sql, params) = scope_filter_sql_and_params("t", &["s1".into()], &HashMap::new());
assert!(sql.is_empty());
assert!(params.is_empty());
}
#[test]
fn scope_filter_binds_scoped_and_unscoped_members() {
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into()]);
let (sql, params) = scope_filter_sql_and_params(
"t",
&["s1".into(), "s2".into()],
&scopes,
);
assert!(sql.contains("t.server_id = ?"));
assert_eq!(params.len(), 3);
}
#[test]
fn scope_filter_multi_folder_on_one_member() {
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into(), "lib-b".into()]);
let (sql, params) = scope_filter_sql_and_params("t", &["s1".into()], &scopes);
assert!(sql.contains(" IN ("));
assert_eq!(params.len(), 3);
}
#[test]
fn single_scope_uses_equals() {
let (sql, params) = library_scope_filter_sql("t", &["lib-a".into()]);
assert!(sql.unwrap().contains("= ?"));
assert_eq!(params.len(), 1);
}
}
@@ -0,0 +1,204 @@
//! Merged track listing for cluster scope (spec §4 Tier 1).
use rusqlite::types::Value as SqlValue;
use crate::dto::{LibraryTrackDto, LibraryTracksEnvelope};
use crate::repos;
use crate::search::{aliased_track_columns, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::{in_list_sql, priority_case_sql};
/// List merged tracks — one row per `cluster_key` (priority winner), solo rows
/// for empty-key tracks and duration outliers.
pub fn list_merged_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryTracksEnvelope, String> {
if servers_ordered.is_empty() {
return Ok(LibraryTracksEnvelope {
tracks: vec![],
total: 0,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (scope_sql, mut scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders}){scope_sql}
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY t.title COLLATE NOCASE, t.server_id, t.id
LIMIT ? OFFSET ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.append(&mut scope_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let tracks: Vec<LibraryTrackDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryTracksEnvelope {
total: tracks.len() as u32,
tracks,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, title: &str, artist: &str, album: &str, dur: i64) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: None,
album: album.into(),
album_id: None,
album_artist: Some(artist.into()),
duration_sec: dur,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merge_collapses_same_cluster_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP", 200),
track("s2", "t2", "Song", "Band", "LP", 201),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0, &HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s1");
}
#[test]
fn unavailable_priority_falls_through() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP", 200),
track("s2", "t2", "Song", "Band", "LP", 201),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s2");
}
#[test]
fn empty_key_tracks_never_merge() {
let store = LibraryStore::open_in_memory();
store
.with_conn_mut("misc", |c| {
c.execute(
"INSERT INTO track (server_id, id, title, artist, album, duration_sec, synced_at, raw_json) \
VALUES ('s1', 't1', 'A', '', 'X', 1, 1, '{}'), \
('s2', 't2', 'A', '', 'X', 1, 1, '{}')",
[],
)
})
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0, &HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 2);
}
}
@@ -0,0 +1,285 @@
//! Merged album listing for cluster scope (spec §4 Tier 1 — dedup by `album_key`).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{LibraryAlbumDto, LibraryClusterAlbumsResponse};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::merge::ALBUM_ROLLUP_AND_PARTITION_CTE;
use super::priority::{in_list_sql, priority_case_sql};
pub fn list_merged_albums(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryClusterAlbumsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterAlbumsResponse {
albums: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (scope_sql, mut scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.album_id,
k.album_key,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND t.album_id IS NOT NULL AND t.album_id != ''{scope_sql}
),
{ALBUM_ROLLUP_AND_PARTITION_CTE}
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT
t.server_id,
t.album_id,
COALESCE(a.name, t.album),
COALESCE(a.artist, t.artist),
COALESCE(a.artist_id, t.artist_id),
COALESCE(a.song_count, (
SELECT COUNT(*) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.duration_sec, (
SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.year, t.year),
COALESCE(a.genre, t.genre),
COALESCE(a.cover_art_id, t.cover_art_id),
COALESCE(a.starred_at, t.starred_at),
COALESCE(a.synced_at, t.synced_at),
a.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
WHERE w.rn = 1
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE, t.server_id, t.album_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.append(&mut scope_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_album_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryClusterAlbumsResponse { albums, has_more })
}
fn map_album_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(
server: &str,
id: &str,
title: &str,
artist: &str,
album: &str,
album_id: &str,
) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merge_collapses_same_album_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track("s2", "t2", "B", "Band", "LP", "alb2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into(), "s2".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn rollup_collapses_multiple_tracks_per_server_album() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track("s1", "t2", "B", "Band", "LP", "alb1"),
track("s1", "t3", "C", "Band", "LP", "alb1"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "alb1");
}
fn track_no_key(server: &str, id: &str, album_id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some(format!("art-{server}")),
album: "LP".into(),
album_id: Some(album_id.into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn rollup_uses_album_key_when_some_tracks_lack_keys() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track_no_key("s1", "t2", "alb1"),
track("s2", "t3", "B", "Band", "LP", "alb2"),
track_no_key("s2", "t4", "alb2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into(), "s2".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
}
@@ -0,0 +1,230 @@
//! Merged artist listing for cluster scope (spec §4 Tier 1 — dedup by `artist_key`).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{LibraryArtistDto, LibraryClusterArtistsResponse};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::priority::{in_list_sql, priority_case_sql};
pub fn list_merged_artists(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryClusterArtistsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterArtistsResponse {
artists: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
let (priority_sql, priority_params) = priority_case_sql("c.server_id", servers_ordered);
let (scope_sql, scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
// Artist-first catalog: one row per artist (not per track), then merge by
// `artist_key`. The previous track-scan + window over every row was O(tracks)
// with correlated album counts and blocked the Artists browse page on large libs.
let sql = format!(
"WITH artist_keys AS (
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
MIN(k.artist_key) AS artist_key
FROM track t
INNER JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders}){scope_sql}
AND k.artist_key IS NOT NULL
GROUP BY t.server_id, artist_ref
),
track_artists AS (
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS id,
MAX(t.artist) AS name,
COUNT(DISTINCT CASE
WHEN t.album_id IS NOT NULL AND t.album_id != '' THEN t.album_id
END) AS album_count,
MAX(t.synced_at) AS synced_at,
CAST(NULL AS TEXT) AS raw_json
FROM track t
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders}){scope_sql}
AND COALESCE(t.artist, '') != ''
AND NOT EXISTS (
SELECT 1 FROM artist ar
WHERE ar.server_id = t.server_id
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
)
GROUP BY t.server_id, COALESCE(NULLIF(t.artist_id, ''), t.artist)
),
catalog AS (
SELECT ar.server_id, ar.id, ar.name, ar.album_count, ar.synced_at, ar.raw_json
FROM artist ar
WHERE ar.server_id IN ({in_placeholders})
UNION ALL
SELECT server_id, id, name, album_count, synced_at, raw_json
FROM track_artists
),
candidates AS (
SELECT
c.server_id,
c.id,
c.name,
c.album_count,
c.synced_at,
c.raw_json,
({priority_sql}) AS priority_rank,
CASE
WHEN ak.artist_key IS NOT NULL THEN ak.artist_key
ELSE 'solo:' || c.server_id || ':' || c.id
END AS merge_key
FROM catalog c
LEFT JOIN artist_keys ak
ON ak.server_id = c.server_id AND ak.artist_ref = c.id
),
winners AS (
SELECT
server_id,
id,
name,
album_count,
synced_at,
raw_json,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM candidates
)
SELECT server_id, id, name, album_count, synced_at, raw_json
FROM winners
WHERE rn = 1
ORDER BY name COLLATE NOCASE, server_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.extend(in_params.iter().cloned());
params.extend(scope_params.iter().cloned());
params.extend(in_params.iter().cloned());
params.extend(scope_params.iter().cloned());
params.extend(in_params.iter().cloned());
params.extend(priority_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let artists: Vec<LibraryArtistDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_artist_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
let has_more = artists.len() as u32 == limit;
Ok(LibraryClusterArtistsResponse { artists, has_more })
}
fn map_artist_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let raw: Option<String> = r.get(5)?;
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: r.get(3)?,
synced_at: r.get(4)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, artist: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: "LP".into(),
album_id: Some("alb1".into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merge_collapses_same_artist_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
}
#[test]
fn prefers_artist_table_album_count_when_present() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn("test", |conn| {
conn.execute(
"INSERT INTO artist (server_id, id, name, album_count, synced_at, raw_json) \
VALUES ('s1', 'art-s1', 'Band', 3, 1, '{}'), \
('s2', 'art-s2', 'Band', 2, 1, '{}')",
[],
)
})
.unwrap();
let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
assert_eq!(resp.artists[0].album_count, Some(3));
}
}
@@ -0,0 +1,452 @@
//! Merged favorites — starred on any member counts (spec §4 Tier 2).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{
LibraryAlbumDto, LibraryArtistDto, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse, LibraryTrackDto,
LibraryTracksEnvelope,
};
use crate::repos;
use crate::search::{aliased_track_columns, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::{in_list_sql, priority_case_sql};
/// Merged starred tracks — one row per merge group when **any** member is starred.
pub fn list_merged_favorite_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryTracksEnvelope, String> {
if servers_ordered.is_empty() {
return Ok(LibraryTracksEnvelope {
tracks: vec![],
total: 0,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
t.starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
starred_merge AS (
SELECT DISTINCT p.merge_key
FROM partitioned p
JOIN candidates c ON c.tid = p.tid
WHERE c.starred_at IS NOT NULL
),
winners AS (
SELECT p.tid, p.merge_key,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY t.title COLLATE NOCASE, t.server_id, t.id
LIMIT ? OFFSET ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let tracks: Vec<LibraryTrackDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryTracksEnvelope {
total: tracks.len() as u32,
tracks,
})
}
/// Merged favorite albums — one row per album merge group when any member is starred.
pub fn list_merged_favorite_albums(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryClusterAlbumsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterAlbumsResponse {
albums: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.album_id,
k.album_key,
COALESCE(a.starred_at, t.starred_at) AS starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
LEFT JOIN album a
ON a.server_id = t.server_id AND a.id = t.album_id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND t.album_id IS NOT NULL AND t.album_id != ''
),
album_rollup AS (
SELECT
c.server_id,
c.album_id,
MIN(c.tid) AS tid,
MIN(c.priority_rank) AS priority_rank,
MAX(c.album_key) AS album_key,
MAX(c.starred_at) AS starred_at
FROM candidates c
GROUP BY c.server_id, c.album_id
),
partitioned AS (
SELECT
r.tid,
CASE
WHEN r.album_key IS NOT NULL THEN r.album_key
ELSE 'solo:' || r.server_id || ':' || r.album_id
END AS merge_key,
r.priority_rank,
r.starred_at
FROM album_rollup r
),
starred_merge AS (
SELECT DISTINCT merge_key
FROM partitioned
WHERE starred_at IS NOT NULL
),
winners AS (
SELECT p.tid,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT
t.server_id,
t.album_id,
COALESCE(a.name, t.album),
COALESCE(a.artist, t.artist),
COALESCE(a.artist_id, t.artist_id),
COALESCE(a.song_count, (
SELECT COUNT(*) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.duration_sec, (
SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.year, t.year),
COALESCE(a.genre, t.genre),
COALESCE(a.cover_art_id, t.cover_art_id),
COALESCE(a.starred_at, t.starred_at),
COALESCE(a.synced_at, t.synced_at),
a.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
WHERE w.rn = 1
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE, t.server_id, t.album_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_album_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryClusterAlbumsResponse {
has_more: albums.len() as u32 == limit,
albums,
})
}
/// Merged favorite artists — one row per artist merge group when any member track is starred.
pub fn list_merged_favorite_artists(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryClusterArtistsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterArtistsResponse {
artists: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
k.artist_key,
t.starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND COALESCE(t.artist, '') != ''
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.artist_key IS NULL THEN 'solo:' || c.server_id || ':' || c.artist_ref
ELSE c.artist_key
END AS merge_key,
c.priority_rank,
c.starred_at
FROM candidates c
),
starred_merge AS (
SELECT DISTINCT merge_key
FROM partitioned
WHERE starred_at IS NOT NULL
),
winners AS (
SELECT p.tid,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist),
COALESCE(ar.name, t.artist),
COALESCE(ar.album_count, (
SELECT COUNT(DISTINCT c.album_id) FROM track c
WHERE c.server_id = t.server_id
AND c.deleted = 0
AND c.album_id IS NOT NULL
AND (c.artist_id = t.artist_id OR c.artist = t.artist)
)),
COALESCE(ar.synced_at, t.synced_at),
ar.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN artist ar ON ar.server_id = t.server_id
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
WHERE w.rn = 1
ORDER BY COALESCE(ar.name, t.artist) COLLATE NOCASE, t.server_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let artists: Vec<LibraryArtistDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_artist_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryClusterArtistsResponse {
has_more: artists.len() as u32 == limit,
artists,
})
}
fn map_album_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
fn map_artist_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let raw: Option<String> = r.get(5)?;
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: r.get(3)?,
synced_at: r.get(4)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, starred: bool) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some("a1".into()),
album: "LP".into(),
album_id: Some("alb1".into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: if starred { Some(1) } else { None },
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn starred_on_lower_priority_still_surfaces_merged_row() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_favorite_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s1");
}
#[test]
fn unstarred_merge_group_excluded() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", false)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_favorite_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert!(env.tracks.is_empty());
}
#[test]
fn favorite_albums_merge_when_any_member_starred() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_favorite_albums(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn favorite_artists_merge_when_any_member_starred() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp =
list_merged_favorite_artists(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
}
}
@@ -0,0 +1,89 @@
//! Duration guard and partition keys for cluster merge (spec §2.3).
pub const DURATION_TOLERANCE_SEC: i64 = 5;
/// Roll up per-track candidates to one row per `(server_id, album_id)` before
/// partitioning by `album_key` (spec §4 — album lists dedup by `album_key`).
pub const ALBUM_ROLLUP_AND_PARTITION_CTE: &str = "
album_rollup AS (
SELECT
c.server_id,
c.album_id,
MIN(c.tid) AS tid,
MIN(c.priority_rank) AS priority_rank,
MAX(c.album_key) AS album_key
FROM candidates c
GROUP BY c.server_id, c.album_id
),
partitioned AS (
SELECT
r.tid,
CASE
WHEN r.album_key IS NOT NULL THEN r.album_key
ELSE 'solo:' || r.server_id || ':' || r.album_id
END AS merge_key,
r.priority_rank
FROM album_rollup r
),
";
/// Synthetic partition for tracks without a `cluster_key` row (never merged).
pub fn solo_partition_key(server_id: &str, track_id: &str) -> String {
format!("solo:{server_id}:{track_id}")
}
/// Within one `cluster_key` group, split rows that fall outside ± tolerance of
/// the reference (priority-1 available candidate duration). Returns partition
/// keys: merged survivors share `cluster_key`; outliers get solo keys.
pub fn duration_partitions(
cluster_key: &str,
rows: &[(String, String, i64, u32)],
) -> Vec<(String, String, String)> {
// (server_id, track_id, duration_sec, priority_rank)
if rows.is_empty() {
return Vec::new();
}
let mut sorted = rows.to_vec();
sorted.sort_by_key(|(_, _, _, rank)| *rank);
let reference_duration = sorted[0].2;
let mut merged: Vec<&(String, String, i64, u32)> = Vec::new();
let mut outliers: Vec<&(String, String, i64, u32)> = Vec::new();
for row in &sorted {
if (row.2 - reference_duration).abs() <= DURATION_TOLERANCE_SEC {
merged.push(row);
} else {
outliers.push(row);
}
}
let mut out = Vec::new();
if !merged.is_empty() {
merged.sort_by_key(|(_, _, _, rank)| *rank);
let (sid, tid, _, _) = merged[0];
out.push((cluster_key.to_string(), sid.clone(), tid.clone()));
}
for (sid, tid, _, _) in outliers {
out.push((solo_partition_key(sid, tid), sid.clone(), tid.clone()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outlier_splits_to_solo_partition() {
let rows = vec![
("s1".into(), "t1".into(), 180, 0),
("s2".into(), "t2".into(), 182, 1),
("s3".into(), "t3".into(), 240, 2),
];
let parts = duration_partitions("ck1", &rows);
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].0, "ck1");
assert_eq!(parts[0].1, "s1");
assert_eq!(parts[1].0, "solo:s3:t3");
}
}
@@ -0,0 +1,44 @@
//! Server cluster identity — derived `cluster_key` / `album_key` / `artist_key`
//! in a separate attached SQLite DB (`library-cluster.db`). Distinct from
//! `repos/play_session/cluster.rs` (listening-session time-gap grouping).
mod detail;
mod advanced_search;
mod db;
mod keys;
mod library_scope;
mod list;
mod list_albums;
mod list_artists;
mod list_favorites;
mod merge;
mod norm;
mod play_stats;
mod priority;
mod rebuild;
mod resolve;
mod search;
pub use detail::{cluster_album_detail, cluster_artist_detail};
pub use advanced_search::run_cluster_advanced_search;
pub use db::{
attach_cluster_database, attach_cluster_database_uri, cluster_db_path, ensure_cluster_schema,
init_cluster_meta, needs_norm_rebuild, ATTACH_ALIAS, CLUSTER_DB_FILENAME, NORM_VERSION,
};
pub use keys::{compute_track_cluster_keys, TrackClusterKeys};
pub use list::list_merged_tracks;
pub use list_albums::list_merged_albums;
pub use list_artists::list_merged_artists;
pub use list_favorites::list_merged_favorite_tracks;
pub use list_favorites::{list_merged_favorite_albums, list_merged_favorite_artists};
pub use merge::DURATION_TOLERANCE_SEC;
pub use play_stats::{
cluster_day_detail, cluster_heatmap, cluster_most_played, cluster_recent_days, cluster_year_summary,
};
pub use rebuild::{
rebuild_all_cluster_keys, rebuild_cluster_keys_for_server, rebuild_if_norm_version_stale,
};
pub use resolve::{
cluster_key_for_track, resolve_candidates_by_cluster_key, resolve_candidates_for_track,
};
pub use search::{run_cluster_random_tracks, run_cluster_search};
@@ -0,0 +1,43 @@
//! Cheap Unicode normalization for cluster identity keys (spec §2.2).
use unicode_normalization::UnicodeNormalization;
/// NFD → drop combining marks → lowercase → letters/digits only.
pub fn norm_field(raw: &str) -> String {
raw.nfd()
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
.flat_map(|c| c.to_lowercase())
.filter(|c| c.is_alphanumeric())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_diacritics_and_punctuation() {
assert_eq!(norm_field("Café"), "cafe");
assert_eq!(norm_field("Mötley Crüe"), "motleycrue");
assert_eq!(norm_field("Hello, World!"), "helloworld");
}
#[test]
fn lowercases_and_removes_whitespace() {
assert_eq!(norm_field(" Pink FLOYD "), "pinkfloyd");
assert_eq!(norm_field("The\tBeatles\n"), "thebeatles");
}
#[test]
fn preserves_unicode_letters_and_digits() {
assert_eq!(norm_field("Sigur Rós"), "sigurros");
assert_eq!(norm_field("Track 99"), "track99");
}
#[test]
fn empty_or_non_alnum_only_becomes_empty() {
assert_eq!(norm_field(""), "");
assert_eq!(norm_field(" "), "");
assert_eq!(norm_field("---"), "");
}
}
@@ -0,0 +1,595 @@
//! Cluster-scoped player statistics — aggregate `play_session` across members (spec §4 Tier 2).
use std::collections::HashMap;
use rusqlite::types::Value as SqlValue;
use crate::dto::{
PlaySessionDayDetailDto, PlaySessionDayTotalsDto, PlaySessionDayTrackDto, PlaySessionHeatmapDayDto,
PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearSummaryDto,
};
use crate::repos;
use crate::search::aliased_track_columns;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::in_list_sql;
use super::priority::priority_case_sql;
const RECENT_DAYS_LIMIT_MAX: u32 = 90;
const MOST_PLAYED_LIMIT_MAX: u32 = 200;
#[derive(Default)]
struct DayAgg {
total_listened_sec: f64,
track_play_count: u32,
full_count: u32,
partial_count: u32,
plays: Vec<(i64, f64)>,
}
fn server_filter_sql(servers_ordered: &[String]) -> Result<(String, Vec<SqlValue>), String> {
if servers_ordered.is_empty() {
return Err("servers_ordered required".into());
}
let (placeholders, params) = in_list_sql(servers_ordered);
Ok((format!("ps.server_id IN ({placeholders})"), params))
}
fn unique_track_expr() -> &'static str {
"COALESCE(k.cluster_key, ps.server_id || ':' || ps.track_id)"
}
fn count_listening_sessions(plays: &[(i64, f64)]) -> u32 {
const GAP_MS: i64 = 30 * 60 * 1000;
if plays.is_empty() {
return 0;
}
let mut sorted = plays.to_vec();
sorted.sort_by_key(|p| p.0);
let mut sessions = 1u32;
let mut prev_end = sorted[0].0 + (sorted[0].1 * 1000.0) as i64;
for (started, listened) in sorted.iter().skip(1) {
if *started - prev_end > GAP_MS {
sessions += 1;
}
let end = *started + (*listened * 1000.0) as i64;
prev_end = prev_end.max(end);
}
sessions
}
fn validate_date_iso(date_iso: &str) -> Result<(), String> {
if date_iso.len() != 10 || date_iso.as_bytes()[4] != b'-' || date_iso.as_bytes()[7] != b'-' {
return Err("dateIso must be YYYY-MM-DD".into());
}
let year: i32 = date_iso[0..4]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
let month: u32 = date_iso[5..7]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
let day: u32 = date_iso[8..10]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
if year < 1970 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err("dateIso must be YYYY-MM-DD".into());
}
Ok(())
}
pub fn cluster_year_summary(
store: &LibraryStore,
servers_ordered: &[String],
year: i32,
) -> Result<PlaySessionYearSummaryDto, String> {
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
let year_str = year.to_string();
let unique = unique_track_expr();
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT \
COALESCE(SUM(ps.listened_sec), 0.0), \
COUNT(*), \
COUNT(DISTINCT {unique}), \
COUNT(DISTINCT date(ps.started_at_ms / 1000, 'unixepoch', 'localtime')), \
COALESCE(SUM(CASE WHEN ps.completion = 'full' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN ps.completion = 'partial' THEN 1 ELSE 0 END), 0) \
FROM play_session ps \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = ps.server_id AND k.track_id = ps.track_id \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?",
);
params.push(SqlValue::Text(year_str.clone()));
let totals = conn.query_row(
&sql,
rusqlite::params_from_iter(params.iter()),
|row| {
Ok((
row.get::<_, f64>(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
row.get::<_, i64>(4)? as u32,
row.get::<_, i64>(5)? as u32,
))
},
)?;
let plays_sql = format!(
"SELECT ps.started_at_ms, ps.listened_sec \
FROM play_session ps \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ? \
ORDER BY ps.started_at_ms ASC",
);
let mut play_params = params[..params.len() - 1].to_vec();
play_params.push(SqlValue::Text(year_str));
let mut stmt = conn.prepare(&plays_sql)?;
let plays = stmt
.query_map(rusqlite::params_from_iter(play_params.iter()), |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let (
total_listened_sec,
track_play_count,
unique_track_count,
listening_day_count,
full_count,
partial_count,
) = totals;
Ok(PlaySessionYearSummaryDto {
total_listened_sec,
session_count: count_listening_sessions(&plays),
track_play_count,
unique_track_count,
listening_day_count,
full_count,
partial_count,
})
})
.map_err(|e| e.to_string())
}
pub fn cluster_heatmap(
store: &LibraryStore,
servers_ordered: &[String],
year: i32,
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
params.push(SqlValue::Text(year.to_string()));
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT \
date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') AS d, \
COUNT(*) AS n \
FROM play_session ps \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ? \
GROUP BY d \
ORDER BY d ASC",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok(PlaySessionHeatmapDayDto {
date: row.get(0)?,
track_play_count: row.get::<_, i64>(1)? as u32,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())
}
pub fn cluster_day_detail(
store: &LibraryStore,
servers_ordered: &[String],
date_iso: &str,
) -> Result<PlaySessionDayDetailDto, String> {
validate_date_iso(date_iso)?;
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
params.push(SqlValue::Text(date_iso.to_string()));
store
.with_read_conn(|conn| {
let totals_sql = format!(
"SELECT \
COALESCE(SUM(ps.listened_sec), 0.0), \
COUNT(*), \
COALESCE(SUM(CASE WHEN ps.completion = 'full' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN ps.completion = 'partial' THEN 1 ELSE 0 END), 0) \
FROM play_session ps \
WHERE {server_sql} \
AND date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?",
);
let (total_listened_sec, track_play_count, full_count, partial_count) = conn.query_row(
&totals_sql,
rusqlite::params_from_iter(params.iter()),
|row| {
Ok((
row.get::<_, f64>(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
))
},
)?;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let rows_sql = format!(
"WITH sessions AS (
SELECT
ps.started_at_ms,
ps.listened_sec,
ps.completion,
COALESCE(k.cluster_key, 'solo:' || ps.server_id || ':' || ps.track_id) AS merge_key
FROM play_session ps
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = ps.server_id AND k.track_id = ps.track_id
WHERE {server_sql}
AND date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?
),
candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT merge_key, tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}, s.listened_sec, s.completion, s.started_at_ms
FROM sessions s
JOIN winners w ON w.merge_key = s.merge_key AND w.rn = 1
JOIN track t ON t.rowid = w.tid
ORDER BY s.started_at_ms DESC",
tol = DURATION_TOLERANCE_SEC,
);
let mut rows_params = params.clone();
rows_params.append(&mut priority_params);
rows_params.append(&mut in_params);
let track_col_count = repos::track_columns().split(',').count();
let mut stmt = conn.prepare(&rows_sql)?;
let tracks = stmt
.query_map(rusqlite::params_from_iter(rows_params.iter()), |row| {
let track = repos::row_to_track_row(row).map(|r| crate::dto::LibraryTrackDto::from_row(&r))?;
Ok(PlaySessionDayTrackDto {
server_id: track.server_id,
track_id: track.id,
title: track.title,
artist: track.artist,
listened_sec: row.get(track_col_count)?,
completion: row.get(track_col_count + 1)?,
started_at_ms: row.get(track_col_count + 2)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let plays: Vec<(i64, f64)> = tracks
.iter()
.map(|t| (t.started_at_ms, t.listened_sec))
.collect();
Ok(PlaySessionDayDetailDto {
totals: PlaySessionDayTotalsDto {
total_listened_sec,
session_count: count_listening_sessions(&plays),
track_play_count,
full_count,
partial_count,
},
tracks,
})
})
.map_err(|e| e.to_string())
}
pub fn cluster_recent_days(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
let limit = limit.clamp(1, RECENT_DAYS_LIMIT_MAX);
let (server_sql, params) = server_filter_sql(servers_ordered)?;
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT
date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') AS d,
ps.started_at_ms,
ps.listened_sec,
ps.completion
FROM play_session ps
WHERE {server_sql}
ORDER BY d DESC, ps.started_at_ms ASC",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, f64>(2)?,
row.get::<_, String>(3)?,
))
})?;
let mut by_day: HashMap<String, DayAgg> = HashMap::new();
for row in rows {
let (date, started_at_ms, listened_sec, completion) = row?;
let agg = by_day.entry(date).or_default();
agg.total_listened_sec += listened_sec;
agg.track_play_count += 1;
if completion == "full" {
agg.full_count += 1;
} else {
agg.partial_count += 1;
}
agg.plays.push((started_at_ms, listened_sec));
}
let mut out: Vec<PlaySessionRecentDayDto> = by_day
.into_iter()
.map(|(date, agg)| PlaySessionRecentDayDto {
date,
total_listened_sec: agg.total_listened_sec,
session_count: count_listening_sessions(&agg.plays),
track_play_count: agg.track_play_count,
full_count: agg.full_count,
partial_count: agg.partial_count,
})
.collect();
out.sort_by(|a, b| b.date.cmp(&a.date));
out.truncate(limit as usize);
Ok(out)
})
.map_err(|e| e.to_string())
}
pub fn cluster_most_played(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<PlaySessionMostPlayedDto>, String> {
let limit = limit.clamp(1, MOST_PLAYED_LIMIT_MAX);
let (server_sql, mut stats_params) = server_filter_sql(servers_ordered)?;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let col_count = repos::track_columns().split(',').count();
let sql = format!(
"WITH session_counts AS (
SELECT
COALESCE(k.cluster_key, 'solo:' || ps.server_id || ':' || ps.track_id) AS merge_key,
COUNT(*) AS track_play_count,
COALESCE(SUM(ps.listened_sec), 0.0) AS total_listened_sec
FROM play_session ps
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = ps.server_id AND k.track_id = ps.track_id
WHERE {server_sql}
GROUP BY merge_key
),
candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT merge_key, tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}, sc.track_play_count, sc.total_listened_sec
FROM session_counts sc
JOIN winners w ON w.merge_key = sc.merge_key AND w.rn = 1
JOIN track t ON t.rowid = w.tid
ORDER BY sc.track_play_count DESC, sc.total_listened_sec DESC, t.title COLLATE NOCASE ASC
LIMIT ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut stats_params);
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let track =
repos::row_to_track_row(row).map(|r| crate::dto::LibraryTrackDto::from_row(&r))?;
Ok(PlaySessionMostPlayedDto {
track,
track_play_count: row.get::<_, i64>(col_count)? as u32,
total_listened_sec: row.get(col_count + 1)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: album.into(),
album_id: Some(format!("alb-{server}")),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn day_detail_merges_track_identity_to_cluster_winner() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP"),
track("s2", "t2", "Song", "Band", "LP"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("test", |conn| {
conn.execute(
"INSERT INTO play_session (server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason)
VALUES (?1, ?2, ?3, 120.0, 120.0, 'full', 'ended')",
rusqlite::params!["s2", "t2", 1_000i64],
)?;
Ok(())
})
.unwrap();
let detail = cluster_day_detail(&store, &["s1".into(), "s2".into()], "1970-01-01").unwrap();
assert_eq!(detail.tracks.len(), 1);
assert_eq!(detail.tracks[0].server_id, "s1");
assert_eq!(detail.tracks[0].track_id, "t1");
assert_eq!(detail.totals.track_play_count, 1);
}
#[test]
fn most_played_aggregates_cluster_members() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP"),
track("s2", "t2", "Song", "Band", "LP"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("test", |conn| {
conn.execute(
"INSERT INTO play_session (server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason)
VALUES
('s1', 't1', 1700000000000, 60.0, 60.0, 'partial', 'ended'),
('s2', 't2', 1700000100000, 90.0, 90.0, 'full', 'ended')",
[],
)?;
Ok(())
})
.unwrap();
let rows = cluster_most_played(&store, &["s1".into(), "s2".into()], 10).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].track.server_id, "s1");
assert_eq!(rows[0].track_play_count, 2);
}
}
@@ -0,0 +1,44 @@
//! Priority rank SQL from an ordered server list (index 0 = highest).
use rusqlite::types::Value as SqlValue;
/// Build `CASE server_col WHEN ? THEN 0 … ELSE 9999 END` plus bind values.
pub fn priority_case_sql(server_col: &str, servers_ordered: &[String]) -> (String, Vec<SqlValue>) {
if servers_ordered.is_empty() {
return ("9999".to_string(), Vec::new());
}
let mut sql = format!("CASE {server_col}");
let mut params = Vec::with_capacity(servers_ordered.len());
for (rank, sid) in servers_ordered.iter().enumerate() {
sql.push_str(&format!(" WHEN ? THEN {rank}"));
params.push(SqlValue::Text(sid.clone()));
}
sql.push_str(" ELSE 9999 END");
(sql, params)
}
/// `server_id IN (?,?,…)` placeholders and bind values.
pub fn in_list_sql(servers: &[String]) -> (String, Vec<SqlValue>) {
if servers.is_empty() {
return ("0".to_string(), Vec::new());
}
let placeholders = vec!["?"; servers.len()].join(", ");
let params = servers
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect();
(placeholders, params)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn priority_case_orders_servers() {
let (sql, params) = priority_case_sql("t.server_id", &["a".into(), "b".into()]);
assert!(sql.contains("WHEN ? THEN 0"));
assert!(sql.contains("WHEN ? THEN 1"));
assert_eq!(params.len(), 2);
}
}
@@ -0,0 +1,362 @@
//! Batch rebuild of `track_cluster_key` — source of truth for cluster identity.
use rusqlite::{params, Transaction};
use crate::store::LibraryStore;
use super::db::{self, ATTACH_ALIAS, NORM_VERSION};
use super::keys::compute_track_cluster_keys;
const REBUILD_BATCH_SIZE: usize = 5_000;
struct TrackRow {
server_id: String,
track_id: String,
artist: Option<String>,
album_artist: Option<String>,
title: String,
album: String,
duration_sec: i64,
}
fn fetch_live_tracks(
tx: &Transaction<'_>,
server_id: Option<&str>,
) -> rusqlite::Result<Vec<TrackRow>> {
let (sql, bind): (&str, Option<&str>) = match server_id {
Some(_) => (
"SELECT server_id, id, artist, album_artist, title, album, duration_sec \
FROM track WHERE deleted = 0 AND server_id = ?1",
server_id,
),
None => (
"SELECT server_id, id, artist, album_artist, title, album, duration_sec \
FROM track WHERE deleted = 0",
None,
),
};
let mut stmt = tx.prepare(sql)?;
let rows = match bind {
Some(sid) => stmt.query_map(params![sid], map_track_row)?.collect(),
None => stmt.query_map([], map_track_row)?.collect(),
};
rows
}
fn map_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TrackRow> {
Ok(TrackRow {
server_id: row.get(0)?,
track_id: row.get(1)?,
artist: row.get(2)?,
album_artist: row.get(3)?,
title: row.get(4)?,
album: row.get(5)?,
duration_sec: row.get(6)?,
})
}
fn upsert_batch(tx: &Transaction<'_>, batch: &[(TrackRow, super::keys::TrackClusterKeys)]) -> rusqlite::Result<()> {
let mut stmt = tx.prepare(&format!(
"INSERT INTO {ATTACH_ALIAS}.track_cluster_key \
(server_id, track_id, cluster_key, album_key, artist_key, duration_sec) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
ON CONFLICT(server_id, track_id) DO UPDATE SET \
cluster_key = excluded.cluster_key, \
album_key = excluded.album_key, \
artist_key = excluded.artist_key, \
duration_sec = excluded.duration_sec"
))?;
for (row, keys) in batch {
stmt.execute(params![
row.server_id,
row.track_id,
keys.cluster_key,
keys.album_key,
keys.artist_key,
row.duration_sec,
])?;
}
Ok(())
}
fn rebuild_in_tx(
tx: &Transaction<'_>,
server_id: Option<&str>,
clear_scope: bool,
) -> rusqlite::Result<u32> {
if clear_scope {
match server_id {
Some(sid) => {
tx.execute(
&format!("DELETE FROM {ATTACH_ALIAS}.track_cluster_key WHERE server_id = ?1"),
params![sid],
)?;
}
None => {
tx.execute(
&format!("DELETE FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
)?;
}
}
}
let tracks = fetch_live_tracks(tx, server_id)?;
let mut written = 0u32;
let mut batch: Vec<(TrackRow, super::keys::TrackClusterKeys)> = Vec::new();
for row in tracks {
let Some(keys) = compute_track_cluster_keys(
row.artist.as_deref(),
row.album_artist.as_deref(),
&row.title,
&row.album,
) else {
continue;
};
batch.push((row, keys));
if batch.len() >= REBUILD_BATCH_SIZE {
upsert_batch(tx, &batch)?;
written = written.saturating_add(batch.len() as u32);
batch.clear();
}
}
if !batch.is_empty() {
upsert_batch(tx, &batch)?;
written = written.saturating_add(batch.len() as u32);
}
tx.execute(
&format!(
"INSERT INTO {ATTACH_ALIAS}.cluster_meta (key, value) VALUES ('norm_version', ?1) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
),
params![NORM_VERSION],
)?;
Ok(written)
}
/// Rebuild cluster keys for every live track in the library index.
pub fn rebuild_all_cluster_keys(store: &LibraryStore) -> Result<u32, String> {
store.with_conn_mut("cluster_rebuild_all", |conn| {
let tx = conn.transaction()?;
let count = rebuild_in_tx(&tx, None, true)?;
tx.commit()?;
Ok(count)
})
}
/// Rebuild cluster keys for one server's live tracks (deletes stale rows first).
pub fn rebuild_cluster_keys_for_server(
store: &LibraryStore,
server_id: &str,
) -> Result<u32, String> {
store.with_conn_mut("cluster_rebuild_server", |conn| {
let tx = conn.transaction()?;
let count = rebuild_in_tx(&tx, Some(server_id), true)?;
tx.commit()?;
Ok(count)
})
}
/// Rebuild when `cluster_meta.norm_version` lags the compiled rules.
pub fn rebuild_if_norm_version_stale(store: &LibraryStore) -> Result<Option<u32>, String> {
let stale = store.with_conn("cluster_norm_check", db::needs_norm_rebuild)?;
if !stale {
return Ok(None);
}
rebuild_all_cluster_keys(store).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::LibraryStore;
use rusqlite::params;
fn seed_track(
store: &LibraryStore,
server: &str,
id: &str,
artist: &str,
title: &str,
album: &str,
duration: i64,
) {
store
.with_conn_mut("misc", |conn| {
conn.execute(
"INSERT INTO track (server_id, id, title, artist, album, duration_sec, synced_at, raw_json) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, '{}')",
params![server, id, title, artist, album, duration],
)
})
.unwrap();
}
#[test]
fn rebuild_is_idempotent() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Artist", "Song", "Album", 200);
seed_track(&store, "s2", "t2", "Artist", "Song", "Album", 205);
let first = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(first, 2);
let count_after: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count_after, 2);
let second = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(second, 2);
let count_again: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count_again, 2);
}
#[test]
fn tracks_with_empty_fields_get_no_row() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "", "Song", "Album", 100);
seed_track(&store, "s1", "t2", "Artist", "Song", "Album", 100);
let written = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(written, 1);
let count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn same_cluster_key_across_servers_after_rebuild() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Band", "Hit", "LP", 180);
seed_track(&store, "s2", "t9", "Band", "Hit", "LP", 182);
rebuild_all_cluster_keys(&store).unwrap();
let keys: Vec<String> = store
.with_conn("misc", |c| {
let mut stmt = c.prepare(&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key ORDER BY server_id"
))?;
let rows: rusqlite::Result<Vec<String>> =
stmt.query_map([], |r| r.get(0))?.collect();
rows
})
.unwrap();
assert_eq!(keys.len(), 2);
assert_eq!(keys[0], keys[1]);
}
#[test]
fn per_server_rebuild_replaces_stale_rows() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "A", "One", "X", 1);
seed_track(&store, "s2", "t1", "B", "Two", "Y", 2);
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("misc", |conn| {
conn.execute(
"UPDATE track SET title = 'Updated' WHERE server_id = 's1' AND id = 't1'",
[],
)
})
.unwrap();
rebuild_cluster_keys_for_server(&store, "s1").unwrap();
let title_norm_row: Option<String> = store
.with_conn("misc", |c| {
use rusqlite::OptionalExtension;
c.query_row(
&format!(
"SELECT k.cluster_key FROM {ATTACH_ALIAS}.track_cluster_key k \
JOIN track t ON t.server_id = k.server_id AND t.id = k.track_id \
WHERE k.server_id = 's1' AND k.track_id = 't1'"
),
[],
|r| r.get(0),
)
.optional()
})
.unwrap();
assert!(title_norm_row.is_some());
let s2_count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key WHERE server_id = 's2'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(s2_count, 1);
}
#[test]
fn norm_version_bump_triggers_full_rebuild() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Artist", "Old", "Album", 1);
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("misc", |conn| {
conn.execute(
&format!(
"UPDATE {ATTACH_ALIAS}.cluster_meta SET value = '0' WHERE key = 'norm_version'"
),
[],
)
})
.unwrap();
assert!(
store
.with_conn("misc", db::needs_norm_rebuild)
.unwrap()
);
let rebuilt = rebuild_if_norm_version_stale(&store).unwrap();
assert_eq!(rebuilt, Some(1));
let version: String = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT value FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(version, NORM_VERSION);
}
}
@@ -0,0 +1,198 @@
//! Resolve cluster candidates for playback / writes (spec §56).
use rusqlite::types::Value as SqlValue;
use rusqlite::OptionalExtension;
use crate::dto::LibraryClusterCandidateDto;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::duration_partitions;
use super::priority::priority_case_sql;
/// All `(server_id, track_id)` rows sharing a `cluster_key`, ordered by priority.
pub fn resolve_candidates_by_cluster_key(
store: &LibraryStore,
servers_ordered: &[String],
cluster_key: &str,
) -> Result<Vec<LibraryClusterCandidateDto>, String> {
if servers_ordered.is_empty() || cluster_key.is_empty() {
return Ok(Vec::new());
}
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let in_placeholders = vec!["?"; servers_ordered.len()].join(", ");
let mut in_params: Vec<SqlValue> = servers_ordered
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect();
let sql = format!(
"SELECT t.server_id, t.id, COALESCE(k.duration_sec, t.duration_sec), ({priority_sql})
FROM {ATTACH_ALIAS}.track_cluster_key k
JOIN track t ON t.server_id = k.server_id AND t.id = k.track_id
WHERE k.cluster_key = ? AND t.deleted = 0 AND t.server_id IN ({in_placeholders})
ORDER BY 4, t.server_id, t.id"
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.push(SqlValue::Text(cluster_key.to_string()));
params.append(&mut in_params);
let rows: Vec<(String, String, i64, u32)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get::<_, i64>(3)? as u32))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
let with_rank = rows;
let partitions = duration_partitions(cluster_key, &with_rank);
let winner = partitions.first();
Ok(with_rank
.into_iter()
.map(|(server_id, track_id, duration_sec, priority_rank)| {
let is_winner = winner
.map(|(_, ws, wt)| ws == &server_id && wt == &track_id)
.unwrap_or(false);
LibraryClusterCandidateDto {
server_id,
track_id,
duration_sec,
priority_rank,
is_winner,
}
})
.collect())
}
/// Resolve `cluster_key` from a seed track, then return candidates.
pub fn resolve_candidates_for_track(
store: &LibraryStore,
servers_ordered: &[String],
server_id: &str,
track_id: &str,
) -> Result<Vec<LibraryClusterCandidateDto>, String> {
let cluster_key: Option<String> = store.with_read_conn(|conn| {
conn.query_row(
&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key \
WHERE server_id = ?1 AND track_id = ?2"
),
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
.optional()
})?;
let Some(cluster_key) = cluster_key else {
return Ok(vec![LibraryClusterCandidateDto {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
duration_sec: track_duration(store, server_id, track_id)?,
priority_rank: servers_ordered
.iter()
.position(|s| s == server_id)
.unwrap_or(9999) as u32,
is_winner: true,
}]);
};
resolve_candidates_by_cluster_key(store, servers_ordered, &cluster_key)
}
fn track_duration(store: &LibraryStore, server_id: &str, track_id: &str) -> Result<i64, String> {
store
.with_read_conn(|conn| {
conn.query_row(
"SELECT duration_sec FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0",
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
})
.map_err(|e| e.to_string())
}
/// Lookup cluster key for a track (for search/seed mapping).
pub fn cluster_key_for_track(
store: &LibraryStore,
server_id: &str,
track_id: &str,
) -> Result<Option<String>, String> {
store
.with_read_conn(|conn| {
conn.query_row(
&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key \
WHERE server_id = ?1 AND track_id = ?2"
),
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
.optional()
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn tr(server: &str, id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: None,
album: "LP".into(),
album_id: None,
album_artist: Some("Band".into()),
duration_sec: 180,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn resolve_orders_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[tr("s1", "t1"), tr("s2", "t2")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let key = cluster_key_for_track(&store, "s1", "t1").unwrap().unwrap();
let cands = resolve_candidates_by_cluster_key(&store, &["s1".into(), "s2".into()], &key).unwrap();
assert_eq!(cands.len(), 2);
assert!(cands[0].is_winner);
assert_eq!(cands[0].server_id, "s1");
}
}
@@ -0,0 +1,243 @@
//! Cluster-mode cross-server search — dedup by `cluster_key` + priority (spec §5).
use std::collections::HashSet;
use rusqlite::types::Value as SqlValue;
use crate::dto::{LibraryCrossServerSearchResponse, LibraryTrackDto};
use crate::repos;
use crate::search::{aliased_track_columns, fts_query, like_contains, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::solo_partition_key;
use super::priority::{in_list_sql, priority_case_sql};
const FUZZY_PER_SERVER_CAP: usize = 20;
/// FTS union over ordered servers; dedup by `cluster_key` (not canonical id).
pub fn run_cluster_search(
store: &LibraryStore,
query: &str,
limit: u32,
offset: u32,
servers_ordered: &[String],
) -> Result<LibraryCrossServerSearchResponse, String> {
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset as usize;
if servers_ordered.is_empty() {
return Ok(LibraryCrossServerSearchResponse::default());
}
let Some(fts) = fts_query(query) else {
return Ok(LibraryCrossServerSearchResponse::default());
};
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let canonical_idx = repos::track_columns().split(',').count();
let sql = format!(
"SELECT {cols}, k.cluster_key, ({priority_sql}) AS priority_rank \
FROM track_fts f \
JOIN track t ON t.rowid = f.rowid \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = t.server_id AND k.track_id = t.id \
WHERE track_fts MATCH ? AND t.deleted = 0 AND t.server_id IN ({in_placeholders}) \
ORDER BY bm25(track_fts) LIMIT ?"
);
let mut params: Vec<SqlValue> = Vec::new();
params.push(SqlValue::Text(fts));
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer((limit as i64).saturating_mul(4)));
let rows: Vec<(LibraryTrackDto, Option<String>, u32)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?;
let cluster_key: Option<String> = r.get(canonical_idx)?;
let rank: i64 = r.get(canonical_idx + 1)?;
Ok((track, cluster_key, rank as u32))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
let mut best_by_key: std::collections::HashMap<String, (LibraryTrackDto, u32)> =
std::collections::HashMap::new();
for (track, cluster_key, priority_rank) in rows {
let dedup_key = cluster_key
.clone()
.unwrap_or_else(|| solo_partition_key(&track.server_id, &track.id));
match best_by_key.get(&dedup_key) {
Some((_, best_rank)) if *best_rank <= priority_rank => {}
_ => {
best_by_key.insert(dedup_key, (track, priority_rank));
}
}
}
let hits: Vec<LibraryTrackDto> = best_by_key
.into_values()
.map(|(t, _)| t)
.skip(offset)
.take(limit as usize)
.collect();
let hit_keys: HashSet<(String, String)> = hits
.iter()
.map(|t| (t.server_id.clone(), t.id.clone()))
.collect();
let fuzzy = fuzzy_cluster_matches(
store,
servers_ordered,
query.trim(),
&hit_keys,
limit as usize,
)?;
Ok(LibraryCrossServerSearchResponse {
hits,
fuzzy,
servers_searched: servers_ordered.to_vec(),
})
}
/// Random merged track sample across cluster scope.
pub fn run_cluster_random_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<LibraryTrackDto>, String> {
if servers_ordered.is_empty() {
return Ok(Vec::new());
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY RANDOM()
LIMIT ?",
tol = super::merge::DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
fn fuzzy_cluster_matches(
store: &LibraryStore,
targets: &[String],
query: &str,
hit_keys: &HashSet<(String, String)>,
overall_cap: usize,
) -> Result<Vec<LibraryTrackDto>, String> {
let like = like_contains(query);
let cols = aliased_track_columns("t");
let key_idx = repos::track_columns().split(',').count();
let sql = format!(
"SELECT {cols}, k.cluster_key \
FROM track t \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = t.server_id AND k.track_id = t.id \
WHERE t.server_id = ? AND t.deleted = 0 AND t.title LIKE ? ESCAPE '\\' \
ORDER BY t.title COLLATE NOCASE ASC LIMIT ?"
);
let mut out: Vec<LibraryTrackDto> = Vec::new();
let mut seen_keys: HashSet<String> = HashSet::new();
for server in targets {
if out.len() >= overall_cap {
break;
}
let bound = [
SqlValue::Text(server.clone()),
SqlValue::Text(like.clone()),
SqlValue::Integer(FUZZY_PER_SERVER_CAP as i64),
];
let rows: Vec<(LibraryTrackDto, Option<String>)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(bound.iter()), |r| {
let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?;
let cluster_key: Option<String> = r.get(key_idx)?;
Ok((track, cluster_key))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
for (track, cluster_key) in rows {
if out.len() >= overall_cap {
break;
}
if hit_keys.contains(&(track.server_id.clone(), track.id.clone())) {
continue;
}
let dedup_key = cluster_key
.clone()
.unwrap_or_else(|| solo_partition_key(&track.server_id, &track.id));
if !seen_keys.insert(dedup_key) {
continue;
}
out.push(track);
}
}
Ok(out)
}
+66 -5
View File
@@ -7,9 +7,11 @@ use std::time::Duration;
use rusqlite::{params, Connection, OpenFlags};
use tauri::Manager;
use crate::server_cluster::{attach_cluster_database, attach_cluster_database_uri, cluster_db_path};
/// Current head of the embedded migrations. Bump each time a new
/// `migrations/NNN_*.sql` is added.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 2;
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
@@ -22,10 +24,15 @@ pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql");
const MIGRATION_002_ALBUM_BROWSE_INDEX: &str =
include_str!("../migrations/002_album_browse_index.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL)];
const MIGRATIONS: &[(i64, &str)] = &[
(1, INITIAL_SQL),
(2, MIGRATION_002_ALBUM_BROWSE_INDEX),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationOutcome {
@@ -40,9 +47,17 @@ pub(crate) enum MigrationOutcome {
/// In-memory tests share one DB across the read/write pair in a single store.
static IN_MEMORY_DB_COUNTER: AtomicU64 = AtomicU64::new(0);
fn in_memory_uri() -> String {
fn in_memory_uri(prefix: &str) -> String {
let n = IN_MEMORY_DB_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("file:psysonic_library_mem_{n}?mode=memory&cache=shared")
format!("file:psysonic_{prefix}_mem_{n}?mode=memory&cache=shared")
}
fn in_memory_library_uri() -> String {
in_memory_uri("library")
}
fn in_memory_cluster_uri() -> String {
in_memory_uri("cluster")
}
pub struct LibraryStore {
@@ -67,10 +82,12 @@ impl LibraryStore {
let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?;
configure_write_connection(&write_conn).map_err(|e| e.to_string())?;
run_migrations(&write_conn).map_err(|e| e.to_string())?;
attach_cluster_file(&write_conn, db_path).map_err(|e| e.to_string())?;
checkpoint_wal_conn(&write_conn, "open").map_err(|e| e.to_string())?;
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&read_conn).map_err(|e| e.to_string())?;
attach_cluster_file(&read_conn, db_path).map_err(|e| e.to_string())?;
Ok(Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
@@ -80,12 +97,15 @@ impl LibraryStore {
/// Build an in-memory DB with the production schema applied.
pub fn open_in_memory() -> Self {
let uri = in_memory_uri();
let uri = in_memory_library_uri();
let cluster_uri = in_memory_cluster_uri();
let write_conn = Connection::open(&uri).expect("in-memory write connection");
configure_write_connection(&write_conn).expect("write pragmas");
run_migrations(&write_conn).expect("schema migration");
attach_cluster_database_uri(&write_conn, &cluster_uri).expect("cluster attach write");
let read_conn = Connection::open(&uri).expect("in-memory read connection");
configure_read_connection(&read_conn).expect("read pragmas");
attach_cluster_database_uri(&read_conn, &cluster_uri).expect("cluster attach read");
Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
@@ -217,9 +237,11 @@ impl LibraryStore {
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_write, active_path).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_read, active_path).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(Some(backup))
@@ -253,9 +275,11 @@ impl LibraryStore {
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_write, active_path).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_read, active_path).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(())
@@ -285,6 +309,10 @@ fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) {
}
}
fn attach_cluster_file(conn: &Connection, library_db_path: &Path) -> rusqlite::Result<()> {
attach_cluster_database(conn, &cluster_db_path(library_db_path))
}
fn library_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
let db_dir = base.join("databases").join("library");
@@ -478,6 +506,39 @@ fn handle_breaking_schema_bump(
mod tests {
use super::*;
#[test]
fn cluster_db_attached_on_open_in_memory() {
use crate::server_cluster::ATTACH_ALIAS;
let store = LibraryStore::open_in_memory();
let count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.sqlite_master \
WHERE type='table' AND name='track_cluster_key'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count, 1);
let read_count: i64 = store
.with_read_conn(|c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(read_count, 1);
}
#[test]
fn read_conn_sees_committed_writes_from_write_conn() {
let store = LibraryStore::open_in_memory();
+17
View File
@@ -708,10 +708,27 @@ pub fn run() {
psysonic_library::commands::library_search,
psysonic_library::commands::library_live_search,
psysonic_library::commands::library_advanced_search,
psysonic_library::commands::library_cluster_advanced_search,
psysonic_library::commands::library_list_lossless_albums,
psysonic_library::commands::library_list_albums,
psysonic_library::commands::library_list_albums_by_genre,
psysonic_library::commands::library_get_artist_lossless_browse,
psysonic_library::commands::library_search_cross_server,
psysonic_library::commands::library_cluster_list_tracks,
psysonic_library::commands::library_cluster_list_albums,
psysonic_library::commands::library_cluster_list_artists,
psysonic_library::commands::library_cluster_list_favorites,
psysonic_library::commands::library_cluster_list_favorite_albums,
psysonic_library::commands::library_cluster_list_favorite_artists,
psysonic_library::commands::library_cluster_player_stats_year_summary,
psysonic_library::commands::library_cluster_player_stats_heatmap,
psysonic_library::commands::library_cluster_player_stats_day_detail,
psysonic_library::commands::library_cluster_player_stats_recent_days,
psysonic_library::commands::library_cluster_player_stats_most_played,
psysonic_library::commands::library_cluster_resolve_candidates,
psysonic_library::commands::library_cluster_album_detail,
psysonic_library::commands::library_cluster_artist_detail,
psysonic_library::commands::library_search_cluster,
psysonic_library::commands::library_get_track,
psysonic_library::commands::library_get_tracks_batch,
psysonic_library::commands::library_get_tracks_by_album,
+427
View File
@@ -202,6 +202,8 @@ export interface LibrarySortClause {
export interface LibraryAdvancedSearchRequest {
serverId: string;
libraryScope?: string | null;
/** Multiple music-folder ids (OR). Preferred over `libraryScope` when length > 1. */
libraryScopeIds?: string[] | null;
query?: string | null; // shorthand → fts clause on text fields
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
@@ -379,9 +381,52 @@ export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<Li
}));
}
export interface LibraryAlbumBrowseRequest {
serverId: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
}
export interface LibraryAlbumBrowseResponse {
albums: LibraryAlbumDto[];
hasMore: boolean;
source: 'local';
}
/** Paginated plain All Albums from the local track index. */
export function libraryListAlbums(
request: LibraryAlbumBrowseRequest,
): Promise<LibraryAlbumBrowseResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryAlbumBrowseResponse>('library_list_albums', {
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
sort: request.sort,
limit: request.limit,
offset: request.offset,
},
}).then(response => ({
...response,
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
}));
}
export interface LibraryLosslessAlbumsRequest {
serverId: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
}
@@ -401,6 +446,9 @@ export function libraryListLosslessAlbums(
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
sort: request.sort,
limit: request.limit,
offset: request.offset,
},
@@ -464,6 +512,327 @@ export function librarySearchCrossServer(args: {
}));
}
export interface LibraryClusterCandidateDto {
serverId: string;
trackId: string;
durationSec: number;
priorityRank: number;
isWinner: boolean;
}
export interface LibraryClusterResolveResponse {
candidates: LibraryClusterCandidateDto[];
clusterKey?: string | null;
}
function mapServersOrderedToIndexKeys(serverIds: string[]): string[] {
return serverIds.map(serverIndexKeyForId);
}
function mapClusterLibraryScopesToIndexKeys(
scopes: Record<string, string[]> | undefined,
): Record<string, string[]> | undefined {
if (!scopes) return undefined;
const out: Record<string, string[]> = {};
for (const [sid, scopeIds] of Object.entries(scopes)) {
if (scopeIds.length > 0) out[serverIndexKeyForId(sid)] = scopeIds;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/** Merged track list for cluster scope (ordered members = priority). */
export function libraryClusterListTracks(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_cluster_list_tracks', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(env => ({
...env,
tracks: mapTracksServerId(env.tracks),
}));
}
export interface LibraryClusterAlbumsResponse {
albums: LibraryAlbumDto[];
hasMore: boolean;
}
export interface LibraryClusterArtistsResponse {
artists: LibraryArtistDto[];
hasMore: boolean;
}
export function libraryClusterListAlbums(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_albums', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(resp => ({
...resp,
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListArtists(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_artists', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(resp => ({
...resp,
artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListFavorites(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_cluster_list_favorites', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(env => ({
...env,
tracks: mapTracksServerId(env.tracks),
}));
}
export function libraryClusterPlayerStatsYearSummary(args: {
serversOrdered: string[];
year: number;
}): Promise<PlaySessionYearSummary> {
return invoke<PlaySessionYearSummary>('library_cluster_player_stats_year_summary', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
year: args.year,
},
});
}
export function libraryClusterPlayerStatsHeatmap(args: {
serversOrdered: string[];
year: number;
}): Promise<PlaySessionHeatmapDay[]> {
return invoke<PlaySessionHeatmapDay[]>('library_cluster_player_stats_heatmap', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
year: args.year,
},
});
}
export function libraryClusterResolveCandidates(args: {
serversOrdered: string[];
clusterKey?: string;
serverId?: string;
trackId?: string;
}): Promise<LibraryClusterResolveResponse> {
return invoke<LibraryClusterResolveResponse>('library_cluster_resolve_candidates', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
clusterKey: args.clusterKey,
serverId: args.serverId ? serverIndexKeyForId(args.serverId) : undefined,
trackId: args.trackId,
},
}).then(response => ({
...response,
candidates: response.candidates.map(c => ({
...c,
serverId: mapServerIdFromIndexKey(c.serverId),
})),
}));
}
export interface LibraryClusterAlbumDetailResponse {
album: LibraryAlbumDto;
tracks: LibraryTrackDto[];
ownerServerId: string;
relatedAlbums: LibraryAlbumDto[];
}
export interface LibraryClusterArtistDetailResponse {
artist: LibraryArtistDto;
albums: LibraryAlbumDto[];
topTracks: LibraryTrackDto[];
ownerServerId: string;
artistKey?: string | null;
}
export function libraryClusterAlbumDetail(args: {
serversOrdered: string[];
serverId: string;
entityId: string;
}): Promise<LibraryClusterAlbumDetailResponse> {
return invoke<LibraryClusterAlbumDetailResponse>('library_cluster_album_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
serverId: serverIndexKeyForId(args.serverId),
entityId: args.entityId,
},
}).then(resp => ({
...resp,
album: { ...resp.album, serverId: mapServerIdFromIndexKey(resp.album.serverId) },
tracks: mapTracksServerId(resp.tracks),
ownerServerId: mapServerIdFromIndexKey(resp.ownerServerId),
relatedAlbums: resp.relatedAlbums.map(a => ({
...a,
serverId: mapServerIdFromIndexKey(a.serverId),
})),
}));
}
export function libraryClusterArtistDetail(args: {
serversOrdered: string[];
serverId: string;
entityId: string;
}): Promise<LibraryClusterArtistDetailResponse> {
return invoke<LibraryClusterArtistDetailResponse>('library_cluster_artist_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
serverId: serverIndexKeyForId(args.serverId),
entityId: args.entityId,
},
}).then(resp => ({
...resp,
artist: { ...resp.artist, serverId: mapServerIdFromIndexKey(resp.artist.serverId) },
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
topTracks: mapTracksServerId(resp.topTracks),
ownerServerId: mapServerIdFromIndexKey(resp.ownerServerId),
}));
}
/** Cluster-mode search — dedup by cluster_key + priority. */
export function librarySearchCluster(args: {
query: string;
limit?: number;
offset?: number;
serversOrdered: string[];
}): Promise<LibraryCrossServerSearchResponse> {
return invoke<LibraryCrossServerSearchResponse>('library_search_cluster', {
query: args.query,
limit: args.limit,
offset: args.offset,
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
}).then(response => ({
...response,
hits: mapTracksServerId(response.hits),
fuzzy: mapTracksServerId(response.fuzzy),
serversSearched: response.serversSearched.map(id => mapServerIdFromIndexKey(id)),
}));
}
export interface LibraryClusterAdvancedSearchRequest {
serversOrdered: string[];
query?: string | null;
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
starredOnly?: boolean | null;
restrictAlbumIds?: string[] | null;
/** Per-member getAlbumList2 allowlists (`serverId` → album ids). */
restrictAlbumScopes?: Record<string, string[]>;
queryAlbumTitleOnly?: boolean | null;
sort?: LibrarySortClause[];
limit: number;
offset?: number;
skipTotals?: boolean;
libraryScopes?: Record<string, string[]>;
}
export function libraryClusterAdvancedSearch(
request: LibraryClusterAdvancedSearchRequest,
): Promise<LibraryAdvancedSearchResponse> {
return invoke<LibraryAdvancedSearchResponse>('library_cluster_advanced_search', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(request.serversOrdered),
query: request.query ?? undefined,
entityTypes: request.entityTypes,
filters: request.filters ?? [],
starredOnly: request.starredOnly ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
restrictAlbumScopes: mapClusterLibraryScopesToIndexKeys(request.restrictAlbumScopes) ?? {},
queryAlbumTitleOnly: request.queryAlbumTitleOnly ?? undefined,
sort: request.sort ?? [],
limit: request.limit,
offset: request.offset ?? 0,
skipTotals: request.skipTotals ?? false,
libraryScopes: mapClusterLibraryScopesToIndexKeys(request.libraryScopes) ?? {},
},
}).then(response => ({
...response,
artists: response.artists.map(artist => ({
...artist,
serverId: mapServerIdFromIndexKey(artist.serverId),
})),
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId),
})),
tracks: mapTracksServerId(response.tracks),
}));
}
export function libraryClusterListFavoriteAlbums(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_favorite_albums', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(resp => ({
...resp,
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListFavoriteArtists(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_favorite_artists', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(resp => ({
...resp,
artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryGetTrack(
serverId: string,
trackId: string,
@@ -724,11 +1093,13 @@ export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise
export function libraryGetGenreAlbumCounts(args: {
serverId: string;
libraryScope?: string;
libraryScopeIds?: string[];
}): Promise<GenreAlbumCountRow[]> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<GenreAlbumCountRow[]>('library_get_genre_album_counts', {
serverId: indexKey,
libraryScope: args.libraryScope,
libraryScopeIds: args.libraryScopeIds,
});
}
@@ -736,6 +1107,7 @@ export type LibraryGenreAlbumsRequest = {
serverId: string;
genre: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
@@ -759,6 +1131,7 @@ export function libraryListAlbumsByGenre(
serverId: indexKey,
genre: request.genre,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
sort: request.sort ?? [],
limit: request.limit ?? 50,
offset: request.offset ?? 0,
@@ -814,6 +1187,60 @@ export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySession
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
}
export interface PlaySessionMostPlayed {
track: LibraryTrackDto;
trackPlayCount: number;
totalListenedSec: number;
}
export function libraryClusterPlayerStatsDayDetail(args: {
serversOrdered: string[];
dateIso: string;
}): Promise<PlaySessionDayDetail> {
return invoke<PlaySessionDayDetail>('library_cluster_player_stats_day_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
dateIso: args.dateIso,
},
}).then(detail => ({
...detail,
tracks: detail.tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId),
})),
}));
}
export function libraryClusterPlayerStatsRecentDays(args: {
serversOrdered: string[];
limit?: number;
}): Promise<PlaySessionRecentDay[]> {
return invoke<PlaySessionRecentDay[]>('library_cluster_player_stats_recent_days', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
},
});
}
export function libraryClusterPlayerStatsMostPlayed(args: {
serversOrdered: string[];
limit?: number;
}): Promise<PlaySessionMostPlayed[]> {
return invoke<PlaySessionMostPlayed[]>('library_cluster_player_stats_most_played', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
},
}).then(rows => rows.map(row => ({
...row,
track: {
...row.track,
serverId: mapServerIdFromIndexKey(row.track.serverId),
},
})));
}
// ── Event subscriptions ───────────────────────────────────────────────
export interface LibrarySyncProgressPayload {
+3 -3
View File
@@ -1,6 +1,7 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { libraryScopeForServer } from './subsonicClient';
import { ndLogin } from './navidromeAdmin';
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
let cachedToken: { serverUrl: string; token: string } | null = null;
@@ -24,10 +25,9 @@ function asString(v: unknown, fallback = ''): string {
* Mirrors the Subsonic `musicFolderId` we pipe through `libraryFilterParams()` Navidrome
* uses the same id space, so the same value is valid for the native API's `library_id` filter. */
function currentLibraryId(): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return null;
const f = musicLibraryFilterByServer[activeServerId];
return !f || f === 'all' ? null : f;
return libraryScopeForServer(activeServerId) ?? null;
}
function asNumber(v: unknown): number | undefined {
+2 -2
View File
@@ -72,7 +72,7 @@ describe('libraryFilterParams', () => {
it('returns { musicFolderId } when the active server has a specific filter', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
musicLibraryFilterByServer: { [serverId]: ['mf-7'] },
});
expect(libraryFilterParams()).toEqual({ musicFolderId: 'mf-7' });
});
@@ -91,7 +91,7 @@ describe('libraryScopeForServer', () => {
it('returns the folder id when scoped', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
musicLibraryFilterByServer: { [serverId]: ['mf-7'] },
});
expect(libraryScopeForServer(serverId)).toBe('mf-7');
});
+109 -2
View File
@@ -8,6 +8,11 @@ import type {
SubsonicArtistInfo,
SubsonicSong,
} from './subsonicTypes';
import { isAllLibrariesFilter, normalizeMusicLibraryFilter } from '../utils/musicLibraryFilter';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { resolveClusterBrowseMembers } from '../utils/serverCluster/clusterBrowse';
import { libraryClusterResolveCandidates } from './library';
import { mergeClusterTracks, resolveClusterSeedIds } from '../utils/serverCluster/clusterDiscoveryMerge';
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: any } }>('getArtists.view', {
@@ -60,6 +65,9 @@ export async function getArtistInfoForServer(
}
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
if (isClusterMode()) {
return getTopSongsCluster(artist);
}
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return [];
return getTopSongsForServer(activeServerId, artist);
@@ -68,7 +76,7 @@ export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
export async function getTopSongsForServer(serverId: string, artist: string): Promise<SubsonicSong[]> {
try {
const { musicLibraryFilterByServer } = useAuthStore.getState();
const scoped = musicLibraryFilterByServer[serverId] && musicLibraryFilterByServer[serverId] !== 'all';
const scoped = !isAllLibrariesFilter(normalizeMusicLibraryFilter(musicLibraryFilterByServer[serverId]));
const topCount = scoped ? 20 : 5;
const data = await apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
@@ -83,7 +91,59 @@ export async function getTopSongsForServer(serverId: string, artist: string): Pr
}
}
async function getTopSongsCluster(artist: string): Promise<SubsonicSong[]> {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const settled = await Promise.allSettled(
members.map(serverId =>
apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
'getTopSongs.view',
{ artist, count: 20, ...libraryFilterParamsForServer(serverId) },
).then(data => ({ serverId, songs: data.topSongs?.song ?? [] })),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.slice(0, 5);
}
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
if (isClusterMode()) {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const requestCount = similarSongsRequestCount(count);
const settled = await Promise.allSettled(
members.map(serverId =>
apiForServer<{ similarSongs2: { song: SubsonicSong[] } }>(
serverId,
'getSimilarSongs2.view',
{ id, count: requestCount, ...libraryFilterParamsForServer(serverId) },
).then(data => ({ serverId, songs: data.similarSongs2?.song ?? [] })),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.filter(s => s.id !== id).slice(0, count);
}
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
@@ -96,7 +156,54 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
}
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
export async function getSimilarSongs(
id: string,
count = 50,
browseServerId?: string,
): Promise<SubsonicSong[]> {
if (isClusterMode()) {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const activeServerId = browseServerId ?? useAuthStore.getState().activeServerId ?? members[0] ?? '';
const requestCount = similarSongsRequestCount(count);
const seedCandidates = await libraryClusterResolveCandidates({
serversOrdered: members,
serverId: activeServerId,
trackId: id,
}).catch(() => null);
const seeds = resolveClusterSeedIds(
Object.fromEntries((seedCandidates?.candidates ?? []).map(c => [c.serverId, c.trackId])),
members,
);
const resolvedSeeds = seeds.length > 0
? seeds
: members.map(serverId => ({ serverId, seedId: id }));
const settled = await Promise.allSettled(
resolvedSeeds.map(({ serverId, seedId }) =>
apiForServer<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverId,
'getSimilarSongs.view',
{ id: seedId, count: requestCount, ...libraryFilterParamsForServer(serverId) },
).then(data => {
const raw = data.similarSongs?.song;
const songs = !raw ? [] : Array.isArray(raw) ? raw : [raw];
return { serverId, songs };
}),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.filter(s => s.id !== id).slice(0, count);
}
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
+8 -8
View File
@@ -4,6 +4,7 @@ import { version } from '../../package.json';
import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes';
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
import { libraryScopeForServer as scopeForServer } from '../utils/musicLibraryFilter';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
export const SUBSONIC_CLIENT = `psysonic/${version}`;
@@ -106,17 +107,16 @@ export function libraryFilterParams(): Record<string, string | number> {
return activeServerId ? libraryFilterParamsForServer(activeServerId) : {};
}
/** Navidrome/Subsonic music folder id for the local library index, or undefined for all libraries. */
export function libraryScopeForServer(serverId: string): string | undefined {
const resolved = resolveServerIdForIndexKey(serverId);
const f = useAuthStore.getState().musicLibraryFilterByServer[resolved];
if (f === undefined || f === 'all') return undefined;
return f;
}
export {
libraryScopeForServer,
libraryScopeIdsForServer,
libraryScopeInvokeArgs,
musicLibraryFilterForServer,
} from '../utils/musicLibraryFilter';
/** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */
export function libraryFilterParamsForServer(serverId: string): Record<string, string | number> {
const scope = libraryScopeForServer(serverId);
const scope = scopeForServer(serverId);
if (!scope) return {};
return { musicFolderId: scope };
}
+44 -30
View File
@@ -1,3 +1,8 @@
import {
isAllLibrariesFilter,
musicLibraryFilterForServer,
musicLibraryFilterStorageKey,
} from '../utils/musicLibraryFilter';
import { useAuthStore } from '../store/authStore';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import type {
@@ -87,46 +92,55 @@ export async function getAlbumList(
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
*/
let scopedLibraryAlbumIdCache: {
serverId: string;
folderId: string;
filterVersion: number;
ids: Set<string>;
} | null = null;
const scopedLibraryAlbumIdCaches = new Map<
string,
{ folderId: string; filterVersion: number; ids: Set<string> }
>();
async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
const { musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
/**
* Union of album ids across selected music folders **network fallback only**
* (Subsonic `getAlbumList2`). Local index browse filters via SQL `library_id`.
*/
export async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
const { musicLibraryFilterVersion } = useAuthStore.getState();
if (!serverId) return null;
const folder = musicLibraryFilterByServer[serverId];
if (folder === undefined || folder === 'all') {
scopedLibraryAlbumIdCache = null;
const filter = musicLibraryFilterForServer(serverId);
if (filter === 'all') {
scopedLibraryAlbumIdCaches.delete(serverId);
return null;
}
const hit = scopedLibraryAlbumIdCache;
const cacheKey = musicLibraryFilterStorageKey(serverId);
const hit = scopedLibraryAlbumIdCaches.get(serverId);
if (
hit &&
hit.serverId === serverId &&
hit.folderId === folder &&
hit.filterVersion === musicLibraryFilterVersion
hit
&& hit.folderId === cacheKey
&& hit.filterVersion === musicLibraryFilterVersion
) {
return hit.ids;
}
const ids = new Set<string>();
const pageSize = 500;
let offset = 0;
for (;;) {
const albums = await getAlbumListForServer(serverId, 'alphabeticalByName', pageSize, offset);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
for (const folderId of filter) {
let offset = 0;
for (;;) {
const albums = await getAlbumListForServer(
serverId,
'alphabeticalByName',
pageSize,
offset,
{ musicFolderId: folderId },
);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
}
}
scopedLibraryAlbumIdCache = {
serverId,
folderId: folder,
scopedLibraryAlbumIdCaches.set(serverId, {
folderId: cacheKey,
filterVersion: musicLibraryFilterVersion,
ids,
};
});
return ids;
}
@@ -175,9 +189,9 @@ export async function filterAlbumsToActiveLibrary(albums: SubsonicAlbum[]): Prom
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
export function similarSongsRequestCount(desired: number): number {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
if (f === undefined || f === 'all') return desired;
const { activeServerId } = useAuthStore.getState();
const f = activeServerId ? musicLibraryFilterForServer(activeServerId) : 'all';
if (isAllLibrariesFilter(f)) return desired;
return Math.min(300, Math.max(desired, desired * 4));
}
+14 -1
View File
@@ -1,6 +1,9 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicNowPlaying } from './subsonicTypes';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { clusterFanOutScrobbleSubmission } from '../utils/serverCluster/clusterWriteFanout';
import { useAuthStore } from '../store/authStore';
async function scrobbleOnServer(
serverId: string,
@@ -13,8 +16,18 @@ async function scrobbleOnServer(
await apiForServer(serverId, 'scrobble.view', params);
}
export async function scrobbleSong(id: string, time: number, serverId: string): Promise<void> {
export async function scrobbleSong(
id: string,
time: number,
serverId: string,
resolvedServerId?: string,
): Promise<void> {
if (!serverId) return;
if (isClusterMode()) {
const browseId = useAuthStore.getState().activeServerId ?? serverId;
await clusterFanOutScrobbleSubmission(browseId, id, time, resolvedServerId ?? serverId);
return;
}
try {
await scrobbleOnServer(serverId, id, true, time);
// Patch-on-use (§6.5 / F3): reflect the play in the local index so the
+21 -2
View File
@@ -7,6 +7,11 @@ import {
invalidateStarredAlbumBrowse,
refreshStarredAlbumIndexFromServer,
} from '../utils/library/starredAlbumIndexSync';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import {
clusterFanOutRating,
clusterFanOutStar,
} from '../utils/serverCluster/clusterWriteFanout';
import type {
EntityRatingSupportLevel,
StarredResults,
@@ -32,12 +37,16 @@ export async function star(
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song' && isClusterMode() && serverId) {
await clusterFanOutStar(serverId, id, true);
return;
}
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('star.view', params);
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
} else if (type === 'album' && serverId) {
@@ -52,12 +61,16 @@ export async function unstar(
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song' && isClusterMode() && serverId) {
await clusterFanOutStar(serverId, id, false);
return;
}
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('unstar.view', params);
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
} else if (type === 'album' && serverId) {
@@ -68,6 +81,12 @@ export async function unstar(
}
export async function setRating(id: string, rating: number): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (isClusterMode() && serverId) {
await clusterFanOutRating(serverId, id, rating);
invalidateEntityUserRatingCaches(id);
return;
}
await api('setRating.view', { id, rating });
// No-op in Rust when `id` is an album/artist (no track row matches).
patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { userRating: rating });
+6
View File
@@ -26,6 +26,8 @@ export interface SubsonicAlbum {
displayArtist?: string;
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
discTitles?: SubsonicDiscTitle[];
/** Psysonic cluster: originating server for merged browse rows (client-only). */
clusterSeedServerId?: string;
}
export interface SubsonicDiscTitle {
@@ -98,6 +100,8 @@ export interface SubsonicSong {
subRole?: string;
artist: { id?: string; name: string };
}>;
/** Psysonic cluster: browse-origin server for fan-out resolution (client-only). */
clusterBrowseServerId?: string;
}
export interface InternetRadioStation {
@@ -144,6 +148,8 @@ export interface SubsonicArtist {
starred?: string;
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
userRating?: number;
/** Psysonic cluster: originating server for merged browse rows (client-only). */
clusterSeedServerId?: string;
}
export interface SubsonicGenre {
+2
View File
@@ -48,6 +48,7 @@ import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelect
import { useAppActivityTracking } from '../hooks/useAppActivityTracking';
import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator';
import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority';
import { useClusterPlaybackMonitor } from '../hooks/useClusterPlaybackMonitor';
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
@@ -102,6 +103,7 @@ export function AppShell() {
const location = useLocation();
const prevPathnameRef = useRef(location.pathname);
useCoverNavigationPriority();
useClusterPlaybackMonitor();
useLiveSearchRouteScope();
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
+5 -2
View File
@@ -77,7 +77,10 @@ function AlbumCard({
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const psyDrag = useDragDrop();
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve });
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
libraryResolve,
clusterSeedServerId: album.clusterSeedServerId,
});
const dragCoverKey = useMemo(() => {
if (!coverRef) return '';
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
@@ -88,7 +91,7 @@ function AlbumCard({
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigateToAlbum(album.id, { search: linkQuery });
navigateToAlbum(album.id, { search: linkQuery, seedServerId: album.clusterSeedServerId });
};
return (
+5 -1
View File
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../cover/types';
import { useCoverLightboxSrc } from '../cover/lightbox';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
@@ -71,6 +72,8 @@ interface AlbumHeaderProps {
headerArtistRefs: SubsonicOpenArtistRef[];
songs: SubsonicSong[];
coverArtId?: string;
/** Cluster / multi-server album detail — fetch cover from the seed member, not representative. */
coverServerScope?: CoverServerScope;
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
@@ -98,6 +101,7 @@ export default function AlbumHeader({
headerArtistRefs,
songs,
coverArtId,
coverServerScope = COVER_SCOPE_ACTIVE,
resolvedCoverUrl,
isStarred,
downloadProgress,
@@ -124,7 +128,7 @@ export default function AlbumHeader({
const isMobile = useIsMobile();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true });
const coverRef = useAlbumCoverRef(info.id, coverArtId, coverServerScope, { libraryResolve: true });
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
alt: `${info.name} Cover`,
});
+8 -2
View File
@@ -18,12 +18,18 @@ interface Props {
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
const { t } = useTranslation();
const navigateToArtist = useNavigateToArtist();
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, {
libraryResolve,
clusterSeedServerId: artist.clusterSeedServerId,
});
return (
<div
className="artist-card"
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
onClick={() => navigateToArtist(artist.id, {
search: linkQuery,
seedServerId: artist.clusterSeedServerId,
})}
>
<div className="artist-card-avatar">
{coverRef ? (
+7 -2
View File
@@ -5,6 +5,7 @@ import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray';
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Music } from 'lucide-react';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
@@ -584,15 +585,19 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
libraryResolve: false,
clusterSeedServerId: album.clusterSeedServerId,
});
const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
const imgSrc = coverImgSrc(coverHandle.src);
const bgResolved = coverHandle.src;
const handleOpen = () => navigate(`/album/${album.id}`);
const handleOpen = () => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId });
const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ServerCluster } from '../utils/serverCluster/types';
import {
formatExcludedMemberLabels,
getClusterMergeDiagnostics,
type ClusterMergeDiagnostics,
} from '../utils/serverCluster/clusterMergeStatus';
export default function ClusterMergeBanner({ cluster }: { cluster: ServerCluster }) {
const { t } = useTranslation();
const [diag, setDiag] = useState<ClusterMergeDiagnostics | null>(null);
useEffect(() => {
let cancelled = false;
void getClusterMergeDiagnostics(cluster).then(res => {
if (!cancelled) setDiag(res);
}).catch(() => {
if (!cancelled) setDiag(null);
});
return () => { cancelled = true; };
}, [cluster]);
if (!diag || diag.mergeCount >= diag.totalCount) return null;
return (
<div className="connection-indicator-cluster-banner">
{t('cluster.mergeBanner', {
excluded: formatExcludedMemberLabels(diag.members),
})}
</div>
);
}
+116 -38
View File
@@ -3,12 +3,14 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown } from 'lucide-react';
import { Check, ChevronDown, Layers } from 'lucide-react';
import { ConnectionStatus } from '../hooks/useConnectionStatus';
import { useClusterConnectionLed } from '../hooks/useClusterConnectionLed';
import { useAuthStore } from '../store/authStore';
import { switchActiveServer } from '../utils/server/switchActiveServer';
import { switchActiveCluster, switchActiveServer } from '../utils/server/switchActiveServer';
import { showToast } from '../utils/ui/toast';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
import type { ServerCluster } from '../utils/serverCluster/types';
interface Props {
status: ConnectionStatus;
@@ -20,14 +22,22 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const { t } = useTranslation();
const navigate = useNavigate();
const servers = useAuthStore(s => s.servers);
const clusters = useAuthStore(s => s.clusters);
const activeServerId = useAuthStore(s => s.activeServerId);
const activeClusterId = useAuthStore(s => s.activeClusterId);
const [menuOpen, setMenuOpen] = useState(false);
const [switchingId, setSwitchingId] = useState<string | null>(null);
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
const hostRef = useRef<HTMLDivElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const multi = servers.length > 1;
const multi = servers.length > 1 || clusters.length > 0;
const activeCluster = activeClusterId
? clusters.find(cluster => cluster.id === activeClusterId) ?? null
: null;
const clusterLed = useClusterConnectionLed(activeCluster);
const effectiveStatus =
activeCluster && clusterLed.ledStatus !== null ? clusterLed.ledStatus : status;
const updateMenuPosition = useCallback(() => {
const el = hostRef.current;
@@ -81,7 +91,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
};
const onPickServer = async (srv: ServerProfile) => {
if (srv.id === activeServerId) {
if (!activeClusterId && srv.id === activeServerId) {
setMenuOpen(false);
return;
}
@@ -96,14 +106,59 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
navigate('/');
};
const onPickCluster = async (cluster: ServerCluster) => {
if (cluster.id === activeClusterId) {
setMenuOpen(false);
return;
}
setSwitchingId(cluster.id);
const ok = await switchActiveCluster(cluster.id);
setSwitchingId(null);
setMenuOpen(false);
if (!ok) {
showToast(t('connection.switchFailed'), 5000, 'error');
return;
}
navigate('/');
};
const label = isLan ? 'LAN' : t('connection.extern');
const tooltip = multi
? t('connection.switchServerHint')
: status === 'connected'
const metaTooltip = multi ? t('connection.switchScopeHint') : undefined;
const statusTooltip =
effectiveStatus === 'connected'
? t('connection.connectedTo', { server: serverName })
: status === 'disconnected'
: effectiveStatus === 'disconnected'
? t('connection.disconnectedFrom', { server: serverName })
: t('connection.checking');
: effectiveStatus === 'degraded'
? t('connection.connectedTo', { server: serverName })
: t('connection.checking');
const clusterActive = Boolean(activeCluster);
const ledTooltip = clusterActive
? (clusterLed.ledTooltip ?? t('connection.checking'))
: statusTooltip;
const renderMenuItem = (id: string, labelText: string, active: boolean, onClick: () => void, icon?: React.ReactNode) => (
<button
key={id}
type="button"
role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={switchingId === id}
onClick={onClick}
>
<span className="nav-library-dropdown-item-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{icon}
{labelText}
</span>
{switchingId === id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
) : (
<span className="nav-library-dropdown-check-spacer" aria-hidden />
)}
</button>
);
return (
<div className="connection-indicator-host" ref={hostRef}>
@@ -111,14 +166,21 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={onTriggerClick}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
role={multi ? 'button' : undefined}
aria-haspopup={multi ? 'menu' : undefined}
aria-expanded={multi ? menuOpen : undefined}
>
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<div
className={`connection-led connection-led--${effectiveStatus}`}
data-tooltip={ledTooltip}
data-tooltip-pos="bottom"
{...(clusterActive ? { 'data-tooltip-wrap': true } : {})}
/>
<div
className="connection-meta"
data-tooltip={metaTooltip}
data-tooltip-pos="bottom"
>
<span className="connection-type">{label}</span>
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
@@ -136,7 +198,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
ref={menuPanelRef}
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
role="menu"
aria-label={t('connection.switchServerTitle')}
aria-label={t('connection.switchScopeTitle')}
style={{
position: 'fixed',
top: menuFixed.top,
@@ -146,6 +208,38 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
zIndex: 10050,
}}
>
{clusters.length > 0 && (
<>
<div
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--text-muted)',
padding: '6px 10px 4px',
}}
>
{t('connection.switchClusterTitle')}
</div>
{clusters.map(cluster =>
renderMenuItem(
cluster.id,
cluster.name,
activeClusterId === cluster.id,
() => void onPickCluster(cluster),
<Layers size={14} style={{ flexShrink: 0, opacity: 0.85 }} aria-hidden />,
),
)}
<div
style={{
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)',
marginTop: 2,
paddingTop: 2,
}}
/>
</>
)}
<div
style={{
fontSize: 10,
@@ -158,30 +252,14 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
>
{t('connection.switchServerTitle')}
</div>
{servers.map(srv => {
const active = srv.id === activeServerId;
const busy = switchingId === srv.id;
const labelText = serverListDisplayLabel(srv, servers);
return (
<button
key={srv.id}
type="button"
role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={busy}
onClick={() => onPickServer(srv)}
>
<span className="nav-library-dropdown-item-label">{labelText}</span>
{switchingId === srv.id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
) : (
<span className="nav-library-dropdown-check-spacer" aria-hidden />
)}
</button>
);
})}
{servers.map(srv =>
renderMenuItem(
srv.id,
serverListDisplayLabel(srv, servers),
!activeClusterId && srv.id === activeServerId,
() => void onPickServer(srv),
),
)}
<div
style={{
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)',
+4 -2
View File
@@ -267,7 +267,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
});
}, [album?.id]);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt, undefined, {
clusterSeedServerId: album?.clusterSeedServerId,
});
const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
@@ -293,7 +295,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
className="hero"
role="banner"
aria-label={t('hero.eyebrow')}
onClick={() => navigateToAlbum(album.id)}
onClick={() => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId })}
style={{ cursor: 'pointer' }}
>
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
+35 -17
View File
@@ -23,6 +23,7 @@ import {
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -34,7 +35,7 @@ import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
import { CoverArtImage } from '../cover/CoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '../cover/layoutSizes';
import { albumCoverRef } from '../cover/ref';
import { albumCoverRef, coverScopeForServerProfileId } from '../cover/ref';
import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults';
@@ -55,11 +56,20 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
type LiveSearchSource = 'local' | 'network';
function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
function LiveSearchAlbumThumb({
albumId,
coverArt,
clusterSeedServerId,
}: {
albumId: string;
coverArt: string;
clusterSeedServerId?: string;
}) {
return (
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
clusterSeedServerId={clusterSeedServerId}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
@@ -70,17 +80,19 @@ function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt
);
}
function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> }) {
// Search results carry the per-track `mf-…` coverArt id, which the cover
// pipeline fails to resolve and the thumbnail goes blank. The album-scoped
// `al-<albumId>_0` id is what actually loads (verified in the RC1 blank-thumb
// investigation), and a song's search thumbnail is its album cover anyway —
// so fetch the album cover from the albumId. Falls back to a music glyph when
// there is no album to key on.
function LiveSearchSongThumb({
song,
}: {
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'>;
}) {
const albumId = song.albumId?.trim();
const scope = React.useMemo(
() => coverScopeForServerProfileId(song.clusterBrowseServerId),
[song.clusterBrowseServerId],
);
const coverRef = React.useMemo(
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`) : undefined),
[albumId],
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`, scope) : undefined),
[albumId, scope],
);
if (!coverRef) return <div className="search-result-icon"><Music size={14} /></div>;
return (
@@ -95,7 +107,11 @@ function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumI
);
}
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
function LiveSearchArtistThumb({
artist,
}: {
artist: Pick<SubsonicArtist, 'id' | 'coverArt' | 'clusterSeedServerId'>;
}) {
const [failed, setFailed] = useState(false);
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
@@ -103,6 +119,7 @@ function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' |
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
clusterSeedServerId={artist.clusterSeedServerId}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
@@ -138,6 +155,7 @@ export default function LiveSearch() {
const liveSearchGenRef = useRef(0);
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const navigateToArtist = useNavigateToArtist();
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
@@ -527,8 +545,8 @@ export default function LiveSearch() {
},
},
] : results ? [
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id); setOpen(false); setQuery(''); } }))),
...(results.artists.map(a => ({ id: a.id, action: () => { navigateToArtist(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => {
const track = songToTrack(s);
enqueue([track]);
@@ -738,7 +756,7 @@ export default function LiveSearch() {
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
onClick={() => { navigateToArtist(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'artist');
@@ -762,14 +780,14 @@ export default function LiveSearch() {
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigateToAlbum(a.id); setOpen(false); setQuery(''); }}
onClick={() => { navigateToAlbum(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} clusterSeedServerId={a.clusterSeedServerId} />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
+7
View File
@@ -8,6 +8,7 @@ import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { usePlaybackServerId } from '../hooks/usePlaybackServerId';
import { useClusterMemberDisplayLabel } from '../hooks/useClusterMemberDisplayLabel';
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
import CachedImage from './CachedImage';
import OverlayScrollArea from './OverlayScrollArea';
@@ -88,6 +89,7 @@ export default function NowPlayingInfo() {
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
const subsonicServerId = usePlaybackServerId();
const subsonicReady = Boolean(subsonicServerId);
const clusterServerLabel = useClusterMemberDisplayLabel(subsonicServerId);
const primaryArtist = currentTrack ? primaryTrackArtistRef(currentTrack) : null;
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
@@ -230,6 +232,11 @@ export default function NowPlayingInfo() {
<div className="np-info-artist-body">
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
<div className="np-info-artist-name">{artistName || t('common.unknownArtist', 'Unknown artist')}</div>
{clusterServerLabel && (
<div className="np-info-cluster-server">
{t('nowPlayingInfo.clusterServer')}: {clusterServerLabel}
</div>
)}
{bioClean && (
<>
<p
+7 -1
View File
@@ -15,6 +15,7 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { isLanUrl, serverShareBaseUrl } from '../utils/server/serverEndpoint';
import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
interface Props { onClose: () => void; }
@@ -36,6 +37,7 @@ export default function OrbitStartModal({ onClose }: Props) {
const [copied, setCopied] = useState(false);
const [hasCopied, setHasCopied] = useState(false);
const [clearQueue, setClearQueue] = useState(false);
const clusterBlocked = isClusterMode();
const server = useAuthStore.getState().getActiveServer();
// Orbit links go to remote guests — use the share URL (public by default
@@ -70,6 +72,10 @@ export default function OrbitStartModal({ onClose }: Props) {
const onStart = async () => {
setError(null);
if (clusterBlocked) {
setError(t('orbit.clusterCreateBlocked'));
return;
}
const trimmed = name.trim();
if (!trimmed) { setError(t('orbit.errNameRequired')); return; }
@@ -222,7 +228,7 @@ export default function OrbitStartModal({ onClose }: Props) {
type="button"
className="btn btn-primary"
onClick={onStart}
disabled={busy || !name.trim()}
disabled={busy || !name.trim() || clusterBlocked}
>
{busy
? t('orbit.btnStarting')
+16 -2
View File
@@ -4,6 +4,7 @@ import { Orbit as OrbitIcon, Plus, LogIn, HelpCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { useHelpModalStore } from '../store/helpModalStore';
import OrbitStartModal from './OrbitStartModal';
import OrbitJoinModal from './OrbitJoinModal';
@@ -57,7 +58,14 @@ export default function OrbitStartTrigger() {
}
: { display: 'none' };
const pickCreate = () => { setPopoverOpen(false); setStartOpen(true); };
const pickCreate = () => {
if (isClusterMode()) {
setPopoverOpen(false);
return;
}
setPopoverOpen(false);
setStartOpen(true);
};
const pickJoin = () => { setPopoverOpen(false); setJoinOpen(true); };
const pickHelp = () => { setPopoverOpen(false); useHelpModalStore.getState().open(); };
@@ -83,7 +91,13 @@ export default function OrbitStartTrigger() {
{popoverOpen && createPortal(
<div ref={popRef} className="nav-library-dropdown-panel orbit-launch-pop" style={popoverStyle} role="menu">
<button type="button" className="orbit-launch-pop__item" onClick={pickCreate}>
<button
type="button"
className="orbit-launch-pop__item"
onClick={pickCreate}
disabled={isClusterMode()}
title={isClusterMode() ? t('orbit.clusterCreateBlocked') : undefined}
>
<Plus size={14} />
<span>{t('orbit.launchCreate')}</span>
</button>
+35 -1
View File
@@ -12,9 +12,11 @@ import { usePlaylistStore } from '../store/playlistStore';
import { useTranslation } from 'react-i18next';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useAuthStore } from '../store/authStore';
import { encodeSharePayload } from '../utils/share/shareLink';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { serverShareBaseUrl } from '../utils/server/serverEndpoint';
import { copyTextToClipboard } from '../utils/server/serverMagicString';
import { encodeSharePayload } from '../utils/share/shareLink';
import { showToast } from '../utils/ui/toast';
import { useThemeStore } from '../store/themeStore';
import { useLyricsStore } from '../store/lyricsStore';
@@ -205,6 +207,38 @@ function QueuePanelHostOrSolo() {
// host on a dual-address profile).
const active = useAuthStore.getState().getActiveServer();
if (!active) return;
if (isClusterMode()) {
const repId = active.id;
const exportRefs = queueItems.filter(
r => resolveServerIdForIndexKey(r.serverId) === repId,
);
const foreignCount = queueItems.length - exportRefs.length;
if (foreignCount > 0) {
const msg = t('queue.shareClusterMixedWarning', {
exported: exportRefs.length,
total: queueItems.length,
});
if (!confirm(msg)) return;
}
if (exportRefs.length === 0) {
showToast(t('queue.shareClusterNothingOnPrimary'), 4000, 'info');
return;
}
const srv = serverShareBaseUrl(active);
if (!srv) return;
const ids = exportRefs.map(r => r.trackId);
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
if (ok) {
showToast(
foreignCount > 0
? t('queue.shareClusterExportedCount', { exported: exportRefs.length, total: queueItems.length })
: t('contextMenu.shareCopied'),
);
} else {
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
}
return;
}
const srv = serverShareBaseUrl(active);
if (!srv) return;
const ids = queueItems.map(r => r.trackId);
+70 -13
View File
@@ -25,8 +25,26 @@ import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
import { useClusterMusicFolders } from '../hooks/useClusterMusicFolders';
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
import SidebarNavBody from './sidebar/SidebarNavBody';
import { getActiveClusterMemberIds, isClusterMode } from '../utils/serverCluster/clusterScope';
import {
clusterLibraryFilterStorageKey,
clusterLibraryPickerEntryId,
clusterLibraryScopeSubtitle,
isClusterAllLibrariesSelected,
isClusterLibraryFolderSelected,
} from '../utils/serverCluster/clusterLibraryScopes';
import { getCachedMusicFolders } from '../utils/musicFoldersCache';
import {
isAllLibrariesFilter,
isLibraryFolderSelected,
libraryScopeSubtitleFromFolders,
musicLibraryFilterForServer,
musicLibraryFilterStorageKey,
normalizeMusicLibraryFilter,
} from '../utils/musicLibraryFilter';
export default function Sidebar({
@@ -55,6 +73,7 @@ export default function Sidebar({
const musicFolders = useAuthStore(s => s.musicFolders);
const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer);
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
const toggleMusicLibraryFolder = useAuthStore(s => s.toggleMusicLibraryFolder);
const hotCacheEnabled = useAuthStore(s => s.hotCacheEnabled);
const setHotCacheEnabled = useAuthStore(s => s.setHotCacheEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
@@ -82,11 +101,46 @@ export default function Sidebar({
}, [playlistsRaw]);
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
const clusterMode = isClusterMode();
const clusterMemberIds = getActiveClusterMemberIds();
const { entries: clusterMusicFolders } = useClusterMusicFolders();
const effectiveMusicFolders =
musicFolders.length > 0 ? musicFolders : (getCachedMusicFolders(serverId) ?? []);
const pickerFolders = clusterMode
? clusterMusicFolders.map(e => ({
id: clusterLibraryPickerEntryId(e.serverId, e.folderId),
name: `${e.serverLabel}${e.folderName}`,
serverId: e.serverId,
folderId: e.folderId,
}))
: effectiveMusicFolders.map(f => ({
id: f.id,
name: f.name,
serverId,
folderId: f.id,
}));
const showLibraryPicker = !isCollapsed && isLoggedIn && (
clusterMode ? clusterMusicFolders.length > 0 : effectiveMusicFolders.length > 1
);
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
const selectedFolderName =
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
const allLibrariesSelected = clusterMode
? isClusterAllLibrariesSelected(clusterMemberIds)
: serverId
? isAllLibrariesFilter(normalizeMusicLibraryFilter(musicLibraryFilterByServer[serverId]))
: true;
const filterStorageKey = clusterMode
? clusterLibraryFilterStorageKey(clusterMemberIds)
: serverId
? musicLibraryFilterStorageKey(serverId)
: 'all';
const multiLibrariesLabel = (count: number) => t('sidebar.librariesCount', { count });
const selectedFolderName = clusterMode
? clusterLibraryScopeSubtitle(clusterMemberIds, clusterMusicFolders, multiLibrariesLabel)
: libraryScopeSubtitleFromFolders(
effectiveMusicFolders,
serverId ? musicLibraryFilterForServer(serverId) : 'all',
multiLibrariesLabel,
);
const libraryItemsForReorder = useMemo(
() => getLibraryItemsForReorder(sidebarItems, randomNavMode),
@@ -129,7 +183,7 @@ export default function Sidebar({
});
const newReleasesUnreadCount = useSidebarNewReleasesUnread({
serverId,
filterId,
filterId: filterStorageKey,
isLoggedIn,
pathname: location.pathname,
});
@@ -139,10 +193,10 @@ export default function Sidebar({
const pickLibrary = (id: 'all' | string) => {
setMusicLibraryFilter(id);
setLibraryDropdownOpen(false);
};
const isFolderSelected = (sid: string, folderId: string) =>
clusterMode
? isClusterLibraryFolderSelected(sid, folderId)
: isLibraryFolderSelected(sid, folderId);
// Fetch playlists when expanded
useEffect(() => {
@@ -195,7 +249,7 @@ export default function Sidebar({
playlists.length,
isLoggedIn,
randomNavMode,
filterId,
filterStorageKey,
hasOfflineContent,
activeJobs.length,
isSyncing,
@@ -206,14 +260,17 @@ export default function Sidebar({
<SidebarNavBody
isCollapsed={isCollapsed}
showLibraryPicker={showLibraryPicker}
filterId={filterId}
allLibrariesSelected={allLibrariesSelected}
selectedFolderName={selectedFolderName}
libraryDropdownOpen={libraryDropdownOpen}
setLibraryDropdownOpen={setLibraryDropdownOpen}
dropdownRect={dropdownRect}
libraryTriggerRef={libraryTriggerRef}
musicFolders={musicFolders}
pickLibrary={pickLibrary}
musicFolders={pickerFolders}
isFolderSelected={isFolderSelected}
onSelectAll={() => setMusicLibraryFilter('all')}
onExclusiveSelect={(sid, folderId) => setMusicLibraryFilter(folderId, sid)}
onToggleFolder={(sid, folderId) => toggleMusicLibraryFolder(folderId, sid)}
visibleLibraryConfigs={visibleLibraryConfigs}
libraryItemsForReorder={libraryItemsForReorder}
visibleSystemConfigs={visibleSystemConfigs}
+1 -1
View File
@@ -65,7 +65,7 @@ function SongCard({
const handleAlbumClick = (e: React.MouseEvent) => {
if (!song.albumId) return;
e.stopPropagation();
navigateToAlbum(song.albumId);
navigateToAlbum(song.albumId, { seedServerId: song.clusterBrowseServerId });
};
return (
+13 -7
View File
@@ -1,4 +1,4 @@
import { getSong } from '../api/subsonicLibrary';
import { getSongForServer } from '../api/subsonicLibrary';
import { libraryGetFacts } from '../api/library';
import type { SubsonicSong } from '../api/subsonicTypes';
import React, { useEffect, useState } from 'react';
@@ -22,6 +22,7 @@ import {
type ParsedTrackEnrichment,
} from '../utils/library/trackEnrichment';
import i18n from '../i18n';
import { useClusterMemberDisplayLabel } from '../hooks/useClusterMemberDisplayLabel';
function formatSize(bytes?: number): string | null {
if (!bytes) return null;
@@ -90,8 +91,9 @@ export default function SongInfoModal() {
setEnrichment(null);
setAbsolutePath(null);
const songId = songInfoModal.songId;
const streamServerId = songInfoModal.serverId ?? useAuthStore.getState().activeServerId ?? '';
void (async () => {
const s = await getSong(songId);
const s = streamServerId ? await getSongForServer(streamServerId, songId) : null;
if (cancelled) return;
setSong(s);
setLoading(false);
@@ -99,8 +101,7 @@ export default function SongInfoModal() {
setEnrichment(null);
return;
}
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const sid = streamServerId;
const indexEnabled = sid ? useLibraryIndexStore.getState().isIndexEnabled(sid) : false;
if (sid && indexEnabled && await libraryIsReady(sid)) {
try {
@@ -115,11 +116,11 @@ export default function SongInfoModal() {
setEnrichment(null);
}
})();
// Try the native API in parallel; only when the active server is Navidrome
// Try the native API in parallel; only when the stream server is Navidrome
// and we have credentials. Failures are silent — modal falls back to
// whatever the Subsonic `path` field carried (typically nothing).
const sid = streamServerId;
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const profile = sid ? auth.servers.find(p => p.id === sid) : null;
const identity = sid ? auth.subsonicServerIdentityByServer[sid] : undefined;
const isNavidrome = identity?.type?.trim().toLowerCase() === 'navidrome';
@@ -130,7 +131,9 @@ export default function SongInfoModal() {
});
}
return () => { cancelled = true; };
}, [songInfoModal.isOpen, songInfoModal.songId]);
}, [songInfoModal.isOpen, songInfoModal.songId, songInfoModal.serverId]);
const clusterServerLabel = useClusterMemberDisplayLabel(songInfoModal.serverId);
useEffect(() => {
if (!songInfoModal.isOpen) return;
@@ -189,6 +192,9 @@ export default function SongInfoModal() {
<CopyableFieldRow label={t('songInfo.songTitle')} text={song.title} />
<CopyableFieldRow label={t('songInfo.artist')} text={song.artist} />
<CopyableFieldRow label={t('songInfo.album')} text={song.album} />
{clusterServerLabel && (
<Row label={t('songInfo.clusterServer')} value={clusterServerLabel} />
)}
{song.albumArtist && song.albumArtist !== song.artist && (
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
)}
+1 -1
View File
@@ -117,7 +117,7 @@ function SongRow({ song, showBpm }: Props) {
<span
className="track-artist-link"
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); navigateToAlbum(song.albumId!); }}
onClick={(e) => { e.stopPropagation(); navigateToAlbum(song.albumId!, { seedServerId: song.clusterBrowseServerId }); }}
title={song.album}
>{song.album}</span>
) : <span title={song.album}>{song.album}</span>}
+2 -2
View File
@@ -8,7 +8,7 @@ import type { Track } from '../../store/playerStoreTypes';
import { songToTrack } from '../../utils/playback/songToTrack';
import { useSelectionStore } from '../../store/selectionStore';
import { useThemeStore } from '../../store/themeStore';
import { usePreviewStore } from '../../store/previewStore';
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import StarRating from '../StarRating';
import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers';
import { formatLongDuration } from '../../utils/format/formatDuration';
@@ -116,7 +116,7 @@ export const TrackRow = React.memo(function TrackRow({
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
onClick={e => {
e.stopPropagation();
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
usePreviewStore.getState().startPreview(previewInputFromSong(song), 'albums');
}}
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
import { songToTrack } from '../../utils/playback/songToTrack';
import { formatTrackTime } from '../../utils/format/formatDuration';
@@ -16,10 +16,12 @@ interface Props {
marginTop: string;
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
losslessOnly?: boolean;
clusterSeedServerId?: string;
}
export default function ArtistDetailTopTracks({
topSongs, albums, marginTop, playTopSongWithContinuation, losslessOnly = false,
clusterSeedServerId,
}: Props) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
@@ -82,7 +84,7 @@ export default function ArtistDetailTopTracks({
<button
type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview(previewInputFromSong(song), 'artist'); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
>
@@ -96,7 +98,9 @@ export default function ArtistDetailTopTracks({
</button>
{(() => {
const albumForCover = topSongAlbumForCover(song, albums);
return albumForCover ? <ArtistTopTrackCover album={albumForCover} /> : null;
return albumForCover ? (
<ArtistTopTrackCover album={albumForCover} clusterSeedServerId={song.clusterBrowseServerId ?? clusterSeedServerId} />
) : null;
})()}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div className="track-title">{song.title}</div>
@@ -5,8 +5,17 @@ import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '../../cover/layoutSizes';
import type { TopSongAlbumCoverSource } from './topSongAlbumForCover';
/** 32px album thumb — same cover ref path as {@link AlbumCard} on artist pages. */
export default function ArtistTopTrackCover({ album }: { album: TopSongAlbumCoverSource }) {
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
export default function ArtistTopTrackCover({
album,
clusterSeedServerId,
}: {
album: TopSongAlbumCoverSource;
clusterSeedServerId?: string;
}) {
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
libraryResolve: false,
clusterSeedServerId,
});
if (!coverRef) return null;
return (
+2
View File
@@ -26,6 +26,7 @@ export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
clusterSeedServerId={artist.clusterSeedServerId}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name}
@@ -53,6 +54,7 @@ export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
clusterSeedServerId={artist.clusterSeedServerId}
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
surface="dense"
alt={artist.name}
+3 -3
View File
@@ -14,7 +14,7 @@ interface TileProps {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -29,7 +29,7 @@ function ArtistGridTile({ artist, ...rest }: TileProps) {
if (rest.selectionMode) {
rest.toggleSelect(artist.id);
} else {
rest.onOpenArtist(artist.id);
rest.onOpenArtist(artist.id, { seedServerId: artist.clusterSeedServerId });
}
}}
onContextMenu={(e) => {
@@ -68,7 +68,7 @@ interface Props {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
+3 -3
View File
@@ -13,7 +13,7 @@ interface RowProps {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -37,7 +37,7 @@ function ArtistListRow({
if (selectionMode) {
toggleSelect(artist.id);
} else {
onOpenArtist(artist.id);
onOpenArtist(artist.id, { seedServerId: artist.clusterSeedServerId });
}
}}
onContextMenu={(e) => {
@@ -78,7 +78,7 @@ interface Props {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -111,7 +111,10 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(
song.id,
song.clusterBrowseServerId ?? queue[queueIndex ?? -1]?.serverId,
))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
</>
@@ -103,7 +103,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
)}
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!, { seedServerId: song.clusterBrowseServerId }))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
@@ -161,7 +161,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id, song.clusterBrowseServerId))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
{playlistId && playlistSongIndex !== undefined && (
@@ -246,7 +246,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!, { seedServerId: song.clusterBrowseServerId }))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
@@ -298,7 +298,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id, song.clusterBrowseServerId))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
<div className="context-menu-divider" />
@@ -31,7 +31,7 @@ export interface ContextMenuItemsProps {
setStarredOverride: (id: string, starred: boolean) => void;
lastfmLovedCache: Record<string, boolean>;
setLastfmLovedForSong: (title: string, artist: string, loved: boolean) => void;
openSongInfo: (id: string) => void;
openSongInfo: (id: string, serverId?: string | null) => void;
userRatingOverrides: Record<string, number>;
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
keyboardRating: KeyboardRating | null;
@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
import type { ColDef } from '../../utils/useTracklistColumns';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import { useSelectionStore } from '../../store/selectionStore';
import { useThemeStore } from '../../store/themeStore';
import { useDragDrop } from '../../contexts/DragDropContext';
@@ -122,7 +122,7 @@ export default function FavoritesSongsTracklist({
L.playTrack(L.visibleTracks[index], L.visibleTracks);
},
startPreview: (song) => usePreviewStore.getState().startPreview(
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
previewInputFromSong(song),
'favorites',
),
rate: (songId, r) => latest.current.handleRate(songId, r),
+10 -3
View File
@@ -14,6 +14,7 @@ import MarqueeText from '../MarqueeText';
import { OpenArtistRefInline } from '../OpenArtistRefInline';
import StarRating from '../StarRating';
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
import { usePlayerStore } from '../../store/playerStore';
import {
usePlayerBarLayoutStore,
@@ -54,13 +55,19 @@ export function PlayerTrackInfo({
navigate, openContextMenu, t,
}: Props) {
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
const navigateToAlbum = useNavigateToAlbum();
const playbackCoverRef = usePlaybackTrackCoverRef(
showPreviewMeta ? null : currentTrack ?? undefined,
);
const previewCoverRef = useAlbumCoverRef(
showPreviewMeta ? coverArtId : null,
showPreviewMeta ? coverArtId : null,
undefined,
showPreviewMeta
? { clusterSeedServerId: previewingTrack?.clusterBrowseServerId, libraryResolve: false }
: undefined,
);
const activeCoverRef = showPreviewMeta ? previewCoverRef : playbackCoverRef;
const layoutItems = usePlayerBarLayoutStore(s => s.items);
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
layoutItems.find(i => i.id === id)?.visible !== false;
@@ -86,10 +93,10 @@ export function PlayerTrackInfo({
<Cast size={20} />
</div>
)
) : !isRadio && (showPreviewMeta ? coverArtId : playbackCoverRef) ? (
) : !isRadio && activeCoverRef ? (
<CoverArtImage
className="player-album-art"
coverRef={showPreviewMeta ? previewCoverRef! : playbackCoverRef!}
coverRef={activeCoverRef}
displayCssPx={128}
surface="sparse"
ensurePriority="high"
@@ -125,7 +132,7 @@ export function PlayerTrackInfo({
: displayTitle}
className="player-track-name"
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigateToAlbum(currentTrack.albumId, { seedServerId: currentTrack.clusterBrowseServerId })}
onContextMenu={!isRadio && !showPreviewMeta && currentTrack?.albumId
? (e) => {
e.preventDefault();
@@ -13,7 +13,7 @@ import type { ColDef } from '../../utils/useTracklistColumns';
import type { SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import { useThemeStore } from '../../store/themeStore';
import { useDragDrop } from '../../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
@@ -149,7 +149,7 @@ export default function PlaylistTracklist({
L.playTrack(L.displayedTracks[index], L.displayedTracks);
},
startPreview: (song) => usePreviewStore.getState().startPreview(
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
previewInputFromSong(song),
'playlists',
),
toggleStar: (song, e) => latest.current.handleToggleStar(song, e),
@@ -17,6 +17,7 @@ import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { OpenArtistRefInline } from '../OpenArtistRefInline';
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
import { usePlayerStore } from '../../store/playerStore';
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
@@ -53,6 +54,7 @@ export function QueueCurrentTrack({
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
}: Props) {
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
const navigateToAlbum = useNavigateToAlbum();
const coverRef = usePlaybackTrackCoverRef(currentTrack);
const artistRefs = resolveTrackArtistRefs(currentTrack);
const enrichment = useQueueTrackEnrichment(currentTrack.id);
@@ -234,7 +236,7 @@ export function QueueCurrentTrack({
</div>
<div
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
onClick={() => currentTrack.albumId && navigateToAlbum(currentTrack.albumId, { seedServerId: currentTrack.clusterBrowseServerId })}
>{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Heart, Play, Square } from 'lucide-react';
import type { SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes';
import { usePreviewStore } from '../../store/previewStore';
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import { useDragDrop } from '../../contexts/DragDropContext';
import { formatRandomMixDuration } from '../../utils/componentHelpers/randomMixHelpers';
@@ -110,7 +110,7 @@ export default function RandomMixTrackRow({
onClick={e => {
e.stopPropagation();
usePreviewStore.getState().startPreview(
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
previewInputFromSong(song),
'randomMix',
);
}}
@@ -0,0 +1,374 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Layers, Pencil, Plus, Trash2 } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import { switchActiveCluster } from '../../utils/server/switchActiveServer';
import type { ServerCluster } from '../../utils/serverCluster/types';
import {
getClusterMergeDiagnostics,
type ClusterMergeDiagnostics,
} from '../../utils/serverCluster/clusterMergeStatus';
type MemberDropTarget = { idx: number; before: boolean } | null;
export function ServerClustersSection() {
const { t } = useTranslation();
const auth = useAuthStore();
const psyDragState = useDragDrop();
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [memberDropTarget, setMemberDropTarget] = useState<MemberDropTarget>(null);
const [diagnosticsByCluster, setDiagnosticsByCluster] = useState<Record<string, ClusterMergeDiagnostics>>({});
const memberDropRef = useRef<MemberDropTarget>(null);
const dragClusterIdRef = useRef<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const showSection = auth.servers.length >= 2;
useEffect(() => {
if (!psyDragState.isDragging) {
memberDropRef.current = null;
setMemberDropTarget(null);
dragClusterIdRef.current = null;
}
}, [psyDragState.isDragging]);
useEffect(() => {
let cancelled = false;
void Promise.all(auth.clusters.map(async cluster => {
const diag = await getClusterMergeDiagnostics(cluster);
return [cluster.id, diag] as const;
})).then(entries => {
if (cancelled) return;
setDiagnosticsByCluster(Object.fromEntries(entries));
}).catch(() => {
if (!cancelled) setDiagnosticsByCluster({});
});
return () => { cancelled = true; };
}, [auth.clusters, auth.servers, auth.activeClusterId, auth.musicLibraryFilterVersion]);
const startCreate = () => {
setCreating(true);
setNewName('');
setSelectedIds([]);
};
const toggleMemberPick = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
);
};
const saveCreate = () => {
if (selectedIds.length < 2) return;
try {
auth.createCluster(newName, selectedIds);
setCreating(false);
} catch {
/* validation */
}
};
const activateCluster = async (cluster: ServerCluster) => {
await switchActiveCluster(cluster.id);
};
const handleMemberDragMove = (clusterId: string, e: React.MouseEvent) => {
if (!psyDragState.isDragging || !containerRef.current) return;
dragClusterIdRef.current = clusterId;
const rows = containerRef.current.querySelectorAll<HTMLElement>(
`[data-cluster-member="${clusterId}"]`,
);
let target: MemberDropTarget = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.memberIdx);
if (e.clientY < rect.top + rect.height / 2) {
target = { idx, before: true };
break;
}
target = { idx, before: false };
}
memberDropRef.current = target;
setMemberDropTarget(target);
};
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; clusterId?: string; index?: number };
try {
parsed = JSON.parse(detail.data as string);
} catch {
return;
}
if (parsed.type !== 'cluster_member_reorder' || parsed.index == null || !parsed.clusterId) return;
const clusterId = parsed.clusterId;
const target = memberDropRef.current;
memberDropRef.current = null;
setMemberDropTarget(null);
if (!target) return;
const cluster = auth.clusters.find(c => c.id === clusterId);
if (!cluster) return;
const fromIdx = parsed.index;
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const next = [...cluster.serverIds];
const [moved] = next.splice(fromIdx, 1);
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
auth.setClusterOrder(clusterId, next);
};
el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [auth]);
if (!showSection) return null;
return (
<section className="settings-section" style={{ marginTop: '1.5rem' }}>
<div className="settings-section-header">
<Layers size={18} />
<h2>{t('settings.clustersTitle')}</h2>
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.clustersHint')}
</p>
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{auth.clusters.map(cluster => {
const isActive = auth.activeClusterId === cluster.id;
const isEditing = editingId === cluster.id;
return (
<div
key={cluster.id}
className="settings-card"
style={{
border: isActive ? '1px solid var(--accent)' : undefined,
background: isActive
? 'color-mix(in srgb, var(--accent) 10%, var(--bg-card))'
: undefined,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', flexWrap: 'wrap' }}>
{isEditing ? (
<input
className="input"
value={editName}
onChange={e => setEditName(e.target.value)}
style={{ flex: 1, minWidth: 120 }}
/>
) : (
<div>
<div style={{ fontWeight: 600 }}>{cluster.name}</div>
{isActive && (
<span
style={{
fontSize: 11,
background: 'var(--accent)',
color: 'var(--ctp-crust)',
padding: '1px 6px',
borderRadius: 'var(--radius-sm)',
fontWeight: 600,
}}
>
{t('settings.clusterActive')}
</span>
)}
</div>
)}
<div style={{ display: 'flex', gap: 6 }}>
{!isActive && (
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => void activateCluster(cluster)}>
{t('settings.useCluster')}
</button>
)}
{isEditing ? (
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12 }}
onClick={() => {
auth.renameCluster(cluster.id, editName);
setEditingId(null);
}}
>
{t('common.save')}
</button>
) : (
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setEditingId(cluster.id);
setEditName(cluster.name);
}}
>
<Pencil size={14} />
</button>
)}
<button
type="button"
className="btn btn-ghost"
style={{ color: 'var(--danger)' }}
onClick={() => auth.deleteCluster(cluster.id)}
>
<Trash2 size={14} />
</button>
</div>
</div>
<div
style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: 4 }}
onMouseMove={e => handleMemberDragMove(cluster.id, e)}
>
{cluster.serverIds.map((sid, memberIdx) => {
const srv = auth.servers.find(s => s.id === sid);
if (!srv) return null;
const status = diagnosticsByCluster[cluster.id]?.members.find(m => m.serverId === sid);
const statusLabel = !status
? ''
: status.included
? t('cluster.memberIncluded')
: status.reason === 'indexing'
? t('cluster.memberExcludedIndexing')
: t('cluster.memberExcludedOffline');
const isBefore =
psyDragState.isDragging &&
dragClusterIdRef.current === cluster.id &&
memberDropTarget?.idx === memberIdx &&
memberDropTarget.before;
const isAfter =
psyDragState.isDragging &&
dragClusterIdRef.current === cluster.id &&
memberDropTarget?.idx === memberIdx &&
!memberDropTarget.before;
return (
<div
key={sid}
data-cluster-member={cluster.id}
data-member-idx={memberIdx}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
boxShadow: isBefore
? 'inset 0 2px 0 0 var(--accent)'
: isAfter
? 'inset 0 -2px 0 0 var(--accent)'
: undefined,
}}
>
<ClusterMemberGrip clusterId={cluster.id} idx={memberIdx} label={cluster.name} />
<span style={{ flex: 1 }}>{serverListDisplayLabel(srv, auth.servers)}</span>
{statusLabel && (
<span style={{ fontSize: 11, color: status?.included ? 'var(--text-muted)' : 'var(--warning)' }}>
{statusLabel}
</span>
)}
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 6px' }}
onClick={() => auth.removeServerFromCluster(cluster.id, sid)}
>
{t('settings.clusterRemoveMember')}
</button>
</div>
);
})}
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 13 }}>
<input
type="checkbox"
checked={cluster.clusterSyncPlayCounts}
onChange={e => auth.setClusterSyncPlayCounts(cluster.id, e.target.checked)}
/>
{t('settings.clusterSyncPlayCounts')}
</label>
{cluster.clusterSyncPlayCounts && (
<p style={{ fontSize: 11, color: 'var(--text-muted)', margin: '4px 0 0' }}>
{t('settings.clusterSyncPlayCountsWarn')}
</p>
)}
</div>
);
})}
{creating ? (
<div className="settings-card">
<input
className="input"
placeholder={t('settings.clusterNamePlaceholder')}
value={newName}
onChange={e => setNewName(e.target.value)}
style={{ marginBottom: 8, width: '100%' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
{auth.servers.map(srv => (
<label key={srv.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input
type="checkbox"
checked={selectedIds.includes(srv.id)}
onChange={() => toggleMemberPick(srv.id)}
/>
{serverListDisplayLabel(srv, auth.servers)}
</label>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={selectedIds.length < 2} onClick={saveCreate}>
{t('settings.clusterCreate')}
</button>
<button type="button" className="btn btn-surface" onClick={() => setCreating(false)}>
{t('common.cancel')}
</button>
</div>
</div>
) : (
<button type="button" className="btn btn-surface" onClick={startCreate}>
<Plus size={14} />
{t('settings.clusterAdd')}
</button>
)}
</div>
</section>
);
}
function ClusterMemberGrip({
clusterId,
idx,
label,
}: {
clusterId: string;
idx: number;
label: string;
}) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'cluster_member_reorder', clusterId, index: idx }),
label,
}));
return (
<span
className="sidebar-customizer-grip"
data-tooltip={t('settings.sidebarDrag')}
onMouseDown={onMouseDown}
onClick={e => e.stopPropagation()}
>
<Layers size={14} />
</span>
);
}
+10 -1
View File
@@ -30,6 +30,8 @@ import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
import { switchActiveServer } from '../../utils/server/switchActiveServer';
import { AddServerForm } from './AddServerForm';
import { ServerGripHandle } from './ServerGripHandle';
import { ServerClustersSection } from './ServerClustersSection';
import { clustersContainingServer } from '../../utils/serverCluster/clusterScope';
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
@@ -464,7 +466,12 @@ export function ServersTab({
className="btn btn-ghost"
style={{ color: 'var(--danger)', padding: '4px 8px' }}
onClick={() => void deleteServer(srv)}
data-tooltip={t('settings.deleteServer')}
disabled={clustersContainingServer(srv.id).length > 0}
data-tooltip={
clustersContainingServer(srv.id).length > 0
? t('settings.deleteServerInCluster')
: t('settings.deleteServer')
}
id={`settings-delete-server-${srv.id}`}
>
<Trash2 size={14} />
@@ -568,6 +575,8 @@ export function ServersTab({
</div>
</section>
<ServerClustersSection />
<section className="settings-section">
<button className="btn btn-danger" onClick={handleLogout} id="settings-logout-btn">
<LogOut size={16} /> {t('settings.logout')}
+114 -27
View File
@@ -1,27 +1,77 @@
import React from 'react';
import React, { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, Music2 } from 'lucide-react';
interface MusicFolder { id: string; name: string }
export interface LibraryPickerFolder {
id: string;
name: string;
serverId: string;
folderId: string;
}
interface Props {
filterId: string;
allLibrariesSelected: boolean;
selectedFolderName: string | null;
libraryDropdownOpen: boolean;
setLibraryDropdownOpen: (open: boolean) => void;
dropdownRect: { top: number; left: number; width: number };
libraryTriggerRef: React.RefObject<HTMLButtonElement | null>;
musicFolders: MusicFolder[];
pickLibrary: (id: 'all' | string) => void;
musicFolders: LibraryPickerFolder[];
isFolderSelected: (serverId: string, folderId: string) => boolean;
onSelectAll: () => void;
onExclusiveSelect: (serverId: string, folderId: string) => void;
onToggleFolder: (serverId: string, folderId: string) => void;
}
export default function SidebarLibraryPicker({
filterId, selectedFolderName, libraryDropdownOpen, setLibraryDropdownOpen,
dropdownRect, libraryTriggerRef, musicFolders, pickLibrary,
allLibrariesSelected,
selectedFolderName,
libraryDropdownOpen,
setLibraryDropdownOpen,
dropdownRect,
libraryTriggerRef,
musicFolders,
isFolderSelected,
onSelectAll,
onExclusiveSelect,
onToggleFolder,
}: Props) {
const { t } = useTranslation();
const libraryTriggerPlain = filterId === 'all';
const libraryTriggerPlain = allLibrariesSelected;
const panelRef = useRef<HTMLDivElement>(null);
const [panelWidth, setPanelWidth] = useState<number | null>(null);
const allLibrariesLabel = t('sidebar.allLibraries');
useLayoutEffect(() => {
if (!libraryDropdownOpen) {
setPanelWidth(null);
return;
}
const measure = () => {
const panel = panelRef.current;
if (!panel) return;
const minW = dropdownRect.width;
const maxW = Math.max(minW, window.innerWidth - dropdownRect.left - 8);
panel.dataset.measure = 'true';
panel.style.width = 'max-content';
panel.style.minWidth = `${minW}px`;
const measured = panel.offsetWidth;
delete panel.dataset.measure;
panel.style.width = '';
panel.style.minWidth = '';
setPanelWidth(Math.min(Math.max(minW, measured), maxW));
};
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [
libraryDropdownOpen,
dropdownRect.left,
dropdownRect.width,
musicFolders,
allLibrariesLabel,
]);
return (
<>
@@ -52,6 +102,7 @@ export default function SidebarLibraryPicker({
{libraryDropdownOpen &&
createPortal(
<div
ref={panelRef}
className={`nav-library-dropdown-panel${musicFolders.length > 10 ? ' nav-library-dropdown-panel--many-libraries' : ''}`}
role="listbox"
aria-label={t('sidebar.libraryScope')}
@@ -59,35 +110,71 @@ export default function SidebarLibraryPicker({
position: 'fixed',
top: dropdownRect.top,
left: dropdownRect.left,
width: dropdownRect.width,
minWidth: dropdownRect.width,
maxWidth: dropdownRect.width,
width: panelWidth ?? 'max-content',
boxSizing: 'border-box',
}}
>
<button
type="button"
<div
role="option"
aria-selected={filterId === 'all'}
className={`nav-library-dropdown-item ${filterId === 'all' ? 'nav-library-dropdown-item--selected' : ''}`}
onClick={() => pickLibrary('all')}
aria-selected={allLibrariesSelected}
className={`nav-library-dropdown-item ${allLibrariesSelected ? 'nav-library-dropdown-item--selected' : ''}`}
>
<span className="nav-library-dropdown-item-label">{t('sidebar.allLibraries')}</span>
{filterId === 'all' ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
</button>
{musicFolders.map(f => (
<button
key={f.id}
type="button"
role="option"
aria-selected={filterId === f.id}
className={`nav-library-dropdown-item ${filterId === f.id ? 'nav-library-dropdown-item--selected' : ''}`}
onClick={() => pickLibrary(f.id)}
className="nav-library-dropdown-item-main"
onClick={() => {
onSelectAll();
setLibraryDropdownOpen(false);
}}
>
<span className="nav-library-dropdown-item-label">{f.name}</span>
{filterId === f.id ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
<span className="nav-library-dropdown-item-label">{allLibrariesLabel}</span>
</button>
))}
<span
className={`nav-library-dropdown-item-toggle ${allLibrariesSelected ? 'nav-library-dropdown-item-toggle--on' : 'nav-library-dropdown-item-toggle--align-only'}`}
aria-hidden
>
{allLibrariesSelected ? <Check size={16} strokeWidth={2.5} /> : null}
</span>
</div>
{musicFolders.map(f => {
const selected = isFolderSelected(f.serverId, f.folderId);
return (
<div
key={f.id}
role="option"
aria-selected={selected}
className={`nav-library-dropdown-item ${selected ? 'nav-library-dropdown-item--selected' : ''}`}
>
<button
type="button"
className="nav-library-dropdown-item-main"
onClick={() => {
onExclusiveSelect(f.serverId, f.folderId);
setLibraryDropdownOpen(false);
}}
>
<span className="nav-library-dropdown-item-label">{f.name}</span>
</button>
<button
type="button"
className={`nav-library-dropdown-item-toggle ${selected ? 'nav-library-dropdown-item-toggle--on' : ''}`}
aria-label={selected ? t('sidebar.libraryDeselect', { name: f.name }) : t('sidebar.librarySelect', { name: f.name })}
aria-pressed={selected}
onClick={e => {
e.stopPropagation();
onToggleFolder(f.serverId, f.folderId);
}}
>
{selected ? (
<Check size={16} strokeWidth={2.5} />
) : (
<span className="nav-library-dropdown-item-toggle-box" aria-hidden />
)}
</button>
</div>
);
})}
</div>,
document.body
)}
+14 -10
View File
@@ -6,7 +6,7 @@ import type { SidebarItemConfig } from '../../store/sidebarStore';
import { ALL_NAV_ITEMS } from '../../config/navItems';
import WhatsNewBanner from '../WhatsNewBanner';
import { displayPlaylistName, isSmartPlaylistName } from '../../utils/componentHelpers/sidebarHelpers';
import SidebarLibraryPicker from './SidebarLibraryPicker';
import SidebarLibraryPicker, { type LibraryPickerFolder } from './SidebarLibraryPicker';
import SidebarActiveJobs from './SidebarActiveJobs';
interface NavDndState {
@@ -14,19 +14,20 @@ interface NavDndState {
fromIdx: number;
}
interface MusicFolder { id: string; name: string }
interface Props {
isCollapsed: boolean;
showLibraryPicker: boolean;
filterId: string;
allLibrariesSelected: boolean;
selectedFolderName: string | null;
libraryDropdownOpen: boolean;
setLibraryDropdownOpen: (open: boolean) => void;
dropdownRect: { top: number; left: number; width: number };
libraryTriggerRef: React.RefObject<HTMLButtonElement | null>;
musicFolders: MusicFolder[];
pickLibrary: (id: 'all' | string) => void;
musicFolders: LibraryPickerFolder[];
isFolderSelected: (serverId: string, folderId: string) => boolean;
onSelectAll: () => void;
onExclusiveSelect: (serverId: string, folderId: string) => void;
onToggleFolder: (serverId: string, folderId: string) => void;
visibleLibraryConfigs: SidebarItemConfig[];
libraryItemsForReorder: SidebarItemConfig[];
visibleSystemConfigs: SidebarItemConfig[];
@@ -54,9 +55,9 @@ interface Props {
export default function SidebarNavBody(props: Props) {
const {
isCollapsed, showLibraryPicker, filterId, selectedFolderName,
isCollapsed, showLibraryPicker, allLibrariesSelected, selectedFolderName,
libraryDropdownOpen, setLibraryDropdownOpen, dropdownRect, libraryTriggerRef,
musicFolders, pickLibrary,
musicFolders, isFolderSelected, onSelectAll, onExclusiveSelect, onToggleFolder,
visibleLibraryConfigs, libraryItemsForReorder,
visibleSystemConfigs, systemItemsForReorder,
playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading,
@@ -89,14 +90,17 @@ export default function SidebarNavBody(props: Props) {
{nowPlayingAtTop && nowPlayingLink}
{!isCollapsed && (showLibraryPicker ? (
<SidebarLibraryPicker
filterId={filterId}
allLibrariesSelected={allLibrariesSelected}
selectedFolderName={selectedFolderName}
libraryDropdownOpen={libraryDropdownOpen}
setLibraryDropdownOpen={setLibraryDropdownOpen}
dropdownRect={dropdownRect}
libraryTriggerRef={libraryTriggerRef}
musicFolders={musicFolders}
pickLibrary={pickLibrary}
isFolderSelected={isFolderSelected}
onSelectAll={onSelectAll}
onExclusiveSelect={onExclusiveSelect}
onToggleFolder={onToggleFolder}
/>
) : (
<span className="nav-section-label">{t('sidebar.library')}</span>
@@ -2,18 +2,17 @@ import React, { useCallback, useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
libraryGetPlayerStatsHeatmap,
libraryGetPlayerStatsYearBounds,
libraryGetPlayerStatsYearSummary,
type PlaySessionYearBounds,
type PlaySessionYearSummary,
} from '../../api/library';
loadPlayerStatsHeatmap,
loadPlayerStatsYearBounds,
loadPlayerStatsYearSummary,
} from '../../utils/serverCluster/clusterPlayerStats';
import { usePlayerStatsLiveRefresh } from '../../hooks/usePlayerStatsLiveRefresh';
import { usePlayerStatsRecordingEnabled } from '../../hooks/usePlayerStatsRecordingEnabled';
import PlayerStatsHeatmap from './PlayerStatsHeatmap';
import PlayerStatsIndexRequiredNotice from './PlayerStatsIndexRequiredNotice';
import PlayerStatsRecentDays from './PlayerStatsRecentDays';
import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration';
import type { PlaySessionYearBounds, PlaySessionYearSummary } from '../../api/library';
const currentCalendarYear = () => new Date().getFullYear();
@@ -41,9 +40,9 @@ export default function PlayerStatisticsPanel() {
setLoading(true);
setSelectedDate(null);
Promise.all([
libraryGetPlayerStatsYearSummary(year),
libraryGetPlayerStatsHeatmap(year),
libraryGetPlayerStatsYearBounds(),
loadPlayerStatsYearSummary(year),
loadPlayerStatsHeatmap(year),
loadPlayerStatsYearBounds(),
])
.then(([s, heat, bounds]) => {
if (cancelled) return;
@@ -67,8 +66,8 @@ export default function PlayerStatisticsPanel() {
if (!recordingEnabled) return;
try {
const [s, heat] = await Promise.all([
libraryGetPlayerStatsYearSummary(year),
libraryGetPlayerStatsHeatmap(year),
loadPlayerStatsYearSummary(year),
loadPlayerStatsHeatmap(year),
]);
setSummary(s);
setDayCounts(new Map(heat.map(h => [h.date, h.trackPlayCount])));
@@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
libraryGetPlayerStatsDayDetail,
libraryGetPlayerStatsRecentDays,
type PlaySessionDayDetail,
type PlaySessionRecentDay,
} from '../../api/library';
import {
loadPlayerStatsDayDetail,
loadPlayerStatsRecentDays,
} from '../../utils/serverCluster/clusterPlayerStats';
import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration';
import {
formatPlayerStatsDayLabel,
@@ -54,7 +56,7 @@ export default function PlayerStatsRecentDays({
const loadDetail = useCallback(async (date: string) => {
setLoadingDates(prev => new Set(prev).add(date));
try {
const detail = await libraryGetPlayerStatsDayDetail(date);
const detail = await loadPlayerStatsDayDetail(date);
setDetails(prev => new Map(prev).set(date, detail));
} catch {
/* ignore */
@@ -70,7 +72,7 @@ export default function PlayerStatsRecentDays({
useEffect(() => {
let cancelled = false;
setLoading(true);
libraryGetPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
loadPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
.then(rows => {
if (!cancelled) {
setDays(rows);
@@ -89,7 +91,7 @@ export default function PlayerStatsRecentDays({
useEffect(() => {
if (liveRefreshKey === 0) return;
let cancelled = false;
libraryGetPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
loadPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
.then(rows => {
if (cancelled) return;
setDays(rows);
+1 -1
View File
@@ -164,7 +164,7 @@ export default function TracksPageChrome({
<span
className={hero.albumId ? 'track-artist-link' : ''}
style={{ cursor: hero.albumId ? 'pointer' : 'default' }}
onClick={() => hero.albumId && navigateToAlbum(hero.albumId)}
onClick={() => hero.albumId && navigateToAlbum(hero.albumId, { seedServerId: hero.clusterBrowseServerId })}
>{hero.album}</span>
</>
)}
+3 -1
View File
@@ -6,6 +6,7 @@ export type AlbumCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
albumId: string;
coverArt?: string | null;
serverScope?: CoverServerScope;
clusterSeedServerId?: string | null;
/** Live search: use API `coverArt` ids only (avoids library IPC per row). */
libraryResolve?: boolean;
};
@@ -14,6 +15,7 @@ export function AlbumCoverArtImage({
albumId,
coverArt,
serverScope,
clusterSeedServerId,
libraryResolve = false,
...rest
}: AlbumCoverArtImageProps) {
@@ -21,7 +23,7 @@ export function AlbumCoverArtImage({
albumId,
coverArt,
serverScope ?? COVER_SCOPE_ACTIVE,
{ libraryResolve },
{ libraryResolve, clusterSeedServerId },
);
if (!coverRef) return null;
return <CoverArtImage coverRef={coverRef} {...rest} />;
+3 -1
View File
@@ -6,6 +6,7 @@ export type ArtistCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
artistId: string;
coverArt?: string | null;
serverScope?: CoverServerScope;
clusterSeedServerId?: string | null;
libraryResolve?: boolean;
};
@@ -13,6 +14,7 @@ export function ArtistCoverArtImage({
artistId,
coverArt,
serverScope,
clusterSeedServerId,
libraryResolve = false,
...rest
}: ArtistCoverArtImageProps) {
@@ -20,7 +22,7 @@ export function ArtistCoverArtImage({
artistId,
coverArt,
serverScope ?? COVER_SCOPE_ACTIVE,
{ libraryResolve },
{ libraryResolve, clusterSeedServerId },
);
if (!coverRef) return null;
return <CoverArtImage coverRef={coverRef} {...rest} />;
+13 -1
View File
@@ -13,7 +13,7 @@ import { useCoverArt } from './useCoverArt';
import type { CoverArtRef, CoverPrefetchPriority, CoverSurfaceKind } from './types';
export type CoverArtImageProps = {
coverRef: CoverArtRef;
coverRef: CoverArtRef | null | undefined;
displayCssPx: number;
surface?: CoverSurfaceKind;
fullRes?: boolean;
@@ -39,6 +39,18 @@ export function CoverArtImage({
onError: restOnError,
...rest
}: CoverArtImageProps) {
if (!coverRef) {
return (
<div
className={className}
data-cover-provisional="true"
role="img"
aria-label={alt ?? ''}
{...(rest as React.HTMLAttributes<HTMLDivElement>)}
/>
);
}
const pinnedHigh = ensurePriorityProp === 'high';
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
ensurePriorityProp ?? 'middle',
+1 -1
View File
@@ -4,7 +4,7 @@ import { useTrackCoverRef } from './useLibraryCoverRef';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from './types';
export type TrackCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'>;
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'>;
serverScope?: CoverServerScope;
/** Default false for browse rails; true for queue/player rows needing per-disc art. */
libraryResolve?: boolean;
+45 -1
View File
@@ -1,8 +1,11 @@
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import { usePlayerStore } from '../store/playerStore';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
import { COVER_SCOPE_ACTIVE } from './types';
import {
albumHasDistinctDiscCovers,
coverEntryToRef,
@@ -185,6 +188,23 @@ export function coverArtRef(
return albumCoverRef(id, id, serverScope);
}
/** Pin cover HTTP/disk scope to a specific library member (cluster browse rows). */
export function coverScopeForServerProfileId(
serverId: string | undefined | null,
fallback: CoverServerScope = COVER_SCOPE_ACTIVE,
): CoverServerScope {
if (!serverId?.trim()) return fallback;
const server = findServerByIdOrIndexKey(serverId);
if (!server) return fallback;
return {
kind: 'server',
serverId: server.id,
url: server.url,
username: server.username,
password: server.password,
};
}
export function resolvePlaybackCoverScope(): CoverServerScope {
const playbackSid = getPlaybackServerId();
const activeSid = useAuthStore.getState().activeServerId;
@@ -202,3 +222,27 @@ export function resolvePlaybackCoverScope(): CoverServerScope {
}
return { kind: 'playback' };
}
/** Playback / queue cover scope — per-track cluster member when queue spans servers. */
export function resolveCoverScopeForPlaybackTrack(
track: Pick<Track, 'clusterBrowseServerId'> | null | undefined,
queueRefServerId?: string | null,
): CoverServerScope {
const fromTrack = coverScopeForServerProfileId(track?.clusterBrowseServerId);
if (fromTrack.kind === 'server') return fromTrack;
if (queueRefServerId) {
const sid = resolveServerIdForIndexKey(queueRefServerId);
const fromQueue = coverScopeForServerProfileId(sid);
if (fromQueue.kind === 'server') return fromQueue;
}
return resolvePlaybackCoverScope();
}
/** Playback integrations (MPRIS, Discord, prewarm) — current track's cluster member. */
export function resolvePlaybackCoverScopeForCurrentTrack(): CoverServerScope {
const st = usePlayerStore.getState();
return resolveCoverScopeForPlaybackTrack(
st.currentTrack,
st.queueItems[st.queueIndex]?.serverId,
);
}
+56 -19
View File
@@ -7,14 +7,16 @@ import {
albumCoverRefForPlayback,
albumCoverRefForSong,
artistCoverRef,
coverScopeForServerProfileId,
resolveCoverScopeForPlaybackTrack,
resolveDistinctDiscCoversForAlbum,
resolvePlaybackCoverScope,
} from './ref';
import {
resolveAlbumCoverRefFromLibrary,
resolveArtistCoverRefFromLibrary,
resolveTrackCoverRefFromLibrary,
} from './resolveEntryLibrary';
import type { Track } from '../store/playerStoreTypes';
import { COVER_SCOPE_ACTIVE, coverScopeKey, type CoverArtRef, type CoverServerScope } from './types';
function coverRefsEqual(a: CoverArtRef, b: CoverArtRef): boolean {
@@ -43,8 +45,18 @@ export type LibraryCoverRefOptions = {
* detail headers and queue rows that need per-disc slots from SQLite.
*/
libraryResolve?: boolean;
/** Cluster browse row — pin cover HTTP/disk to this library member. */
clusterSeedServerId?: string | null;
};
function coverScopeWithClusterSeed(
serverScope: CoverServerScope,
clusterSeedServerId?: string | null,
): CoverServerScope {
if (!clusterSeedServerId) return serverScope;
return coverScopeForServerProfileId(clusterSeedServerId, serverScope);
}
/** Album grid / card — sync fallback, then local library index when indexed. */
export function useAlbumCoverRef(
albumId: string | null | undefined,
@@ -53,7 +65,11 @@ export function useAlbumCoverRef(
options?: LibraryCoverRefOptions,
): CoverArtRef | null {
const libraryResolve = options?.libraryResolve !== false;
const scopeKey = coverScopeKey(serverScope);
const resolvedScope = useMemo(
() => coverScopeWithClusterSeed(serverScope, options?.clusterSeedServerId),
[serverScope, options?.clusterSeedServerId],
);
const scopeKey = coverScopeKey(resolvedScope);
const distinctDiscCovers = useMemo(
() => resolveDistinctDiscCoversForAlbum(albumId ?? '', fallbackCoverArt),
[albumId, fallbackCoverArt],
@@ -61,8 +77,8 @@ export function useAlbumCoverRef(
const syncRef = useMemo(() => {
const id = albumId?.trim();
if (!id) return null;
return albumCoverRef(id, fallbackCoverArt, { serverScope, distinctDiscCovers });
}, [albumId, fallbackCoverArt, scopeKey, serverScope, distinctDiscCovers]);
return albumCoverRef(id, fallbackCoverArt, { serverScope: resolvedScope, distinctDiscCovers });
}, [albumId, fallbackCoverArt, scopeKey, resolvedScope, distinctDiscCovers]);
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
@@ -72,7 +88,7 @@ export function useAlbumCoverRef(
const id = albumId?.trim();
if (!id) return;
let cancelled = false;
void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => {
void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, resolvedScope).then(next => {
if (!cancelled) {
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
}
@@ -80,7 +96,7 @@ export function useAlbumCoverRef(
return () => {
cancelled = true;
};
}, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
}, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve, resolvedScope]);
return libraryResolve ? ref : syncRef;
}
@@ -93,12 +109,16 @@ export function useArtistCoverRef(
options?: LibraryCoverRefOptions,
): CoverArtRef | null {
const libraryResolve = options?.libraryResolve !== false;
const scopeKey = coverScopeKey(serverScope);
const resolvedScope = useMemo(
() => coverScopeWithClusterSeed(serverScope, options?.clusterSeedServerId),
[serverScope, options?.clusterSeedServerId],
);
const scopeKey = coverScopeKey(resolvedScope);
const syncRef = useMemo(() => {
const id = artistId?.trim();
if (!id) return null;
return artistCoverRef(id, fallbackCoverArt, serverScope);
}, [artistId, fallbackCoverArt, scopeKey, serverScope]);
return artistCoverRef(id, fallbackCoverArt, resolvedScope);
}, [artistId, fallbackCoverArt, scopeKey, resolvedScope]);
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
@@ -108,7 +128,7 @@ export function useArtistCoverRef(
const id = artistId?.trim();
if (!id) return;
let cancelled = false;
void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => {
void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, resolvedScope).then(next => {
if (!cancelled) {
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
}
@@ -116,19 +136,24 @@ export function useArtistCoverRef(
return () => {
cancelled = true;
};
}, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
}, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve, resolvedScope]);
return libraryResolve ? ref : syncRef;
}
/** Track row / song card — album-scoped; multi-CD from library when indexed. */
export function useTrackCoverRef(
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> | null | undefined,
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'> | null | undefined,
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
options?: LibraryCoverRefOptions,
): CoverArtRef | undefined {
const libraryResolve = options?.libraryResolve !== false;
const scopeKey = coverScopeKey(serverScope);
const browseServerId = song?.clusterBrowseServerId;
const resolvedScope = useMemo(
() => (browseServerId ? coverScopeForServerProfileId(browseServerId, serverScope) : serverScope),
[browseServerId, serverScope],
);
const scopeKey = coverScopeKey(resolvedScope);
const songId = song?.id;
const albumId = song?.albumId;
const coverArt = song?.coverArt;
@@ -151,8 +176,9 @@ export function useTrackCoverRef(
return albumCoverRefForSong(
{ id: songId, albumId, coverArt, discNumber },
distinctDiscCovers,
resolvedScope,
);
}, [songId, albumId, coverArt, discNumber, distinctDiscCovers]);
}, [songId, albumId, coverArt, discNumber, distinctDiscCovers, resolvedScope]);
const [ref, setRef] = useState<CoverArtRef | undefined>(syncRef);
@@ -165,7 +191,7 @@ export function useTrackCoverRef(
let cancelled = false;
void resolveTrackCoverRefFromLibrary(
{ ...song, id: trackId, albumId: al },
serverScope,
resolvedScope,
distinctDiscCovers,
).then(next => {
if (!cancelled) {
@@ -190,16 +216,18 @@ export function useTrackCoverRef(
return () => {
cancelled = true;
};
}, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers]);
}, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers, resolvedScope]);
return libraryResolve ? ref : syncRef;
}
/** Now playing / queue — playback server scope + library-backed multi-CD. */
export function usePlaybackTrackCoverRef(
track: Parameters<typeof albumCoverRefForPlayback>[0] | null | undefined,
track: (Parameters<typeof albumCoverRefForPlayback>[0] & Pick<Track, 'clusterBrowseServerId'>) | null | undefined,
): CoverArtRef | undefined {
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueIndex = usePlayerStore(s => s.queueIndex);
const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId ?? null);
const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId);
const serversFingerprint = useAuthStore(s =>
@@ -209,8 +237,17 @@ export function usePlaybackTrackCoverRef(
);
const scope = useMemo(
() => resolvePlaybackCoverScope(),
[queueServerId, queueLength, activeServerId, serversFingerprint],
() => resolveCoverScopeForPlaybackTrack(track, queueRefServerId),
[
track,
track?.clusterBrowseServerId,
queueRefServerId,
queueServerId,
queueIndex,
queueLength,
activeServerId,
serversFingerprint,
],
);
const scopeKey = coverScopeKey(scope);
+18 -3
View File
@@ -1,16 +1,22 @@
import { useMemo } from 'react';
import { resolvePlaybackCoverScope } from './ref';
import { resolveCoverScopeForPlaybackTrack } from './ref';
import type { CoverArtHandle, CoverArtRef } from './types';
import { useCoverArt } from './useCoverArt';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import type { Track } from '../store/playerStoreTypes';
/** Cover art for playback queue — uses queue server when it differs from browsed server. */
export function usePlaybackCoverArt(
coverRef: CoverArtRef | undefined,
displayCssPx: number,
track?: Pick<Track, 'clusterBrowseServerId'> | null,
): CoverArtHandle {
const storeTrack = usePlayerStore(s => s.currentTrack);
const resolvedTrack = track ?? storeTrack ?? null;
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueIndex = usePlayerStore(s => s.queueIndex);
const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId ?? null);
const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId);
const serversFingerprint = useAuthStore(s =>
@@ -20,8 +26,17 @@ export function usePlaybackCoverArt(
);
const scope = useMemo(
() => resolvePlaybackCoverScope(),
[queueServerId, queueLength, activeServerId, serversFingerprint],
() => resolveCoverScopeForPlaybackTrack(resolvedTrack, queueRefServerId),
[
resolvedTrack,
resolvedTrack?.clusterBrowseServerId,
queueRefServerId,
queueServerId,
queueIndex,
queueLength,
activeServerId,
serversFingerprint,
],
);
const refWithScope = useMemo(
() => (coverRef ? { ...coverRef, serverScope: scope } : null),
+1 -1
View File
@@ -48,7 +48,7 @@ export function useCliBridge(navigate: NavigateFunction) {
}
const serverId = useAuthStore.getState().activeServerId;
try {
const similar = await getSimilarSongs(song.id, 50);
const similar = await getSimilarSongs(song.id, 50, song.clusterBrowseServerId);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const mixCfg = getMixMinRatingsConfigFromAuth();
const ratedFiltered = await filterSongsForLuckyMixRatings(
+41 -11
View File
@@ -18,6 +18,8 @@ import type { AlbumCompFilter } from '../utils/library/albumCompilation';
import {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
albumBrowseMultiGenreBrowse,
albumBrowseUseSliceCatalog,
fetchAlbumBrowseGenreOptions,
fetchAlbumBrowsePage,
fetchLocalAlbumCatalogChunk,
@@ -26,7 +28,9 @@ import {
type AlbumBrowseQuery,
type GenreFilterOption,
} from '../utils/library/albumBrowseLoad';
import { libraryScopeForServer } from '../api/subsonicClient';
import { libraryScopeIdsForServer } from '../api/subsonicClient';
import { isClusterLibraryScopeNarrowed } from '../utils/serverCluster/clusterLibraryScopes';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import {
ALBUM_YEAR_FILTER_DEBOUNCE_MS,
resolveAlbumYearBounds,
@@ -42,6 +46,13 @@ const CATALOG_CHUNK_SIZE = 200;
/** Wait for visible-row cover ensures to drain before fetching the next SQL page (network mode). */
const LOAD_MORE_COVER_BACKLOG_MAX = 12;
/** Let the loading spinner paint before heavy browse work blocks the main thread. */
function yieldToPaint(): Promise<void> {
return new Promise(resolve => {
requestAnimationFrame(() => resolve());
});
}
type AlbumBrowseMode = 'slice' | 'page';
export type UseAlbumBrowseDataArgs = {
@@ -167,11 +178,14 @@ export function useAlbumBrowseData({
}, [browseMode, visibleAlbums, visibleCount]);
const genreFiltered = albumBrowseHasGenreFilter(browseQuery);
const multiGenreBrowse = albumBrowseMultiGenreBrowse(browseQuery);
const serverFilterActive = albumBrowseHasServerFilters(browseQuery);
const libraryScopeActive = libraryScopeForServer(serverId) != null;
const libraryScopeActive = isClusterMode()
? isClusterLibraryScopeNarrowed()
: libraryScopeIdsForServer(serverId) != null;
const narrowGenreList = yearFilterActive || losslessOnly || starredOnly || compFilterActive;
/** When true, GenreFilterBar uses `genreCatalogOptions` instead of server `getGenres()`. */
const genreCatalogActive = narrowGenreList || (indexEnabled && libraryScopeActive);
const genreCatalogActive = narrowGenreList;
const compScanExhausted = useMemo(
() => compFilterClientOnly && !genreFiltered
@@ -184,7 +198,7 @@ export function useAlbumBrowseData({
const gridHasMore = browseMode === 'slice'
? visibleCount < visibleAlbums.length || catalogHasMore
: hasMore && !genreFiltered;
: hasMore && !multiGenreBrowse;
const gridLoadingMore = browseMode === 'slice'
? sliceLoadingMore || catalogLoadingMore
@@ -294,6 +308,7 @@ export function useAlbumBrowseData({
},
},
);
await yieldToPaint();
applyPage(pageResult);
} finally {
coverTrafficEndGridPagination();
@@ -321,6 +336,14 @@ export function useAlbumBrowseData({
setLoading(true);
void (async () => {
await yieldToPaint();
if (cancelled) return;
const useSliceCatalog = albumBrowseUseSliceCatalog(browseQuery, libraryScopeActive);
if (indexEnabled && serverId && !useSliceCatalog) {
setBrowseMode('page');
if (!cancelled) await loadBrowse(browseQuery, 0, false);
return;
}
if (indexEnabled && serverId) {
const generation = ++loadGenerationRef.current;
coverTrafficBeginGridPagination();
@@ -329,10 +352,11 @@ export function useAlbumBrowseData({
serverId,
browseQuery,
0,
CATALOG_CHUNK_SIZE,
CLIENT_SLICE_PAGE_SIZE,
);
if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) {
await yieldToPaint();
setBrowseMode('slice');
setAlbums(first.albums);
catalogOffsetRef.current = first.albums.length;
@@ -353,22 +377,28 @@ export function useAlbumBrowseData({
return () => {
cancelled = true;
};
}, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion]);
}, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion, libraryScopeActive]);
useEffect(() => {
if (!genreCatalogActive) {
setGenreCatalogOptions(null);
return;
}
// Load genre bar after the grid paints — avoids competing with the first SQL page.
if (loading) return;
let cancelled = false;
void fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre).then(options => {
if (!cancelled) setGenreCatalogOptions(options);
});
const timer = window.setTimeout(() => {
void fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre).then(options => {
if (!cancelled) setGenreCatalogOptions(options);
});
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [
genreCatalogActive,
loading,
serverId,
indexEnabled,
browseQueryWithoutGenre,
@@ -376,7 +406,7 @@ export function useAlbumBrowseData({
]);
const loadMorePage = useCallback(() => {
if (loadingRef.current || loadPendingRef.current || !hasMore || genreFiltered) return;
if (loadingRef.current || loadPendingRef.current || !hasMore || multiGenreBrowse) return;
if (coverEnsureQueueBacklog() > LOAD_MORE_COVER_BACKLOG_MAX) return;
if (compFilterClientOnly && visibleAlbums.length === 0
&& albumBrowseCompScanComplete(albums, compFilter, hasMore)) {
@@ -390,7 +420,7 @@ export function useAlbumBrowseData({
hasMore,
browseQuery,
loadBrowse,
genreFiltered,
multiGenreBrowse,
compFilterClientOnly,
visibleAlbums.length,
albums,
+57 -21
View File
@@ -1,9 +1,13 @@
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { getAlbum } from '../api/subsonicLibrary';
import { getArtist } from '../api/subsonicArtists';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
import { loadClusterAlbumDetail } from '../utils/serverCluster/clusterDetail';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { readClusterSeedServerId } from '../utils/navigation/albumDetailNavigation';
type AlbumPayload = Awaited<ReturnType<typeof getAlbum>>;
type AlbumPayload = { album: SubsonicAlbum; songs: import('../api/subsonicTypes').SubsonicSong[] };
interface UseAlbumDetailDataResult {
album: AlbumPayload | null;
@@ -21,13 +25,12 @@ interface UseAlbumDetailDataResult {
* a follow-up call so the related-albums grid can render without blocking
* the initial paint.
*
* On every id change we reset `relatedAlbums` to an empty array so the
* grid doesn't briefly show the previous album's neighbours while the
* new fetch is in flight. The two starred state pieces (`isStarred`,
* `starredSongs`) are seeded from the response so optimistic toggles
* have a baseline to revert to.
* In cluster mode, loads a virtual aggregate from the merged local index
* (spec §4) never falls back to a single-server `getAlbum`.
*/
export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataResult {
const location = useLocation();
const activeServerId = useAuthStore(s => s.activeServerId);
const [album, setAlbum] = useState<AlbumPayload | null>(null);
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
@@ -36,23 +39,56 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
useEffect(() => {
if (!id) return;
let cancelled = false;
setLoading(true);
setRelatedAlbums([]);
getAlbum(id).then(async data => {
setAlbum(data);
setIsStarred(!!data.album.starred);
const initialStarred = new Set<string>();
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
setStarredSongs(initialStarred);
setLoading(false);
try {
const artistData = await getArtist(data.album.artistId);
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
} catch (e) {
console.error('Failed to fetch related albums', e);
(async () => {
if (isClusterMode()) {
const seedServerId = readClusterSeedServerId(location.state) ?? activeServerId ?? '';
const clusterData = await loadClusterAlbumDetail({ albumId: id, seedServerId });
if (cancelled) return;
if (!clusterData) {
setAlbum(null);
setLoading(false);
return;
}
const payload: AlbumPayload = { album: clusterData.album, songs: clusterData.songs };
setAlbum(payload);
setIsStarred(!!clusterData.album.starred);
const initialStarred = new Set<string>();
clusterData.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
setStarredSongs(initialStarred);
setRelatedAlbums(clusterData.relatedAlbums);
setLoading(false);
return;
}
}).catch(() => setLoading(false));
}, [id]);
try {
const data = await getAlbum(id);
if (cancelled) return;
setAlbum(data);
setIsStarred(!!data.album.starred);
const initialStarred = new Set<string>();
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
setStarredSongs(initialStarred);
setLoading(false);
try {
const { getArtist } = await import('../api/subsonicArtists');
const artistData = await getArtist(data.album.artistId);
if (!cancelled) {
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
}
} catch (e) {
console.error('Failed to fetch related albums', e);
}
} catch {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [id, location.state, activeServerId]);
return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs };
}
+16
View File
@@ -15,6 +15,22 @@ import type { SubsonicArtistInfo } from '../api/subsonicTypes';
vi.mock('../api/subsonicArtists');
vi.mock('../api/subsonicSearch');
vi.mock('../utils/serverCluster/clusterScope', () => ({
isClusterMode: () => false,
}));
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router-dom')>();
return {
...actual,
useLocation: () => ({
pathname: '/artist/test',
search: '',
hash: '',
state: null,
key: 'default',
}),
};
});
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import { search } from '../api/subsonicSearch';
+39 -2
View File
@@ -1,12 +1,16 @@
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { search } from '../api/subsonicSearch';
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import { getArtist, getArtistInfo, getArtistForServer, getTopSongs } from '../api/subsonicArtists';
import type {
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
} from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
import { isLosslessSuffix } from '../utils/library/losslessFormats';
import { loadClusterArtistDetail } from '../utils/serverCluster/clusterDetail';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { readClusterSeedServerId } from '../utils/navigation/albumDetailNavigation';
export interface UseArtistDetailDataOptions {
/** When true, albums and top tracks are limited to lossless containers (local index preferred). */
@@ -45,6 +49,7 @@ export function useArtistDetailData(
options: UseArtistDetailDataOptions = {},
): ArtistDetailDataResult {
const losslessOnly = options.losslessOnly ?? false;
const location = useLocation();
const serverId = useAuthStore(s => s.activeServerId);
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
@@ -71,6 +76,38 @@ export function useArtistDetailData(
(async () => {
try {
if (isClusterMode()) {
const seedServerId = readClusterSeedServerId(location.state) ?? serverId ?? '';
const clusterData = await loadClusterArtistDetail({ artistId: id, seedServerId });
if (cancelled) return;
if (!clusterData) {
setArtist(null);
setAlbums([]);
setLoading(false);
return;
}
let nextAlbums = clusterData.albums;
let nextSongs = clusterData.topSongs;
if (nextAlbums.length === 0 && seedServerId) {
const seeded = await getArtistForServer(seedServerId, id).catch(() => null);
if (seeded?.albums?.length) {
nextAlbums = seeded.albums.map(a => ({
...a,
clusterSeedServerId: a.clusterSeedServerId ?? seedServerId,
}));
}
}
if (losslessOnly) {
({ albums: nextAlbums, songs: nextSongs } = filterNetworkArtistToLossless(nextAlbums, nextSongs));
}
setArtist(clusterData.artist);
setAlbums(nextAlbums);
setTopSongs(nextSongs);
setIsStarred(!!clusterData.artist.starred);
setLoading(false);
return;
}
if (losslessOnly && serverId) {
const local = await runLocalArtistLosslessBrowse(serverId, id);
if (cancelled) return;
@@ -112,7 +149,7 @@ export function useArtistDetailData(
})();
return () => { cancelled = true; };
}, [id, losslessOnly, serverId]);
}, [id, losslessOnly, serverId, location.state]);
useEffect(() => {
if (!id) return;
+101
View File
@@ -0,0 +1,101 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const runLocalBrowseAlbums = vi.fn();
const runNetworkBrowseAlbums = vi.fn();
const isClusterMultiLibraryScopeBrowse = vi.fn();
const isClusterMode = vi.fn();
vi.mock('../utils/library/browseTextSearch', () => ({
BROWSE_TEXT_DEBOUNCE_RACE_MS: 0,
BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 0,
runLocalBrowseAlbums: (...args: unknown[]) => runLocalBrowseAlbums(...args),
runNetworkBrowseAlbums: (...args: unknown[]) => runNetworkBrowseAlbums(...args),
}));
vi.mock('../utils/serverCluster/clusterScope', () => ({
isClusterMode: () => isClusterMode(),
}));
vi.mock('../utils/serverCluster/clusterLibraryScopes', () => ({
isClusterMultiLibraryScopeBrowse: () => isClusterMultiLibraryScopeBrowse(),
}));
import { useBrowseAlbumTextSearch } from './useBrowseAlbumTextSearch';
beforeEach(() => {
vi.clearAllMocks();
isClusterMultiLibraryScopeBrowse.mockReturnValue(false);
isClusterMode.mockReturnValue(false);
runLocalBrowseAlbums.mockResolvedValue([
{ id: 'al-1', name: 'Local', artist: 'A', artistId: 'a', songCount: 1, duration: 1 },
]);
runNetworkBrowseAlbums.mockResolvedValue([
{ id: 'net-1', name: 'Net', artist: 'B', artistId: 'b', songCount: 1, duration: 1 },
]);
});
describe('useBrowseAlbumTextSearch', () => {
it('uses local cluster index only when multi-library scope is active', async () => {
isClusterMultiLibraryScopeBrowse.mockReturnValue(true);
const { result } = renderHook(() =>
useBrowseAlbumTextSearch('beatles', true, 'srv-a'),
);
await waitFor(() => {
expect(result.current.textSearchLoading).toBe(false);
expect(runLocalBrowseAlbums).toHaveBeenCalled();
});
expect(runLocalBrowseAlbums).toHaveBeenCalledWith('srv-a', 'beatles', undefined, false, true);
expect(runNetworkBrowseAlbums).not.toHaveBeenCalled();
expect(result.current.textSearchAlbums).toHaveLength(1);
expect(result.current.textSearchAlbums![0].id).toBe('al-1');
});
it('uses local index only in cluster mode', async () => {
isClusterMode.mockReturnValue(true);
const { result } = renderHook(() =>
useBrowseAlbumTextSearch('beatles', true, 'srv-a'),
);
await waitFor(() => {
expect(result.current.textSearchLoading).toBe(false);
expect(runLocalBrowseAlbums).toHaveBeenCalled();
});
expect(runNetworkBrowseAlbums).not.toHaveBeenCalled();
});
it('prefers local index when enabled and falls back to network only when local is unavailable', async () => {
const { result } = renderHook(() =>
useBrowseAlbumTextSearch('beatles', true, 'srv-a'),
);
await waitFor(() => {
expect(result.current.textSearchLoading).toBe(false);
expect(runLocalBrowseAlbums).toHaveBeenCalled();
});
expect(runLocalBrowseAlbums).toHaveBeenCalledWith('srv-a', 'beatles', undefined, false, false);
expect(runNetworkBrowseAlbums).not.toHaveBeenCalled();
expect(result.current.textSearchAlbums![0].id).toBe('al-1');
});
it('falls back to network when the local index cannot serve the query', async () => {
runLocalBrowseAlbums.mockResolvedValueOnce(null);
const { result } = renderHook(() =>
useBrowseAlbumTextSearch('beatles', true, 'srv-a'),
);
await waitFor(() => {
expect(result.current.textSearchLoading).toBe(false);
expect(runNetworkBrowseAlbums).toHaveBeenCalled();
});
expect(result.current.textSearchAlbums![0].id).toBe('net-1');
});
});
+20 -14
View File
@@ -3,11 +3,11 @@ import { useEffect, useRef, useState } from 'react';
import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
BROWSE_TEXT_DEBOUNCE_RACE_MS,
browseRaceCountsAlbums,
raceBrowseWithLocalFallback,
runLocalBrowseAlbums,
runNetworkBrowseAlbums,
} from '../utils/library/browseTextSearch';
import { isClusterMultiLibraryScopeBrowse } from '../utils/serverCluster/clusterLibraryScopes';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
/**
* Debounced album title search with local-vs-network race when the
@@ -18,6 +18,7 @@ export function useBrowseAlbumTextSearch(
indexEnabled: boolean,
serverId: string | null | undefined,
losslessOnly = false,
musicLibraryFilterVersion = 0,
) {
const [debouncedFilter, setDebouncedFilter] = useState('');
const [textSearchAlbums, setTextSearchAlbums] = useState<SubsonicAlbum[] | null>(null);
@@ -51,22 +52,27 @@ export function useBrowseAlbumTextSearch(
return;
}
const outcome = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly),
() => runNetworkBrowseAlbums(q),
{
surface: 'albums_browse',
query: q,
indexEnabled,
counts: browseRaceCountsAlbums,
},
const skipReadyCheck = isClusterMode() || isClusterMultiLibraryScopeBrowse();
const localAlbums = await runLocalBrowseAlbums(
serverId,
q,
undefined,
losslessOnly,
skipReadyCheck,
);
if (isStale()) return;
setTextSearchAlbums(outcome?.result ?? null);
if (localAlbums != null) {
setTextSearchAlbums(localAlbums);
setTextSearchLoading(false);
return;
}
const networkAlbums = await runNetworkBrowseAlbums(q);
if (isStale()) return;
setTextSearchAlbums(networkAlbums);
setTextSearchLoading(false);
})();
}, [debouncedFilter, indexEnabled, serverId, losslessOnly]);
}, [debouncedFilter, indexEnabled, serverId, losslessOnly, musicLibraryFilterVersion]);
const effectiveFilter = textSearchAlbums != null ? '' : filter;
return { textSearchAlbums, textSearchLoading, effectiveFilter };
+55
View File
@@ -0,0 +1,55 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ConnectionStatus } from './useConnectionStatus';
import type { ServerCluster } from '../utils/serverCluster/types';
import {
buildClusterMemberStatusTooltip,
clusterLedStatusFromDiagnostics,
getClusterMergeDiagnostics,
type ClusterMergeDiagnostics,
} from '../utils/serverCluster/clusterMergeStatus';
/** Cluster-mode LED state derived from all member probes (not representative only). */
export function useClusterConnectionLed(activeCluster: ServerCluster | null): {
ledStatus: ConnectionStatus | null;
ledTooltip: string | null;
} {
const { t } = useTranslation();
const [diag, setDiag] = useState<ClusterMergeDiagnostics | null>(null);
const [probing, setProbing] = useState(false);
const refresh = useCallback(async () => {
if (!activeCluster) {
setDiag(null);
return;
}
setProbing(true);
try {
const next = await getClusterMergeDiagnostics(activeCluster, { probeMembers: true });
setDiag(next);
} catch {
setDiag(null);
} finally {
setProbing(false);
}
}, [activeCluster]);
useEffect(() => {
void refresh();
const id = window.setInterval(() => void refresh(), 60_000);
return () => window.clearInterval(id);
}, [refresh]);
if (!activeCluster) {
return { ledStatus: null, ledTooltip: null };
}
if (probing && !diag) {
return { ledStatus: 'checking', ledTooltip: t('connection.checking') };
}
const resolved = diag ?? { members: [], mergeCount: 0, totalCount: 0 };
const ledStatus = clusterLedStatusFromDiagnostics(resolved);
const ledTooltip = buildClusterMemberStatusTooltip(t, resolved) || null;
return { ledStatus, ledTooltip };
}
+14
View File
@@ -0,0 +1,14 @@
import { useMemo } from 'react';
import { useAuthStore } from '../store/authStore';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
/** Cluster member label for UI rows; null when not in cluster mode or server unknown. */
export function useClusterMemberDisplayLabel(serverId: string | undefined | null): string | null {
const servers = useAuthStore(s => s.servers);
return useMemo(() => {
if (!isClusterMode() || !serverId?.trim()) return null;
const server = servers.find(s => s.id === serverId);
return server ? serverListDisplayLabel(server, servers) : serverId;
}, [serverId, servers]);
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useMemo, useState } from 'react';
import { apiForServer, libraryFilterParamsForServer } from '../api/subsonicClient';
import { useAuthStore } from '../store/authStore';
import { getCachedMusicFolders, setCachedMusicFolders } from '../utils/musicFoldersCache';
import { getActiveClusterMemberIds, isClusterMode } from '../utils/serverCluster/clusterScope';
import { isServerLikelyReachable } from '../utils/serverCluster/representative';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
export interface ClusterMusicFolderEntry {
serverId: string;
serverLabel: string;
folderId: string;
folderName: string;
}
function buildEntriesForMembers(memberIds: string[]): ClusterMusicFolderEntry[] {
const all = useAuthStore.getState().servers;
const flat: ClusterMusicFolderEntry[] = [];
for (const serverId of memberIds) {
const folders = getCachedMusicFolders(serverId);
if (!folders?.length) continue;
const server = all.find(s => s.id === serverId);
const label = server ? serverListDisplayLabel(server, all) : serverId;
for (const f of folders) {
flat.push({
serverId,
serverLabel: label,
folderId: f.id,
folderName: f.name?.trim() || f.id,
});
}
}
return flat;
}
async function fetchMusicFoldersForServer(serverId: string): Promise<ClusterMusicFolderEntry[]> {
const data = await apiForServer<{
musicFolders?: { musicFolder?: Array<{ id: string; name: string }> };
}>(
serverId,
'getMusicFolders.view',
libraryFilterParamsForServer(serverId),
);
const folders = (data.musicFolders?.musicFolder ?? []).map(f => ({
id: String(f.id),
name: f.name?.trim() || String(f.id),
}));
setCachedMusicFolders(serverId, folders);
const all = useAuthStore.getState().servers;
const server = all.find(s => s.id === serverId);
const label = server ? serverListDisplayLabel(server, all) : serverId;
return folders.map(f => ({
serverId,
serverLabel: label,
folderId: f.id,
folderName: f.name,
}));
}
/** Navidrome/Subsonic music folders for cluster members (sidebar Library picker). */
export function useClusterMusicFolders(): { entries: ClusterMusicFolderEntry[]; loading: boolean } {
const clusterId = useAuthStore(s => s.activeClusterId);
const memberIds = useMemo(
() => (clusterId && isClusterMode() ? getActiveClusterMemberIds() : []),
[clusterId],
);
const [entries, setEntries] = useState<ClusterMusicFolderEntry[]>(() => buildEntriesForMembers(memberIds));
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!clusterId || !isClusterMode() || memberIds.length === 0) {
setEntries([]);
setLoading(false);
return;
}
setEntries(buildEntriesForMembers(memberIds));
let cancelled = false;
setLoading(true);
const targets = memberIds.filter(isServerLikelyReachable);
const fetchIds = targets.length > 0 ? targets : memberIds;
void (async () => {
const settled = await Promise.allSettled(fetchIds.map(fetchMusicFoldersForServer));
if (cancelled) return;
const flat: ClusterMusicFolderEntry[] = [];
for (const r of settled) {
if (r.status === 'fulfilled') flat.push(...r.value);
}
setEntries(flat);
setLoading(false);
})();
return () => { cancelled = true; };
}, [clusterId, memberIds.join('|')]);
return { entries, loading };
}
+1
View File
@@ -0,0 +1 @@
export { useClusterPlaybackMonitor } from '../utils/serverCluster/clusterPlaybackMonitor';
+12 -6
View File
@@ -13,7 +13,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
// Backward-compatible re-export for call sites that still import from the hook.
export { isLanUrl };
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
export type ConnectionStatus = 'connected' | 'degraded' | 'disconnected' | 'checking';
export function useConnectionStatus() {
const perfFlags = usePerfProbeFlags();
@@ -25,6 +25,8 @@ export function useConnectionStatus() {
// public alternate must read as 'public', not 'local'.
const [activeEndpointKind, setActiveEndpointKind] = useState<ServerEndpointKind | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const activeServerId = useAuthStore(s => s.activeServerId);
const activeClusterId = useAuthStore(s => s.activeClusterId);
const check = useCallback(async () => {
const server = useAuthStore.getState().getActiveServer();
@@ -100,14 +102,18 @@ export function useConnectionStatus() {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [check, perfFlags.disableBackgroundPolling]);
}, [check, perfFlags.disableBackgroundPolling, activeServerId, activeClusterId]);
const server = useAuthStore(s => s.getActiveServer());
const servers = useAuthStore(s => s.servers);
const serverName = useMemo(
() => (server ? serverListDisplayLabel(server, servers) : ''),
[server, servers],
);
const clusters = useAuthStore(s => s.clusters);
const serverName = useMemo(() => {
if (activeClusterId) {
const cluster = clusters.find(c => c.id === activeClusterId);
if (cluster) return cluster.name;
}
return server ? serverListDisplayLabel(server, servers) : '';
}, [activeClusterId, clusters, server, servers]);
return {
status,
+13 -7
View File
@@ -7,6 +7,7 @@ import type {
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import type { TopFavoriteArtist } from '../components/favorites/TopFavoriteArtists';
import { clusterLoadFavorites } from '../utils/serverCluster/clusterBrowse';
export interface FavoritesDataResult {
albums: SubsonicAlbum[];
@@ -32,13 +33,18 @@ export function useFavoritesData(): FavoritesDataResult {
useEffect(() => {
const loadAll = async () => {
const [starredResult] = await Promise.allSettled([
getStarred(),
]);
if (starredResult.status === 'fulfilled') {
setAlbums(starredResult.value.albums);
setArtists(starredResult.value.artists);
setSongs(starredResult.value.songs);
const clusterFavorites = await clusterLoadFavorites();
if (clusterFavorites) {
setAlbums(clusterFavorites.albums);
setArtists(clusterFavorites.artists);
setSongs(clusterFavorites.songs);
} else {
const [starredResult] = await Promise.allSettled([getStarred()]);
if (starredResult.status === 'fulfilled') {
setAlbums(starredResult.value.albums);
setArtists(starredResult.value.artists);
setSongs(starredResult.value.songs);
}
}
// Radio favorites: read IDs from localStorage, fetch all stations, filter

Some files were not shown because too many files have changed in this diff Show More