mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(cluster): artist Play All/top tracks and member-scoped browse UX
Load artist album tracks via getAlbumForServer when clusterSeedServerId is set so Play All, shuffle, and top-track clicks resolve on the correct member server. Also improve connection indicator tooltips, Song Info server labels, artist browse covers/detail fallbacks, and faster merged artist list SQL.
This commit is contained in:
@@ -13,6 +13,7 @@ use crate::search::aliased_track_columns;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
use super::db::ATTACH_ALIAS;
|
||||
use super::keys::artist_key_from_display_name;
|
||||
use super::list_albums::list_merged_albums;
|
||||
use super::merge::{solo_partition_key, DURATION_TOLERANCE_SEC};
|
||||
use super::priority::{in_list_sql, priority_case_sql};
|
||||
@@ -96,8 +97,8 @@ fn member_album_pairs(
|
||||
}
|
||||
}
|
||||
|
||||
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 (in_placeholders, in_params) = in_list_sql(servers_ordered);
|
||||
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT t.server_id, t.album_id, ({priority_sql}) AS priority_rank
|
||||
FROM track t
|
||||
@@ -110,8 +111,8 @@ fn member_album_pairs(
|
||||
ORDER BY priority_rank, t.server_id, t.album_id"
|
||||
);
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.append(&mut priority_params);
|
||||
params.append(&mut in_params);
|
||||
params.extend(priority_params);
|
||||
params.extend(in_params);
|
||||
params.push(SqlValue::Text(merge_key.to_string()));
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
@@ -231,7 +232,7 @@ fn merged_tracks_for_album_pairs(
|
||||
if pairs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let pair_clauses: Vec<String> = pairs
|
||||
.iter()
|
||||
.map(|_| "(t.server_id = ? AND t.album_id = ?)".to_string())
|
||||
@@ -289,7 +290,7 @@ fn merged_tracks_for_album_pairs(
|
||||
);
|
||||
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.append(&mut priority_params);
|
||||
params.extend(priority_params);
|
||||
for (sid, aid, _) in pairs {
|
||||
params.push(SqlValue::Text(sid.clone()));
|
||||
params.push(SqlValue::Text(aid.clone()));
|
||||
@@ -406,6 +407,9 @@ fn resolve_artist_seed(
|
||||
let exists: bool = store.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM artist ar
|
||||
WHERE ar.server_id = ?1 AND ar.id = ?2
|
||||
) OR EXISTS(
|
||||
SELECT 1 FROM track
|
||||
WHERE server_id = ?1 AND deleted = 0
|
||||
AND (artist_id = ?2 OR artist = ?2)
|
||||
@@ -526,8 +530,8 @@ fn merged_albums_for_artist_key(
|
||||
if servers_ordered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
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 (in_placeholders, in_params) = in_list_sql(servers_ordered);
|
||||
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let sql = format!(
|
||||
"WITH candidates AS (
|
||||
SELECT
|
||||
@@ -585,8 +589,8 @@ fn merged_albums_for_artist_key(
|
||||
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE",
|
||||
);
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.append(&mut priority_params);
|
||||
params.append(&mut in_params);
|
||||
params.extend(priority_params);
|
||||
params.extend(in_params);
|
||||
params.push(SqlValue::Text(artist_key.to_string()));
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
@@ -625,8 +629,8 @@ fn merged_top_tracks_for_artist_key(
|
||||
if servers_ordered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
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 (in_placeholders, in_params) = in_list_sql(servers_ordered);
|
||||
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let cols = aliased_track_columns("t");
|
||||
let sql = format!(
|
||||
"WITH candidates AS (
|
||||
@@ -681,8 +685,8 @@ fn merged_top_tracks_for_artist_key(
|
||||
tol = DURATION_TOLERANCE_SEC,
|
||||
);
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.append(&mut priority_params);
|
||||
params.append(&mut in_params);
|
||||
params.extend(priority_params);
|
||||
params.extend(in_params);
|
||||
params.push(SqlValue::Text(artist_key.to_string()));
|
||||
params.push(SqlValue::Integer(limit as i64));
|
||||
|
||||
@@ -696,6 +700,165 @@ fn merged_top_tracks_for_artist_key(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Album rows for an artist when cluster-key merge yields nothing — match the
|
||||
/// `album` table (and track-only albums) by artist id or display name.
|
||||
fn fallback_albums_for_artist_scope(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
artist_ref: &str,
|
||||
artist_name: &str,
|
||||
) -> Result<Vec<LibraryAlbumDto>, String> {
|
||||
if servers_ordered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
|
||||
let album_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 a.server_id IN ({in_placeholders})
|
||||
AND (a.artist_id = ? OR a.artist = ? OR a.artist = ?)
|
||||
ORDER BY a.name COLLATE NOCASE, a.server_id",
|
||||
);
|
||||
let mut album_params: Vec<SqlValue> = Vec::new();
|
||||
album_params.extend(in_params.clone());
|
||||
album_params.push(SqlValue::Text(artist_ref.to_string()));
|
||||
album_params.push(SqlValue::Text(artist_ref.to_string()));
|
||||
album_params.push(SqlValue::Text(artist_name.to_string()));
|
||||
|
||||
let from_table: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&album_sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(album_params.iter()), map_album_dto_row)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})?;
|
||||
if !from_table.is_empty() {
|
||||
return Ok(from_table);
|
||||
}
|
||||
|
||||
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let track_sql = format!(
|
||||
"WITH picks AS (
|
||||
SELECT t.server_id, t.album_id, MIN(t.rowid) AS tid
|
||||
FROM track t
|
||||
WHERE t.deleted = 0
|
||||
AND t.server_id IN ({in_placeholders})
|
||||
AND t.album_id IS NOT NULL AND t.album_id != ''
|
||||
AND (t.artist_id = ? OR t.artist = ? OR t.artist_id = ?)
|
||||
GROUP BY t.server_id, t.album_id
|
||||
)
|
||||
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 picks p
|
||||
JOIN track t ON t.rowid = p.tid
|
||||
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
|
||||
ORDER BY ({priority_sql}), COALESCE(a.name, t.album) COLLATE NOCASE",
|
||||
);
|
||||
let mut track_params: Vec<SqlValue> = Vec::new();
|
||||
track_params.extend(in_params);
|
||||
track_params.push(SqlValue::Text(artist_ref.to_string()));
|
||||
track_params.push(SqlValue::Text(artist_name.to_string()));
|
||||
track_params.push(SqlValue::Text(artist_ref.to_string()));
|
||||
track_params.extend(priority_params);
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&track_sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(track_params.iter()), map_album_dto_row)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn map_album_dto_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 fallback_top_tracks_for_artist_scope(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
artist_ref: &str,
|
||||
artist_name: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<LibraryTrackDto>, String> {
|
||||
if servers_ordered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
|
||||
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let sql = format!(
|
||||
"SELECT {cols}
|
||||
FROM track t
|
||||
WHERE t.deleted = 0
|
||||
AND t.server_id IN ({in_placeholders})
|
||||
AND (t.artist_id = ? OR t.artist = ? OR t.artist_id = ?)
|
||||
ORDER BY ({priority_sql}), COALESCE(t.play_count, 0) DESC, t.title COLLATE NOCASE
|
||||
LIMIT ?",
|
||||
cols = aliased_track_columns("t"),
|
||||
);
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.extend(priority_params);
|
||||
params.extend(in_params);
|
||||
params.push(SqlValue::Text(artist_ref.to_string()));
|
||||
params.push(SqlValue::Text(artist_name.to_string()));
|
||||
params.push(SqlValue::Text(artist_ref.to_string()));
|
||||
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())
|
||||
}
|
||||
|
||||
pub fn cluster_artist_detail(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
@@ -708,8 +871,6 @@ pub fn cluster_artist_detail(
|
||||
return Err("artist not found in cluster scope".to_string());
|
||||
};
|
||||
|
||||
let artist_key = artist_key_for_pair(store, &seed_sid, &seed_aid)?;
|
||||
|
||||
let owner_sid = seed_sid.clone();
|
||||
let owner_aid = seed_aid.clone();
|
||||
|
||||
@@ -717,17 +878,39 @@ pub fn cluster_artist_detail(
|
||||
.or_else(|| fallback_artist_from_tracks(store, &owner_sid, &owner_aid).ok().flatten())
|
||||
.ok_or_else(|| "artist metadata missing".to_string())?;
|
||||
|
||||
let albums = if let Some(ref key) = artist_key {
|
||||
let mut artist_key = artist_key_for_pair(store, &owner_sid, &owner_aid)?;
|
||||
if artist_key.is_none() {
|
||||
artist_key = artist_key_from_display_name(&artist.name);
|
||||
}
|
||||
|
||||
let mut albums = if let Some(ref key) = artist_key {
|
||||
merged_albums_for_artist_key(store, servers_ordered, key)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if albums.is_empty() {
|
||||
albums = fallback_albums_for_artist_scope(
|
||||
store,
|
||||
servers_ordered,
|
||||
&owner_aid,
|
||||
&artist.name,
|
||||
)?;
|
||||
}
|
||||
|
||||
let top_tracks = if let Some(ref key) = artist_key {
|
||||
let mut top_tracks = if let Some(ref key) = artist_key {
|
||||
merged_top_tracks_for_artist_key(store, servers_ordered, key, TOP_TRACKS_LIMIT)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if top_tracks.is_empty() {
|
||||
top_tracks = fallback_top_tracks_for_artist_scope(
|
||||
store,
|
||||
servers_ordered,
|
||||
&owner_aid,
|
||||
&artist.name,
|
||||
TOP_TRACKS_LIMIT,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(LibraryClusterArtistDetailResponse {
|
||||
artist,
|
||||
|
||||
@@ -73,6 +73,15 @@ pub fn compute_track_cluster_keys(
|
||||
})
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
@@ -110,6 +119,13 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -24,63 +24,95 @@ pub fn list_merged_artists(
|
||||
}
|
||||
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 (in_placeholders, in_params) = in_list_sql(servers_ordered);
|
||||
let (priority_sql, priority_params) = priority_case_sql("c.server_id", servers_ordered);
|
||||
|
||||
// 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 candidates AS (
|
||||
"WITH artist_keys AS (
|
||||
SELECT
|
||||
t.rowid AS tid,
|
||||
t.server_id,
|
||||
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
|
||||
k.artist_key,
|
||||
({priority_sql}) AS priority_rank
|
||||
MIN(k.artist_key) AS artist_key
|
||||
FROM track t
|
||||
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
|
||||
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})
|
||||
AND COALESCE(t.artist, '') != ''
|
||||
AND k.artist_key IS NOT NULL
|
||||
GROUP BY t.server_id, artist_ref
|
||||
),
|
||||
partitioned AS (
|
||||
SELECT c.tid,
|
||||
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})
|
||||
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 c.artist_key IS NULL THEN 'solo:' || c.server_id || ':' || c.artist_ref
|
||||
ELSE c.artist_key
|
||||
END AS merge_key,
|
||||
c.priority_rank
|
||||
FROM candidates c
|
||||
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 tid,
|
||||
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 partitioned
|
||||
FROM candidates
|
||||
)
|
||||
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 ?",
|
||||
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.append(&mut priority_params);
|
||||
params.append(&mut in_params);
|
||||
params.extend(in_params.iter().cloned());
|
||||
params.extend(in_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));
|
||||
|
||||
@@ -166,4 +198,28 @@ mod tests {
|
||||
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).unwrap();
|
||||
assert_eq!(resp.artists.len(), 1);
|
||||
assert_eq!(resp.artists[0].server_id, "s1");
|
||||
assert_eq!(resp.artists[0].album_count, Some(3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,7 +18,10 @@ 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,12 +5,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Check, ChevronDown, Layers } from 'lucide-react';
|
||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useClusterConnectionLed } from '../hooks/useClusterConnectionLed';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
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';
|
||||
import ClusterMergeBanner from './ClusterMergeBanner';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
@@ -35,6 +35,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
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;
|
||||
@@ -120,13 +123,19 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
};
|
||||
|
||||
const label = isLan ? 'LAN' : t('connection.extern');
|
||||
const tooltip = multi
|
||||
? t('connection.switchScopeHint')
|
||||
: 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
|
||||
@@ -157,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>
|
||||
@@ -174,7 +190,6 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{activeCluster && <ClusterMergeBanner cluster={activeCluster} />}
|
||||
{multi &&
|
||||
menuOpen &&
|
||||
typeof document !== 'undefined' &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +55,7 @@ export function PlayerTrackInfo({
|
||||
navigate, openContextMenu, t,
|
||||
}: Props) {
|
||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const playbackCoverRef = usePlaybackTrackCoverRef(
|
||||
showPreviewMeta ? null : currentTrack ?? undefined,
|
||||
);
|
||||
@@ -125,7 +127,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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function TracksPageChrome({
|
||||
<span
|
||||
className={hero.albumId ? 'track-artist-link' : ''}
|
||||
style={{ cursor: hero.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => hero.albumId && navigateToAlbum(hero.albumId)}
|
||||
onClick={() => hero.albumId && navigateToAlbum(hero.albumId, { seedServerId: hero.clusterBrowseServerId })}
|
||||
>{hero.album}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type AlbumCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
|
||||
albumId: string;
|
||||
coverArt?: string | null;
|
||||
serverScope?: CoverServerScope;
|
||||
clusterSeedServerId?: string | null;
|
||||
/** Live search: use API `coverArt` ids only (avoids library IPC per row). */
|
||||
libraryResolve?: boolean;
|
||||
};
|
||||
@@ -14,6 +15,7 @@ export function AlbumCoverArtImage({
|
||||
albumId,
|
||||
coverArt,
|
||||
serverScope,
|
||||
clusterSeedServerId,
|
||||
libraryResolve = false,
|
||||
...rest
|
||||
}: AlbumCoverArtImageProps) {
|
||||
@@ -21,7 +23,7 @@ export function AlbumCoverArtImage({
|
||||
albumId,
|
||||
coverArt,
|
||||
serverScope ?? COVER_SCOPE_ACTIVE,
|
||||
{ libraryResolve },
|
||||
{ libraryResolve, clusterSeedServerId },
|
||||
);
|
||||
if (!coverRef) return null;
|
||||
return <CoverArtImage coverRef={coverRef} {...rest} />;
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ArtistCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
|
||||
artistId: string;
|
||||
coverArt?: string | null;
|
||||
serverScope?: CoverServerScope;
|
||||
clusterSeedServerId?: string | null;
|
||||
libraryResolve?: boolean;
|
||||
};
|
||||
|
||||
@@ -13,6 +14,7 @@ export function ArtistCoverArtImage({
|
||||
artistId,
|
||||
coverArt,
|
||||
serverScope,
|
||||
clusterSeedServerId,
|
||||
libraryResolve = false,
|
||||
...rest
|
||||
}: ArtistCoverArtImageProps) {
|
||||
@@ -20,7 +22,7 @@ export function ArtistCoverArtImage({
|
||||
artistId,
|
||||
coverArt,
|
||||
serverScope ?? COVER_SCOPE_ACTIVE,
|
||||
{ libraryResolve },
|
||||
{ libraryResolve, clusterSeedServerId },
|
||||
);
|
||||
if (!coverRef) return null;
|
||||
return <CoverArtImage coverRef={coverRef} {...rest} />;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTrackCoverRef } from './useLibraryCoverRef';
|
||||
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from './types';
|
||||
|
||||
export type TrackCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
|
||||
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'>;
|
||||
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'>;
|
||||
serverScope?: CoverServerScope;
|
||||
/** Default false for browse rails; true for queue/player rows needing per-disc art. */
|
||||
libraryResolve?: boolean;
|
||||
|
||||
+45
-1
@@ -1,8 +1,11 @@
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
|
||||
import { COVER_SCOPE_ACTIVE } from './types';
|
||||
import {
|
||||
albumHasDistinctDiscCovers,
|
||||
coverEntryToRef,
|
||||
@@ -185,6 +188,23 @@ export function coverArtRef(
|
||||
return albumCoverRef(id, id, serverScope);
|
||||
}
|
||||
|
||||
/** Pin cover HTTP/disk scope to a specific library member (cluster browse rows). */
|
||||
export function coverScopeForServerProfileId(
|
||||
serverId: string | undefined | null,
|
||||
fallback: CoverServerScope = COVER_SCOPE_ACTIVE,
|
||||
): CoverServerScope {
|
||||
if (!serverId?.trim()) return fallback;
|
||||
const server = findServerByIdOrIndexKey(serverId);
|
||||
if (!server) return fallback;
|
||||
return {
|
||||
kind: 'server',
|
||||
serverId: server.id,
|
||||
url: server.url,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlaybackCoverScope(): CoverServerScope {
|
||||
const playbackSid = getPlaybackServerId();
|
||||
const activeSid = useAuthStore.getState().activeServerId;
|
||||
@@ -202,3 +222,27 @@ export function resolvePlaybackCoverScope(): CoverServerScope {
|
||||
}
|
||||
return { kind: 'playback' };
|
||||
}
|
||||
|
||||
/** Playback / queue cover scope — per-track cluster member when queue spans servers. */
|
||||
export function resolveCoverScopeForPlaybackTrack(
|
||||
track: Pick<Track, 'clusterBrowseServerId'> | null | undefined,
|
||||
queueRefServerId?: string | null,
|
||||
): CoverServerScope {
|
||||
const fromTrack = coverScopeForServerProfileId(track?.clusterBrowseServerId);
|
||||
if (fromTrack.kind === 'server') return fromTrack;
|
||||
if (queueRefServerId) {
|
||||
const sid = resolveServerIdForIndexKey(queueRefServerId);
|
||||
const fromQueue = coverScopeForServerProfileId(sid);
|
||||
if (fromQueue.kind === 'server') return fromQueue;
|
||||
}
|
||||
return resolvePlaybackCoverScope();
|
||||
}
|
||||
|
||||
/** Playback integrations (MPRIS, Discord, prewarm) — current track's cluster member. */
|
||||
export function resolvePlaybackCoverScopeForCurrentTrack(): CoverServerScope {
|
||||
const st = usePlayerStore.getState();
|
||||
return resolveCoverScopeForPlaybackTrack(
|
||||
st.currentTrack,
|
||||
st.queueItems[st.queueIndex]?.serverId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,16 @@ import {
|
||||
albumCoverRefForPlayback,
|
||||
albumCoverRefForSong,
|
||||
artistCoverRef,
|
||||
coverScopeForServerProfileId,
|
||||
resolveCoverScopeForPlaybackTrack,
|
||||
resolveDistinctDiscCoversForAlbum,
|
||||
resolvePlaybackCoverScope,
|
||||
} from './ref';
|
||||
import {
|
||||
resolveAlbumCoverRefFromLibrary,
|
||||
resolveArtistCoverRefFromLibrary,
|
||||
resolveTrackCoverRefFromLibrary,
|
||||
} from './resolveEntryLibrary';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import { COVER_SCOPE_ACTIVE, coverScopeKey, type CoverArtRef, type CoverServerScope } from './types';
|
||||
|
||||
function coverRefsEqual(a: CoverArtRef, b: CoverArtRef): boolean {
|
||||
@@ -43,8 +45,18 @@ export type LibraryCoverRefOptions = {
|
||||
* detail headers and queue rows that need per-disc slots from SQLite.
|
||||
*/
|
||||
libraryResolve?: boolean;
|
||||
/** Cluster browse row — pin cover HTTP/disk to this library member. */
|
||||
clusterSeedServerId?: string | null;
|
||||
};
|
||||
|
||||
function coverScopeWithClusterSeed(
|
||||
serverScope: CoverServerScope,
|
||||
clusterSeedServerId?: string | null,
|
||||
): CoverServerScope {
|
||||
if (!clusterSeedServerId) return serverScope;
|
||||
return coverScopeForServerProfileId(clusterSeedServerId, serverScope);
|
||||
}
|
||||
|
||||
/** Album grid / card — sync fallback, then local library index when indexed. */
|
||||
export function useAlbumCoverRef(
|
||||
albumId: string | null | undefined,
|
||||
@@ -53,7 +65,11 @@ export function useAlbumCoverRef(
|
||||
options?: LibraryCoverRefOptions,
|
||||
): CoverArtRef | null {
|
||||
const libraryResolve = options?.libraryResolve !== false;
|
||||
const scopeKey = coverScopeKey(serverScope);
|
||||
const resolvedScope = useMemo(
|
||||
() => coverScopeWithClusterSeed(serverScope, options?.clusterSeedServerId),
|
||||
[serverScope, options?.clusterSeedServerId],
|
||||
);
|
||||
const scopeKey = coverScopeKey(resolvedScope);
|
||||
const distinctDiscCovers = useMemo(
|
||||
() => resolveDistinctDiscCoversForAlbum(albumId ?? '', fallbackCoverArt),
|
||||
[albumId, fallbackCoverArt],
|
||||
@@ -61,8 +77,8 @@ export function useAlbumCoverRef(
|
||||
const syncRef = useMemo(() => {
|
||||
const id = albumId?.trim();
|
||||
if (!id) return null;
|
||||
return albumCoverRef(id, fallbackCoverArt, { serverScope, distinctDiscCovers });
|
||||
}, [albumId, fallbackCoverArt, scopeKey, serverScope, distinctDiscCovers]);
|
||||
return albumCoverRef(id, fallbackCoverArt, { serverScope: resolvedScope, distinctDiscCovers });
|
||||
}, [albumId, fallbackCoverArt, scopeKey, resolvedScope, distinctDiscCovers]);
|
||||
|
||||
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
|
||||
|
||||
@@ -72,7 +88,7 @@ export function useAlbumCoverRef(
|
||||
const id = albumId?.trim();
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => {
|
||||
void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, resolvedScope).then(next => {
|
||||
if (!cancelled) {
|
||||
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
|
||||
}
|
||||
@@ -80,7 +96,7 @@ export function useAlbumCoverRef(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
|
||||
}, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve, resolvedScope]);
|
||||
|
||||
return libraryResolve ? ref : syncRef;
|
||||
}
|
||||
@@ -93,12 +109,16 @@ export function useArtistCoverRef(
|
||||
options?: LibraryCoverRefOptions,
|
||||
): CoverArtRef | null {
|
||||
const libraryResolve = options?.libraryResolve !== false;
|
||||
const scopeKey = coverScopeKey(serverScope);
|
||||
const resolvedScope = useMemo(
|
||||
() => coverScopeWithClusterSeed(serverScope, options?.clusterSeedServerId),
|
||||
[serverScope, options?.clusterSeedServerId],
|
||||
);
|
||||
const scopeKey = coverScopeKey(resolvedScope);
|
||||
const syncRef = useMemo(() => {
|
||||
const id = artistId?.trim();
|
||||
if (!id) return null;
|
||||
return artistCoverRef(id, fallbackCoverArt, serverScope);
|
||||
}, [artistId, fallbackCoverArt, scopeKey, serverScope]);
|
||||
return artistCoverRef(id, fallbackCoverArt, resolvedScope);
|
||||
}, [artistId, fallbackCoverArt, scopeKey, resolvedScope]);
|
||||
|
||||
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
|
||||
|
||||
@@ -108,7 +128,7 @@ export function useArtistCoverRef(
|
||||
const id = artistId?.trim();
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => {
|
||||
void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, resolvedScope).then(next => {
|
||||
if (!cancelled) {
|
||||
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
|
||||
}
|
||||
@@ -116,19 +136,24 @@ export function useArtistCoverRef(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
|
||||
}, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve, resolvedScope]);
|
||||
|
||||
return libraryResolve ? ref : syncRef;
|
||||
}
|
||||
|
||||
/** Track row / song card — album-scoped; multi-CD from library when indexed. */
|
||||
export function useTrackCoverRef(
|
||||
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> | null | undefined,
|
||||
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'> | null | undefined,
|
||||
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
|
||||
options?: LibraryCoverRefOptions,
|
||||
): CoverArtRef | undefined {
|
||||
const libraryResolve = options?.libraryResolve !== false;
|
||||
const scopeKey = coverScopeKey(serverScope);
|
||||
const browseServerId = song?.clusterBrowseServerId;
|
||||
const resolvedScope = useMemo(
|
||||
() => (browseServerId ? coverScopeForServerProfileId(browseServerId, serverScope) : serverScope),
|
||||
[browseServerId, serverScope],
|
||||
);
|
||||
const scopeKey = coverScopeKey(resolvedScope);
|
||||
const songId = song?.id;
|
||||
const albumId = song?.albumId;
|
||||
const coverArt = song?.coverArt;
|
||||
@@ -151,8 +176,9 @@ export function useTrackCoverRef(
|
||||
return albumCoverRefForSong(
|
||||
{ id: songId, albumId, coverArt, discNumber },
|
||||
distinctDiscCovers,
|
||||
resolvedScope,
|
||||
);
|
||||
}, [songId, albumId, coverArt, discNumber, distinctDiscCovers]);
|
||||
}, [songId, albumId, coverArt, discNumber, distinctDiscCovers, resolvedScope]);
|
||||
|
||||
const [ref, setRef] = useState<CoverArtRef | undefined>(syncRef);
|
||||
|
||||
@@ -165,7 +191,7 @@ export function useTrackCoverRef(
|
||||
let cancelled = false;
|
||||
void resolveTrackCoverRefFromLibrary(
|
||||
{ ...song, id: trackId, albumId: al },
|
||||
serverScope,
|
||||
resolvedScope,
|
||||
distinctDiscCovers,
|
||||
).then(next => {
|
||||
if (!cancelled) {
|
||||
@@ -190,16 +216,18 @@ export function useTrackCoverRef(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers]);
|
||||
}, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers, resolvedScope]);
|
||||
|
||||
return libraryResolve ? ref : syncRef;
|
||||
}
|
||||
|
||||
/** Now playing / queue — playback server scope + library-backed multi-CD. */
|
||||
export function usePlaybackTrackCoverRef(
|
||||
track: Parameters<typeof albumCoverRefForPlayback>[0] | null | undefined,
|
||||
track: (Parameters<typeof albumCoverRefForPlayback>[0] & Pick<Track, 'clusterBrowseServerId'>) | null | undefined,
|
||||
): CoverArtRef | undefined {
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId ?? null);
|
||||
const queueLength = usePlayerStore(s => s.queueItems.length);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const serversFingerprint = useAuthStore(s =>
|
||||
@@ -209,8 +237,17 @@ export function usePlaybackTrackCoverRef(
|
||||
);
|
||||
|
||||
const scope = useMemo(
|
||||
() => resolvePlaybackCoverScope(),
|
||||
[queueServerId, queueLength, activeServerId, serversFingerprint],
|
||||
() => resolveCoverScopeForPlaybackTrack(track, queueRefServerId),
|
||||
[
|
||||
track,
|
||||
track?.clusterBrowseServerId,
|
||||
queueRefServerId,
|
||||
queueServerId,
|
||||
queueIndex,
|
||||
queueLength,
|
||||
activeServerId,
|
||||
serversFingerprint,
|
||||
],
|
||||
);
|
||||
const scopeKey = coverScopeKey(scope);
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { useMemo } from 'react';
|
||||
import { resolvePlaybackCoverScope } from './ref';
|
||||
import { resolveCoverScopeForPlaybackTrack } from './ref';
|
||||
import type { CoverArtHandle, CoverArtRef } from './types';
|
||||
import { useCoverArt } from './useCoverArt';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
|
||||
/** Cover art for playback queue — uses queue server when it differs from browsed server. */
|
||||
export function usePlaybackCoverArt(
|
||||
coverRef: CoverArtRef | undefined,
|
||||
displayCssPx: number,
|
||||
track?: Pick<Track, 'clusterBrowseServerId'> | null,
|
||||
): CoverArtHandle {
|
||||
const storeTrack = usePlayerStore(s => s.currentTrack);
|
||||
const resolvedTrack = track ?? storeTrack ?? null;
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId ?? null);
|
||||
const queueLength = usePlayerStore(s => s.queueItems.length);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const serversFingerprint = useAuthStore(s =>
|
||||
@@ -20,8 +26,17 @@ export function usePlaybackCoverArt(
|
||||
);
|
||||
|
||||
const scope = useMemo(
|
||||
() => resolvePlaybackCoverScope(),
|
||||
[queueServerId, queueLength, activeServerId, serversFingerprint],
|
||||
() => resolveCoverScopeForPlaybackTrack(resolvedTrack, queueRefServerId),
|
||||
[
|
||||
resolvedTrack,
|
||||
resolvedTrack?.clusterBrowseServerId,
|
||||
queueRefServerId,
|
||||
queueServerId,
|
||||
queueIndex,
|
||||
queueLength,
|
||||
activeServerId,
|
||||
serversFingerprint,
|
||||
],
|
||||
);
|
||||
const refWithScope = useMemo(
|
||||
() => (coverRef ? { ...coverRef, serverScope: scope } : null),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
||||
import { getArtist, getArtistInfo, getArtistForServer, getTopSongs } from '../api/subsonicArtists';
|
||||
import type {
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
|
||||
} from '../api/subsonicTypes';
|
||||
@@ -88,6 +88,15 @@ export function useArtistDetailData(
|
||||
}
|
||||
let nextAlbums = clusterData.albums;
|
||||
let nextSongs = clusterData.topSongs;
|
||||
if (nextAlbums.length === 0 && seedServerId) {
|
||||
const seeded = await getArtistForServer(seedServerId, id).catch(() => null);
|
||||
if (seeded?.albums?.length) {
|
||||
nextAlbums = seeded.albums.map(a => ({
|
||||
...a,
|
||||
clusterSeedServerId: a.clusterSeedServerId ?? seedServerId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (losslessOnly) {
|
||||
({ albums: nextAlbums, songs: nextSongs } = filterNetworkArtistToLossless(nextAlbums, nextSongs));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ConnectionStatus } from './useConnectionStatus';
|
||||
import type { ServerCluster } from '../utils/serverCluster/types';
|
||||
import {
|
||||
buildClusterMemberStatusTooltip,
|
||||
clusterLedStatusFromDiagnostics,
|
||||
getClusterMergeDiagnostics,
|
||||
type ClusterMergeDiagnostics,
|
||||
} from '../utils/serverCluster/clusterMergeStatus';
|
||||
|
||||
/** Cluster-mode LED state derived from all member probes (not representative only). */
|
||||
export function useClusterConnectionLed(activeCluster: ServerCluster | null): {
|
||||
ledStatus: ConnectionStatus | null;
|
||||
ledTooltip: string | null;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const [diag, setDiag] = useState<ClusterMergeDiagnostics | null>(null);
|
||||
const [probing, setProbing] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!activeCluster) {
|
||||
setDiag(null);
|
||||
return;
|
||||
}
|
||||
setProbing(true);
|
||||
try {
|
||||
const next = await getClusterMergeDiagnostics(activeCluster, { probeMembers: true });
|
||||
setDiag(next);
|
||||
} catch {
|
||||
setDiag(null);
|
||||
} finally {
|
||||
setProbing(false);
|
||||
}
|
||||
}, [activeCluster]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const id = window.setInterval(() => void refresh(), 60_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
if (!activeCluster) {
|
||||
return { ledStatus: null, ledTooltip: null };
|
||||
}
|
||||
|
||||
if (probing && !diag) {
|
||||
return { ledStatus: 'checking', ledTooltip: t('connection.checking') };
|
||||
}
|
||||
|
||||
const resolved = diag ?? { members: [], mergeCount: 0, totalCount: 0 };
|
||||
const ledStatus = clusterLedStatusFromDiagnostics(resolved);
|
||||
const ledTooltip = buildClusterMemberStatusTooltip(t, resolved) || null;
|
||||
return { ledStatus, ledTooltip };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { isClusterMode } from '../utils/serverCluster/clusterScope';
|
||||
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
|
||||
|
||||
/** Cluster member label for UI rows; null when not in cluster mode or server unknown. */
|
||||
export function useClusterMemberDisplayLabel(serverId: string | undefined | null): string | null {
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
return useMemo(() => {
|
||||
if (!isClusterMode() || !serverId?.trim()) return null;
|
||||
const server = servers.find(s => s.id === serverId);
|
||||
return server ? serverListDisplayLabel(server, servers) : serverId;
|
||||
}, [serverId, servers]);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
// Backward-compatible re-export for call sites that still import from the hook.
|
||||
export { isLanUrl };
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||
export type ConnectionStatus = 'connected' | 'degraded' | 'disconnected' | 'checking';
|
||||
|
||||
export function useConnectionStatus() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
|
||||
import { albumCoverRef } from '../cover/ref';
|
||||
import { resolvePlaybackCoverScope } from '../cover/ref';
|
||||
import { resolvePlaybackCoverScopeForCurrentTrack } from '../cover/ref';
|
||||
import { resolveTrackCoverRefFromLibrary } from '../cover/resolveEntryLibrary';
|
||||
import { getDiskSrc, rememberDiskSrc } from '../cover/diskSrcCache';
|
||||
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||
@@ -75,7 +75,7 @@ export function useNowPlayingPrewarm(): void {
|
||||
coverArt: currentTrack.coverArt,
|
||||
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
||||
},
|
||||
resolvePlaybackCoverScope(),
|
||||
resolvePlaybackCoverScopeForCurrentTrack(),
|
||||
).then(ref => {
|
||||
if (ref) void prewarmCoverRef(ref);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
import { getPlaybackServerId, resolveStreamServerIdForTrack } from '../utils/playback/playbackServer';
|
||||
|
||||
/**
|
||||
* Subsonic server that owns the current queue / stream (may differ from the browsed
|
||||
* server). Use for Now Playing metadata without calling `ensurePlaybackServerActive`.
|
||||
* server). When a track is playing, resolves the cluster member for that track.
|
||||
* Use for Now Playing metadata without calling `ensurePlaybackServerActive`.
|
||||
*/
|
||||
export function usePlaybackServerId(): string {
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const queueLength = usePlayerStore(s => s.queueItems.length);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
return useMemo(
|
||||
() => getPlaybackServerId(),
|
||||
[queueServerId, queueLength, activeServerId],
|
||||
() => (currentTrack
|
||||
? resolveStreamServerIdForTrack(currentTrack, queueRefServerId)
|
||||
: getPlaybackServerId()),
|
||||
[currentTrack, queueRefServerId, queueServerId, queueLength, queueIndex, activeServerId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ export const cluster = {
|
||||
fanOutRatingFailed: 'Bewertungs-Synchronisierung fehlgeschlagen auf: {{servers}}.',
|
||||
fanOutScrobbleFailed: 'Scrobble-Synchronisierung fehlgeschlagen auf: {{servers}}.',
|
||||
mergeBanner: 'Cluster-Zusammenführung schließt aus: {{excluded}}.',
|
||||
connectionTooltipPartial: '{{available}}/{{total}} Server erreichbar. {{details}}',
|
||||
connectionTooltipNone: 'Keine Cluster-Server erreichbar. {{details}}',
|
||||
memberStatusOffline: '{{name}}: offline',
|
||||
memberStatusIndexing: '{{name}}: Index läuft',
|
||||
memberStatusAvailable: '{{name}}: erreichbar',
|
||||
memberIncluded: 'Eingeschlossen',
|
||||
memberExcludedOffline: 'Ausgeschlossen (offline)',
|
||||
memberExcludedIndexing: 'Ausgeschlossen (Index läuft)',
|
||||
|
||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
||||
enableBandsintownPromptDesc: 'Optional. Lädt Tourdaten der aktuellen Künstler*in über die öffentliche Bandsintown-API.',
|
||||
enableBandsintownPrivacy: 'Beim Aktivieren wird der Name der aktuell gespielten Künstler*in an die Bandsintown-API übertragen, um Tourdaten abzurufen. Es werden keine Konto- oder persönlichen Daten gesendet.',
|
||||
enableBandsintownAction: 'Aktivieren',
|
||||
clusterServer: 'Server',
|
||||
role: {
|
||||
artist: 'Hauptkünstler*in',
|
||||
albumArtist: 'Album-Künstler*in',
|
||||
|
||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
clusterServer: 'Server',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ export const cluster = {
|
||||
fanOutRatingFailed: 'Rating sync failed on: {{servers}}.',
|
||||
fanOutScrobbleFailed: 'Scrobble sync failed on: {{servers}}.',
|
||||
mergeBanner: 'Cluster merge excludes: {{excluded}}.',
|
||||
connectionTooltipPartial: '{{available}}/{{total}} servers available. {{details}}',
|
||||
connectionTooltipNone: 'No cluster servers available. {{details}}',
|
||||
memberStatusOffline: '{{name}}: offline',
|
||||
memberStatusIndexing: '{{name}}: indexing',
|
||||
memberStatusAvailable: '{{name}}: available',
|
||||
memberIncluded: 'Included',
|
||||
memberExcludedOffline: 'Excluded (offline)',
|
||||
memberExcludedIndexing: 'Excluded (indexing)',
|
||||
|
||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
||||
enableBandsintownPromptDesc: 'Optional. Loads concerts for the current artist via the public Bandsintown API.',
|
||||
enableBandsintownPrivacy: 'When enabled, the name of the currently playing artist is sent to the Bandsintown API to fetch tour dates. No account or personal data leaves your device.',
|
||||
enableBandsintownAction: 'Enable',
|
||||
clusterServer: 'Server',
|
||||
role: {
|
||||
artist: 'Artist',
|
||||
albumArtist: 'Album artist',
|
||||
|
||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
clusterServer: 'Server',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ export const cluster = {
|
||||
fanOutRatingFailed: 'La sincronización de valoraciones falló en: {{servers}}.',
|
||||
fanOutScrobbleFailed: 'La sincronización de scrobble falló en: {{servers}}.',
|
||||
mergeBanner: 'La fusión del clúster excluye: {{excluded}}.',
|
||||
connectionTooltipPartial: '{{available}}/{{total}} servidores disponibles. {{details}}',
|
||||
connectionTooltipNone: 'Ningún servidor del clúster disponible. {{details}}',
|
||||
memberStatusOffline: '{{name}}: sin conexión',
|
||||
memberStatusIndexing: '{{name}}: indexando',
|
||||
memberStatusAvailable: '{{name}}: disponible',
|
||||
memberIncluded: 'Incluido',
|
||||
memberExcludedOffline: 'Excluido (sin conexión)',
|
||||
memberExcludedIndexing: 'Excluido (indexando)',
|
||||
|
||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
||||
enableBandsintownPromptDesc: 'Opcional. Carga conciertos del artista actual vía la API pública de Bandsintown.',
|
||||
enableBandsintownPrivacy: 'Al activar, el nombre del artista actual se envía a la API de Bandsintown para obtener fechas de gira. No se envían datos de cuenta ni personales.',
|
||||
enableBandsintownAction: 'Activar',
|
||||
clusterServer: 'Servidor',
|
||||
role: {
|
||||
artist: 'Artista',
|
||||
albumArtist: 'Artista del álbum',
|
||||
|
||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
||||
replayGainPeak: 'RG Pico de Pista',
|
||||
mono: 'Mono',
|
||||
stereo: 'Estéreo',
|
||||
clusterServer: 'Servidor',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ export const cluster = {
|
||||
fanOutRatingFailed: 'Échec de synchro des notes sur : {{servers}}.',
|
||||
fanOutScrobbleFailed: 'Échec de synchro des scrobbles sur : {{servers}}.',
|
||||
mergeBanner: 'La fusion de cluster exclut : {{excluded}}.',
|
||||
connectionTooltipPartial: '{{available}}/{{total}} serveurs disponibles. {{details}}',
|
||||
connectionTooltipNone: 'Aucun serveur du cluster disponible. {{details}}',
|
||||
memberStatusOffline: '{{name}} : hors ligne',
|
||||
memberStatusIndexing: '{{name}} : indexation',
|
||||
memberStatusAvailable: '{{name}} : disponible',
|
||||
memberIncluded: 'Inclus',
|
||||
memberExcludedOffline: 'Exclu (hors ligne)',
|
||||
memberExcludedIndexing: 'Exclu (indexation)',
|
||||
|
||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
||||
enableBandsintownPromptDesc: 'Optionnel. Charge les concerts de l\'artiste via l\'API publique Bandsintown.',
|
||||
enableBandsintownPrivacy: 'Lors de l\'activation, le nom de l\'artiste en cours de lecture est envoyé à l\'API Bandsintown pour récupérer les dates de tournée. Aucun compte ni données personnelles ne quittent votre appareil.',
|
||||
enableBandsintownAction: 'Activer',
|
||||
clusterServer: 'Serveur',
|
||||
role: {
|
||||
artist: 'Artiste',
|
||||
albumArtist: 'Artiste de l\'album',
|
||||
|
||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
||||
replayGainPeak: 'RG Crête de piste',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stéréo',
|
||||
clusterServer: 'Serveur',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ export const cluster = {
|
||||
fanOutRatingFailed: 'Vurderings-synk mislyktes på: {{servers}}.',
|
||||
fanOutScrobbleFailed: 'Scrobble-synk mislyktes på: {{servers}}.',
|
||||
mergeBanner: 'Cluster-fletting ekskluderer: {{excluded}}.',
|
||||
connectionTooltipPartial: '{{available}}/{{total}} servere tilgjengelige. {{details}}',
|
||||
connectionTooltipNone: 'Ingen cluster-servere tilgjengelige. {{details}}',
|
||||
memberStatusOffline: '{{name}}: offline',
|
||||
memberStatusIndexing: '{{name}}: indeksering',
|
||||
memberStatusAvailable: '{{name}}: tilgjengelig',
|
||||
memberIncluded: 'Inkludert',
|
||||
memberExcludedOffline: 'Ekskludert (offline)',
|
||||
memberExcludedIndexing: 'Ekskludert (indeksering)',
|
||||
|
||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
||||
enableBandsintownPromptDesc: 'Valgfritt. Henter konserter for gjeldende artist via det offentlige Bandsintown-API-et.',
|
||||
enableBandsintownPrivacy: 'Ved aktivering sendes navnet på artisten som spilles av til Bandsintown-API-et for å hente turnédatoer. Ingen konto- eller personopplysninger forlater enheten din.',
|
||||
enableBandsintownAction: 'Aktiver',
|
||||
clusterServer: 'Server',
|
||||
role: {
|
||||
artist: 'Artist',
|
||||
albumArtist: 'Albumartist',
|
||||
|
||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
||||
replayGainPeak: 'RG-sportopp',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
clusterServer: 'Server',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ export const cluster = {
|
||||
fanOutRatingFailed: 'Beoordelings-sync mislukt op: {{servers}}.',
|
||||
fanOutScrobbleFailed: 'Scrobble-sync mislukt op: {{servers}}.',
|
||||
mergeBanner: 'Cluster-samenvoeging sluit uit: {{excluded}}.',
|
||||
connectionTooltipPartial: '{{available}}/{{total}} servers beschikbaar. {{details}}',
|
||||
connectionTooltipNone: 'Geen clusterservers beschikbaar. {{details}}',
|
||||
memberStatusOffline: '{{name}}: offline',
|
||||
memberStatusIndexing: '{{name}}: indexeren',
|
||||
memberStatusAvailable: '{{name}}: beschikbaar',
|
||||
memberIncluded: 'Inbegrepen',
|
||||
memberExcludedOffline: 'Uitgesloten (offline)',
|
||||
memberExcludedIndexing: 'Uitgesloten (indexeren)',
|
||||
|
||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
||||
enableBandsintownPromptDesc: 'Optioneel. Laadt concerten van de huidige artiest via de openbare Bandsintown-API.',
|
||||
enableBandsintownPrivacy: 'Bij inschakelen wordt de naam van de huidige artiest naar de Bandsintown-API gestuurd om tourdata op te halen. Er worden geen account- of persoonlijke gegevens verzonden.',
|
||||
enableBandsintownAction: 'Inschakelen',
|
||||
clusterServer: 'Server',
|
||||
role: {
|
||||
artist: 'Artiest',
|
||||
albumArtist: 'Albumartiest',
|
||||
|
||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
||||
replayGainPeak: 'RG Track Peak',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
clusterServer: 'Server',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ export const cluster = {
|
||||
fanOutRatingFailed: 'Sincronizarea rating-urilor a eșuat pe: {{servers}}.',
|
||||
fanOutScrobbleFailed: 'Sincronizarea scrobble a eșuat pe: {{servers}}.',
|
||||
mergeBanner: 'Îmbinarea clusterului exclude: {{excluded}}.',
|
||||
connectionTooltipPartial: '{{available}}/{{total}} servere disponibile. {{details}}',
|
||||
connectionTooltipNone: 'Niciun server din cluster disponibil. {{details}}',
|
||||
memberStatusOffline: '{{name}}: offline',
|
||||
memberStatusIndexing: '{{name}}: indexare',
|
||||
memberStatusAvailable: '{{name}}: disponibil',
|
||||
memberIncluded: 'Inclus',
|
||||
memberExcludedOffline: 'Exclus (offline)',
|
||||
memberExcludedIndexing: 'Exclus (indexare)',
|
||||
|
||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
||||
enableBandsintownPromptDesc: 'Opțional. Încarcă concertele pentru artistul curent prin API-ul public Bandsintown.',
|
||||
enableBandsintownPrivacy: 'Când este pornit, numele artistului piesei curente redate este trimis către API-ul Bandsintown pentru a prelua datele turneelor. Nimic din datele personale sau ale contului nu părăsesc dispozitivul.',
|
||||
enableBandsintownAction: 'Pornește',
|
||||
clusterServer: 'Server',
|
||||
role: {
|
||||
artist: 'Artist',
|
||||
albumArtist: 'Artist album',
|
||||
|
||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
||||
replayGainPeak: 'RG Vârf Piesă',
|
||||
mono: 'Mono',
|
||||
stereo: 'Stereo',
|
||||
clusterServer: 'Server',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ export const cluster = {
|
||||
fanOutRatingFailed: 'Не удалось синхронизировать рейтинг на: {{servers}}.',
|
||||
fanOutScrobbleFailed: 'Не удалось синхронизировать скроббл на: {{servers}}.',
|
||||
mergeBanner: 'Из объединения кластера исключены: {{excluded}}.',
|
||||
connectionTooltipPartial: 'Доступно {{available}} из {{total}} серверов. {{details}}',
|
||||
connectionTooltipNone: 'Нет доступных серверов кластера. {{details}}',
|
||||
memberStatusOffline: '{{name}}: офлайн',
|
||||
memberStatusIndexing: '{{name}}: индексация',
|
||||
memberStatusAvailable: '{{name}}: доступен',
|
||||
memberIncluded: 'Включён',
|
||||
memberExcludedOffline: 'Исключён (офлайн)',
|
||||
memberExcludedIndexing: 'Исключён (индексация)',
|
||||
|
||||
@@ -18,6 +18,7 @@ export const nowPlayingInfo = {
|
||||
enableBandsintownPromptDesc: 'Опционально. Загружает концерты текущего исполнителя через публичный API Bandsintown.',
|
||||
enableBandsintownPrivacy: 'При включении имя текущего исполнителя отправляется в API Bandsintown для получения дат концертов. Аккаунт и личные данные не передаются.',
|
||||
enableBandsintownAction: 'Включить',
|
||||
clusterServer: 'Сервер',
|
||||
role: {
|
||||
artist: 'Исполнитель',
|
||||
albumArtist: 'Исполнитель альбома',
|
||||
|
||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
||||
replayGainPeak: 'Пик RG',
|
||||
mono: 'Моно',
|
||||
stereo: 'Стерео',
|
||||
clusterServer: 'Сервер',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ export const cluster = {
|
||||
fanOutRatingFailed: '以下服务器评分同步失败:{{servers}}。',
|
||||
fanOutScrobbleFailed: '以下服务器 scrobble 同步失败:{{servers}}。',
|
||||
mergeBanner: '集群合并已排除:{{excluded}}。',
|
||||
connectionTooltipPartial: '{{available}}/{{total}} 个服务器可用。{{details}}',
|
||||
connectionTooltipNone: '没有可用的集群服务器。{{details}}',
|
||||
memberStatusOffline: '{{name}}:离线',
|
||||
memberStatusIndexing: '{{name}}:索引中',
|
||||
memberStatusAvailable: '{{name}}:可用',
|
||||
memberIncluded: '已纳入',
|
||||
memberExcludedOffline: '已排除(离线)',
|
||||
memberExcludedIndexing: '已排除(索引中)',
|
||||
|
||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
||||
enableBandsintownPromptDesc: '可选。通过公开的 Bandsintown API 加载当前艺术家的演出。',
|
||||
enableBandsintownPrivacy: '启用后,当前播放的艺术家名称将发送到 Bandsintown API 以获取巡演日期。不会发送任何账户或个人数据。',
|
||||
enableBandsintownAction: '启用',
|
||||
clusterServer: '服务器',
|
||||
role: {
|
||||
artist: '艺术家',
|
||||
albumArtist: '专辑艺术家',
|
||||
|
||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
||||
replayGainPeak: 'RG 曲目峰值',
|
||||
mono: '单声道',
|
||||
stereo: '立体声',
|
||||
clusterServer: '服务器',
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useParams, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -26,8 +26,10 @@ import { useCoverArt } from '../cover/useCoverArt';
|
||||
import {
|
||||
forgetAlbumDistinctDiscCovers,
|
||||
rememberAlbumDistinctDiscCovers,
|
||||
coverScopeForServerProfileId,
|
||||
} from '../cover/ref';
|
||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { readClusterSeedServerId } from '../utils/navigation/albumDetailNavigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useSelectionStore } from '../store/selectionStore';
|
||||
@@ -45,6 +47,7 @@ export default function AlbumDetail() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const losslessOnly = isLosslessMode(searchParams);
|
||||
const auth = useAuthStore();
|
||||
@@ -104,8 +107,10 @@ export default function AlbumDetail() {
|
||||
const handlePlayAll = () => {
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const seedSid = album.album.clusterSeedServerId;
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
});
|
||||
@@ -115,8 +120,10 @@ const handlePlayAll = () => {
|
||||
const handleEnqueueAll = () => {
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const seedSid = album.album.clusterSeedServerId;
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
});
|
||||
@@ -126,8 +133,10 @@ const handleEnqueueAll = () => {
|
||||
const handleShuffleAll = () => {
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const seedSid = album.album.clusterSeedServerId;
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
});
|
||||
@@ -141,12 +150,15 @@ const handleShuffleAll = () => {
|
||||
if (orbitActive) { queueHint(); return; }
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const seedSid = album.album.clusterSeedServerId;
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
});
|
||||
const track = tracks.find(t => t.id === song.id) || songToTrack(song);
|
||||
if (!track.clusterBrowseServerId && seedSid) track.clusterBrowseServerId = seedSid;
|
||||
playTrack(track, tracks);
|
||||
};
|
||||
|
||||
@@ -281,11 +293,17 @@ const handleShuffleAll = () => {
|
||||
userRatingOverrides,
|
||||
});
|
||||
|
||||
const albumSeedServerId = album?.album.clusterSeedServerId ?? readClusterSeedServerId(location.state);
|
||||
const albumCoverScope = useMemo(
|
||||
() => coverScopeForServerProfileId(albumSeedServerId),
|
||||
[albumSeedServerId],
|
||||
);
|
||||
|
||||
const albumCoverRefResolved = useAlbumCoverRef(
|
||||
album?.album.id,
|
||||
album?.album.coverArt,
|
||||
undefined,
|
||||
{ libraryResolve: true },
|
||||
{ libraryResolve: true, clusterSeedServerId: albumSeedServerId },
|
||||
);
|
||||
const albumCover = useCoverArt(albumCoverRefResolved, 400, { surface: 'sparse' });
|
||||
const resolvedCoverUrl = albumCover.src || null;
|
||||
@@ -318,6 +336,7 @@ const handleShuffleAll = () => {
|
||||
headerArtistRefs={headerArtistRefs}
|
||||
songs={songs}
|
||||
coverArtId={info.coverArt}
|
||||
coverServerScope={albumCoverScope}
|
||||
resolvedCoverUrl={resolvedCoverUrl}
|
||||
isStarred={isStarred}
|
||||
downloadProgress={null}
|
||||
|
||||
@@ -2,9 +2,7 @@ import { uploadArtistImage } from '../api/subsonicPlaylists';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
@@ -33,6 +31,8 @@ import {
|
||||
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
||||
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
||||
import {
|
||||
buildArtistTopSongPlayQueue,
|
||||
fetchArtistCatalogTracks,
|
||||
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
|
||||
} from '../utils/componentHelpers/runArtistDetailPlay';
|
||||
import {
|
||||
@@ -137,29 +137,12 @@ export default function ArtistDetail() {
|
||||
};
|
||||
|
||||
const playTopSongWithContinuation = async (startIndex: number) => {
|
||||
if (!artist || albums.length === 0) return;
|
||||
if (!artist || topSongs.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
// Get all artist tracks ordered by album and track number
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
const allTracks = sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||
|
||||
// Top songs from clicked index onward
|
||||
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
|
||||
|
||||
// Track IDs for deduplication
|
||||
const topSongIds = new Set(topSongs.map(s => s.id));
|
||||
|
||||
// Filter remaining tracks to exclude top songs (prevent duplicates)
|
||||
const remainingTracks = allTracks.filter(tr => !topSongIds.has(tr.id));
|
||||
|
||||
// Build queue: remaining top songs + rest of artist catalog
|
||||
const queue = [...topTracksFromIndex, ...remainingTracks];
|
||||
|
||||
if (queue.length > 0) {
|
||||
playTrack(queue[0], queue);
|
||||
}
|
||||
const catalogTracks = albums.length > 0 ? await fetchArtistCatalogTracks(albums) : [];
|
||||
const queue = buildArtistTopSongPlayQueue(topSongs, startIndex, catalogTracks);
|
||||
if (queue.length > 0) playTrack(queue[0], queue);
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
@@ -173,6 +156,7 @@ export default function ArtistDetail() {
|
||||
const coverId = artist ? (artist.coverArt || artist.id) : '';
|
||||
const artistCoverRefResolved = useArtistCoverRef(artist?.id, artist?.coverArt, undefined, {
|
||||
libraryResolve: true,
|
||||
clusterSeedServerId: artist?.clusterSeedServerId,
|
||||
});
|
||||
const artistCoverFallback = useCoverArt(artistCoverRefResolved, 80, { surface: 'sparse' });
|
||||
|
||||
@@ -346,6 +330,7 @@ export default function ArtistDetail() {
|
||||
marginTop={sectionMt('topTracks')}
|
||||
playTopSongWithContinuation={playTopSongWithContinuation}
|
||||
losslessOnly={losslessOnly}
|
||||
clusterSeedServerId={artist.clusterSeedServerId}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { resolvePlaybackCoverScope } from '../../cover/ref';
|
||||
import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref';
|
||||
import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary';
|
||||
import { coverArtUrlForDiscord } from '../../cover/integrations/discord';
|
||||
import { useAuthStore } from '../authStore';
|
||||
@@ -96,7 +96,7 @@ export function setupDiscordPresence(): () => void {
|
||||
coverArt: currentTrack.coverArt,
|
||||
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
||||
},
|
||||
resolvePlaybackCoverScope(),
|
||||
resolvePlaybackCoverScopeForCurrentTrack(),
|
||||
).then(ref => {
|
||||
if (!ref) {
|
||||
sendPresence(null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { resolvePlaybackCoverScope } from '../../cover/ref';
|
||||
import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref';
|
||||
import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary';
|
||||
import { coverArtUrlForMpris } from '../../cover/integrations/mpris';
|
||||
import { usePlayerStore } from '../playerStore';
|
||||
@@ -35,7 +35,7 @@ export function setupMprisSync(): () => void {
|
||||
coverArt: currentTrack.coverArt,
|
||||
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
||||
},
|
||||
resolvePlaybackCoverScope(),
|
||||
resolvePlaybackCoverScopeForCurrentTrack(),
|
||||
).then(ref => {
|
||||
if (!ref) return;
|
||||
coverArtUrlForMpris(ref)
|
||||
|
||||
@@ -266,7 +266,10 @@ export function runPlayTrack(
|
||||
&& getPlaybackCacheServerKey(),
|
||||
);
|
||||
|
||||
const browseServerId = authState.activeServerId ?? '';
|
||||
const browseServerId =
|
||||
track.clusterBrowseServerId
|
||||
?? authState.activeServerId
|
||||
?? '';
|
||||
const browseTrackId = track.id;
|
||||
|
||||
void (async () => {
|
||||
@@ -276,7 +279,11 @@ export function runPlayTrack(
|
||||
if (isClusterMode() && browseServerId) {
|
||||
const resolved = await resolveClusterPlaybackForTrack(browseServerId, track.id);
|
||||
if (resolved) {
|
||||
playTrackResolved = { ...track, id: resolved.trackId };
|
||||
playTrackResolved = {
|
||||
...track,
|
||||
id: resolved.trackId,
|
||||
clusterBrowseServerId: resolved.serverId,
|
||||
};
|
||||
streamServerProfileId = resolved.serverId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,11 +112,25 @@ describe('openContextMenu / closeContextMenu', () => {
|
||||
|
||||
describe('openSongInfo / closeSongInfo', () => {
|
||||
it('opens with the song id and clears on close', () => {
|
||||
useAuthStore.setState({ activeServerId: 'a' });
|
||||
usePlayerStore.getState().openSongInfo('song-1');
|
||||
expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: true, songId: 'song-1' });
|
||||
expect(usePlayerStore.getState().songInfoModal).toEqual({
|
||||
isOpen: true,
|
||||
songId: 'song-1',
|
||||
serverId: 'a',
|
||||
});
|
||||
|
||||
usePlayerStore.getState().closeSongInfo();
|
||||
expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: false, songId: null });
|
||||
expect(usePlayerStore.getState().songInfoModal).toEqual({
|
||||
isOpen: false,
|
||||
songId: null,
|
||||
serverId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores explicit cluster member server id', () => {
|
||||
usePlayerStore.getState().openSongInfo('song-2', 'b');
|
||||
expect(usePlayerStore.getState().songInfoModal.serverId).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
scheduledResumeStartMs: null,
|
||||
repeatMode: initialPlayerPrefs.repeatMode,
|
||||
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
|
||||
songInfoModal: { isOpen: false, songId: null },
|
||||
songInfoModal: { isOpen: false, songId: null, serverId: null },
|
||||
|
||||
...createUiStateActions(set),
|
||||
...createLastfmActions(set, get),
|
||||
|
||||
@@ -227,7 +227,7 @@ export interface PlayerState {
|
||||
) => void;
|
||||
closeContextMenu: () => void;
|
||||
|
||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
||||
openSongInfo: (songId: string) => void;
|
||||
songInfoModal: { isOpen: boolean; songId: string | null; serverId: string | null };
|
||||
openSongInfo: (songId: string, serverId?: string | null) => void;
|
||||
closeSongInfo: () => void;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import type { PlayerState } from './playerStoreTypes';
|
||||
import {
|
||||
ensurePlaybackServerActive,
|
||||
playbackServerDiffersFromActive,
|
||||
resolveStreamServerIdForTrack,
|
||||
} from '../utils/playback/playbackServer';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
@@ -77,8 +81,24 @@ export function createUiStateActions(set: SetState): Pick<
|
||||
contextMenu: { ...state.contextMenu, isOpen: false },
|
||||
})),
|
||||
|
||||
openSongInfo: (songId) => set({ songInfoModal: { isOpen: true, songId } }),
|
||||
closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null } }),
|
||||
openSongInfo: (songId, serverId) => {
|
||||
let sid: string | null = null;
|
||||
if (serverId?.trim()) {
|
||||
sid = resolveServerIdForIndexKey(serverId) || serverId;
|
||||
} else {
|
||||
const st = usePlayerStore.getState();
|
||||
if (st.currentTrack?.id === songId) {
|
||||
sid = resolveStreamServerIdForTrack(
|
||||
st.currentTrack,
|
||||
st.queueItems[st.queueIndex]?.serverId,
|
||||
);
|
||||
} else {
|
||||
sid = useAuthStore.getState().activeServerId ?? null;
|
||||
}
|
||||
}
|
||||
set({ songInfoModal: { isOpen: true, songId, serverId: sid } });
|
||||
},
|
||||
closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null, serverId: null } }),
|
||||
|
||||
toggleQueue: () =>
|
||||
set(state => {
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
box-shadow: 0 0 6px 2px rgba(166, 227, 161, 0.55);
|
||||
}
|
||||
|
||||
.connection-led--degraded {
|
||||
background: #f9e2af;
|
||||
box-shadow: 0 0 6px 2px rgba(249, 226, 175, 0.55);
|
||||
}
|
||||
|
||||
.connection-led--disconnected {
|
||||
background: #f38ba8;
|
||||
box-shadow: 0 0 6px 2px rgba(243, 139, 168, 0.55);
|
||||
|
||||
@@ -599,6 +599,12 @@ html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-c
|
||||
letter-spacing: -0.01em;
|
||||
text-align: left;
|
||||
}
|
||||
.np-info-cluster-server {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
.np-info-artist-bio {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import {
|
||||
buildArtistTopSongPlayQueue,
|
||||
fetchArtistCatalogTracks,
|
||||
} from './runArtistDetailPlay';
|
||||
|
||||
vi.mock('../../api/subsonicLibrary', () => ({
|
||||
getAlbum: vi.fn(),
|
||||
getAlbumForServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getAlbum, getAlbumForServer } from '../../api/subsonicLibrary';
|
||||
|
||||
const mockGetAlbum = vi.mocked(getAlbum);
|
||||
const mockGetAlbumForServer = vi.mocked(getAlbumForServer);
|
||||
|
||||
describe('fetchArtistCatalogTracks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses getAlbumForServer when clusterSeedServerId is set', async () => {
|
||||
const albums: SubsonicAlbum[] = [
|
||||
{ id: 'a1', name: 'Album', artist: 'A', artistId: 'ar1', songCount: 1, duration: 100, clusterSeedServerId: 'srv-1' },
|
||||
];
|
||||
mockGetAlbumForServer.mockResolvedValue({
|
||||
album: { id: 'a1', name: 'Album', artist: 'A', artistId: 'ar1', songCount: 1, duration: 100, year: 2020 },
|
||||
songs: [{ id: 't1', title: 'Track 1', artist: 'A', artistId: 'ar1', album: 'Album', albumId: 'a1', track: 1, duration: 100 }],
|
||||
});
|
||||
|
||||
const tracks = await fetchArtistCatalogTracks(albums);
|
||||
|
||||
expect(mockGetAlbumForServer).toHaveBeenCalledWith('srv-1', 'a1');
|
||||
expect(mockGetAlbum).not.toHaveBeenCalled();
|
||||
expect(tracks).toHaveLength(1);
|
||||
expect(tracks[0].id).toBe('t1');
|
||||
expect(tracks[0].clusterBrowseServerId).toBe('srv-1');
|
||||
});
|
||||
|
||||
it('uses getAlbum when no clusterSeedServerId', async () => {
|
||||
const albums: SubsonicAlbum[] = [
|
||||
{ id: 'a2', name: 'Album 2', artist: 'A', artistId: 'ar1', songCount: 1, duration: 100 },
|
||||
];
|
||||
mockGetAlbum.mockResolvedValue({
|
||||
album: { id: 'a2', name: 'Album 2', artist: 'A', artistId: 'ar1', songCount: 1, duration: 100, year: 2019 },
|
||||
songs: [{ id: 't2', title: 'Track 2', artist: 'A', artistId: 'ar1', album: 'Album 2', albumId: 'a2', track: 1, duration: 100 }],
|
||||
});
|
||||
|
||||
const tracks = await fetchArtistCatalogTracks(albums);
|
||||
|
||||
expect(mockGetAlbum).toHaveBeenCalledWith('a2');
|
||||
expect(mockGetAlbumForServer).not.toHaveBeenCalled();
|
||||
expect(tracks[0].id).toBe('t2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildArtistTopSongPlayQueue', () => {
|
||||
const topSongs: SubsonicSong[] = [
|
||||
{ id: 'top1', title: 'Top 1', artist: 'A', artistId: 'ar1', album: 'X', albumId: 'ax', duration: 100, clusterBrowseServerId: 's1' },
|
||||
{ id: 'top2', title: 'Top 2', artist: 'A', artistId: 'ar1', album: 'Y', albumId: 'ay', duration: 100, clusterBrowseServerId: 's1' },
|
||||
];
|
||||
|
||||
it('returns top songs from index when catalog is empty', () => {
|
||||
const queue = buildArtistTopSongPlayQueue(topSongs, 1, []);
|
||||
expect(queue.map(t => t.id)).toEqual(['top2']);
|
||||
});
|
||||
|
||||
it('appends catalog tracks excluding top song ids', () => {
|
||||
const catalog = [
|
||||
{ id: 'top1', title: 'Top 1', artist: 'A', artistId: 'ar1', album: 'X', albumId: 'ax', duration: 100 },
|
||||
{ id: 'other', title: 'Other', artist: 'A', artistId: 'ar1', album: 'Z', albumId: 'az', duration: 100 },
|
||||
];
|
||||
const queue = buildArtistTopSongPlayQueue(topSongs, 0, catalog);
|
||||
expect(queue.map(t => t.id)).toEqual(['top1', 'top2', 'other']);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,49 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getAlbum, getAlbumForServer } from '../../api/subsonicLibrary';
|
||||
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { songToTrack } from '../playback/songToTrack';
|
||||
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
||||
|
||||
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||
async function fetchAlbumTracks(album: SubsonicAlbum): Promise<{ year: number; tracks: Track[] }> {
|
||||
const serverId = album.clusterSeedServerId;
|
||||
const data = serverId
|
||||
? await getAlbumForServer(serverId, album.id)
|
||||
: await getAlbum(album.id);
|
||||
const genre = data.album.genre ?? album.genre;
|
||||
const tracks = [...data.songs]
|
||||
.sort((a, b) => (a.track ?? 0) - (b.track ?? 0))
|
||||
.map(s => {
|
||||
const track = songToTrack(
|
||||
serverId ? { ...s, clusterBrowseServerId: s.clusterBrowseServerId ?? serverId } : s,
|
||||
);
|
||||
if (!track.genre && genre) track.genre = genre;
|
||||
return track;
|
||||
});
|
||||
return { year: data.album.year ?? album.year ?? 0, tracks };
|
||||
}
|
||||
|
||||
/** All album tracks for artist Play All / shuffle (cluster-aware per album). */
|
||||
export async function fetchArtistCatalogTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
||||
if (albums.length === 0) return [];
|
||||
const parts = await Promise.all(albums.map(fetchAlbumTracks));
|
||||
return [...parts]
|
||||
.sort((a, b) => a.year - b.year)
|
||||
.flatMap(p => p.tracks);
|
||||
}
|
||||
|
||||
/** Top-song click queue: from index through top list, then rest of catalog without dupes. */
|
||||
export function buildArtistTopSongPlayQueue(
|
||||
topSongs: SubsonicSong[],
|
||||
startIndex: number,
|
||||
catalogTracks: Track[],
|
||||
): Track[] {
|
||||
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
|
||||
if (catalogTracks.length === 0) return topTracksFromIndex;
|
||||
const topSongIds = new Set(topSongs.map(s => s.id));
|
||||
const remaining = catalogTracks.filter(tr => !topSongIds.has(tr.id));
|
||||
return [...topTracksFromIndex, ...remaining];
|
||||
}
|
||||
|
||||
export interface RunArtistDetailPlayDeps {
|
||||
@@ -21,13 +55,21 @@ export interface RunArtistDetailPlayDeps {
|
||||
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
await runBulkPlayAll({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
||||
await runBulkPlayAll({
|
||||
fetchTracks: () => fetchArtistCatalogTracks(albums),
|
||||
setLoading: setPlayAllLoading,
|
||||
playTrack,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
await runBulkShuffle({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
||||
await runBulkShuffle({
|
||||
fetchTracks: () => fetchArtistCatalogTracks(albums),
|
||||
setLoading: setPlayAllLoading,
|
||||
playTrack,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RunArtistDetailStartRadioDeps {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { listen, emitTo } from '@tauri-apps/api/event';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { resolveQueueTrack } from './library/queueTrackView';
|
||||
import { getCurrentTrackStreamServerId } from './playback/playbackServer';
|
||||
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||
|
||||
export const MINI_WINDOW_LABEL = 'mini';
|
||||
@@ -266,7 +267,9 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
||||
w.unminimize().catch(() => {});
|
||||
w.show().catch(() => {});
|
||||
w.setFocus().catch(() => {});
|
||||
usePlayerStore.getState().openSongInfo(id);
|
||||
const st = usePlayerStore.getState();
|
||||
const serverId = st.currentTrack?.id === id ? getCurrentTrackStreamServerId() : undefined;
|
||||
st.openSongInfo(id, serverId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
bindQueueServerForPlayback,
|
||||
clearQueueServerForPlayback,
|
||||
ensurePlaybackServerActive,
|
||||
getCurrentTrackStreamServerId,
|
||||
getPlaybackServerId,
|
||||
playbackCoverArtForId,
|
||||
playbackServerDiffersFromActive,
|
||||
prepareActiveServerForNewMix,
|
||||
resolveStreamServerIdForTrack,
|
||||
shouldBindQueueServerForPlay,
|
||||
shouldHandoffQueueToActiveServer,
|
||||
} from './playbackServer';
|
||||
@@ -48,6 +50,28 @@ describe('playbackServer', () => {
|
||||
expect(getPlaybackServerId()).toBe('b');
|
||||
});
|
||||
|
||||
it('resolveStreamServerIdForTrack prefers clusterBrowseServerId over queue pin', () => {
|
||||
usePlayerStore.setState({
|
||||
queueItems: [{ serverId: 'a', trackId: 't1' }],
|
||||
queueServerId: 'a',
|
||||
queueIndex: 0,
|
||||
currentTrack: {
|
||||
id: 't1',
|
||||
title: 'T',
|
||||
artist: 'A',
|
||||
album: 'Al',
|
||||
albumId: 'al1',
|
||||
duration: 100,
|
||||
clusterBrowseServerId: 'b',
|
||||
},
|
||||
});
|
||||
expect(resolveStreamServerIdForTrack(
|
||||
usePlayerStore.getState().currentTrack,
|
||||
'a',
|
||||
)).toBe('b');
|
||||
expect(getCurrentTrackStreamServerId()).toBe('b');
|
||||
});
|
||||
|
||||
it('bindQueueServerForPlayback pins active server as canonical index key', () => {
|
||||
useAuthStore.setState({ activeServerId: 'b' });
|
||||
bindQueueServerForPlayback();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildCoverArtFetchUrl } from '../../cover/fetchUrl';
|
||||
import { resolvePlaybackCoverScope } from '../../cover/ref';
|
||||
import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref';
|
||||
import { coverEntryToRef, resolveAlbumCoverEntry } from '../../cover/resolveEntry';
|
||||
import { coverStorageKeyFromRef } from '../../cover/storageKeys';
|
||||
import { resolveCoverDisplayTier } from '../../cover/tiers';
|
||||
@@ -15,6 +15,31 @@ import {
|
||||
serverIndexKeyFromUrl,
|
||||
} from '../server/serverIndexKey';
|
||||
|
||||
/** Per-track streaming server in cluster mixed queues (track ref → queue ref → queue pin). */
|
||||
export function resolveStreamServerIdForTrack(
|
||||
track: Pick<Track, 'clusterBrowseServerId'> | null | undefined,
|
||||
queueRefServerId?: string | null,
|
||||
): string {
|
||||
if (track?.clusterBrowseServerId?.trim()) {
|
||||
const sid = resolveServerIdForIndexKey(track.clusterBrowseServerId);
|
||||
if (sid) return sid;
|
||||
}
|
||||
if (queueRefServerId) {
|
||||
const sid = resolveServerIdForIndexKey(queueRefServerId);
|
||||
if (sid) return sid;
|
||||
}
|
||||
return getPlaybackServerId();
|
||||
}
|
||||
|
||||
/** Subsonic server that owns the currently playing track (cluster-aware). */
|
||||
export function getCurrentTrackStreamServerId(): string {
|
||||
const st = usePlayerStore.getState();
|
||||
return resolveStreamServerIdForTrack(
|
||||
st.currentTrack,
|
||||
st.queueItems[st.queueIndex]?.serverId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Server that owns the current queue / stream URLs (may differ from the browsed server). */
|
||||
export function getPlaybackServerId(): string {
|
||||
const { queueServerId, queueItems } = usePlayerStore.getState();
|
||||
@@ -132,7 +157,7 @@ export function playbackCoverArtForAlbum(
|
||||
if (!entry) {
|
||||
return playbackCoverArtForId(coverArt, displayCssPx);
|
||||
}
|
||||
const ref = coverEntryToRef(entry, resolvePlaybackCoverScope());
|
||||
const ref = coverEntryToRef(entry, resolvePlaybackCoverScopeForCurrentTrack());
|
||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'sparse' });
|
||||
return {
|
||||
src: buildCoverArtFetchUrl(ref, tier),
|
||||
|
||||
@@ -60,6 +60,11 @@ describe('songToTrack', () => {
|
||||
expect(t.coverArt).toBe('s2');
|
||||
});
|
||||
|
||||
it('preserves clusterBrowseServerId for cluster-mode playback and covers', () => {
|
||||
const song = makeSubsonicSong({ id: 's3', clusterBrowseServerId: 'server-b' });
|
||||
expect(songToTrack(song).clusterBrowseServerId).toBe('server-b');
|
||||
});
|
||||
|
||||
it('flattens replayGain into replayGainTrackDb / AlbumDb / Peak', () => {
|
||||
const song = makeSubsonicSong({
|
||||
replayGain: {
|
||||
|
||||
@@ -25,5 +25,6 @@ export function songToTrack(song: SubsonicSong): Track {
|
||||
samplingRate: song.samplingRate,
|
||||
bitDepth: song.bitDepth,
|
||||
size: song.size,
|
||||
clusterBrowseServerId: song.clusterBrowseServerId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
buildClusterConnectionTooltip,
|
||||
buildClusterMemberStatusTooltip,
|
||||
clusterLedStatusFromDiagnostics,
|
||||
type ClusterMergeDiagnostics,
|
||||
} from './clusterMergeStatus';
|
||||
|
||||
describe('clusterLedStatusFromDiagnostics', () => {
|
||||
it('returns disconnected when no members merge', () => {
|
||||
const diag: ClusterMergeDiagnostics = {
|
||||
members: [{ serverId: 'a', label: 'A', included: false, reason: 'unreachable' }],
|
||||
mergeCount: 0,
|
||||
totalCount: 1,
|
||||
};
|
||||
expect(clusterLedStatusFromDiagnostics(diag)).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('returns degraded when some members merge', () => {
|
||||
const diag: ClusterMergeDiagnostics = {
|
||||
members: [
|
||||
{ serverId: 'a', label: 'A', included: true },
|
||||
{ serverId: 'b', label: 'B', included: false, reason: 'unreachable' },
|
||||
],
|
||||
mergeCount: 1,
|
||||
totalCount: 2,
|
||||
};
|
||||
expect(clusterLedStatusFromDiagnostics(diag)).toBe('degraded');
|
||||
});
|
||||
|
||||
it('returns connected when all members merge', () => {
|
||||
const diag: ClusterMergeDiagnostics = {
|
||||
members: [{ serverId: 'a', label: 'A', included: true }],
|
||||
mergeCount: 1,
|
||||
totalCount: 1,
|
||||
};
|
||||
expect(clusterLedStatusFromDiagnostics(diag)).toBe('connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildClusterMemberStatusTooltip', () => {
|
||||
const t = vi.fn((key: string, opts?: Record<string, unknown>) => {
|
||||
if (key === 'cluster.memberStatusAvailable') return `${opts?.name}: ok`;
|
||||
if (key === 'cluster.memberStatusOffline') return `${opts?.name}: offline`;
|
||||
if (key === 'cluster.memberStatusIndexing') return `${opts?.name}: indexing`;
|
||||
return key;
|
||||
}) as unknown as TFunction;
|
||||
|
||||
it('lists every member on separate lines', () => {
|
||||
const diag: ClusterMergeDiagnostics = {
|
||||
members: [
|
||||
{ serverId: 'a', label: 'A', included: true },
|
||||
{ serverId: 'b', label: 'B', included: false, reason: 'unreachable' },
|
||||
{ serverId: 'c', label: 'C', included: false, reason: 'indexing' },
|
||||
],
|
||||
mergeCount: 1,
|
||||
totalCount: 3,
|
||||
};
|
||||
expect(buildClusterMemberStatusTooltip(t, diag)).toBe(
|
||||
'A: ok\nB: offline\nC: indexing',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty when there are no members', () => {
|
||||
expect(buildClusterMemberStatusTooltip(t, { members: [], mergeCount: 0, totalCount: 0 })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildClusterConnectionTooltip', () => {
|
||||
const t = vi.fn((key: string, opts?: Record<string, unknown>) => {
|
||||
if (key === 'cluster.memberStatusOffline') return `${opts?.name}: offline`;
|
||||
if (key === 'cluster.connectionTooltipNone') return `None: ${opts?.details}`;
|
||||
if (key === 'cluster.connectionTooltipPartial') {
|
||||
return `${opts?.available}/${opts?.total}: ${opts?.details}`;
|
||||
}
|
||||
return key;
|
||||
}) as unknown as TFunction;
|
||||
|
||||
it('returns empty when all included', () => {
|
||||
const diag: ClusterMergeDiagnostics = {
|
||||
members: [{ serverId: 'a', label: 'A', included: true }],
|
||||
mergeCount: 1,
|
||||
totalCount: 1,
|
||||
};
|
||||
expect(buildClusterConnectionTooltip(t, diag)).toBe('');
|
||||
});
|
||||
|
||||
it('builds partial tooltip with member details', () => {
|
||||
const diag: ClusterMergeDiagnostics = {
|
||||
members: [
|
||||
{ serverId: 'a', label: 'A', included: true },
|
||||
{ serverId: 'b', label: 'B', included: false, reason: 'unreachable' },
|
||||
],
|
||||
mergeCount: 1,
|
||||
totalCount: 2,
|
||||
};
|
||||
expect(buildClusterConnectionTooltip(t, diag)).toBe('1/2: B: offline');
|
||||
});
|
||||
|
||||
it('builds none tooltip when mergeCount is zero', () => {
|
||||
const diag: ClusterMergeDiagnostics = {
|
||||
members: [{ serverId: 'a', label: 'A', included: false, reason: 'unreachable' }],
|
||||
mergeCount: 0,
|
||||
totalCount: 1,
|
||||
};
|
||||
expect(buildClusterConnectionTooltip(t, diag)).toBe('None: A: offline');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { libraryIsReady } from '../library/libraryReady';
|
||||
import { ensureConnectUrlResolved } from '../server/serverEndpoint';
|
||||
import { serverListDisplayLabel } from '../server/serverDisplayName';
|
||||
import { getClusterMemberProfiles } from './clusterScope';
|
||||
import { isServerLikelyReachable } from './representative';
|
||||
import type { ServerCluster } from './types';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { ConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||
|
||||
export type ClusterMemberExcludeReason = 'unreachable' | 'indexing';
|
||||
|
||||
@@ -22,11 +25,15 @@ export interface ClusterMergeDiagnostics {
|
||||
/** Per-member merge eligibility (spec §4 exclusion rules). */
|
||||
export async function getClusterMergeDiagnostics(
|
||||
cluster: ServerCluster,
|
||||
options?: { probeMembers?: boolean },
|
||||
): Promise<ClusterMergeDiagnostics> {
|
||||
const profiles = getClusterMemberProfiles(cluster);
|
||||
const members: ClusterMemberStatus[] = [];
|
||||
let mergeCount = 0;
|
||||
for (const p of profiles) {
|
||||
if (options?.probeMembers) {
|
||||
await ensureConnectUrlResolved(p);
|
||||
}
|
||||
const label = serverListDisplayLabel(p, profiles);
|
||||
if (!isServerLikelyReachable(p.id)) {
|
||||
members.push({ serverId: p.id, label, included: false, reason: 'unreachable' });
|
||||
@@ -53,3 +60,47 @@ export function formatExcludedMemberLabels(
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** LED color for cluster scope: green = all members merge-ready, yellow = partial, red = none. */
|
||||
export function clusterLedStatusFromDiagnostics(diag: ClusterMergeDiagnostics): ConnectionStatus {
|
||||
if (diag.totalCount === 0 || diag.mergeCount === 0) return 'disconnected';
|
||||
if (diag.mergeCount < diag.totalCount) return 'degraded';
|
||||
return 'connected';
|
||||
}
|
||||
|
||||
function memberStatusLine(t: TFunction, member: ClusterMemberStatus): string {
|
||||
if (member.included) {
|
||||
return t('cluster.memberStatusAvailable', { name: member.label });
|
||||
}
|
||||
if (member.reason === 'indexing') {
|
||||
return t('cluster.memberStatusIndexing', { name: member.label });
|
||||
}
|
||||
return t('cluster.memberStatusOffline', { name: member.label });
|
||||
}
|
||||
|
||||
/** Hover tooltip for the cluster connection LED — one line per member. */
|
||||
export function buildClusterMemberStatusTooltip(
|
||||
t: TFunction,
|
||||
diag: ClusterMergeDiagnostics,
|
||||
): string {
|
||||
if (diag.members.length === 0) return '';
|
||||
return diag.members.map(m => memberStatusLine(t, m)).join('\n');
|
||||
}
|
||||
|
||||
/** @deprecated Prefer {@link buildClusterMemberStatusTooltip} for LED hover. */
|
||||
export function buildClusterConnectionTooltip(
|
||||
t: TFunction,
|
||||
diag: ClusterMergeDiagnostics,
|
||||
): string {
|
||||
const excluded = diag.members.filter(m => !m.included);
|
||||
if (excluded.length === 0) return '';
|
||||
const details = excluded.map(m => memberStatusLine(t, m)).join(' · ');
|
||||
if (diag.mergeCount === 0) {
|
||||
return t('cluster.connectionTooltipNone', { details });
|
||||
}
|
||||
return t('cluster.connectionTooltipPartial', {
|
||||
available: diag.mergeCount,
|
||||
total: diag.totalCount,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,14 +30,14 @@ export function useClusterPlaybackMonitor(): void {
|
||||
const streamSid = resolveServerIdForIndexKey(ref.serverId);
|
||||
if (isServerLikelyReachable(streamSid)) return;
|
||||
|
||||
const browseId = useAuthStore.getState().activeServerId ?? streamSid;
|
||||
const track = st.currentTrack;
|
||||
if (!track) return;
|
||||
const browseId = track.clusterBrowseServerId ?? useAuthStore.getState().activeServerId ?? streamSid;
|
||||
void cascadeClusterPlayback(browseId, ref.trackId, streamSid).then(next => {
|
||||
if (!next) {
|
||||
usePlayerStore.getState().next(false);
|
||||
return;
|
||||
}
|
||||
const track = st.currentTrack;
|
||||
if (!track) return;
|
||||
usePlayerStore.getState().playTrack(
|
||||
{ ...track, id: next.trackId, clusterBrowseServerId: next.serverId },
|
||||
undefined,
|
||||
|
||||
@@ -47,11 +47,13 @@ export async function getClusterMergeMemberIds(clusterId: string): Promise<strin
|
||||
const { useAuthStore } = await import('../../store/authStore');
|
||||
const cluster = useAuthStore.getState().clusters.find(c => c.id === clusterId);
|
||||
if (!cluster) return [];
|
||||
const out: string[] = [];
|
||||
for (const server of getClusterMemberProfiles(cluster)) {
|
||||
if (!isServerLikelyReachable(server.id)) continue;
|
||||
if (!(await libraryIsReady(server.id))) continue;
|
||||
out.push(server.id);
|
||||
}
|
||||
return out;
|
||||
const members = getClusterMemberProfiles(cluster);
|
||||
const ready = await Promise.all(
|
||||
members.map(async server => {
|
||||
if (!isServerLikelyReachable(server.id)) return null;
|
||||
if (!(await libraryIsReady(server.id))) return null;
|
||||
return server.id;
|
||||
}),
|
||||
);
|
||||
return ready.filter((id): id is string => id != null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user