mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 07:45:45 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0feb54ac33 | |||
| e640d14a0e | |||
| 38ec62a639 | |||
| 6c64c5aa41 | |||
| 478c049d0a | |||
| 26716e2fb0 | |||
| 496b39a4da | |||
| 558f42dba6 | |||
| 0a09d29b06 | |||
| 1d65a8d339 | |||
| 884951fbc2 | |||
| 3447cf9fdd | |||
| e601b5888a | |||
| 2ee601683c | |||
| 4f37fb8752 | |||
| a7effbc271 | |||
| 9813c79c91 | |||
| f3d4a06726 | |||
| dbccbea6cc |
Generated
+20
@@ -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"
|
||||
|
||||
@@ -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.1–2.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 §5–6).
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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() });
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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`,
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)',
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useClusterPlaybackMonitor } from '../utils/serverCluster/clusterPlaybackMonitor';
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user