mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +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 crate::store::LibraryStore;
|
||||||
|
|
||||||
use super::db::ATTACH_ALIAS;
|
use super::db::ATTACH_ALIAS;
|
||||||
|
use super::keys::artist_key_from_display_name;
|
||||||
use super::list_albums::list_merged_albums;
|
use super::list_albums::list_merged_albums;
|
||||||
use super::merge::{solo_partition_key, DURATION_TOLERANCE_SEC};
|
use super::merge::{solo_partition_key, DURATION_TOLERANCE_SEC};
|
||||||
use super::priority::{in_list_sql, priority_case_sql};
|
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 (in_placeholders, in_params) = in_list_sql(servers_ordered);
|
||||||
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 sql = format!(
|
let sql = format!(
|
||||||
"SELECT DISTINCT t.server_id, t.album_id, ({priority_sql}) AS priority_rank
|
"SELECT DISTINCT t.server_id, t.album_id, ({priority_sql}) AS priority_rank
|
||||||
FROM track t
|
FROM track t
|
||||||
@@ -110,8 +111,8 @@ fn member_album_pairs(
|
|||||||
ORDER BY priority_rank, t.server_id, t.album_id"
|
ORDER BY priority_rank, t.server_id, t.album_id"
|
||||||
);
|
);
|
||||||
let mut params: Vec<SqlValue> = Vec::new();
|
let mut params: Vec<SqlValue> = Vec::new();
|
||||||
params.append(&mut priority_params);
|
params.extend(priority_params);
|
||||||
params.append(&mut in_params);
|
params.extend(in_params);
|
||||||
params.push(SqlValue::Text(merge_key.to_string()));
|
params.push(SqlValue::Text(merge_key.to_string()));
|
||||||
|
|
||||||
store.with_read_conn(|conn| {
|
store.with_read_conn(|conn| {
|
||||||
@@ -231,7 +232,7 @@ fn merged_tracks_for_album_pairs(
|
|||||||
if pairs.is_empty() {
|
if pairs.is_empty() {
|
||||||
return Ok(Vec::new());
|
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
|
let pair_clauses: Vec<String> = pairs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|_| "(t.server_id = ? AND t.album_id = ?)".to_string())
|
.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();
|
let mut params: Vec<SqlValue> = Vec::new();
|
||||||
params.append(&mut priority_params);
|
params.extend(priority_params);
|
||||||
for (sid, aid, _) in pairs {
|
for (sid, aid, _) in pairs {
|
||||||
params.push(SqlValue::Text(sid.clone()));
|
params.push(SqlValue::Text(sid.clone()));
|
||||||
params.push(SqlValue::Text(aid.clone()));
|
params.push(SqlValue::Text(aid.clone()));
|
||||||
@@ -406,6 +407,9 @@ fn resolve_artist_seed(
|
|||||||
let exists: bool = store.with_read_conn(|conn| {
|
let exists: bool = store.with_read_conn(|conn| {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS(
|
"SELECT EXISTS(
|
||||||
|
SELECT 1 FROM artist ar
|
||||||
|
WHERE ar.server_id = ?1 AND ar.id = ?2
|
||||||
|
) OR EXISTS(
|
||||||
SELECT 1 FROM track
|
SELECT 1 FROM track
|
||||||
WHERE server_id = ?1 AND deleted = 0
|
WHERE server_id = ?1 AND deleted = 0
|
||||||
AND (artist_id = ?2 OR artist = ?2)
|
AND (artist_id = ?2 OR artist = ?2)
|
||||||
@@ -526,8 +530,8 @@ fn merged_albums_for_artist_key(
|
|||||||
if servers_ordered.is_empty() {
|
if servers_ordered.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
|
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
|
||||||
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 sql = format!(
|
let sql = format!(
|
||||||
"WITH candidates AS (
|
"WITH candidates AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -585,8 +589,8 @@ fn merged_albums_for_artist_key(
|
|||||||
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE",
|
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE",
|
||||||
);
|
);
|
||||||
let mut params: Vec<SqlValue> = Vec::new();
|
let mut params: Vec<SqlValue> = Vec::new();
|
||||||
params.append(&mut priority_params);
|
params.extend(priority_params);
|
||||||
params.append(&mut in_params);
|
params.extend(in_params);
|
||||||
params.push(SqlValue::Text(artist_key.to_string()));
|
params.push(SqlValue::Text(artist_key.to_string()));
|
||||||
|
|
||||||
store.with_read_conn(|conn| {
|
store.with_read_conn(|conn| {
|
||||||
@@ -625,8 +629,8 @@ fn merged_top_tracks_for_artist_key(
|
|||||||
if servers_ordered.is_empty() {
|
if servers_ordered.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
|
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
|
||||||
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 cols = aliased_track_columns("t");
|
let cols = aliased_track_columns("t");
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"WITH candidates AS (
|
"WITH candidates AS (
|
||||||
@@ -681,8 +685,8 @@ fn merged_top_tracks_for_artist_key(
|
|||||||
tol = DURATION_TOLERANCE_SEC,
|
tol = DURATION_TOLERANCE_SEC,
|
||||||
);
|
);
|
||||||
let mut params: Vec<SqlValue> = Vec::new();
|
let mut params: Vec<SqlValue> = Vec::new();
|
||||||
params.append(&mut priority_params);
|
params.extend(priority_params);
|
||||||
params.append(&mut in_params);
|
params.extend(in_params);
|
||||||
params.push(SqlValue::Text(artist_key.to_string()));
|
params.push(SqlValue::Text(artist_key.to_string()));
|
||||||
params.push(SqlValue::Integer(limit as i64));
|
params.push(SqlValue::Integer(limit as i64));
|
||||||
|
|
||||||
@@ -696,6 +700,165 @@ fn merged_top_tracks_for_artist_key(
|
|||||||
.map_err(|e| e.to_string())
|
.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(
|
pub fn cluster_artist_detail(
|
||||||
store: &LibraryStore,
|
store: &LibraryStore,
|
||||||
servers_ordered: &[String],
|
servers_ordered: &[String],
|
||||||
@@ -708,8 +871,6 @@ pub fn cluster_artist_detail(
|
|||||||
return Err("artist not found in cluster scope".to_string());
|
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_sid = seed_sid.clone();
|
||||||
let owner_aid = seed_aid.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())
|
.or_else(|| fallback_artist_from_tracks(store, &owner_sid, &owner_aid).ok().flatten())
|
||||||
.ok_or_else(|| "artist metadata missing".to_string())?;
|
.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)?
|
merged_albums_for_artist_key(store, servers_ordered, key)?
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
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)?
|
merged_top_tracks_for_artist_key(store, servers_ordered, key, TOP_TRACKS_LIMIT)?
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
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 {
|
Ok(LibraryClusterArtistDetailResponse {
|
||||||
artist,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -110,6 +119,13 @@ mod tests {
|
|||||||
assert!(compute_track_cluster_keys(None, None, "Title", "Album").is_none());
|
assert!(compute_track_cluster_keys(None, None, "Title", "Album").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_key_from_display_name_matches_track_derived_key() {
|
||||||
|
let from_track = compute_track_cluster_keys(Some("Pink Floyd"), None, "x", "y").unwrap();
|
||||||
|
let from_name = artist_key_from_display_name("Pink Floyd").unwrap();
|
||||||
|
assert_eq!(from_track.artist_key, from_name);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn punctuation_insensitive_cluster_key() {
|
fn punctuation_insensitive_cluster_key() {
|
||||||
let a = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time", "Dark Side").unwrap();
|
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 limit = limit.clamp(1, PAGE_LIMIT_MAX);
|
||||||
let offset = offset.min(i32::MAX as u32) as i32;
|
let offset = offset.min(i32::MAX as u32) as i32;
|
||||||
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
|
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
|
||||||
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", 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!(
|
let sql = format!(
|
||||||
"WITH candidates AS (
|
"WITH artist_keys AS (
|
||||||
SELECT
|
SELECT
|
||||||
t.rowid AS tid,
|
|
||||||
t.server_id,
|
t.server_id,
|
||||||
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
|
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
|
||||||
k.artist_key,
|
MIN(k.artist_key) AS artist_key
|
||||||
({priority_sql}) AS priority_rank
|
|
||||||
FROM track t
|
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
|
ON k.server_id = t.server_id AND k.track_id = t.id
|
||||||
WHERE t.deleted = 0
|
WHERE t.deleted = 0
|
||||||
AND t.server_id IN ({in_placeholders})
|
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 (
|
track_artists AS (
|
||||||
SELECT c.tid,
|
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
|
CASE
|
||||||
WHEN c.artist_key IS NULL THEN 'solo:' || c.server_id || ':' || c.artist_ref
|
WHEN ak.artist_key IS NOT NULL THEN ak.artist_key
|
||||||
ELSE c.artist_key
|
ELSE 'solo:' || c.server_id || ':' || c.id
|
||||||
END AS merge_key,
|
END AS merge_key
|
||||||
c.priority_rank
|
FROM catalog c
|
||||||
FROM candidates c
|
LEFT JOIN artist_keys ak
|
||||||
|
ON ak.server_id = c.server_id AND ak.artist_ref = c.id
|
||||||
),
|
),
|
||||||
winners AS (
|
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
|
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
|
||||||
FROM partitioned
|
FROM candidates
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT server_id, id, name, album_count, synced_at, raw_json
|
||||||
t.server_id,
|
FROM winners
|
||||||
COALESCE(NULLIF(t.artist_id, ''), t.artist),
|
WHERE rn = 1
|
||||||
COALESCE(ar.name, t.artist),
|
ORDER BY name COLLATE NOCASE, server_id
|
||||||
COALESCE(ar.album_count, (
|
LIMIT ? OFFSET ?",
|
||||||
SELECT COUNT(DISTINCT c.album_id) FROM track c
|
|
||||||
WHERE c.server_id = t.server_id
|
|
||||||
AND c.deleted = 0
|
|
||||||
AND c.album_id IS NOT NULL
|
|
||||||
AND (c.artist_id = t.artist_id OR c.artist = t.artist)
|
|
||||||
)),
|
|
||||||
COALESCE(ar.synced_at, t.synced_at),
|
|
||||||
ar.raw_json
|
|
||||||
FROM winners w
|
|
||||||
JOIN track t ON t.rowid = w.tid
|
|
||||||
LEFT JOIN artist ar ON ar.server_id = t.server_id
|
|
||||||
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
|
|
||||||
WHERE w.rn = 1
|
|
||||||
ORDER BY COALESCE(ar.name, t.artist) COLLATE NOCASE, t.server_id
|
|
||||||
LIMIT ? OFFSET ?",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut params: Vec<SqlValue> = Vec::new();
|
let mut params: Vec<SqlValue> = Vec::new();
|
||||||
params.append(&mut priority_params);
|
params.extend(in_params.iter().cloned());
|
||||||
params.append(&mut in_params);
|
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(limit as i64));
|
||||||
params.push(SqlValue::Integer(offset 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.len(), 1);
|
||||||
assert_eq!(resp.artists[0].server_id, "s1");
|
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}`]);
|
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||||||
});
|
});
|
||||||
const psyDrag = useDragDrop();
|
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(() => {
|
const dragCoverKey = useMemo(() => {
|
||||||
if (!coverRef) return '';
|
if (!coverRef) return '';
|
||||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
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 { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
|
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../cover/types';
|
||||||
import { useCoverLightboxSrc } from '../cover/lightbox';
|
import { useCoverLightboxSrc } from '../cover/lightbox';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
@@ -71,6 +72,8 @@ interface AlbumHeaderProps {
|
|||||||
headerArtistRefs: SubsonicOpenArtistRef[];
|
headerArtistRefs: SubsonicOpenArtistRef[];
|
||||||
songs: SubsonicSong[];
|
songs: SubsonicSong[];
|
||||||
coverArtId?: string;
|
coverArtId?: string;
|
||||||
|
/** Cluster / multi-server album detail — fetch cover from the seed member, not representative. */
|
||||||
|
coverServerScope?: CoverServerScope;
|
||||||
resolvedCoverUrl: string | null;
|
resolvedCoverUrl: string | null;
|
||||||
isStarred: boolean;
|
isStarred: boolean;
|
||||||
downloadProgress: number | null;
|
downloadProgress: number | null;
|
||||||
@@ -98,6 +101,7 @@ export default function AlbumHeader({
|
|||||||
headerArtistRefs,
|
headerArtistRefs,
|
||||||
songs,
|
songs,
|
||||||
coverArtId,
|
coverArtId,
|
||||||
|
coverServerScope = COVER_SCOPE_ACTIVE,
|
||||||
resolvedCoverUrl,
|
resolvedCoverUrl,
|
||||||
isStarred,
|
isStarred,
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
@@ -124,7 +128,7 @@ export default function AlbumHeader({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
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, {
|
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
|
||||||
alt: `${info.name} Cover`,
|
alt: `${info.name} Cover`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ interface Props {
|
|||||||
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigateToArtist = useNavigateToArtist();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { songToTrack } from '../utils/playback/songToTrack';
|
|||||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||||
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Play, ListPlus, Music } from 'lucide-react';
|
import { Play, ListPlus, Music } from 'lucide-react';
|
||||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
@@ -584,15 +585,19 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
|||||||
onLongPress: () => playAlbumShuffled(album.id),
|
onLongPress: () => playAlbumShuffled(album.id),
|
||||||
});
|
});
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const navigateToAlbum = useNavigateToAlbum();
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
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, {
|
const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, {
|
||||||
surface: 'dense',
|
surface: 'dense',
|
||||||
ensurePriority: 'high',
|
ensurePriority: 'high',
|
||||||
});
|
});
|
||||||
const imgSrc = coverImgSrc(coverHandle.src);
|
const imgSrc = coverImgSrc(coverHandle.src);
|
||||||
const bgResolved = 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) => {
|
const handleEnqueue = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Check, ChevronDown, Layers } from 'lucide-react';
|
import { Check, ChevronDown, Layers } from 'lucide-react';
|
||||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||||
|
import { useClusterConnectionLed } from '../hooks/useClusterConnectionLed';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { switchActiveCluster, switchActiveServer } from '../utils/server/switchActiveServer';
|
import { switchActiveCluster, switchActiveServer } from '../utils/server/switchActiveServer';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
|
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
|
||||||
import type { ServerCluster } from '../utils/serverCluster/types';
|
import type { ServerCluster } from '../utils/serverCluster/types';
|
||||||
import ClusterMergeBanner from './ClusterMergeBanner';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
@@ -35,6 +35,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
|||||||
const activeCluster = activeClusterId
|
const activeCluster = activeClusterId
|
||||||
? clusters.find(cluster => cluster.id === activeClusterId) ?? null
|
? clusters.find(cluster => cluster.id === activeClusterId) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
const clusterLed = useClusterConnectionLed(activeCluster);
|
||||||
|
const effectiveStatus =
|
||||||
|
activeCluster && clusterLed.ledStatus !== null ? clusterLed.ledStatus : status;
|
||||||
|
|
||||||
const updateMenuPosition = useCallback(() => {
|
const updateMenuPosition = useCallback(() => {
|
||||||
const el = hostRef.current;
|
const el = hostRef.current;
|
||||||
@@ -120,13 +123,19 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
|||||||
};
|
};
|
||||||
|
|
||||||
const label = isLan ? 'LAN' : t('connection.extern');
|
const label = isLan ? 'LAN' : t('connection.extern');
|
||||||
const tooltip = multi
|
const metaTooltip = multi ? t('connection.switchScopeHint') : undefined;
|
||||||
? t('connection.switchScopeHint')
|
const statusTooltip =
|
||||||
: status === 'connected'
|
effectiveStatus === 'connected'
|
||||||
? t('connection.connectedTo', { server: serverName })
|
? t('connection.connectedTo', { server: serverName })
|
||||||
: status === 'disconnected'
|
: effectiveStatus === 'disconnected'
|
||||||
? t('connection.disconnectedFrom', { server: serverName })
|
? 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) => (
|
const renderMenuItem = (id: string, labelText: string, active: boolean, onClick: () => void, icon?: React.ReactNode) => (
|
||||||
<button
|
<button
|
||||||
@@ -157,14 +166,21 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
|||||||
className="connection-indicator"
|
className="connection-indicator"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={onTriggerClick}
|
onClick={onTriggerClick}
|
||||||
data-tooltip={tooltip}
|
|
||||||
data-tooltip-pos="bottom"
|
|
||||||
role={multi ? 'button' : undefined}
|
role={multi ? 'button' : undefined}
|
||||||
aria-haspopup={multi ? 'menu' : undefined}
|
aria-haspopup={multi ? 'menu' : undefined}
|
||||||
aria-expanded={multi ? menuOpen : undefined}
|
aria-expanded={multi ? menuOpen : undefined}
|
||||||
>
|
>
|
||||||
<div className={`connection-led connection-led--${status}`} />
|
<div
|
||||||
<div className="connection-meta">
|
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-type">{label}</span>
|
||||||
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
|
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
|
||||||
@@ -174,7 +190,6 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activeCluster && <ClusterMergeBanner cluster={activeCluster} />}
|
|
||||||
{multi &&
|
{multi &&
|
||||||
menuOpen &&
|
menuOpen &&
|
||||||
typeof document !== 'undefined' &&
|
typeof document !== 'undefined' &&
|
||||||
|
|||||||
@@ -267,7 +267,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
});
|
});
|
||||||
}, [album?.id]);
|
}, [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, {
|
const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, {
|
||||||
surface: 'dense',
|
surface: 'dense',
|
||||||
ensurePriority: 'high',
|
ensurePriority: 'high',
|
||||||
@@ -293,7 +295,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
className="hero"
|
className="hero"
|
||||||
role="banner"
|
role="banner"
|
||||||
aria-label={t('hero.eyebrow')}
|
aria-label={t('hero.eyebrow')}
|
||||||
onClick={() => navigateToAlbum(album.id)}
|
onClick={() => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId })}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
|
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||||
|
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||||
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
|
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
@@ -34,7 +35,7 @@ import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
|
|||||||
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
|
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../cover/layoutSizes';
|
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 { showToast } from '../utils/ui/toast';
|
||||||
import { useShareSearch } from '../hooks/useShareSearch';
|
import { useShareSearch } from '../hooks/useShareSearch';
|
||||||
import ShareSearchResults from './search/ShareSearchResults';
|
import ShareSearchResults from './search/ShareSearchResults';
|
||||||
@@ -55,11 +56,20 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
|
|||||||
|
|
||||||
type LiveSearchSource = 'local' | 'network';
|
type LiveSearchSource = 'local' | 'network';
|
||||||
|
|
||||||
function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
|
function LiveSearchAlbumThumb({
|
||||||
|
albumId,
|
||||||
|
coverArt,
|
||||||
|
clusterSeedServerId,
|
||||||
|
}: {
|
||||||
|
albumId: string;
|
||||||
|
coverArt: string;
|
||||||
|
clusterSeedServerId?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<AlbumCoverArtImage
|
<AlbumCoverArtImage
|
||||||
albumId={albumId}
|
albumId={albumId}
|
||||||
coverArt={coverArt}
|
coverArt={coverArt}
|
||||||
|
clusterSeedServerId={clusterSeedServerId}
|
||||||
libraryResolve={false}
|
libraryResolve={false}
|
||||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
@@ -70,17 +80,19 @@ function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> }) {
|
function LiveSearchSongThumb({
|
||||||
// Search results carry the per-track `mf-…` coverArt id, which the cover
|
song,
|
||||||
// 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
|
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'>;
|
||||||
// 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.
|
|
||||||
const albumId = song.albumId?.trim();
|
const albumId = song.albumId?.trim();
|
||||||
|
const scope = React.useMemo(
|
||||||
|
() => coverScopeForServerProfileId(song.clusterBrowseServerId),
|
||||||
|
[song.clusterBrowseServerId],
|
||||||
|
);
|
||||||
const coverRef = React.useMemo(
|
const coverRef = React.useMemo(
|
||||||
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`) : undefined),
|
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`, scope) : undefined),
|
||||||
[albumId],
|
[albumId, scope],
|
||||||
);
|
);
|
||||||
if (!coverRef) return <div className="search-result-icon"><Music size={14} /></div>;
|
if (!coverRef) return <div className="search-result-icon"><Music size={14} /></div>;
|
||||||
return (
|
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);
|
const [failed, setFailed] = useState(false);
|
||||||
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
|
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
|
||||||
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
|
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
|
||||||
@@ -103,6 +119,7 @@ function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' |
|
|||||||
<ArtistCoverArtImage
|
<ArtistCoverArtImage
|
||||||
artistId={artist.id}
|
artistId={artist.id}
|
||||||
coverArt={artist.coverArt}
|
coverArt={artist.coverArt}
|
||||||
|
clusterSeedServerId={artist.clusterSeedServerId}
|
||||||
libraryResolve={false}
|
libraryResolve={false}
|
||||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
@@ -138,6 +155,7 @@ export default function LiveSearch() {
|
|||||||
const liveSearchGenRef = useRef(0);
|
const liveSearchGenRef = useRef(0);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigateToAlbum = useNavigateToAlbum();
|
const navigateToAlbum = useNavigateToAlbum();
|
||||||
|
const navigateToArtist = useNavigateToArtist();
|
||||||
const enqueue = usePlayerStore(state => state.enqueue);
|
const enqueue = usePlayerStore(state => state.enqueue);
|
||||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||||
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
|
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
|
||||||
@@ -527,8 +545,8 @@ export default function LiveSearch() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
] : results ? [
|
] : results ? [
|
||||||
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${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); 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: () => {
|
...(results.songs.map(s => ({ id: s.id, action: () => {
|
||||||
const track = songToTrack(s);
|
const track = songToTrack(s);
|
||||||
enqueue([track]);
|
enqueue([track]);
|
||||||
@@ -738,7 +756,7 @@ export default function LiveSearch() {
|
|||||||
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
|
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
|
||||||
return (
|
return (
|
||||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
|
<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) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openContextMenu(e.clientX, e.clientY, a, 'artist');
|
openContextMenu(e.clientX, e.clientY, a, 'artist');
|
||||||
@@ -762,14 +780,14 @@ export default function LiveSearch() {
|
|||||||
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
|
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
|
||||||
return (
|
return (
|
||||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
|
<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) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openContextMenu(e.clientX, e.clientY, a, 'album');
|
openContextMenu(e.clientX, e.clientY, a, 'album');
|
||||||
}}
|
}}
|
||||||
role="option" aria-selected={activeIndex === i}>
|
role="option" aria-selected={activeIndex === i}>
|
||||||
{a.coverArt ? (
|
{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>
|
<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 { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlaybackServerId } from '../hooks/usePlaybackServerId';
|
import { usePlaybackServerId } from '../hooks/usePlaybackServerId';
|
||||||
|
import { useClusterMemberDisplayLabel } from '../hooks/useClusterMemberDisplayLabel';
|
||||||
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
import OverlayScrollArea from './OverlayScrollArea';
|
import OverlayScrollArea from './OverlayScrollArea';
|
||||||
@@ -88,6 +89,7 @@ export default function NowPlayingInfo() {
|
|||||||
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
|
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
|
||||||
const subsonicServerId = usePlaybackServerId();
|
const subsonicServerId = usePlaybackServerId();
|
||||||
const subsonicReady = Boolean(subsonicServerId);
|
const subsonicReady = Boolean(subsonicServerId);
|
||||||
|
const clusterServerLabel = useClusterMemberDisplayLabel(subsonicServerId);
|
||||||
|
|
||||||
const primaryArtist = currentTrack ? primaryTrackArtistRef(currentTrack) : null;
|
const primaryArtist = currentTrack ? primaryTrackArtistRef(currentTrack) : null;
|
||||||
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
|
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
|
||||||
@@ -230,6 +232,11 @@ export default function NowPlayingInfo() {
|
|||||||
<div className="np-info-artist-body">
|
<div className="np-info-artist-body">
|
||||||
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
|
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
|
||||||
<div className="np-info-artist-name">{artistName || t('common.unknownArtist', 'Unknown 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 && (
|
{bioClean && (
|
||||||
<>
|
<>
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function SongCard({
|
|||||||
const handleAlbumClick = (e: React.MouseEvent) => {
|
const handleAlbumClick = (e: React.MouseEvent) => {
|
||||||
if (!song.albumId) return;
|
if (!song.albumId) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigateToAlbum(song.albumId);
|
navigateToAlbum(song.albumId, { seedServerId: song.clusterBrowseServerId });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getSong } from '../api/subsonicLibrary';
|
import { getSongForServer } from '../api/subsonicLibrary';
|
||||||
import { libraryGetFacts } from '../api/library';
|
import { libraryGetFacts } from '../api/library';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type ParsedTrackEnrichment,
|
type ParsedTrackEnrichment,
|
||||||
} from '../utils/library/trackEnrichment';
|
} from '../utils/library/trackEnrichment';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
|
import { useClusterMemberDisplayLabel } from '../hooks/useClusterMemberDisplayLabel';
|
||||||
|
|
||||||
function formatSize(bytes?: number): string | null {
|
function formatSize(bytes?: number): string | null {
|
||||||
if (!bytes) return null;
|
if (!bytes) return null;
|
||||||
@@ -90,8 +91,9 @@ export default function SongInfoModal() {
|
|||||||
setEnrichment(null);
|
setEnrichment(null);
|
||||||
setAbsolutePath(null);
|
setAbsolutePath(null);
|
||||||
const songId = songInfoModal.songId;
|
const songId = songInfoModal.songId;
|
||||||
|
const streamServerId = songInfoModal.serverId ?? useAuthStore.getState().activeServerId ?? '';
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const s = await getSong(songId);
|
const s = streamServerId ? await getSongForServer(streamServerId, songId) : null;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSong(s);
|
setSong(s);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -99,8 +101,7 @@ export default function SongInfoModal() {
|
|||||||
setEnrichment(null);
|
setEnrichment(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const auth = useAuthStore.getState();
|
const sid = streamServerId;
|
||||||
const sid = auth.activeServerId;
|
|
||||||
const indexEnabled = sid ? useLibraryIndexStore.getState().isIndexEnabled(sid) : false;
|
const indexEnabled = sid ? useLibraryIndexStore.getState().isIndexEnabled(sid) : false;
|
||||||
if (sid && indexEnabled && await libraryIsReady(sid)) {
|
if (sid && indexEnabled && await libraryIsReady(sid)) {
|
||||||
try {
|
try {
|
||||||
@@ -115,11 +116,11 @@ export default function SongInfoModal() {
|
|||||||
setEnrichment(null);
|
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
|
// and we have credentials. Failures are silent — modal falls back to
|
||||||
// whatever the Subsonic `path` field carried (typically nothing).
|
// whatever the Subsonic `path` field carried (typically nothing).
|
||||||
|
const sid = streamServerId;
|
||||||
const auth = useAuthStore.getState();
|
const auth = useAuthStore.getState();
|
||||||
const sid = auth.activeServerId;
|
|
||||||
const profile = sid ? auth.servers.find(p => p.id === sid) : null;
|
const profile = sid ? auth.servers.find(p => p.id === sid) : null;
|
||||||
const identity = sid ? auth.subsonicServerIdentityByServer[sid] : undefined;
|
const identity = sid ? auth.subsonicServerIdentityByServer[sid] : undefined;
|
||||||
const isNavidrome = identity?.type?.trim().toLowerCase() === 'navidrome';
|
const isNavidrome = identity?.type?.trim().toLowerCase() === 'navidrome';
|
||||||
@@ -130,7 +131,9 @@ export default function SongInfoModal() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [songInfoModal.isOpen, songInfoModal.songId]);
|
}, [songInfoModal.isOpen, songInfoModal.songId, songInfoModal.serverId]);
|
||||||
|
|
||||||
|
const clusterServerLabel = useClusterMemberDisplayLabel(songInfoModal.serverId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!songInfoModal.isOpen) return;
|
if (!songInfoModal.isOpen) return;
|
||||||
@@ -189,6 +192,9 @@ export default function SongInfoModal() {
|
|||||||
<CopyableFieldRow label={t('songInfo.songTitle')} text={song.title} />
|
<CopyableFieldRow label={t('songInfo.songTitle')} text={song.title} />
|
||||||
<CopyableFieldRow label={t('songInfo.artist')} text={song.artist} />
|
<CopyableFieldRow label={t('songInfo.artist')} text={song.artist} />
|
||||||
<CopyableFieldRow label={t('songInfo.album')} text={song.album} />
|
<CopyableFieldRow label={t('songInfo.album')} text={song.album} />
|
||||||
|
{clusterServerLabel && (
|
||||||
|
<Row label={t('songInfo.clusterServer')} value={clusterServerLabel} />
|
||||||
|
)}
|
||||||
{song.albumArtist && song.albumArtist !== song.artist && (
|
{song.albumArtist && song.albumArtist !== song.artist && (
|
||||||
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
|
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function SongRow({ song, showBpm }: Props) {
|
|||||||
<span
|
<span
|
||||||
className="track-artist-link"
|
className="track-artist-link"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={(e) => { e.stopPropagation(); navigateToAlbum(song.albumId!); }}
|
onClick={(e) => { e.stopPropagation(); navigateToAlbum(song.albumId!, { seedServerId: song.clusterBrowseServerId }); }}
|
||||||
title={song.album}
|
title={song.album}
|
||||||
>{song.album}</span>
|
>{song.album}</span>
|
||||||
) : <span title={song.album}>{song.album}</span>}
|
) : <span title={song.album}>{song.album}</span>}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ interface Props {
|
|||||||
marginTop: string;
|
marginTop: string;
|
||||||
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
|
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
|
||||||
losslessOnly?: boolean;
|
losslessOnly?: boolean;
|
||||||
|
clusterSeedServerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArtistDetailTopTracks({
|
export default function ArtistDetailTopTracks({
|
||||||
topSongs, albums, marginTop, playTopSongWithContinuation, losslessOnly = false,
|
topSongs, albums, marginTop, playTopSongWithContinuation, losslessOnly = false,
|
||||||
|
clusterSeedServerId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
@@ -96,7 +98,9 @@ export default function ArtistDetailTopTracks({
|
|||||||
</button>
|
</button>
|
||||||
{(() => {
|
{(() => {
|
||||||
const albumForCover = topSongAlbumForCover(song, albums);
|
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 style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
<div className="track-title">{song.title}</div>
|
<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';
|
import type { TopSongAlbumCoverSource } from './topSongAlbumForCover';
|
||||||
|
|
||||||
/** 32px album thumb — same cover ref path as {@link AlbumCard} on artist pages. */
|
/** 32px album thumb — same cover ref path as {@link AlbumCard} on artist pages. */
|
||||||
export default function ArtistTopTrackCover({ album }: { album: TopSongAlbumCoverSource }) {
|
export default function ArtistTopTrackCover({
|
||||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
|
album,
|
||||||
|
clusterSeedServerId,
|
||||||
|
}: {
|
||||||
|
album: TopSongAlbumCoverSource;
|
||||||
|
clusterSeedServerId?: string;
|
||||||
|
}) {
|
||||||
|
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
|
||||||
|
libraryResolve: false,
|
||||||
|
clusterSeedServerId,
|
||||||
|
});
|
||||||
if (!coverRef) return null;
|
if (!coverRef) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
|
|||||||
<ArtistCoverArtImage
|
<ArtistCoverArtImage
|
||||||
artistId={artist.id}
|
artistId={artist.id}
|
||||||
coverArt={artist.coverArt}
|
coverArt={artist.coverArt}
|
||||||
|
clusterSeedServerId={artist.clusterSeedServerId}
|
||||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
@@ -53,6 +54,7 @@ export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
|
|||||||
<ArtistCoverArtImage
|
<ArtistCoverArtImage
|
||||||
artistId={artist.id}
|
artistId={artist.id}
|
||||||
coverArt={artist.coverArt}
|
coverArt={artist.coverArt}
|
||||||
|
clusterSeedServerId={artist.clusterSeedServerId}
|
||||||
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
|
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface TileProps {
|
|||||||
selectedArtists: SubsonicArtist[];
|
selectedArtists: SubsonicArtist[];
|
||||||
showArtistImages: boolean;
|
showArtistImages: boolean;
|
||||||
toggleSelect: (id: string) => void;
|
toggleSelect: (id: string) => void;
|
||||||
onOpenArtist: (id: string) => void;
|
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
|
||||||
openContextMenu: PlayerState['openContextMenu'];
|
openContextMenu: PlayerState['openContextMenu'];
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ function ArtistGridTile({ artist, ...rest }: TileProps) {
|
|||||||
if (rest.selectionMode) {
|
if (rest.selectionMode) {
|
||||||
rest.toggleSelect(artist.id);
|
rest.toggleSelect(artist.id);
|
||||||
} else {
|
} else {
|
||||||
rest.onOpenArtist(artist.id);
|
rest.onOpenArtist(artist.id, { seedServerId: artist.clusterSeedServerId });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
@@ -68,7 +68,7 @@ interface Props {
|
|||||||
selectedArtists: SubsonicArtist[];
|
selectedArtists: SubsonicArtist[];
|
||||||
showArtistImages: boolean;
|
showArtistImages: boolean;
|
||||||
toggleSelect: (id: string) => void;
|
toggleSelect: (id: string) => void;
|
||||||
onOpenArtist: (id: string) => void;
|
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
|
||||||
openContextMenu: PlayerState['openContextMenu'];
|
openContextMenu: PlayerState['openContextMenu'];
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface RowProps {
|
|||||||
selectedArtists: SubsonicArtist[];
|
selectedArtists: SubsonicArtist[];
|
||||||
showArtistImages: boolean;
|
showArtistImages: boolean;
|
||||||
toggleSelect: (id: string) => void;
|
toggleSelect: (id: string) => void;
|
||||||
onOpenArtist: (id: string) => void;
|
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
|
||||||
openContextMenu: PlayerState['openContextMenu'];
|
openContextMenu: PlayerState['openContextMenu'];
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ function ArtistListRow({
|
|||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
toggleSelect(artist.id);
|
toggleSelect(artist.id);
|
||||||
} else {
|
} else {
|
||||||
onOpenArtist(artist.id);
|
onOpenArtist(artist.id, { seedServerId: artist.clusterSeedServerId });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
@@ -78,7 +78,7 @@ interface Props {
|
|||||||
selectedArtists: SubsonicArtist[];
|
selectedArtists: SubsonicArtist[];
|
||||||
showArtistImages: boolean;
|
showArtistImages: boolean;
|
||||||
toggleSelect: (id: string) => void;
|
toggleSelect: (id: string) => void;
|
||||||
onOpenArtist: (id: string) => void;
|
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
|
||||||
openContextMenu: PlayerState['openContextMenu'];
|
openContextMenu: PlayerState['openContextMenu'];
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
|||||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||||
</div>
|
</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')}
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
{song.albumId && (
|
{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')}
|
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -161,7 +161,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||||
</div>
|
</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')}
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
</div>
|
</div>
|
||||||
{playlistId && playlistSongIndex !== undefined && (
|
{playlistId && playlistSongIndex !== undefined && (
|
||||||
@@ -246,7 +246,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
{song.albumId && (
|
{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')}
|
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -298,7 +298,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||||
</div>
|
</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')}
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface ContextMenuItemsProps {
|
|||||||
setStarredOverride: (id: string, starred: boolean) => void;
|
setStarredOverride: (id: string, starred: boolean) => void;
|
||||||
lastfmLovedCache: Record<string, boolean>;
|
lastfmLovedCache: Record<string, boolean>;
|
||||||
setLastfmLovedForSong: (title: string, artist: string, loved: boolean) => void;
|
setLastfmLovedForSong: (title: string, artist: string, loved: boolean) => void;
|
||||||
openSongInfo: (id: string) => void;
|
openSongInfo: (id: string, serverId?: string | null) => void;
|
||||||
userRatingOverrides: Record<string, number>;
|
userRatingOverrides: Record<string, number>;
|
||||||
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
|
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
|
||||||
keyboardRating: KeyboardRating | null;
|
keyboardRating: KeyboardRating | null;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import MarqueeText from '../MarqueeText';
|
|||||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
||||||
|
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import {
|
import {
|
||||||
usePlayerBarLayoutStore,
|
usePlayerBarLayoutStore,
|
||||||
@@ -54,6 +55,7 @@ export function PlayerTrackInfo({
|
|||||||
navigate, openContextMenu, t,
|
navigate, openContextMenu, t,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||||
|
const navigateToAlbum = useNavigateToAlbum();
|
||||||
const playbackCoverRef = usePlaybackTrackCoverRef(
|
const playbackCoverRef = usePlaybackTrackCoverRef(
|
||||||
showPreviewMeta ? null : currentTrack ?? undefined,
|
showPreviewMeta ? null : currentTrack ?? undefined,
|
||||||
);
|
);
|
||||||
@@ -125,7 +127,7 @@ export function PlayerTrackInfo({
|
|||||||
: displayTitle}
|
: displayTitle}
|
||||||
className="player-track-name"
|
className="player-track-name"
|
||||||
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.albumId ? 'pointer' : 'default' }}
|
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
|
onContextMenu={!isRadio && !showPreviewMeta && currentTrack?.albumId
|
||||||
? (e) => {
|
? (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
|||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||||
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
|
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
|
||||||
|
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
|
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function QueueCurrentTrack({
|
|||||||
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
|
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||||
|
const navigateToAlbum = useNavigateToAlbum();
|
||||||
const coverRef = usePlaybackTrackCoverRef(currentTrack);
|
const coverRef = usePlaybackTrackCoverRef(currentTrack);
|
||||||
const artistRefs = resolveTrackArtistRefs(currentTrack);
|
const artistRefs = resolveTrackArtistRefs(currentTrack);
|
||||||
const enrichment = useQueueTrackEnrichment(currentTrack.id);
|
const enrichment = useQueueTrackEnrichment(currentTrack.id);
|
||||||
@@ -234,7 +236,7 @@ export function QueueCurrentTrack({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
|
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.album}</div>
|
||||||
{currentTrack.year && (
|
{currentTrack.year && (
|
||||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export default function TracksPageChrome({
|
|||||||
<span
|
<span
|
||||||
className={hero.albumId ? 'track-artist-link' : ''}
|
className={hero.albumId ? 'track-artist-link' : ''}
|
||||||
style={{ cursor: hero.albumId ? 'pointer' : 'default' }}
|
style={{ cursor: hero.albumId ? 'pointer' : 'default' }}
|
||||||
onClick={() => hero.albumId && navigateToAlbum(hero.albumId)}
|
onClick={() => hero.albumId && navigateToAlbum(hero.albumId, { seedServerId: hero.clusterBrowseServerId })}
|
||||||
>{hero.album}</span>
|
>{hero.album}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type AlbumCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
|
|||||||
albumId: string;
|
albumId: string;
|
||||||
coverArt?: string | null;
|
coverArt?: string | null;
|
||||||
serverScope?: CoverServerScope;
|
serverScope?: CoverServerScope;
|
||||||
|
clusterSeedServerId?: string | null;
|
||||||
/** Live search: use API `coverArt` ids only (avoids library IPC per row). */
|
/** Live search: use API `coverArt` ids only (avoids library IPC per row). */
|
||||||
libraryResolve?: boolean;
|
libraryResolve?: boolean;
|
||||||
};
|
};
|
||||||
@@ -14,6 +15,7 @@ export function AlbumCoverArtImage({
|
|||||||
albumId,
|
albumId,
|
||||||
coverArt,
|
coverArt,
|
||||||
serverScope,
|
serverScope,
|
||||||
|
clusterSeedServerId,
|
||||||
libraryResolve = false,
|
libraryResolve = false,
|
||||||
...rest
|
...rest
|
||||||
}: AlbumCoverArtImageProps) {
|
}: AlbumCoverArtImageProps) {
|
||||||
@@ -21,7 +23,7 @@ export function AlbumCoverArtImage({
|
|||||||
albumId,
|
albumId,
|
||||||
coverArt,
|
coverArt,
|
||||||
serverScope ?? COVER_SCOPE_ACTIVE,
|
serverScope ?? COVER_SCOPE_ACTIVE,
|
||||||
{ libraryResolve },
|
{ libraryResolve, clusterSeedServerId },
|
||||||
);
|
);
|
||||||
if (!coverRef) return null;
|
if (!coverRef) return null;
|
||||||
return <CoverArtImage coverRef={coverRef} {...rest} />;
|
return <CoverArtImage coverRef={coverRef} {...rest} />;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type ArtistCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
|
|||||||
artistId: string;
|
artistId: string;
|
||||||
coverArt?: string | null;
|
coverArt?: string | null;
|
||||||
serverScope?: CoverServerScope;
|
serverScope?: CoverServerScope;
|
||||||
|
clusterSeedServerId?: string | null;
|
||||||
libraryResolve?: boolean;
|
libraryResolve?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export function ArtistCoverArtImage({
|
|||||||
artistId,
|
artistId,
|
||||||
coverArt,
|
coverArt,
|
||||||
serverScope,
|
serverScope,
|
||||||
|
clusterSeedServerId,
|
||||||
libraryResolve = false,
|
libraryResolve = false,
|
||||||
...rest
|
...rest
|
||||||
}: ArtistCoverArtImageProps) {
|
}: ArtistCoverArtImageProps) {
|
||||||
@@ -20,7 +22,7 @@ export function ArtistCoverArtImage({
|
|||||||
artistId,
|
artistId,
|
||||||
coverArt,
|
coverArt,
|
||||||
serverScope ?? COVER_SCOPE_ACTIVE,
|
serverScope ?? COVER_SCOPE_ACTIVE,
|
||||||
{ libraryResolve },
|
{ libraryResolve, clusterSeedServerId },
|
||||||
);
|
);
|
||||||
if (!coverRef) return null;
|
if (!coverRef) return null;
|
||||||
return <CoverArtImage coverRef={coverRef} {...rest} />;
|
return <CoverArtImage coverRef={coverRef} {...rest} />;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTrackCoverRef } from './useLibraryCoverRef';
|
|||||||
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from './types';
|
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from './types';
|
||||||
|
|
||||||
export type TrackCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
|
export type TrackCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
|
||||||
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'>;
|
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'>;
|
||||||
serverScope?: CoverServerScope;
|
serverScope?: CoverServerScope;
|
||||||
/** Default false for browse rails; true for queue/player rows needing per-disc art. */
|
/** Default false for browse rails; true for queue/player rows needing per-disc art. */
|
||||||
libraryResolve?: boolean;
|
libraryResolve?: boolean;
|
||||||
|
|||||||
+45
-1
@@ -1,8 +1,11 @@
|
|||||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { useAuthStore } from '../store/authStore';
|
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 { SubsonicSong } from '../api/subsonicTypes';
|
||||||
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
|
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
|
||||||
|
import { COVER_SCOPE_ACTIVE } from './types';
|
||||||
import {
|
import {
|
||||||
albumHasDistinctDiscCovers,
|
albumHasDistinctDiscCovers,
|
||||||
coverEntryToRef,
|
coverEntryToRef,
|
||||||
@@ -185,6 +188,23 @@ export function coverArtRef(
|
|||||||
return albumCoverRef(id, id, serverScope);
|
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 {
|
export function resolvePlaybackCoverScope(): CoverServerScope {
|
||||||
const playbackSid = getPlaybackServerId();
|
const playbackSid = getPlaybackServerId();
|
||||||
const activeSid = useAuthStore.getState().activeServerId;
|
const activeSid = useAuthStore.getState().activeServerId;
|
||||||
@@ -202,3 +222,27 @@ export function resolvePlaybackCoverScope(): CoverServerScope {
|
|||||||
}
|
}
|
||||||
return { kind: 'playback' };
|
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,
|
albumCoverRefForPlayback,
|
||||||
albumCoverRefForSong,
|
albumCoverRefForSong,
|
||||||
artistCoverRef,
|
artistCoverRef,
|
||||||
|
coverScopeForServerProfileId,
|
||||||
|
resolveCoverScopeForPlaybackTrack,
|
||||||
resolveDistinctDiscCoversForAlbum,
|
resolveDistinctDiscCoversForAlbum,
|
||||||
resolvePlaybackCoverScope,
|
|
||||||
} from './ref';
|
} from './ref';
|
||||||
import {
|
import {
|
||||||
resolveAlbumCoverRefFromLibrary,
|
resolveAlbumCoverRefFromLibrary,
|
||||||
resolveArtistCoverRefFromLibrary,
|
resolveArtistCoverRefFromLibrary,
|
||||||
resolveTrackCoverRefFromLibrary,
|
resolveTrackCoverRefFromLibrary,
|
||||||
} from './resolveEntryLibrary';
|
} from './resolveEntryLibrary';
|
||||||
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import { COVER_SCOPE_ACTIVE, coverScopeKey, type CoverArtRef, type CoverServerScope } from './types';
|
import { COVER_SCOPE_ACTIVE, coverScopeKey, type CoverArtRef, type CoverServerScope } from './types';
|
||||||
|
|
||||||
function coverRefsEqual(a: CoverArtRef, b: CoverArtRef): boolean {
|
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.
|
* detail headers and queue rows that need per-disc slots from SQLite.
|
||||||
*/
|
*/
|
||||||
libraryResolve?: boolean;
|
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. */
|
/** Album grid / card — sync fallback, then local library index when indexed. */
|
||||||
export function useAlbumCoverRef(
|
export function useAlbumCoverRef(
|
||||||
albumId: string | null | undefined,
|
albumId: string | null | undefined,
|
||||||
@@ -53,7 +65,11 @@ export function useAlbumCoverRef(
|
|||||||
options?: LibraryCoverRefOptions,
|
options?: LibraryCoverRefOptions,
|
||||||
): CoverArtRef | null {
|
): CoverArtRef | null {
|
||||||
const libraryResolve = options?.libraryResolve !== false;
|
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(
|
const distinctDiscCovers = useMemo(
|
||||||
() => resolveDistinctDiscCoversForAlbum(albumId ?? '', fallbackCoverArt),
|
() => resolveDistinctDiscCoversForAlbum(albumId ?? '', fallbackCoverArt),
|
||||||
[albumId, fallbackCoverArt],
|
[albumId, fallbackCoverArt],
|
||||||
@@ -61,8 +77,8 @@ export function useAlbumCoverRef(
|
|||||||
const syncRef = useMemo(() => {
|
const syncRef = useMemo(() => {
|
||||||
const id = albumId?.trim();
|
const id = albumId?.trim();
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
return albumCoverRef(id, fallbackCoverArt, { serverScope, distinctDiscCovers });
|
return albumCoverRef(id, fallbackCoverArt, { serverScope: resolvedScope, distinctDiscCovers });
|
||||||
}, [albumId, fallbackCoverArt, scopeKey, serverScope, distinctDiscCovers]);
|
}, [albumId, fallbackCoverArt, scopeKey, resolvedScope, distinctDiscCovers]);
|
||||||
|
|
||||||
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
|
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
|
||||||
|
|
||||||
@@ -72,7 +88,7 @@ export function useAlbumCoverRef(
|
|||||||
const id = albumId?.trim();
|
const id = albumId?.trim();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => {
|
void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, resolvedScope).then(next => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
|
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
|
||||||
}
|
}
|
||||||
@@ -80,7 +96,7 @@ export function useAlbumCoverRef(
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
|
}, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve, resolvedScope]);
|
||||||
|
|
||||||
return libraryResolve ? ref : syncRef;
|
return libraryResolve ? ref : syncRef;
|
||||||
}
|
}
|
||||||
@@ -93,12 +109,16 @@ export function useArtistCoverRef(
|
|||||||
options?: LibraryCoverRefOptions,
|
options?: LibraryCoverRefOptions,
|
||||||
): CoverArtRef | null {
|
): CoverArtRef | null {
|
||||||
const libraryResolve = options?.libraryResolve !== false;
|
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 syncRef = useMemo(() => {
|
||||||
const id = artistId?.trim();
|
const id = artistId?.trim();
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
return artistCoverRef(id, fallbackCoverArt, serverScope);
|
return artistCoverRef(id, fallbackCoverArt, resolvedScope);
|
||||||
}, [artistId, fallbackCoverArt, scopeKey, serverScope]);
|
}, [artistId, fallbackCoverArt, scopeKey, resolvedScope]);
|
||||||
|
|
||||||
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
|
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
|
||||||
|
|
||||||
@@ -108,7 +128,7 @@ export function useArtistCoverRef(
|
|||||||
const id = artistId?.trim();
|
const id = artistId?.trim();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => {
|
void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, resolvedScope).then(next => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
|
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
|
||||||
}
|
}
|
||||||
@@ -116,19 +136,24 @@ export function useArtistCoverRef(
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
|
}, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve, resolvedScope]);
|
||||||
|
|
||||||
return libraryResolve ? ref : syncRef;
|
return libraryResolve ? ref : syncRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Track row / song card — album-scoped; multi-CD from library when indexed. */
|
/** Track row / song card — album-scoped; multi-CD from library when indexed. */
|
||||||
export function useTrackCoverRef(
|
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,
|
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
|
||||||
options?: LibraryCoverRefOptions,
|
options?: LibraryCoverRefOptions,
|
||||||
): CoverArtRef | undefined {
|
): CoverArtRef | undefined {
|
||||||
const libraryResolve = options?.libraryResolve !== false;
|
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 songId = song?.id;
|
||||||
const albumId = song?.albumId;
|
const albumId = song?.albumId;
|
||||||
const coverArt = song?.coverArt;
|
const coverArt = song?.coverArt;
|
||||||
@@ -151,8 +176,9 @@ export function useTrackCoverRef(
|
|||||||
return albumCoverRefForSong(
|
return albumCoverRefForSong(
|
||||||
{ id: songId, albumId, coverArt, discNumber },
|
{ id: songId, albumId, coverArt, discNumber },
|
||||||
distinctDiscCovers,
|
distinctDiscCovers,
|
||||||
|
resolvedScope,
|
||||||
);
|
);
|
||||||
}, [songId, albumId, coverArt, discNumber, distinctDiscCovers]);
|
}, [songId, albumId, coverArt, discNumber, distinctDiscCovers, resolvedScope]);
|
||||||
|
|
||||||
const [ref, setRef] = useState<CoverArtRef | undefined>(syncRef);
|
const [ref, setRef] = useState<CoverArtRef | undefined>(syncRef);
|
||||||
|
|
||||||
@@ -165,7 +191,7 @@ export function useTrackCoverRef(
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void resolveTrackCoverRefFromLibrary(
|
void resolveTrackCoverRefFromLibrary(
|
||||||
{ ...song, id: trackId, albumId: al },
|
{ ...song, id: trackId, albumId: al },
|
||||||
serverScope,
|
resolvedScope,
|
||||||
distinctDiscCovers,
|
distinctDiscCovers,
|
||||||
).then(next => {
|
).then(next => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -190,16 +216,18 @@ export function useTrackCoverRef(
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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;
|
return libraryResolve ? ref : syncRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Now playing / queue — playback server scope + library-backed multi-CD. */
|
/** Now playing / queue — playback server scope + library-backed multi-CD. */
|
||||||
export function usePlaybackTrackCoverRef(
|
export function usePlaybackTrackCoverRef(
|
||||||
track: Parameters<typeof albumCoverRefForPlayback>[0] | null | undefined,
|
track: (Parameters<typeof albumCoverRefForPlayback>[0] & Pick<Track, 'clusterBrowseServerId'>) | null | undefined,
|
||||||
): CoverArtRef | undefined {
|
): CoverArtRef | undefined {
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
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 queueLength = usePlayerStore(s => s.queueItems.length);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
const serversFingerprint = useAuthStore(s =>
|
const serversFingerprint = useAuthStore(s =>
|
||||||
@@ -209,8 +237,17 @@ export function usePlaybackTrackCoverRef(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const scope = useMemo(
|
const scope = useMemo(
|
||||||
() => resolvePlaybackCoverScope(),
|
() => resolveCoverScopeForPlaybackTrack(track, queueRefServerId),
|
||||||
[queueServerId, queueLength, activeServerId, serversFingerprint],
|
[
|
||||||
|
track,
|
||||||
|
track?.clusterBrowseServerId,
|
||||||
|
queueRefServerId,
|
||||||
|
queueServerId,
|
||||||
|
queueIndex,
|
||||||
|
queueLength,
|
||||||
|
activeServerId,
|
||||||
|
serversFingerprint,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const scopeKey = coverScopeKey(scope);
|
const scopeKey = coverScopeKey(scope);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { resolvePlaybackCoverScope } from './ref';
|
import { resolveCoverScopeForPlaybackTrack } from './ref';
|
||||||
import type { CoverArtHandle, CoverArtRef } from './types';
|
import type { CoverArtHandle, CoverArtRef } from './types';
|
||||||
import { useCoverArt } from './useCoverArt';
|
import { useCoverArt } from './useCoverArt';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
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. */
|
/** Cover art for playback queue — uses queue server when it differs from browsed server. */
|
||||||
export function usePlaybackCoverArt(
|
export function usePlaybackCoverArt(
|
||||||
coverRef: CoverArtRef | undefined,
|
coverRef: CoverArtRef | undefined,
|
||||||
displayCssPx: number,
|
displayCssPx: number,
|
||||||
|
track?: Pick<Track, 'clusterBrowseServerId'> | null,
|
||||||
): CoverArtHandle {
|
): CoverArtHandle {
|
||||||
|
const storeTrack = usePlayerStore(s => s.currentTrack);
|
||||||
|
const resolvedTrack = track ?? storeTrack ?? null;
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
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 queueLength = usePlayerStore(s => s.queueItems.length);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
const serversFingerprint = useAuthStore(s =>
|
const serversFingerprint = useAuthStore(s =>
|
||||||
@@ -20,8 +26,17 @@ export function usePlaybackCoverArt(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const scope = useMemo(
|
const scope = useMemo(
|
||||||
() => resolvePlaybackCoverScope(),
|
() => resolveCoverScopeForPlaybackTrack(resolvedTrack, queueRefServerId),
|
||||||
[queueServerId, queueLength, activeServerId, serversFingerprint],
|
[
|
||||||
|
resolvedTrack,
|
||||||
|
resolvedTrack?.clusterBrowseServerId,
|
||||||
|
queueRefServerId,
|
||||||
|
queueServerId,
|
||||||
|
queueIndex,
|
||||||
|
queueLength,
|
||||||
|
activeServerId,
|
||||||
|
serversFingerprint,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const refWithScope = useMemo(
|
const refWithScope = useMemo(
|
||||||
() => (coverRef ? { ...coverRef, serverScope: scope } : null),
|
() => (coverRef ? { ...coverRef, serverScope: scope } : null),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { search } from '../api/subsonicSearch';
|
import { search } from '../api/subsonicSearch';
|
||||||
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
import { getArtist, getArtistInfo, getArtistForServer, getTopSongs } from '../api/subsonicArtists';
|
||||||
import type {
|
import type {
|
||||||
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
|
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
|
||||||
} from '../api/subsonicTypes';
|
} from '../api/subsonicTypes';
|
||||||
@@ -88,6 +88,15 @@ export function useArtistDetailData(
|
|||||||
}
|
}
|
||||||
let nextAlbums = clusterData.albums;
|
let nextAlbums = clusterData.albums;
|
||||||
let nextSongs = clusterData.topSongs;
|
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) {
|
if (losslessOnly) {
|
||||||
({ albums: nextAlbums, songs: nextSongs } = filterNetworkArtistToLossless(nextAlbums, nextSongs));
|
({ 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.
|
// Backward-compatible re-export for call sites that still import from the hook.
|
||||||
export { isLanUrl };
|
export { isLanUrl };
|
||||||
|
|
||||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
export type ConnectionStatus = 'connected' | 'degraded' | 'disconnected' | 'checking';
|
||||||
|
|
||||||
export function useConnectionStatus() {
|
export function useConnectionStatus() {
|
||||||
const perfFlags = usePerfProbeFlags();
|
const perfFlags = usePerfProbeFlags();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
|
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
|
||||||
import { albumCoverRef } from '../cover/ref';
|
import { albumCoverRef } from '../cover/ref';
|
||||||
import { resolvePlaybackCoverScope } from '../cover/ref';
|
import { resolvePlaybackCoverScopeForCurrentTrack } from '../cover/ref';
|
||||||
import { resolveTrackCoverRefFromLibrary } from '../cover/resolveEntryLibrary';
|
import { resolveTrackCoverRefFromLibrary } from '../cover/resolveEntryLibrary';
|
||||||
import { getDiskSrc, rememberDiskSrc } from '../cover/diskSrcCache';
|
import { getDiskSrc, rememberDiskSrc } from '../cover/diskSrcCache';
|
||||||
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||||
@@ -75,7 +75,7 @@ export function useNowPlayingPrewarm(): void {
|
|||||||
coverArt: currentTrack.coverArt,
|
coverArt: currentTrack.coverArt,
|
||||||
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
||||||
},
|
},
|
||||||
resolvePlaybackCoverScope(),
|
resolvePlaybackCoverScopeForCurrentTrack(),
|
||||||
).then(ref => {
|
).then(ref => {
|
||||||
if (ref) void prewarmCoverRef(ref);
|
if (ref) void prewarmCoverRef(ref);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
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
|
* 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 {
|
export function usePlaybackServerId(): string {
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||||
const queueLength = usePlayerStore(s => s.queueItems.length);
|
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);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => getPlaybackServerId(),
|
() => (currentTrack
|
||||||
[queueServerId, queueLength, activeServerId],
|
? resolveStreamServerIdForTrack(currentTrack, queueRefServerId)
|
||||||
|
: getPlaybackServerId()),
|
||||||
|
[currentTrack, queueRefServerId, queueServerId, queueLength, queueIndex, activeServerId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export const cluster = {
|
|||||||
fanOutRatingFailed: 'Bewertungs-Synchronisierung fehlgeschlagen auf: {{servers}}.',
|
fanOutRatingFailed: 'Bewertungs-Synchronisierung fehlgeschlagen auf: {{servers}}.',
|
||||||
fanOutScrobbleFailed: 'Scrobble-Synchronisierung fehlgeschlagen auf: {{servers}}.',
|
fanOutScrobbleFailed: 'Scrobble-Synchronisierung fehlgeschlagen auf: {{servers}}.',
|
||||||
mergeBanner: 'Cluster-Zusammenführung schließt aus: {{excluded}}.',
|
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',
|
memberIncluded: 'Eingeschlossen',
|
||||||
memberExcludedOffline: 'Ausgeschlossen (offline)',
|
memberExcludedOffline: 'Ausgeschlossen (offline)',
|
||||||
memberExcludedIndexing: 'Ausgeschlossen (Index läuft)',
|
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.',
|
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.',
|
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',
|
enableBandsintownAction: 'Aktivieren',
|
||||||
|
clusterServer: 'Server',
|
||||||
role: {
|
role: {
|
||||||
artist: 'Hauptkünstler*in',
|
artist: 'Hauptkünstler*in',
|
||||||
albumArtist: 'Album-Künstler*in',
|
albumArtist: 'Album-Künstler*in',
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
|||||||
replayGainPeak: 'RG Track Peak',
|
replayGainPeak: 'RG Track Peak',
|
||||||
mono: 'Mono',
|
mono: 'Mono',
|
||||||
stereo: 'Stereo',
|
stereo: 'Stereo',
|
||||||
|
clusterServer: 'Server',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export const cluster = {
|
|||||||
fanOutRatingFailed: 'Rating sync failed on: {{servers}}.',
|
fanOutRatingFailed: 'Rating sync failed on: {{servers}}.',
|
||||||
fanOutScrobbleFailed: 'Scrobble sync failed on: {{servers}}.',
|
fanOutScrobbleFailed: 'Scrobble sync failed on: {{servers}}.',
|
||||||
mergeBanner: 'Cluster merge excludes: {{excluded}}.',
|
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',
|
memberIncluded: 'Included',
|
||||||
memberExcludedOffline: 'Excluded (offline)',
|
memberExcludedOffline: 'Excluded (offline)',
|
||||||
memberExcludedIndexing: 'Excluded (indexing)',
|
memberExcludedIndexing: 'Excluded (indexing)',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
|||||||
enableBandsintownPromptDesc: 'Optional. Loads concerts for the current artist via the public Bandsintown API.',
|
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.',
|
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',
|
enableBandsintownAction: 'Enable',
|
||||||
|
clusterServer: 'Server',
|
||||||
role: {
|
role: {
|
||||||
artist: 'Artist',
|
artist: 'Artist',
|
||||||
albumArtist: 'Album artist',
|
albumArtist: 'Album artist',
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
|||||||
replayGainPeak: 'RG Track Peak',
|
replayGainPeak: 'RG Track Peak',
|
||||||
mono: 'Mono',
|
mono: 'Mono',
|
||||||
stereo: 'Stereo',
|
stereo: 'Stereo',
|
||||||
|
clusterServer: 'Server',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export const cluster = {
|
|||||||
fanOutRatingFailed: 'La sincronización de valoraciones falló en: {{servers}}.',
|
fanOutRatingFailed: 'La sincronización de valoraciones falló en: {{servers}}.',
|
||||||
fanOutScrobbleFailed: 'La sincronización de scrobble falló en: {{servers}}.',
|
fanOutScrobbleFailed: 'La sincronización de scrobble falló en: {{servers}}.',
|
||||||
mergeBanner: 'La fusión del clúster excluye: {{excluded}}.',
|
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',
|
memberIncluded: 'Incluido',
|
||||||
memberExcludedOffline: 'Excluido (sin conexión)',
|
memberExcludedOffline: 'Excluido (sin conexión)',
|
||||||
memberExcludedIndexing: 'Excluido (indexando)',
|
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.',
|
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.',
|
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',
|
enableBandsintownAction: 'Activar',
|
||||||
|
clusterServer: 'Servidor',
|
||||||
role: {
|
role: {
|
||||||
artist: 'Artista',
|
artist: 'Artista',
|
||||||
albumArtist: 'Artista del álbum',
|
albumArtist: 'Artista del álbum',
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
|||||||
replayGainPeak: 'RG Pico de Pista',
|
replayGainPeak: 'RG Pico de Pista',
|
||||||
mono: 'Mono',
|
mono: 'Mono',
|
||||||
stereo: 'Estéreo',
|
stereo: 'Estéreo',
|
||||||
|
clusterServer: 'Servidor',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export const cluster = {
|
|||||||
fanOutRatingFailed: 'Échec de synchro des notes sur : {{servers}}.',
|
fanOutRatingFailed: 'Échec de synchro des notes sur : {{servers}}.',
|
||||||
fanOutScrobbleFailed: 'Échec de synchro des scrobbles sur : {{servers}}.',
|
fanOutScrobbleFailed: 'Échec de synchro des scrobbles sur : {{servers}}.',
|
||||||
mergeBanner: 'La fusion de cluster exclut : {{excluded}}.',
|
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',
|
memberIncluded: 'Inclus',
|
||||||
memberExcludedOffline: 'Exclu (hors ligne)',
|
memberExcludedOffline: 'Exclu (hors ligne)',
|
||||||
memberExcludedIndexing: 'Exclu (indexation)',
|
memberExcludedIndexing: 'Exclu (indexation)',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
|||||||
enableBandsintownPromptDesc: 'Optionnel. Charge les concerts de l\'artiste via l\'API publique Bandsintown.',
|
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.',
|
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',
|
enableBandsintownAction: 'Activer',
|
||||||
|
clusterServer: 'Serveur',
|
||||||
role: {
|
role: {
|
||||||
artist: 'Artiste',
|
artist: 'Artiste',
|
||||||
albumArtist: 'Artiste de l\'album',
|
albumArtist: 'Artiste de l\'album',
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
|||||||
replayGainPeak: 'RG Crête de piste',
|
replayGainPeak: 'RG Crête de piste',
|
||||||
mono: 'Mono',
|
mono: 'Mono',
|
||||||
stereo: 'Stéréo',
|
stereo: 'Stéréo',
|
||||||
|
clusterServer: 'Serveur',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export const cluster = {
|
|||||||
fanOutRatingFailed: 'Vurderings-synk mislyktes på: {{servers}}.',
|
fanOutRatingFailed: 'Vurderings-synk mislyktes på: {{servers}}.',
|
||||||
fanOutScrobbleFailed: 'Scrobble-synk mislyktes på: {{servers}}.',
|
fanOutScrobbleFailed: 'Scrobble-synk mislyktes på: {{servers}}.',
|
||||||
mergeBanner: 'Cluster-fletting ekskluderer: {{excluded}}.',
|
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',
|
memberIncluded: 'Inkludert',
|
||||||
memberExcludedOffline: 'Ekskludert (offline)',
|
memberExcludedOffline: 'Ekskludert (offline)',
|
||||||
memberExcludedIndexing: 'Ekskludert (indeksering)',
|
memberExcludedIndexing: 'Ekskludert (indeksering)',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
|||||||
enableBandsintownPromptDesc: 'Valgfritt. Henter konserter for gjeldende artist via det offentlige Bandsintown-API-et.',
|
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.',
|
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',
|
enableBandsintownAction: 'Aktiver',
|
||||||
|
clusterServer: 'Server',
|
||||||
role: {
|
role: {
|
||||||
artist: 'Artist',
|
artist: 'Artist',
|
||||||
albumArtist: 'Albumartist',
|
albumArtist: 'Albumartist',
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
|||||||
replayGainPeak: 'RG-sportopp',
|
replayGainPeak: 'RG-sportopp',
|
||||||
mono: 'Mono',
|
mono: 'Mono',
|
||||||
stereo: 'Stereo',
|
stereo: 'Stereo',
|
||||||
|
clusterServer: 'Server',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export const cluster = {
|
|||||||
fanOutRatingFailed: 'Beoordelings-sync mislukt op: {{servers}}.',
|
fanOutRatingFailed: 'Beoordelings-sync mislukt op: {{servers}}.',
|
||||||
fanOutScrobbleFailed: 'Scrobble-sync mislukt op: {{servers}}.',
|
fanOutScrobbleFailed: 'Scrobble-sync mislukt op: {{servers}}.',
|
||||||
mergeBanner: 'Cluster-samenvoeging sluit uit: {{excluded}}.',
|
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',
|
memberIncluded: 'Inbegrepen',
|
||||||
memberExcludedOffline: 'Uitgesloten (offline)',
|
memberExcludedOffline: 'Uitgesloten (offline)',
|
||||||
memberExcludedIndexing: 'Uitgesloten (indexeren)',
|
memberExcludedIndexing: 'Uitgesloten (indexeren)',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
|||||||
enableBandsintownPromptDesc: 'Optioneel. Laadt concerten van de huidige artiest via de openbare Bandsintown-API.',
|
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.',
|
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',
|
enableBandsintownAction: 'Inschakelen',
|
||||||
|
clusterServer: 'Server',
|
||||||
role: {
|
role: {
|
||||||
artist: 'Artiest',
|
artist: 'Artiest',
|
||||||
albumArtist: 'Albumartiest',
|
albumArtist: 'Albumartiest',
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
|||||||
replayGainPeak: 'RG Track Peak',
|
replayGainPeak: 'RG Track Peak',
|
||||||
mono: 'Mono',
|
mono: 'Mono',
|
||||||
stereo: 'Stereo',
|
stereo: 'Stereo',
|
||||||
|
clusterServer: 'Server',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export const cluster = {
|
|||||||
fanOutRatingFailed: 'Sincronizarea rating-urilor a eșuat pe: {{servers}}.',
|
fanOutRatingFailed: 'Sincronizarea rating-urilor a eșuat pe: {{servers}}.',
|
||||||
fanOutScrobbleFailed: 'Sincronizarea scrobble a eșuat pe: {{servers}}.',
|
fanOutScrobbleFailed: 'Sincronizarea scrobble a eșuat pe: {{servers}}.',
|
||||||
mergeBanner: 'Îmbinarea clusterului exclude: {{excluded}}.',
|
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',
|
memberIncluded: 'Inclus',
|
||||||
memberExcludedOffline: 'Exclus (offline)',
|
memberExcludedOffline: 'Exclus (offline)',
|
||||||
memberExcludedIndexing: 'Exclus (indexare)',
|
memberExcludedIndexing: 'Exclus (indexare)',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
|||||||
enableBandsintownPromptDesc: 'Opțional. Încarcă concertele pentru artistul curent prin API-ul public Bandsintown.',
|
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.',
|
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',
|
enableBandsintownAction: 'Pornește',
|
||||||
|
clusterServer: 'Server',
|
||||||
role: {
|
role: {
|
||||||
artist: 'Artist',
|
artist: 'Artist',
|
||||||
albumArtist: 'Artist album',
|
albumArtist: 'Artist album',
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
|||||||
replayGainPeak: 'RG Vârf Piesă',
|
replayGainPeak: 'RG Vârf Piesă',
|
||||||
mono: 'Mono',
|
mono: 'Mono',
|
||||||
stereo: 'Stereo',
|
stereo: 'Stereo',
|
||||||
|
clusterServer: 'Server',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export const cluster = {
|
|||||||
fanOutRatingFailed: 'Не удалось синхронизировать рейтинг на: {{servers}}.',
|
fanOutRatingFailed: 'Не удалось синхронизировать рейтинг на: {{servers}}.',
|
||||||
fanOutScrobbleFailed: 'Не удалось синхронизировать скроббл на: {{servers}}.',
|
fanOutScrobbleFailed: 'Не удалось синхронизировать скроббл на: {{servers}}.',
|
||||||
mergeBanner: 'Из объединения кластера исключены: {{excluded}}.',
|
mergeBanner: 'Из объединения кластера исключены: {{excluded}}.',
|
||||||
|
connectionTooltipPartial: 'Доступно {{available}} из {{total}} серверов. {{details}}',
|
||||||
|
connectionTooltipNone: 'Нет доступных серверов кластера. {{details}}',
|
||||||
|
memberStatusOffline: '{{name}}: офлайн',
|
||||||
|
memberStatusIndexing: '{{name}}: индексация',
|
||||||
|
memberStatusAvailable: '{{name}}: доступен',
|
||||||
memberIncluded: 'Включён',
|
memberIncluded: 'Включён',
|
||||||
memberExcludedOffline: 'Исключён (офлайн)',
|
memberExcludedOffline: 'Исключён (офлайн)',
|
||||||
memberExcludedIndexing: 'Исключён (индексация)',
|
memberExcludedIndexing: 'Исключён (индексация)',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const nowPlayingInfo = {
|
|||||||
enableBandsintownPromptDesc: 'Опционально. Загружает концерты текущего исполнителя через публичный API Bandsintown.',
|
enableBandsintownPromptDesc: 'Опционально. Загружает концерты текущего исполнителя через публичный API Bandsintown.',
|
||||||
enableBandsintownPrivacy: 'При включении имя текущего исполнителя отправляется в API Bandsintown для получения дат концертов. Аккаунт и личные данные не передаются.',
|
enableBandsintownPrivacy: 'При включении имя текущего исполнителя отправляется в API Bandsintown для получения дат концертов. Аккаунт и личные данные не передаются.',
|
||||||
enableBandsintownAction: 'Включить',
|
enableBandsintownAction: 'Включить',
|
||||||
|
clusterServer: 'Сервер',
|
||||||
role: {
|
role: {
|
||||||
artist: 'Исполнитель',
|
artist: 'Исполнитель',
|
||||||
albumArtist: 'Исполнитель альбома',
|
albumArtist: 'Исполнитель альбома',
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
|||||||
replayGainPeak: 'Пик RG',
|
replayGainPeak: 'Пик RG',
|
||||||
mono: 'Моно',
|
mono: 'Моно',
|
||||||
stereo: 'Стерео',
|
stereo: 'Стерео',
|
||||||
|
clusterServer: 'Сервер',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export const cluster = {
|
|||||||
fanOutRatingFailed: '以下服务器评分同步失败:{{servers}}。',
|
fanOutRatingFailed: '以下服务器评分同步失败:{{servers}}。',
|
||||||
fanOutScrobbleFailed: '以下服务器 scrobble 同步失败:{{servers}}。',
|
fanOutScrobbleFailed: '以下服务器 scrobble 同步失败:{{servers}}。',
|
||||||
mergeBanner: '集群合并已排除:{{excluded}}。',
|
mergeBanner: '集群合并已排除:{{excluded}}。',
|
||||||
|
connectionTooltipPartial: '{{available}}/{{total}} 个服务器可用。{{details}}',
|
||||||
|
connectionTooltipNone: '没有可用的集群服务器。{{details}}',
|
||||||
|
memberStatusOffline: '{{name}}:离线',
|
||||||
|
memberStatusIndexing: '{{name}}:索引中',
|
||||||
|
memberStatusAvailable: '{{name}}:可用',
|
||||||
memberIncluded: '已纳入',
|
memberIncluded: '已纳入',
|
||||||
memberExcludedOffline: '已排除(离线)',
|
memberExcludedOffline: '已排除(离线)',
|
||||||
memberExcludedIndexing: '已排除(索引中)',
|
memberExcludedIndexing: '已排除(索引中)',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
|
|||||||
enableBandsintownPromptDesc: '可选。通过公开的 Bandsintown API 加载当前艺术家的演出。',
|
enableBandsintownPromptDesc: '可选。通过公开的 Bandsintown API 加载当前艺术家的演出。',
|
||||||
enableBandsintownPrivacy: '启用后,当前播放的艺术家名称将发送到 Bandsintown API 以获取巡演日期。不会发送任何账户或个人数据。',
|
enableBandsintownPrivacy: '启用后,当前播放的艺术家名称将发送到 Bandsintown API 以获取巡演日期。不会发送任何账户或个人数据。',
|
||||||
enableBandsintownAction: '启用',
|
enableBandsintownAction: '启用',
|
||||||
|
clusterServer: '服务器',
|
||||||
role: {
|
role: {
|
||||||
artist: '艺术家',
|
artist: '艺术家',
|
||||||
albumArtist: '专辑艺术家',
|
albumArtist: '专辑艺术家',
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const songInfo = {
|
|||||||
replayGainPeak: 'RG 曲目峰值',
|
replayGainPeak: 'RG 曲目峰值',
|
||||||
mono: '单声道',
|
mono: '单声道',
|
||||||
stereo: '立体声',
|
stereo: '立体声',
|
||||||
|
clusterServer: '服务器',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { SubsonicSong } from '../api/subsonicTypes';
|
|||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
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 { invoke } from '@tauri-apps/api/core';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
@@ -26,8 +26,10 @@ import { useCoverArt } from '../cover/useCoverArt';
|
|||||||
import {
|
import {
|
||||||
forgetAlbumDistinctDiscCovers,
|
forgetAlbumDistinctDiscCovers,
|
||||||
rememberAlbumDistinctDiscCovers,
|
rememberAlbumDistinctDiscCovers,
|
||||||
|
coverScopeForServerProfileId,
|
||||||
} from '../cover/ref';
|
} from '../cover/ref';
|
||||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
|
import { readClusterSeedServerId } from '../utils/navigation/albumDetailNavigation';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { useSelectionStore } from '../store/selectionStore';
|
import { useSelectionStore } from '../store/selectionStore';
|
||||||
@@ -45,6 +47,7 @@ export default function AlbumDetail() {
|
|||||||
const perfFlags = usePerfProbeFlags();
|
const perfFlags = usePerfProbeFlags();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const losslessOnly = isLosslessMode(searchParams);
|
const losslessOnly = isLosslessMode(searchParams);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -104,8 +107,10 @@ export default function AlbumDetail() {
|
|||||||
const handlePlayAll = () => {
|
const handlePlayAll = () => {
|
||||||
if (!album || !effectiveSongs) return;
|
if (!album || !effectiveSongs) return;
|
||||||
const albumGenre = album.album.genre;
|
const albumGenre = album.album.genre;
|
||||||
|
const seedSid = album.album.clusterSeedServerId;
|
||||||
const tracks = effectiveSongs.map(s => {
|
const tracks = effectiveSongs.map(s => {
|
||||||
const t = songToTrack(s);
|
const t = songToTrack(s);
|
||||||
|
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
|
||||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
@@ -115,8 +120,10 @@ const handlePlayAll = () => {
|
|||||||
const handleEnqueueAll = () => {
|
const handleEnqueueAll = () => {
|
||||||
if (!album || !effectiveSongs) return;
|
if (!album || !effectiveSongs) return;
|
||||||
const albumGenre = album.album.genre;
|
const albumGenre = album.album.genre;
|
||||||
|
const seedSid = album.album.clusterSeedServerId;
|
||||||
const tracks = effectiveSongs.map(s => {
|
const tracks = effectiveSongs.map(s => {
|
||||||
const t = songToTrack(s);
|
const t = songToTrack(s);
|
||||||
|
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
|
||||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
@@ -126,8 +133,10 @@ const handleEnqueueAll = () => {
|
|||||||
const handleShuffleAll = () => {
|
const handleShuffleAll = () => {
|
||||||
if (!album || !effectiveSongs) return;
|
if (!album || !effectiveSongs) return;
|
||||||
const albumGenre = album.album.genre;
|
const albumGenre = album.album.genre;
|
||||||
|
const seedSid = album.album.clusterSeedServerId;
|
||||||
const tracks = effectiveSongs.map(s => {
|
const tracks = effectiveSongs.map(s => {
|
||||||
const t = songToTrack(s);
|
const t = songToTrack(s);
|
||||||
|
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
|
||||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
@@ -141,12 +150,15 @@ const handleShuffleAll = () => {
|
|||||||
if (orbitActive) { queueHint(); return; }
|
if (orbitActive) { queueHint(); return; }
|
||||||
if (!album || !effectiveSongs) return;
|
if (!album || !effectiveSongs) return;
|
||||||
const albumGenre = album.album.genre;
|
const albumGenre = album.album.genre;
|
||||||
|
const seedSid = album.album.clusterSeedServerId;
|
||||||
const tracks = effectiveSongs.map(s => {
|
const tracks = effectiveSongs.map(s => {
|
||||||
const t = songToTrack(s);
|
const t = songToTrack(s);
|
||||||
|
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
|
||||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
const track = tracks.find(t => t.id === song.id) || songToTrack(song);
|
const track = tracks.find(t => t.id === song.id) || songToTrack(song);
|
||||||
|
if (!track.clusterBrowseServerId && seedSid) track.clusterBrowseServerId = seedSid;
|
||||||
playTrack(track, tracks);
|
playTrack(track, tracks);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -281,11 +293,17 @@ const handleShuffleAll = () => {
|
|||||||
userRatingOverrides,
|
userRatingOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const albumSeedServerId = album?.album.clusterSeedServerId ?? readClusterSeedServerId(location.state);
|
||||||
|
const albumCoverScope = useMemo(
|
||||||
|
() => coverScopeForServerProfileId(albumSeedServerId),
|
||||||
|
[albumSeedServerId],
|
||||||
|
);
|
||||||
|
|
||||||
const albumCoverRefResolved = useAlbumCoverRef(
|
const albumCoverRefResolved = useAlbumCoverRef(
|
||||||
album?.album.id,
|
album?.album.id,
|
||||||
album?.album.coverArt,
|
album?.album.coverArt,
|
||||||
undefined,
|
undefined,
|
||||||
{ libraryResolve: true },
|
{ libraryResolve: true, clusterSeedServerId: albumSeedServerId },
|
||||||
);
|
);
|
||||||
const albumCover = useCoverArt(albumCoverRefResolved, 400, { surface: 'sparse' });
|
const albumCover = useCoverArt(albumCoverRefResolved, 400, { surface: 'sparse' });
|
||||||
const resolvedCoverUrl = albumCover.src || null;
|
const resolvedCoverUrl = albumCover.src || null;
|
||||||
@@ -318,6 +336,7 @@ const handleShuffleAll = () => {
|
|||||||
headerArtistRefs={headerArtistRefs}
|
headerArtistRefs={headerArtistRefs}
|
||||||
songs={songs}
|
songs={songs}
|
||||||
coverArtId={info.coverArt}
|
coverArtId={info.coverArt}
|
||||||
|
coverServerScope={albumCoverScope}
|
||||||
resolvedCoverUrl={resolvedCoverUrl}
|
resolvedCoverUrl={resolvedCoverUrl}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
downloadProgress={null}
|
downloadProgress={null}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { uploadArtistImage } from '../api/subsonicPlaylists';
|
|||||||
import { useCoverArt } from '../cover/useCoverArt';
|
import { useCoverArt } from '../cover/useCoverArt';
|
||||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||||
import { getAlbum } from '../api/subsonicLibrary';
|
|
||||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
|
||||||
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
@@ -33,6 +31,8 @@ import {
|
|||||||
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
||||||
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
||||||
import {
|
import {
|
||||||
|
buildArtistTopSongPlayQueue,
|
||||||
|
fetchArtistCatalogTracks,
|
||||||
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
|
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
|
||||||
} from '../utils/componentHelpers/runArtistDetailPlay';
|
} from '../utils/componentHelpers/runArtistDetailPlay';
|
||||||
import {
|
import {
|
||||||
@@ -137,29 +137,12 @@ export default function ArtistDetail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const playTopSongWithContinuation = async (startIndex: number) => {
|
const playTopSongWithContinuation = async (startIndex: number) => {
|
||||||
if (!artist || albums.length === 0) return;
|
if (!artist || topSongs.length === 0) return;
|
||||||
setPlayAllLoading(true);
|
setPlayAllLoading(true);
|
||||||
try {
|
try {
|
||||||
// Get all artist tracks ordered by album and track number
|
const catalogTracks = albums.length > 0 ? await fetchArtistCatalogTracks(albums) : [];
|
||||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
const queue = buildArtistTopSongPlayQueue(topSongs, startIndex, catalogTracks);
|
||||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
if (queue.length > 0) playTrack(queue[0], queue);
|
||||||
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);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setPlayAllLoading(false);
|
setPlayAllLoading(false);
|
||||||
}
|
}
|
||||||
@@ -173,6 +156,7 @@ export default function ArtistDetail() {
|
|||||||
const coverId = artist ? (artist.coverArt || artist.id) : '';
|
const coverId = artist ? (artist.coverArt || artist.id) : '';
|
||||||
const artistCoverRefResolved = useArtistCoverRef(artist?.id, artist?.coverArt, undefined, {
|
const artistCoverRefResolved = useArtistCoverRef(artist?.id, artist?.coverArt, undefined, {
|
||||||
libraryResolve: true,
|
libraryResolve: true,
|
||||||
|
clusterSeedServerId: artist?.clusterSeedServerId,
|
||||||
});
|
});
|
||||||
const artistCoverFallback = useCoverArt(artistCoverRefResolved, 80, { surface: 'sparse' });
|
const artistCoverFallback = useCoverArt(artistCoverRefResolved, 80, { surface: 'sparse' });
|
||||||
|
|
||||||
@@ -346,6 +330,7 @@ export default function ArtistDetail() {
|
|||||||
marginTop={sectionMt('topTracks')}
|
marginTop={sectionMt('topTracks')}
|
||||||
playTopSongWithContinuation={playTopSongWithContinuation}
|
playTopSongWithContinuation={playTopSongWithContinuation}
|
||||||
losslessOnly={losslessOnly}
|
losslessOnly={losslessOnly}
|
||||||
|
clusterSeedServerId={artist.clusterSeedServerId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { resolvePlaybackCoverScope } from '../../cover/ref';
|
import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref';
|
||||||
import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary';
|
import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary';
|
||||||
import { coverArtUrlForDiscord } from '../../cover/integrations/discord';
|
import { coverArtUrlForDiscord } from '../../cover/integrations/discord';
|
||||||
import { useAuthStore } from '../authStore';
|
import { useAuthStore } from '../authStore';
|
||||||
@@ -96,7 +96,7 @@ export function setupDiscordPresence(): () => void {
|
|||||||
coverArt: currentTrack.coverArt,
|
coverArt: currentTrack.coverArt,
|
||||||
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
||||||
},
|
},
|
||||||
resolvePlaybackCoverScope(),
|
resolvePlaybackCoverScopeForCurrentTrack(),
|
||||||
).then(ref => {
|
).then(ref => {
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
sendPresence(null);
|
sendPresence(null);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { resolvePlaybackCoverScope } from '../../cover/ref';
|
import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref';
|
||||||
import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary';
|
import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary';
|
||||||
import { coverArtUrlForMpris } from '../../cover/integrations/mpris';
|
import { coverArtUrlForMpris } from '../../cover/integrations/mpris';
|
||||||
import { usePlayerStore } from '../playerStore';
|
import { usePlayerStore } from '../playerStore';
|
||||||
@@ -35,7 +35,7 @@ export function setupMprisSync(): () => void {
|
|||||||
coverArt: currentTrack.coverArt,
|
coverArt: currentTrack.coverArt,
|
||||||
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
||||||
},
|
},
|
||||||
resolvePlaybackCoverScope(),
|
resolvePlaybackCoverScopeForCurrentTrack(),
|
||||||
).then(ref => {
|
).then(ref => {
|
||||||
if (!ref) return;
|
if (!ref) return;
|
||||||
coverArtUrlForMpris(ref)
|
coverArtUrlForMpris(ref)
|
||||||
|
|||||||
@@ -266,7 +266,10 @@ export function runPlayTrack(
|
|||||||
&& getPlaybackCacheServerKey(),
|
&& getPlaybackCacheServerKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const browseServerId = authState.activeServerId ?? '';
|
const browseServerId =
|
||||||
|
track.clusterBrowseServerId
|
||||||
|
?? authState.activeServerId
|
||||||
|
?? '';
|
||||||
const browseTrackId = track.id;
|
const browseTrackId = track.id;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -276,7 +279,11 @@ export function runPlayTrack(
|
|||||||
if (isClusterMode() && browseServerId) {
|
if (isClusterMode() && browseServerId) {
|
||||||
const resolved = await resolveClusterPlaybackForTrack(browseServerId, track.id);
|
const resolved = await resolveClusterPlaybackForTrack(browseServerId, track.id);
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
playTrackResolved = { ...track, id: resolved.trackId };
|
playTrackResolved = {
|
||||||
|
...track,
|
||||||
|
id: resolved.trackId,
|
||||||
|
clusterBrowseServerId: resolved.serverId,
|
||||||
|
};
|
||||||
streamServerProfileId = resolved.serverId;
|
streamServerProfileId = resolved.serverId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,11 +112,25 @@ describe('openContextMenu / closeContextMenu', () => {
|
|||||||
|
|
||||||
describe('openSongInfo / closeSongInfo', () => {
|
describe('openSongInfo / closeSongInfo', () => {
|
||||||
it('opens with the song id and clears on close', () => {
|
it('opens with the song id and clears on close', () => {
|
||||||
|
useAuthStore.setState({ activeServerId: 'a' });
|
||||||
usePlayerStore.getState().openSongInfo('song-1');
|
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();
|
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,
|
scheduledResumeStartMs: null,
|
||||||
repeatMode: initialPlayerPrefs.repeatMode,
|
repeatMode: initialPlayerPrefs.repeatMode,
|
||||||
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
|
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),
|
...createUiStateActions(set),
|
||||||
...createLastfmActions(set, get),
|
...createLastfmActions(set, get),
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export interface PlayerState {
|
|||||||
) => void;
|
) => void;
|
||||||
closeContextMenu: () => void;
|
closeContextMenu: () => void;
|
||||||
|
|
||||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
songInfoModal: { isOpen: boolean; songId: string | null; serverId: string | null };
|
||||||
openSongInfo: (songId: string) => void;
|
openSongInfo: (songId: string, serverId?: string | null) => void;
|
||||||
closeSongInfo: () => void;
|
closeSongInfo: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import type { PlayerState } from './playerStoreTypes';
|
|||||||
import {
|
import {
|
||||||
ensurePlaybackServerActive,
|
ensurePlaybackServerActive,
|
||||||
playbackServerDiffersFromActive,
|
playbackServerDiffersFromActive,
|
||||||
|
resolveStreamServerIdForTrack,
|
||||||
} from '../utils/playback/playbackServer';
|
} from '../utils/playback/playbackServer';
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
import { usePlayerStore } from './playerStore';
|
||||||
|
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||||
|
|
||||||
type SetState = (
|
type SetState = (
|
||||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||||
@@ -77,8 +81,24 @@ export function createUiStateActions(set: SetState): Pick<
|
|||||||
contextMenu: { ...state.contextMenu, isOpen: false },
|
contextMenu: { ...state.contextMenu, isOpen: false },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
openSongInfo: (songId) => set({ songInfoModal: { isOpen: true, songId } }),
|
openSongInfo: (songId, serverId) => {
|
||||||
closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null } }),
|
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: () =>
|
toggleQueue: () =>
|
||||||
set(state => {
|
set(state => {
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
box-shadow: 0 0 6px 2px rgba(166, 227, 161, 0.55);
|
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 {
|
.connection-led--disconnected {
|
||||||
background: #f38ba8;
|
background: #f38ba8;
|
||||||
box-shadow: 0 0 6px 2px rgba(243, 139, 168, 0.55);
|
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;
|
letter-spacing: -0.01em;
|
||||||
text-align: left;
|
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 {
|
.np-info-artist-bio {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
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 type { TFunction } from 'i18next';
|
||||||
import { getAlbum } from '../../api/subsonicLibrary';
|
import { getAlbum, getAlbumForServer } from '../../api/subsonicLibrary';
|
||||||
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
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 type { Track } from '../../store/playerStoreTypes';
|
||||||
import { songToTrack } from '../playback/songToTrack';
|
import { songToTrack } from '../playback/songToTrack';
|
||||||
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
||||||
|
|
||||||
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
async function fetchAlbumTracks(album: SubsonicAlbum): Promise<{ year: number; tracks: Track[] }> {
|
||||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
const serverId = album.clusterSeedServerId;
|
||||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
const data = serverId
|
||||||
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
? 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 {
|
export interface RunArtistDetailPlayDeps {
|
||||||
@@ -21,13 +55,21 @@ export interface RunArtistDetailPlayDeps {
|
|||||||
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
|
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||||
if (albums.length === 0) return;
|
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> {
|
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||||
if (albums.length === 0) return;
|
if (albums.length === 0) return;
|
||||||
await runBulkShuffle({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
await runBulkShuffle({
|
||||||
|
fetchTracks: () => fetchArtistCatalogTracks(albums),
|
||||||
|
setLoading: setPlayAllLoading,
|
||||||
|
playTrack,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunArtistDetailStartRadioDeps {
|
export interface RunArtistDetailStartRadioDeps {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { listen, emitTo } from '@tauri-apps/api/event';
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { resolveQueueTrack } from './library/queueTrackView';
|
import { resolveQueueTrack } from './library/queueTrackView';
|
||||||
|
import { getCurrentTrackStreamServerId } from './playback/playbackServer';
|
||||||
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||||
|
|
||||||
export const MINI_WINDOW_LABEL = 'mini';
|
export const MINI_WINDOW_LABEL = 'mini';
|
||||||
@@ -266,7 +267,9 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
w.unminimize().catch(() => {});
|
w.unminimize().catch(() => {});
|
||||||
w.show().catch(() => {});
|
w.show().catch(() => {});
|
||||||
w.setFocus().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 () => {
|
return () => {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {
|
|||||||
bindQueueServerForPlayback,
|
bindQueueServerForPlayback,
|
||||||
clearQueueServerForPlayback,
|
clearQueueServerForPlayback,
|
||||||
ensurePlaybackServerActive,
|
ensurePlaybackServerActive,
|
||||||
|
getCurrentTrackStreamServerId,
|
||||||
getPlaybackServerId,
|
getPlaybackServerId,
|
||||||
playbackCoverArtForId,
|
playbackCoverArtForId,
|
||||||
playbackServerDiffersFromActive,
|
playbackServerDiffersFromActive,
|
||||||
prepareActiveServerForNewMix,
|
prepareActiveServerForNewMix,
|
||||||
|
resolveStreamServerIdForTrack,
|
||||||
shouldBindQueueServerForPlay,
|
shouldBindQueueServerForPlay,
|
||||||
shouldHandoffQueueToActiveServer,
|
shouldHandoffQueueToActiveServer,
|
||||||
} from './playbackServer';
|
} from './playbackServer';
|
||||||
@@ -48,6 +50,28 @@ describe('playbackServer', () => {
|
|||||||
expect(getPlaybackServerId()).toBe('b');
|
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', () => {
|
it('bindQueueServerForPlayback pins active server as canonical index key', () => {
|
||||||
useAuthStore.setState({ activeServerId: 'b' });
|
useAuthStore.setState({ activeServerId: 'b' });
|
||||||
bindQueueServerForPlayback();
|
bindQueueServerForPlayback();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildCoverArtFetchUrl } from '../../cover/fetchUrl';
|
import { buildCoverArtFetchUrl } from '../../cover/fetchUrl';
|
||||||
import { resolvePlaybackCoverScope } from '../../cover/ref';
|
import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref';
|
||||||
import { coverEntryToRef, resolveAlbumCoverEntry } from '../../cover/resolveEntry';
|
import { coverEntryToRef, resolveAlbumCoverEntry } from '../../cover/resolveEntry';
|
||||||
import { coverStorageKeyFromRef } from '../../cover/storageKeys';
|
import { coverStorageKeyFromRef } from '../../cover/storageKeys';
|
||||||
import { resolveCoverDisplayTier } from '../../cover/tiers';
|
import { resolveCoverDisplayTier } from '../../cover/tiers';
|
||||||
@@ -15,6 +15,31 @@ import {
|
|||||||
serverIndexKeyFromUrl,
|
serverIndexKeyFromUrl,
|
||||||
} from '../server/serverIndexKey';
|
} 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). */
|
/** Server that owns the current queue / stream URLs (may differ from the browsed server). */
|
||||||
export function getPlaybackServerId(): string {
|
export function getPlaybackServerId(): string {
|
||||||
const { queueServerId, queueItems } = usePlayerStore.getState();
|
const { queueServerId, queueItems } = usePlayerStore.getState();
|
||||||
@@ -132,7 +157,7 @@ export function playbackCoverArtForAlbum(
|
|||||||
if (!entry) {
|
if (!entry) {
|
||||||
return playbackCoverArtForId(coverArt, displayCssPx);
|
return playbackCoverArtForId(coverArt, displayCssPx);
|
||||||
}
|
}
|
||||||
const ref = coverEntryToRef(entry, resolvePlaybackCoverScope());
|
const ref = coverEntryToRef(entry, resolvePlaybackCoverScopeForCurrentTrack());
|
||||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'sparse' });
|
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'sparse' });
|
||||||
return {
|
return {
|
||||||
src: buildCoverArtFetchUrl(ref, tier),
|
src: buildCoverArtFetchUrl(ref, tier),
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ describe('songToTrack', () => {
|
|||||||
expect(t.coverArt).toBe('s2');
|
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', () => {
|
it('flattens replayGain into replayGainTrackDb / AlbumDb / Peak', () => {
|
||||||
const song = makeSubsonicSong({
|
const song = makeSubsonicSong({
|
||||||
replayGain: {
|
replayGain: {
|
||||||
|
|||||||
@@ -25,5 +25,6 @@ export function songToTrack(song: SubsonicSong): Track {
|
|||||||
samplingRate: song.samplingRate,
|
samplingRate: song.samplingRate,
|
||||||
bitDepth: song.bitDepth,
|
bitDepth: song.bitDepth,
|
||||||
size: song.size,
|
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 { libraryIsReady } from '../library/libraryReady';
|
||||||
|
import { ensureConnectUrlResolved } from '../server/serverEndpoint';
|
||||||
import { serverListDisplayLabel } from '../server/serverDisplayName';
|
import { serverListDisplayLabel } from '../server/serverDisplayName';
|
||||||
import { getClusterMemberProfiles } from './clusterScope';
|
import { getClusterMemberProfiles } from './clusterScope';
|
||||||
import { isServerLikelyReachable } from './representative';
|
import { isServerLikelyReachable } from './representative';
|
||||||
import type { ServerCluster } from './types';
|
import type { ServerCluster } from './types';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { ConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||||
|
|
||||||
export type ClusterMemberExcludeReason = 'unreachable' | 'indexing';
|
export type ClusterMemberExcludeReason = 'unreachable' | 'indexing';
|
||||||
|
|
||||||
@@ -22,11 +25,15 @@ export interface ClusterMergeDiagnostics {
|
|||||||
/** Per-member merge eligibility (spec §4 exclusion rules). */
|
/** Per-member merge eligibility (spec §4 exclusion rules). */
|
||||||
export async function getClusterMergeDiagnostics(
|
export async function getClusterMergeDiagnostics(
|
||||||
cluster: ServerCluster,
|
cluster: ServerCluster,
|
||||||
|
options?: { probeMembers?: boolean },
|
||||||
): Promise<ClusterMergeDiagnostics> {
|
): Promise<ClusterMergeDiagnostics> {
|
||||||
const profiles = getClusterMemberProfiles(cluster);
|
const profiles = getClusterMemberProfiles(cluster);
|
||||||
const members: ClusterMemberStatus[] = [];
|
const members: ClusterMemberStatus[] = [];
|
||||||
let mergeCount = 0;
|
let mergeCount = 0;
|
||||||
for (const p of profiles) {
|
for (const p of profiles) {
|
||||||
|
if (options?.probeMembers) {
|
||||||
|
await ensureConnectUrlResolved(p);
|
||||||
|
}
|
||||||
const label = serverListDisplayLabel(p, profiles);
|
const label = serverListDisplayLabel(p, profiles);
|
||||||
if (!isServerLikelyReachable(p.id)) {
|
if (!isServerLikelyReachable(p.id)) {
|
||||||
members.push({ serverId: p.id, label, included: false, reason: 'unreachable' });
|
members.push({ serverId: p.id, label, included: false, reason: 'unreachable' });
|
||||||
@@ -53,3 +60,47 @@ export function formatExcludedMemberLabels(
|
|||||||
})
|
})
|
||||||
.join(', ');
|
.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);
|
const streamSid = resolveServerIdForIndexKey(ref.serverId);
|
||||||
if (isServerLikelyReachable(streamSid)) return;
|
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 => {
|
void cascadeClusterPlayback(browseId, ref.trackId, streamSid).then(next => {
|
||||||
if (!next) {
|
if (!next) {
|
||||||
usePlayerStore.getState().next(false);
|
usePlayerStore.getState().next(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const track = st.currentTrack;
|
|
||||||
if (!track) return;
|
|
||||||
usePlayerStore.getState().playTrack(
|
usePlayerStore.getState().playTrack(
|
||||||
{ ...track, id: next.trackId, clusterBrowseServerId: next.serverId },
|
{ ...track, id: next.trackId, clusterBrowseServerId: next.serverId },
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -47,11 +47,13 @@ export async function getClusterMergeMemberIds(clusterId: string): Promise<strin
|
|||||||
const { useAuthStore } = await import('../../store/authStore');
|
const { useAuthStore } = await import('../../store/authStore');
|
||||||
const cluster = useAuthStore.getState().clusters.find(c => c.id === clusterId);
|
const cluster = useAuthStore.getState().clusters.find(c => c.id === clusterId);
|
||||||
if (!cluster) return [];
|
if (!cluster) return [];
|
||||||
const out: string[] = [];
|
const members = getClusterMemberProfiles(cluster);
|
||||||
for (const server of getClusterMemberProfiles(cluster)) {
|
const ready = await Promise.all(
|
||||||
if (!isServerLikelyReachable(server.id)) continue;
|
members.map(async server => {
|
||||||
if (!(await libraryIsReady(server.id))) continue;
|
if (!isServerLikelyReachable(server.id)) return null;
|
||||||
out.push(server.id);
|
if (!(await libraryIsReady(server.id))) return null;
|
||||||
}
|
return server.id;
|
||||||
return out;
|
}),
|
||||||
|
);
|
||||||
|
return ready.filter((id): id is string => id != null);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user