mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-25 00:35: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 |
@@ -21,25 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Fullscreen player — rebuilt for much lower CPU/RAM
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1001](https://github.com/Psychotoxical/psysonic/pull/1001)**
|
||||
|
||||
* The previous fullscreen player was a heavy CPU and memory consumer — constant repaints from animated/blurred backgrounds and effects kept the GPU and a CPU core busy the whole time it was open. It has been **completely replaced** by a static, low-overhead screen: only the seekbar, elapsed time, and clock update live; everything else stays still.
|
||||
* Features: sharp high-res background, large album cover, true waveform seekbar, up-next queue popover, scrolling synced lyrics, clickable rating stars, and an on-screen clock.
|
||||
* The artist photo now always shows as the background (album cover as fallback); the old **Appearance → "Fullscreen player"** settings were removed.
|
||||
|
||||
|
||||
|
||||
### Queue — Timeline display mode
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1004](https://github.com/Psychotoxical/psysonic/pull/1004), suggested by [@Legislate3030](https://github.com/Legislate3030)**
|
||||
|
||||
* New third queue display mode (cycle the header button, or pick it in **Settings → Personalisation → Queue display**). Timeline keeps the current track centered with played history above and upcoming tracks below — both visible at once — so it's easy to follow playback and jump back to earlier songs.
|
||||
* The up-next order respects shuffle, and a "History" / "Up next" divider marks the boundary.
|
||||
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Dependencies — npm and Rust refresh
|
||||
|
||||
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"
|
||||
|
||||
@@ -140,12 +140,6 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let mut last_stall_recover_at: Option<Instant> = None;
|
||||
let mut last_poll_at = Instant::now();
|
||||
let mut watchdog_armed_until: Option<Instant> = None;
|
||||
// DIAGNOSTIC (issue #996, EXPERIMENT BUILD ONLY — revert before release):
|
||||
// counts watcher cycles so the heavy CoreAudio enumeration below runs only
|
||||
// ~every 60s (20 × 3s) instead of every cycle. Tests whether that periodic
|
||||
// enumeration is the source of the ~3s macOS playback stutter. The 3s loop,
|
||||
// watchdog and stall detector are left untouched on purpose.
|
||||
let mut diag_enum_skip: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
@@ -230,17 +224,6 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
// DIAGNOSTIC (issue #996, EXPERIMENT BUILD ONLY — revert before release):
|
||||
// run the heavy CoreAudio enumeration + device-change detection only
|
||||
// ~every 60s. The stall detector above still runs every 3s, so playback
|
||||
// recovery is unaffected. If the stutter cadence shifts to ~60s (or
|
||||
// stops), the periodic enumeration is the culprit.
|
||||
diag_enum_skip += 1;
|
||||
if diag_enum_skip < 20 {
|
||||
continue;
|
||||
}
|
||||
diag_enum_skip = 0;
|
||||
|
||||
// Enumerate all available output devices and the current default.
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -14,7 +14,7 @@ import LiveSearch from '../components/LiveSearch';
|
||||
import NowPlayingDropdown from '../components/NowPlayingDropdown';
|
||||
import QueuePanel from '../components/QueuePanel';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import FullscreenPlayer from '../components/fullscreenPlayer/FullscreenPlayerStatic';
|
||||
import FullscreenPlayer from '../components/FullscreenPlayer';
|
||||
import ContextMenu from '../components/ContextMenu';
|
||||
import SongInfoModal from '../components/SongInfoModal';
|
||||
import DownloadFolderModal from '../components/DownloadFolderModal';
|
||||
@@ -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)',
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* `FullscreenPlayer` characterization (Phase F5c).
|
||||
*
|
||||
* Includes the §4.5 regression test from the v2 plan — the cover image
|
||||
* must call `useCachedUrl(coverUrl, coverKey, false)`. The `false`
|
||||
* third argument selects the fallback path that avoids a double
|
||||
* crossfade (fetchUrl → blobUrl). A refactor that "tidies up" the
|
||||
* useCachedUrl call sites would silently regress the FS player.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
||||
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
|
||||
getSong: vi.fn(async () => null),
|
||||
getRandomSongs: vi.fn(async () => []),
|
||||
getSimilarSongs2: vi.fn(async () => []),
|
||||
getTopSongs: vi.fn(async () => []),
|
||||
getAlbumInfo2: vi.fn(async () => null),
|
||||
reportNowPlaying: vi.fn(async () => undefined),
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
star: vi.fn(async () => undefined),
|
||||
unstar: vi.fn(async () => undefined),
|
||||
getLyricsBySongId: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
// `useCachedUrl` is the surface §4.5 needs to characterize. Mock the module
|
||||
// so we can assert the third positional arg `false` is preserved.
|
||||
vi.mock('./CachedImage', async () => {
|
||||
const actual = await vi.importActual<typeof import('./CachedImage')>('./CachedImage');
|
||||
return {
|
||||
...actual,
|
||||
useCachedUrl: vi.fn((url, _key, opt) => `mock://${url}?opt=${String(opt ?? 'default')}`),
|
||||
};
|
||||
});
|
||||
|
||||
import FullscreenPlayer from './FullscreenPlayer';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
vi.mocked(useCachedUrl).mockClear();
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
onInvoke('audio_seek', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
onInvoke('discord_update_presence', () => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(useCachedUrl).mockClear();
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — render', () => {
|
||||
it('renders the labelled Fullscreen Player dialog', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(getByLabelText('Fullscreen Player')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the Close Fullscreen button', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(getByLabelText('Close Fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
|
||||
// The component calls `useCachedUrl` twice:
|
||||
// - line 338: for the small art box (default behaviour, opt=true)
|
||||
// - line 674: for the cover (opt=false, no fetchUrl fallback)
|
||||
// The `false` arg is load-bearing — it avoids a double crossfade by
|
||||
// routing through the cache-only path. Pin it.
|
||||
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
|
||||
});
|
||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
const coverCall = calls.find(c => c[2] === false);
|
||||
expect(coverCall).toBeDefined();
|
||||
expect(typeof coverCall?.[1]).toBe('string');
|
||||
expect(String(coverCall?.[1])).toContain('album-1');
|
||||
});
|
||||
|
||||
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
|
||||
});
|
||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
const defaultOptCalls = calls.filter(c => c[2] !== false);
|
||||
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(defaultOptCalls.some(c => String(c[1]).includes('album-1'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — control wiring', () => {
|
||||
it('clicking Close Fullscreen calls the onClose prop', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const onClose = vi.fn();
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={onClose} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Close Fullscreen'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Stop calls stop()', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Stop'));
|
||||
expect(stopSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Previous Track calls previous()', () => {
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 1,
|
||||
currentTrack: makeTrack({ id: 'b' }),
|
||||
});
|
||||
usePlayerStore.setState({ currentTime: 5 });
|
||||
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Previous Track'));
|
||||
expect(prevSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Next Track calls next()', () => {
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 0,
|
||||
currentTrack: makeTrack({ id: 'a' }),
|
||||
});
|
||||
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Next Track'));
|
||||
expect(nextSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Repeat cycles via toggleRepeat', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('off');
|
||||
fireEvent.click(getByLabelText('Repeat'));
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('all');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { queueSongStar } from '../store/pendingStarSync';
|
||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { playbackCoverArtForAlbum } from '../utils/playback/playbackServer';
|
||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { getCachedBlob } from '../utils/imageCache';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { FsLyricsApple } from './fullscreenPlayer/FsLyricsApple';
|
||||
import { FsLyricsRail } from './fullscreenPlayer/FsLyricsRail';
|
||||
import { FsArt } from './fullscreenPlayer/FsArt';
|
||||
import { FsPortrait } from './fullscreenPlayer/FsPortrait';
|
||||
import { FsSeekbar } from './fullscreenPlayer/FsSeekbar';
|
||||
import { FsLyricsMenu } from './fullscreenPlayer/FsLyricsMenu';
|
||||
import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
|
||||
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
|
||||
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
|
||||
import { useFsIdleFade } from '../hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '../hooks/useQueueTracks';
|
||||
|
||||
interface FullscreenPlayerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
// Derive isStarred inside the selector so we only re-render when the boolean
|
||||
// actually flips — not when any unrelated track's star status changes.
|
||||
const isStarred = usePlayerStore(s => {
|
||||
const track = s.currentTrack;
|
||||
if (!track) return false;
|
||||
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
|
||||
});
|
||||
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
|
||||
|
||||
const artCover = usePlaybackCoverArt(playbackCoverRef, 300);
|
||||
const artUrl = artCover.src;
|
||||
const artKey = artCover.cacheKey;
|
||||
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
|
||||
const coverUrl = portraitCover.src;
|
||||
const coverKey = portraitCover.cacheKey;
|
||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
|
||||
// Dynamic accent color extracted from the current album cover, applied as
|
||||
// --dynamic-fs-accent on the root element. Cache hits return instantly so
|
||||
// same-album tracks reuse the color without re-fetching.
|
||||
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
|
||||
|
||||
// Artist image → portrait on right. Falls back to cover art.
|
||||
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
|
||||
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
||||
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
|
||||
|
||||
// Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
|
||||
// (thin-state): the next ref resolves from the cache (the prefetch window
|
||||
// around the current index keeps it warm).
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const nextTrack = useQueueTrackAt(queueIndex + 1);
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
useEffect(() => {
|
||||
if (!nextTrack?.albumId || !nextTrack.coverArt) return;
|
||||
const { src: url, cacheKey: key } = playbackCoverArtForAlbum(
|
||||
nextTrack.albumId,
|
||||
nextTrack.coverArt,
|
||||
300,
|
||||
);
|
||||
getCachedBlob(url, key).catch(() => {});
|
||||
}, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]);
|
||||
|
||||
// Lyrics settings popover state
|
||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
|
||||
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const fsControlsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Idle-fade system — hides controls after 3 s of inactivity; Esc closes.
|
||||
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
|
||||
|
||||
const metaParts = useMemo(() => [
|
||||
currentTrack?.album,
|
||||
currentTrack?.year?.toString(),
|
||||
currentTrack?.suffix?.toUpperCase(),
|
||||
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
|
||||
].filter(Boolean), [currentTrack]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fs-player"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
data-lyrics={isAppleMode || undefined}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={{
|
||||
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
|
||||
'--fs-portrait-dim': String(fsPortraitDim / 100),
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
|
||||
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
|
||||
<div className="fs-mesh-bg" aria-hidden="true">
|
||||
<div className="fs-mesh-blob fs-mesh-blob-a" />
|
||||
<div className="fs-mesh-blob fs-mesh-blob-b" />
|
||||
</div>
|
||||
|
||||
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
|
||||
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
|
||||
|
||||
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
|
||||
<div className="fs-scrim" aria-hidden="true" />
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
|
||||
|
||||
{/* Layer 3 — info cluster, bottom-left */}
|
||||
<div className="fs-cluster">
|
||||
|
||||
{/* Album art */}
|
||||
<div className="fs-art-wrap">
|
||||
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
|
||||
</div>
|
||||
|
||||
{/* Track title — massive statement */}
|
||||
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
|
||||
|
||||
{/* Artist — secondary, below track */}
|
||||
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
{/* Metadata row */}
|
||||
{metaParts.length > 0 && (
|
||||
<div className="fs-meta">
|
||||
{metaParts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="fs-meta-dot">·</span>}
|
||||
<span>{part}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="fs-controls" ref={fsControlsRef}>
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
|
||||
<Square size={13} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ position: 'relative', zIndex: 9 }}>
|
||||
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
|
||||
<button
|
||||
ref={lyricsMenuTriggerRef}
|
||||
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsMenuOpen(v => !v)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
|
||||
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Layer 4 — full-width seekbar, bottom edge */}
|
||||
<FsSeekbar duration={duration} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -156,16 +156,16 @@ describe('QueuePanel — display mode', () => {
|
||||
expect(container.textContent).toContain('No upcoming tracks');
|
||||
});
|
||||
|
||||
it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => {
|
||||
it('header mode-toggle button flips queueDisplayMode (default queue → playlist)', () => {
|
||||
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
// The mode toggle is the first .queue-action-btn in the header (the
|
||||
// collapse chevron is the second). The toggle's label names its target;
|
||||
// from the default 'queue' that is the next mode in the cycle, "Timeline".
|
||||
// collapse chevron is the second). The default mode is 'queue', so the
|
||||
// toggle's label names its target ("Playlist").
|
||||
const toggle = container.querySelector<HTMLButtonElement>('.queue-header .queue-action-btn');
|
||||
expect(toggle?.getAttribute('aria-label')).toBe('Timeline');
|
||||
expect(toggle?.getAttribute('aria-label')).toBe('Playlist');
|
||||
toggle!.click();
|
||||
expect(useAuthStore.getState().queueDisplayMode).toBe('timeline');
|
||||
expect(useAuthStore.getState().queueDisplayMode).toBe('playlist');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Music } from 'lucide-react';
|
||||
import { useCachedUrl } from '../CachedImage';
|
||||
|
||||
// Album art box — crossfades layers so old art stays visible while new loads.
|
||||
// Uses 300px thumbnails (portrait fallback uses 500px separately).
|
||||
//
|
||||
// Why onLoad instead of new Image() preload:
|
||||
// React batches setLayers(add invisible) + rAF setLayers(make visible) into one
|
||||
// commit, so the browser never sees opacity:0 and the CSS transition never fires.
|
||||
// Using the DOM img's own onLoad guarantees the element was painted at opacity:0
|
||||
// before we flip it to 1.
|
||||
export const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) {
|
||||
// true = show raw fetchUrl immediately as fallback while blob resolves.
|
||||
// PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit.
|
||||
// Showing the URL directly avoids the multi-second blank wait.
|
||||
const blobUrl = useCachedUrl(fetchUrl, cacheKey, true);
|
||||
|
||||
const [layers, setLayers] = useState<Array<{ src: string; id: number; vis: boolean }>>([]);
|
||||
const counter = useRef(0);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blobUrl) return;
|
||||
const id = ++counter.current;
|
||||
setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]);
|
||||
}, [blobUrl]);
|
||||
|
||||
const handleLoad = useCallback((id: number) => {
|
||||
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
|
||||
setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id })));
|
||||
cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400);
|
||||
}, []);
|
||||
|
||||
if (layers.length === 0) {
|
||||
return <div className="fs-art fs-art-placeholder"><Music size={40} /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{layers.map(l => (
|
||||
<img
|
||||
key={l.id}
|
||||
src={l.src}
|
||||
className="fs-art"
|
||||
style={{ opacity: l.vis ? 1 : 0 }}
|
||||
onLoad={() => handleLoad(l.id)}
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
function formatClock(): string {
|
||||
return new Date().toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone wall-clock for the fullscreen player. Owns its own state so the
|
||||
* static player never re-renders for the time. Ticks every 30s (enough to catch
|
||||
* the minute rollover) and pauses while the window/tab is hidden.
|
||||
*/
|
||||
export const FsClock = memo(function FsClock() {
|
||||
const [time, setTime] = useState(formatClock);
|
||||
|
||||
useEffect(() => {
|
||||
let id: number | undefined;
|
||||
const tick = () => setTime(formatClock());
|
||||
const sync = () => {
|
||||
if (document.hidden) {
|
||||
if (id !== undefined) { clearInterval(id); id = undefined; }
|
||||
} else {
|
||||
tick();
|
||||
if (id === undefined) id = window.setInterval(tick, 30_000);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
document.addEventListener('visibilitychange', sync);
|
||||
return () => {
|
||||
if (id !== undefined) clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <span className="fsp-clock">{time}</span>;
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { memo, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
accentColor: string | null;
|
||||
triggerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
// Lyrics settings popover — shown above the mic button.
|
||||
export const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const setLyrics = useAuthStore(s => s.setShowFullscreenLyrics);
|
||||
const setStyle = useAuthStore(s => s.setFsLyricsStyle);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside the panel or on Escape.
|
||||
// Ignore clicks on the trigger button so re-clicking it toggles normally
|
||||
// instead of outside-handler closing + click re-opening.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const onMouse = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (panelRef.current?.contains(target)) return;
|
||||
if (triggerRef?.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onMouse);
|
||||
};
|
||||
}, [open, onClose, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const accent = accentColor ?? 'var(--accent)';
|
||||
|
||||
return (
|
||||
<div className="fslm-panel" ref={panelRef}>
|
||||
<div className="fslm-row">
|
||||
<span className="fslm-label">{t('player.fsLyricsToggle')}</span>
|
||||
<label className="toggle-switch" aria-label={t('player.fsLyricsToggle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLyrics}
|
||||
onChange={e => setLyrics(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`fslm-style-row${showLyrics ? '' : ' fslm-disabled'}`}>
|
||||
{(['rail', 'apple'] as const).map(style => (
|
||||
<button
|
||||
key={style}
|
||||
className={`fslm-style-btn${lyricsStyle === style ? ' fslm-style-active' : ''}`}
|
||||
onClick={() => setStyle(style)}
|
||||
style={lyricsStyle === style ? { borderColor: accent, color: accent, background: `color-mix(in srgb, ${accent} 14%, transparent)` } : undefined}
|
||||
>
|
||||
<span className="fslm-style-name">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}</span>
|
||||
<span className="fslm-style-desc">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}Desc` as any)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fslm-arrow" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLyrics, type WordLyricsLine } from '../../hooks/useLyrics';
|
||||
import { useWordLyricsSync } from '../../hooks/useWordLyricsSync';
|
||||
import type { LrcLine } from '../../api/lrclib';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
|
||||
// Classic 5-line rail lyrics (original "Rail" style).
|
||||
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
|
||||
export const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack: Track | null }) {
|
||||
const { syncedLines, wordLines, loading } = useLyrics(currentTrack);
|
||||
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
|
||||
|
||||
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
|
||||
const lineSrc: LrcLine[] | null = useWords
|
||||
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
|
||||
: (syncedLines as LrcLine[] | null);
|
||||
const hasSynced = !staticOnly && lineSrc !== null && lineSrc.length > 0;
|
||||
|
||||
const linesRef = useRef<LrcLine[]>([]);
|
||||
linesRef.current = hasSynced ? lineSrc! : [];
|
||||
|
||||
const activeIdx = usePlayerStore(s => {
|
||||
const ls = linesRef.current;
|
||||
if (ls.length === 0) return -1;
|
||||
return ls.reduce((acc, line, i) => s.currentTime >= line.time ? i : acc, -1);
|
||||
});
|
||||
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
const slotH = useRef(window.innerHeight * 0.06);
|
||||
useEffect(() => {
|
||||
const onResize = () => { slotH.current = window.innerHeight * 0.06; };
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const handleLineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-time]');
|
||||
if (!target || duration <= 0) return;
|
||||
seek(parseFloat(target.dataset.time!) / duration);
|
||||
}, [duration, seek]);
|
||||
|
||||
const { setWordRef } = useWordLyricsSync({
|
||||
enabled: useWords,
|
||||
wordLines: useWords ? (wordLines as WordLyricsLine[]) : null,
|
||||
currentTrack,
|
||||
classPrefix: 'fsr',
|
||||
});
|
||||
|
||||
if (!currentTrack || loading || !hasSynced) return null;
|
||||
|
||||
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
|
||||
|
||||
return (
|
||||
<div className="fsr-lyrics-overlay" aria-hidden="true">
|
||||
<div
|
||||
className="fsr-lyrics-rail"
|
||||
style={{ transform: `translateY(${railY}px)` }}
|
||||
onClick={handleLineClick}
|
||||
>
|
||||
{useWords
|
||||
? (wordLines as WordLyricsLine[]).map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.words.length > 0 ? line.words.map((w, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="fsr-lyric-word"
|
||||
ref={setWordRef(i, j)}
|
||||
>{w.text}</span>
|
||||
)) : (line.text || ' ')}
|
||||
</div>
|
||||
))
|
||||
: lineSrc!.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.text || ' '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export const FsPlayBtn = memo(function FsPlayBtn({
|
||||
: <Sunrise size={12} strokeWidth={2.5} />}
|
||||
<span className="player-btn-schedule-time player-btn-schedule-time--fs">{scheduleRemaining.remaining}</span>
|
||||
</span>
|
||||
) : isPlaying ? <Pause size={20} /> : <Play size={20} fill="currentColor" />}
|
||||
) : isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
|
||||
</button>
|
||||
</span>
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Artist portrait — right half, crossfades on track change.
|
||||
export const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const counterRef = useRef(1);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
let cancelled = false;
|
||||
const id = counterRef.current++;
|
||||
const img = new Image();
|
||||
img.onload = img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
cleanupTimer.current = setTimeout(() => {
|
||||
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 1000);
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
if (layers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fs-portrait-wrap" aria-hidden="true">
|
||||
{layers.map(layer => (
|
||||
<img
|
||||
key={layer.id}
|
||||
src={layer.url}
|
||||
className="fs-portrait"
|
||||
style={{ opacity: layer.visible ? 1 : 0 }}
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
alt=""
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { memo, useMemo, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '../../utils/library/queueTrackResolver';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semi-transparent "Up next" overlay for the fullscreen player — lists the
|
||||
* upcoming queue (no blur, in keeping with the static player). Clicking a row
|
||||
* jumps to that queue item (same-queue jump as the queue panel).
|
||||
*/
|
||||
export const FsQueueModal = memo(function FsQueueModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const queueItems = usePlayerStore(s => s.queueItems);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
// Re-resolve as the resolver cache fills.
|
||||
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const out: { track: Track; absIdx: number }[] = [];
|
||||
for (let i = queueIndex + 1; i < queueItems.length; i++) {
|
||||
const ref = queueItems[i];
|
||||
if (ref) out.push({ track: resolveQueueTrack(ref), absIdx: i });
|
||||
}
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queueItems, queueIndex, version]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fsq-backdrop"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fsUpNext')}
|
||||
>
|
||||
<div className="fsq-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="fsq-header">
|
||||
<span className="fsq-title">{t('player.fsUpNext')}</span>
|
||||
<button className="fsq-close" onClick={onClose} aria-label={t('common.close')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="fsq-list">
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="fsq-empty">{t('player.fsQueueEmpty')}</div>
|
||||
) : (
|
||||
upcoming.map(({ track, absIdx }) => (
|
||||
<button
|
||||
key={`${track.id}:${absIdx}`}
|
||||
className="fsq-item"
|
||||
onClick={() => {
|
||||
playTrack(track, undefined, undefined, undefined, absIdx);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="fsq-item-pos">{absIdx + 1}</span>
|
||||
<span className="fsq-item-info">
|
||||
<span className="fsq-item-title">{track.title}</span>
|
||||
<span className="fsq-item-artist">{track.artist}</span>
|
||||
</span>
|
||||
<span className="fsq-item-dur">{formatTrackTime(track.duration ?? 0)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
|
||||
// Full-width seekbar — imperative DOM updates, zero React re-renders on tick.
|
||||
export const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const timeRef = useRef<HTMLSpanElement>(null);
|
||||
const playedRef = useRef<HTMLDivElement>(null);
|
||||
const bufRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
|
||||
const previewSeek = useCallback((progress: number) => {
|
||||
const s = usePlayerStore.getState();
|
||||
const p = Math.max(0, Math.min(1, progress));
|
||||
pendingSeekRef.current = p;
|
||||
if (timeRef.current) {
|
||||
const previewTime = duration > 0 ? p * duration : s.currentTime;
|
||||
timeRef.current.textContent = formatTrackTime(previewTime);
|
||||
}
|
||||
if (playedRef.current) playedRef.current.style.width = `${p * 100}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p * 100, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(p);
|
||||
}, [duration]);
|
||||
|
||||
const commitSeek = useCallback(() => {
|
||||
const pending = pendingSeekRef.current;
|
||||
if (pending === null) return;
|
||||
pendingSeekRef.current = null;
|
||||
seek(pending);
|
||||
}, [seek]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = getPlaybackProgressSnapshot();
|
||||
const pct = s.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTrackTime(s.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
|
||||
return subscribePlaybackProgress(state => {
|
||||
if (isDraggingRef.current) return;
|
||||
const p = state.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTrackTime(state.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${p}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(state.progress);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
previewSeek(parseFloat(e.target.value));
|
||||
},
|
||||
[previewSeek]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fs-seekbar-wrap">
|
||||
<div className="fs-seekbar-times">
|
||||
<span ref={timeRef} />
|
||||
<span>{formatTrackTime(duration)}</span>
|
||||
</div>
|
||||
<div className="fs-seekbar">
|
||||
<div className="fs-seekbar-bg" />
|
||||
<div className="fs-seekbar-buf" ref={bufRef} />
|
||||
<div className="fs-seekbar-played" ref={playedRef} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
defaultValue={0}
|
||||
onChange={handleSeek}
|
||||
onMouseDown={() => { isDraggingRef.current = true; }}
|
||||
onMouseUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onTouchStart={() => { isDraggingRef.current = true; }}
|
||||
onTouchEnd={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onPointerDown={() => { isDraggingRef.current = true; }}
|
||||
onPointerUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onKeyUp={commitSeek}
|
||||
onBlur={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
aria-label="seek"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
|
||||
/**
|
||||
* Centered "current / total" readout for the control bar. Updates the current
|
||||
* time imperatively from the playback-progress store — no React re-render per
|
||||
* tick (same pattern as FsSeekbar).
|
||||
*/
|
||||
export const FsTimeReadout = memo(function FsTimeReadout({ duration }: { duration: number }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const apply = (state: { currentTime: number }) => {
|
||||
if (ref.current) ref.current.textContent = formatTrackTime(state.currentTime);
|
||||
};
|
||||
apply(getPlaybackProgressSnapshot());
|
||||
return subscribePlaybackProgress(apply);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span className="fsp-time">
|
||||
<span ref={ref} /> / {formatTrackTime(duration)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -1,252 +0,0 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
SkipBack, SkipForward, Square, Repeat, Repeat1, Heart,
|
||||
Shuffle, ListMusic, ChevronDown, Star, MicVocal,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { queueSongStar, queueSongRating } from '../../store/pendingStarSync';
|
||||
import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef';
|
||||
import { usePlaybackCoverArt } from '../../hooks/usePlaybackCoverArt';
|
||||
import { useCachedUrl } from '../CachedImage';
|
||||
import { useFsArtistPortrait } from '../../hooks/useFsArtistPortrait';
|
||||
import { useFsIdleFade } from '../../hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '../../hooks/useQueueTracks';
|
||||
import WaveformSeek from '../WaveformSeek';
|
||||
import { FsQueueModal } from './FsQueueModal';
|
||||
import { FsLyricsApple } from './FsLyricsApple';
|
||||
import { FsPlayBtn } from './FsPlayBtn';
|
||||
import { FsClock } from './FsClock';
|
||||
import { FsTimeReadout } from './FsTimeReadout';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
export default function FullscreenPlayerStatic({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const shuffleUpcomingQueue = usePlayerStore(s => s.shuffleUpcomingQueue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const queueLen = usePlayerStore(s => s.queueItems.length);
|
||||
|
||||
// Derive the boolean inside the selector so the cluster only re-renders when
|
||||
// the star actually flips, not on any unrelated track's star change.
|
||||
const isStarred = usePlayerStore(s => {
|
||||
const track = s.currentTrack;
|
||||
if (!track) return false;
|
||||
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
|
||||
});
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Album-keyed cover ref so the cover stays stable across track changes within
|
||||
// the same album. A per-track ref re-keys on `track.coverArt` (Navidrome hands
|
||||
// out `mf-<trackId>` per track), which the distinct-disc heuristic mistakes for
|
||||
// per-disc art and reloads the cover on every song change. Keying on albumId
|
||||
// sidesteps that — same lesson as the artist portrait keying on artistId.
|
||||
// `usePlaybackCoverArt` still re-scopes it to the playback server.
|
||||
const playbackCoverRef =
|
||||
useAlbumCoverRef(currentTrack?.albumId, undefined, undefined, { libraryResolve: false }) ?? undefined;
|
||||
// One high-res cover (cucadmuh's fullRes 2000px path) feeds both the background
|
||||
// fallback and the foreground thumbnail: crisp instead of the old low-res tier,
|
||||
// and a single fetch/decode shared by both.
|
||||
const cover = usePlaybackCoverArt(playbackCoverRef, 2000, { fullRes: true });
|
||||
// `true` = show the raw URL immediately while the blob resolves (same as FsArt).
|
||||
const coverUrl = useCachedUrl(cover.src, cover.cacheKey, true);
|
||||
const resolvedCoverUrl = coverUrl;
|
||||
const thumbUrl = coverUrl;
|
||||
// Artist photo is the background; fall back to the album cover.
|
||||
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
|
||||
const bgUrl = artistBgUrl || resolvedCoverUrl;
|
||||
|
||||
const nextTrack = useQueueTrackAt(queueIndex + 1);
|
||||
|
||||
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
|
||||
const controlsRef = useRef<HTMLDivElement>(null);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [lyricsOpen, setLyricsOpen] = useState(false);
|
||||
|
||||
// Prefix the title with the queue position so it matches "Track x / N".
|
||||
const titlePrefix = queueLen > 0
|
||||
? `${String(queueIndex + 1).padStart(2, '0')}. `
|
||||
: '';
|
||||
const metaParts = useMemo(
|
||||
() => [currentTrack?.year?.toString(), currentTrack?.genre].filter(Boolean) as string[],
|
||||
[currentTrack?.year, currentTrack?.genre],
|
||||
);
|
||||
// Override-aware rating (a just-set rating lives in the override before it syncs
|
||||
// back onto the track object).
|
||||
const rating = usePlayerStore(s => {
|
||||
const track = s.currentTrack;
|
||||
if (!track) return 0;
|
||||
return track.id in s.userRatingOverrides ? s.userRatingOverrides[track.id] : (track.userRating ?? 0);
|
||||
});
|
||||
// Hover preview for the clickable rating stars (0 = no preview).
|
||||
const [hoverRating, setHoverRating] = useState(0);
|
||||
const applyRating = useCallback((stars: number) => {
|
||||
if (!currentTrack) return;
|
||||
// Click the current rating again to clear it (matches StarRating's toggle-off).
|
||||
queueSongRating(currentTrack.id, rating === stars ? 0 : stars);
|
||||
}, [currentTrack, rating]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fsp"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{/* Static sharp background — no blur, no animation */}
|
||||
{bgUrl
|
||||
? <img className="fsp-bg" src={bgUrl} alt="" aria-hidden="true" draggable={false} />
|
||||
: <div className="fsp-bg fsp-bg--empty" aria-hidden="true" />}
|
||||
<div className="fsp-scrim" aria-hidden="true" />
|
||||
<div className="fsp-vignette" aria-hidden="true" />
|
||||
|
||||
{/* Top bar */}
|
||||
<div className="fsp-top">
|
||||
<div className="fsp-nowplaying">
|
||||
<span className="fsp-nowplaying-label">{t('player.fsNowPlaying')}</span>
|
||||
{queueLen > 0 && (
|
||||
<span className="fsp-nowplaying-pos">{t('player.fsTrackPosition', { current: queueIndex + 1, total: queueLen })}</span>
|
||||
)}
|
||||
</div>
|
||||
<FsClock />
|
||||
</div>
|
||||
|
||||
<button className="fsp-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={20} />
|
||||
</button>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="fsp-foot">
|
||||
<div className="fsp-info-row">
|
||||
{/* Big cover — bottom-aligned with the text, top pokes above the bar */}
|
||||
<div className="fsp-cover">
|
||||
{thumbUrl
|
||||
? <img className="fsp-cover-img" src={thumbUrl} alt="" draggable={false} />
|
||||
: <div className="fsp-cover-img fsp-cover-img--empty" />}
|
||||
</div>
|
||||
<div className="fsp-info-text">
|
||||
<p className="fsp-title">{titlePrefix}{currentTrack?.title ?? '—'}</p>
|
||||
<p className="fsp-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
{currentTrack && (
|
||||
<div className="fsp-meta">
|
||||
{metaParts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="fsp-meta-dot">·</span>}
|
||||
<span>{part}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span
|
||||
className="fsp-stars"
|
||||
role="radiogroup"
|
||||
aria-label={t('albumDetail.ratingLabel')}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => {
|
||||
const n = i + 1;
|
||||
const filled = (hoverRating || rating) >= n;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="fsp-star-btn"
|
||||
role="radio"
|
||||
aria-checked={rating === n}
|
||||
aria-label={`${n}`}
|
||||
onMouseEnter={() => setHoverRating(n)}
|
||||
onClick={() => applyRating(n)}
|
||||
>
|
||||
<Star size={16} fill={filled ? 'currentColor' : 'none'} strokeWidth={1.5} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{nextTrack && (
|
||||
<p className="fsp-next">{t('player.fsNext')}: {nextTrack.artist} – {nextTrack.title}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fsp-controls" ref={controlsRef}>
|
||||
<div className="fsp-transport">
|
||||
<button className="fsp-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<FsPlayBtn controlsAnchorRef={controlsRef} />
|
||||
<button className="fsp-btn fsp-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fsp-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FsTimeReadout duration={duration} />
|
||||
|
||||
<div className="fsp-actions">
|
||||
<button className="fsp-btn fsp-btn-sm" onClick={() => setQueueOpen(true)} aria-label={t('queue.title')} data-tooltip={t('queue.title')}>
|
||||
<ListMusic size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`fsp-btn fsp-btn-sm${lyricsOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsOpen(v => !v)}
|
||||
aria-label={t('player.lyrics')}
|
||||
data-tooltip={t('player.lyrics')}
|
||||
>
|
||||
<MicVocal size={20} />
|
||||
</button>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className={`fsp-btn fsp-btn-sm${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={20} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`fsp-btn fsp-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
|
||||
</button>
|
||||
<button className="fsp-btn fsp-btn-sm" onClick={shuffleUpcomingQueue} aria-label={t('player.shuffle')} data-tooltip={t('player.shuffle')}>
|
||||
<Shuffle size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* True waveform seekbar (cucadmuh's idea) instead of the thin bar. */}
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
|
||||
{queueOpen && <FsQueueModal onClose={() => setQueueOpen(false)} />}
|
||||
|
||||
{/* Scrolling synced lyrics (reuses FsLyricsApple) in a semi-transparent
|
||||
overlay over the upper area. */}
|
||||
{lyricsOpen && (
|
||||
<div className="fsp-lyrics-overlay">
|
||||
<FsLyricsApple currentTrack={currentTrack} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDeferredValue, useMemo, useSyncExternalStore } from 'react';
|
||||
import { AlignCenterVertical, ChevronDown, ListMusic, ListOrdered } from 'lucide-react';
|
||||
import { ChevronDown, ListMusic, ListOrdered } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
@@ -81,16 +81,6 @@ export function QueueHeader({
|
||||
|
||||
const isEta = durationMode === 'eta';
|
||||
|
||||
// Cycle the panel through the three render modes; icon + title show the active
|
||||
// one, tooltip names the one a click switches to.
|
||||
const DISPLAY_MODE_CYCLE: QueueDisplayMode[] = ['queue', 'timeline', 'playlist'];
|
||||
const nextDisplayMode =
|
||||
DISPLAY_MODE_CYCLE[(DISPLAY_MODE_CYCLE.indexOf(queueDisplayMode) + 1) % DISPLAY_MODE_CYCLE.length];
|
||||
const displayModeLabel = (m: QueueDisplayMode) =>
|
||||
m === 'playlist' ? t('queue.modePlaylist') : m === 'timeline' ? t('queue.modeTimeline') : t('queue.title');
|
||||
const displayModeIcon = (m: QueueDisplayMode) =>
|
||||
m === 'playlist' ? <ListMusic size={15} /> : m === 'timeline' ? <AlignCenterVertical size={15} /> : <ListOrdered size={15} />;
|
||||
|
||||
return (
|
||||
<div className="queue-header">
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||
@@ -101,17 +91,17 @@ export function QueueHeader({
|
||||
<button
|
||||
type="button"
|
||||
className="queue-action-btn"
|
||||
onClick={() => setQueueDisplayMode(nextDisplayMode)}
|
||||
data-tooltip={displayModeLabel(nextDisplayMode)}
|
||||
aria-label={displayModeLabel(nextDisplayMode)}
|
||||
onClick={() => setQueueDisplayMode(queueDisplayMode === 'playlist' ? 'queue' : 'playlist')}
|
||||
data-tooltip={queueDisplayMode === 'playlist' ? t('queue.title') : t('queue.modePlaylist')}
|
||||
aria-label={queueDisplayMode === 'playlist' ? t('queue.title') : t('queue.modePlaylist')}
|
||||
style={{ width: 24, height: 24, alignSelf: 'center', flexShrink: 0 }}
|
||||
>
|
||||
{displayModeIcon(queueDisplayMode)}
|
||||
{queueDisplayMode === 'playlist' ? <ListMusic size={15} /> : <ListOrdered size={15} />}
|
||||
</button>
|
||||
{/* Title doubles as the mode indicator so the panel names the active
|
||||
mode rather than always reading "Queue". */}
|
||||
{/* Title doubles as the mode indicator so the panel reads "Playlist"
|
||||
in playlist mode rather than the misleading "Queue". */}
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>
|
||||
{displayModeLabel(queueDisplayMode)}
|
||||
{queueDisplayMode === 'playlist' ? t('queue.modePlaylist') : t('queue.title')}
|
||||
</h2>
|
||||
{queue.length > 0 && (
|
||||
<span style={{ fontSize: "13px", color: "var(--text-muted)", whiteSpace: "nowrap", userSelect: "none" }}>
|
||||
|
||||
@@ -108,16 +108,6 @@ export function QueueList({
|
||||
return pinToTop(0, displayBaseIndex);
|
||||
}
|
||||
|
||||
if (queueDisplayMode === 'timeline') {
|
||||
// Anchor the current track in the middle — history above, up-next below.
|
||||
rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' });
|
||||
const id = requestAnimationFrame(() => {
|
||||
const el = queueListRef.current?.querySelector<HTMLElement>(`[data-queue-idx="${queueIndex}"]`);
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
// Playlist: lazy. Let the highlight wander while the now-playing row stays
|
||||
// visible; only re-pin it to the top once it has scrolled out of view.
|
||||
const viewport = queueListRef.current;
|
||||
@@ -158,8 +148,6 @@ export function QueueList({
|
||||
const base = queue[idx];
|
||||
const track = resolveQueueTrack(base);
|
||||
const isPlaying = absIdx === queueIndex;
|
||||
const isTimeline = queueDisplayMode === 'timeline';
|
||||
const isPast = isTimeline && absIdx < queueIndex;
|
||||
const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
|
||||
@@ -181,16 +169,6 @@ export function QueueList({
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||
>
|
||||
{isTimeline && idx === 0 && queueIndex > 0 && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.history')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isTimeline && absIdx === queueIndex + 1 && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.upNext')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isFirstRadioAdded && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
|
||||
@@ -235,7 +213,7 @@ export function QueueList({
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
style={{ ...(isPast && !isPlaying ? { opacity: 0.5 } : null), ...dragStyle }}
|
||||
style={dragStyle}
|
||||
>
|
||||
<div className="queue-item-info">
|
||||
<div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Clock, LayoutGrid, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
|
||||
import { Clock, LayoutGrid, Maximize2, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
@@ -367,6 +367,41 @@ export function AppearanceTab() {
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.fsPlayerSection')}
|
||||
icon={<Maximize2 size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.fsShowArtistPortrait')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.fsShowArtistPortraitDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.fsShowArtistPortrait')}>
|
||||
<input type="checkbox" checked={auth.showFsArtistPortrait} onChange={e => auth.setShowFsArtistPortrait(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.showFsArtistPortrait && (
|
||||
<div style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.fsPortraitDim')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 36, textAlign: 'right' }}>{auth.fsPortraitDim}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={80}
|
||||
step={1}
|
||||
value={auth.fsPortraitDim}
|
||||
onChange={e => auth.setFsPortraitDim(parseInt(e.target.value, 10))}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.seekbarStyle')}
|
||||
icon={<Sliders size={16} />}
|
||||
|
||||
@@ -84,8 +84,8 @@ export function PersonalisationTab() {
|
||||
icon={<ListOrdered size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{/* Three mutually exclusive modes — exactly one is always active, so
|
||||
turning one on turns the others off; the active one cannot be
|
||||
{/* Two mutually exclusive modes — exactly one is always active, so
|
||||
turning one on turns the other off; the active one cannot be
|
||||
switched off directly (ignore the uncheck). */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
@@ -116,21 +116,6 @@ export function PersonalisationTab() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('queue.modeTimeline')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModeTimelineSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('queue.modeTimeline')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={queueDisplayMode === 'timeline'}
|
||||
onChange={e => { if (e.target.checked) setQueueDisplayMode('timeline'); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -64,6 +64,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'appearance', titleKey: 'settings.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' },
|
||||
{ tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
|
||||
{ tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' },
|
||||
{ tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' },
|
||||
{ tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform reduced animations performance gpu fps low-end framerate cap' },
|
||||
{ tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
|
||||
{ tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user