diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/detail.rs b/src-tauri/crates/psysonic-library/src/server_cluster/detail.rs index b814f542..c0f3e4ab 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/detail.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/detail.rs @@ -13,6 +13,7 @@ use crate::search::aliased_track_columns; use crate::store::LibraryStore; use super::db::ATTACH_ALIAS; +use super::keys::artist_key_from_display_name; use super::list_albums::list_merged_albums; use super::merge::{solo_partition_key, DURATION_TOLERANCE_SEC}; use super::priority::{in_list_sql, priority_case_sql}; @@ -96,8 +97,8 @@ fn member_album_pairs( } } - let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); - let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + let (in_placeholders, in_params) = in_list_sql(servers_ordered); + let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered); let sql = format!( "SELECT DISTINCT t.server_id, t.album_id, ({priority_sql}) AS priority_rank FROM track t @@ -110,8 +111,8 @@ fn member_album_pairs( ORDER BY priority_rank, t.server_id, t.album_id" ); let mut params: Vec = Vec::new(); - params.append(&mut priority_params); - params.append(&mut in_params); + params.extend(priority_params); + params.extend(in_params); params.push(SqlValue::Text(merge_key.to_string())); store.with_read_conn(|conn| { @@ -231,7 +232,7 @@ fn merged_tracks_for_album_pairs( if pairs.is_empty() { return Ok(Vec::new()); } - let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered); let pair_clauses: Vec = pairs .iter() .map(|_| "(t.server_id = ? AND t.album_id = ?)".to_string()) @@ -289,7 +290,7 @@ fn merged_tracks_for_album_pairs( ); let mut params: Vec = Vec::new(); - params.append(&mut priority_params); + params.extend(priority_params); for (sid, aid, _) in pairs { params.push(SqlValue::Text(sid.clone())); params.push(SqlValue::Text(aid.clone())); @@ -406,6 +407,9 @@ fn resolve_artist_seed( let exists: bool = store.with_read_conn(|conn| { conn.query_row( "SELECT EXISTS( + SELECT 1 FROM artist ar + WHERE ar.server_id = ?1 AND ar.id = ?2 + ) OR EXISTS( SELECT 1 FROM track WHERE server_id = ?1 AND deleted = 0 AND (artist_id = ?2 OR artist = ?2) @@ -526,8 +530,8 @@ fn merged_albums_for_artist_key( if servers_ordered.is_empty() { return Ok(Vec::new()); } - let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); - let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + let (in_placeholders, in_params) = in_list_sql(servers_ordered); + let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered); let sql = format!( "WITH candidates AS ( SELECT @@ -585,8 +589,8 @@ fn merged_albums_for_artist_key( ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE", ); let mut params: Vec = Vec::new(); - params.append(&mut priority_params); - params.append(&mut in_params); + params.extend(priority_params); + params.extend(in_params); params.push(SqlValue::Text(artist_key.to_string())); store.with_read_conn(|conn| { @@ -625,8 +629,8 @@ fn merged_top_tracks_for_artist_key( if servers_ordered.is_empty() { return Ok(Vec::new()); } - let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); - let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + let (in_placeholders, in_params) = in_list_sql(servers_ordered); + let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered); let cols = aliased_track_columns("t"); let sql = format!( "WITH candidates AS ( @@ -681,8 +685,8 @@ fn merged_top_tracks_for_artist_key( tol = DURATION_TOLERANCE_SEC, ); let mut params: Vec = Vec::new(); - params.append(&mut priority_params); - params.append(&mut in_params); + params.extend(priority_params); + params.extend(in_params); params.push(SqlValue::Text(artist_key.to_string())); params.push(SqlValue::Integer(limit as i64)); @@ -696,6 +700,165 @@ fn merged_top_tracks_for_artist_key( .map_err(|e| e.to_string()) } +/// Album rows for an artist when cluster-key merge yields nothing — match the +/// `album` table (and track-only albums) by artist id or display name. +fn fallback_albums_for_artist_scope( + store: &LibraryStore, + servers_ordered: &[String], + artist_ref: &str, + artist_name: &str, +) -> Result, 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 = 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 = 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::>>() + })?; + 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 = 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::>>() + }) + .map_err(|e| e.to_string()) +} + +fn map_album_dto_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let raw: Option = 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, 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 = 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::>>() + }) + .map_err(|e| e.to_string()) +} + pub fn cluster_artist_detail( store: &LibraryStore, servers_ordered: &[String], @@ -708,8 +871,6 @@ pub fn cluster_artist_detail( return Err("artist not found in cluster scope".to_string()); }; - let artist_key = artist_key_for_pair(store, &seed_sid, &seed_aid)?; - let owner_sid = seed_sid.clone(); let owner_aid = seed_aid.clone(); @@ -717,17 +878,39 @@ pub fn cluster_artist_detail( .or_else(|| fallback_artist_from_tracks(store, &owner_sid, &owner_aid).ok().flatten()) .ok_or_else(|| "artist metadata missing".to_string())?; - let albums = if let Some(ref key) = artist_key { + let mut artist_key = artist_key_for_pair(store, &owner_sid, &owner_aid)?; + if artist_key.is_none() { + artist_key = artist_key_from_display_name(&artist.name); + } + + let mut albums = if let Some(ref key) = artist_key { merged_albums_for_artist_key(store, servers_ordered, key)? } else { Vec::new() }; + if albums.is_empty() { + albums = fallback_albums_for_artist_scope( + store, + servers_ordered, + &owner_aid, + &artist.name, + )?; + } - let top_tracks = if let Some(ref key) = artist_key { + let mut top_tracks = if let Some(ref key) = artist_key { merged_top_tracks_for_artist_key(store, servers_ordered, key, TOP_TRACKS_LIMIT)? } else { Vec::new() }; + if top_tracks.is_empty() { + top_tracks = fallback_top_tracks_for_artist_scope( + store, + servers_ordered, + &owner_aid, + &artist.name, + TOP_TRACKS_LIMIT, + )?; + } Ok(LibraryClusterArtistDetailResponse { artist, diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/keys.rs b/src-tauri/crates/psysonic-library/src/server_cluster/keys.rs index 4667519b..36925026 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/keys.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/keys.rs @@ -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 { + let norm = norm_field(name); + if norm.is_empty() { + return None; + } + Some(hash_parts(&[&norm], None)) +} + #[cfg(test)] mod tests { use super::*; @@ -110,6 +119,13 @@ mod tests { assert!(compute_track_cluster_keys(None, None, "Title", "Album").is_none()); } + #[test] + fn artist_key_from_display_name_matches_track_derived_key() { + let from_track = compute_track_cluster_keys(Some("Pink Floyd"), None, "x", "y").unwrap(); + let from_name = artist_key_from_display_name("Pink Floyd").unwrap(); + assert_eq!(from_track.artist_key, from_name); + } + #[test] fn punctuation_insensitive_cluster_key() { let a = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time", "Dark Side").unwrap(); diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/list_artists.rs b/src-tauri/crates/psysonic-library/src/server_cluster/list_artists.rs index 4e8e3357..f89848ca 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/list_artists.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/list_artists.rs @@ -24,63 +24,95 @@ pub fn list_merged_artists( } let limit = limit.clamp(1, PAGE_LIMIT_MAX); let offset = offset.min(i32::MAX as u32) as i32; - let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); - let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + let (in_placeholders, in_params) = in_list_sql(servers_ordered); + let (priority_sql, priority_params) = priority_case_sql("c.server_id", servers_ordered); + // Artist-first catalog: one row per artist (not per track), then merge by + // `artist_key`. The previous track-scan + window over every row was O(tracks) + // with correlated album counts and blocked the Artists browse page on large libs. let sql = format!( - "WITH candidates AS ( + "WITH artist_keys AS ( SELECT - t.rowid AS tid, t.server_id, COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref, - k.artist_key, - ({priority_sql}) AS priority_rank + MIN(k.artist_key) AS artist_key FROM track t - LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + INNER JOIN {ATTACH_ALIAS}.track_cluster_key k ON k.server_id = t.server_id AND k.track_id = t.id WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders}) - AND COALESCE(t.artist, '') != '' + AND k.artist_key IS NOT NULL + GROUP BY t.server_id, artist_ref ), - partitioned AS ( - SELECT c.tid, + track_artists AS ( + SELECT + t.server_id, + COALESCE(NULLIF(t.artist_id, ''), t.artist) AS id, + MAX(t.artist) AS name, + COUNT(DISTINCT CASE + WHEN t.album_id IS NOT NULL AND t.album_id != '' THEN t.album_id + END) AS album_count, + MAX(t.synced_at) AS synced_at, + CAST(NULL AS TEXT) AS raw_json + FROM track t + WHERE t.deleted = 0 + AND t.server_id IN ({in_placeholders}) + AND COALESCE(t.artist, '') != '' + AND NOT EXISTS ( + SELECT 1 FROM artist ar + WHERE ar.server_id = t.server_id + AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist) + ) + GROUP BY t.server_id, COALESCE(NULLIF(t.artist_id, ''), t.artist) + ), + catalog AS ( + SELECT ar.server_id, ar.id, ar.name, ar.album_count, ar.synced_at, ar.raw_json + FROM artist ar + WHERE ar.server_id IN ({in_placeholders}) + UNION ALL + SELECT server_id, id, name, album_count, synced_at, raw_json + FROM track_artists + ), + candidates AS ( + SELECT + c.server_id, + c.id, + c.name, + c.album_count, + c.synced_at, + c.raw_json, + ({priority_sql}) AS priority_rank, CASE - WHEN c.artist_key IS NULL THEN 'solo:' || c.server_id || ':' || c.artist_ref - ELSE c.artist_key - END AS merge_key, - c.priority_rank - FROM candidates c + WHEN ak.artist_key IS NOT NULL THEN ak.artist_key + ELSE 'solo:' || c.server_id || ':' || c.id + END AS merge_key + FROM catalog c + LEFT JOIN artist_keys ak + ON ak.server_id = c.server_id AND ak.artist_ref = c.id ), winners AS ( - SELECT tid, + SELECT + server_id, + id, + name, + album_count, + synced_at, + raw_json, ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn - FROM partitioned + FROM candidates ) - SELECT - t.server_id, - COALESCE(NULLIF(t.artist_id, ''), t.artist), - COALESCE(ar.name, t.artist), - COALESCE(ar.album_count, ( - SELECT COUNT(DISTINCT c.album_id) FROM track c - WHERE c.server_id = t.server_id - AND c.deleted = 0 - AND c.album_id IS NOT NULL - AND (c.artist_id = t.artist_id OR c.artist = t.artist) - )), - COALESCE(ar.synced_at, t.synced_at), - ar.raw_json - FROM winners w - JOIN track t ON t.rowid = w.tid - LEFT JOIN artist ar ON ar.server_id = t.server_id - AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist) - WHERE w.rn = 1 - ORDER BY COALESCE(ar.name, t.artist) COLLATE NOCASE, t.server_id - LIMIT ? OFFSET ?", + SELECT server_id, id, name, album_count, synced_at, raw_json + FROM winners + WHERE rn = 1 + ORDER BY name COLLATE NOCASE, server_id + LIMIT ? OFFSET ?", ); let mut params: Vec = Vec::new(); - params.append(&mut priority_params); - params.append(&mut in_params); + params.extend(in_params.iter().cloned()); + params.extend(in_params.iter().cloned()); + params.extend(in_params.iter().cloned()); + params.extend(priority_params); params.push(SqlValue::Integer(limit as i64)); params.push(SqlValue::Integer(offset as i64)); @@ -166,4 +198,28 @@ mod tests { assert_eq!(resp.artists.len(), 1); assert_eq!(resp.artists[0].server_id, "s1"); } + + #[test] + fn prefers_artist_table_album_count_when_present() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")]) + .unwrap(); + rebuild_all_cluster_keys(&store).unwrap(); + store + .with_conn("test", |conn| { + conn.execute( + "INSERT INTO artist (server_id, id, name, album_count, synced_at, raw_json) \ + VALUES ('s1', 'art-s1', 'Band', 3, 1, '{}'), \ + ('s2', 'art-s2', 'Band', 2, 1, '{}')", + [], + ) + }) + .unwrap(); + + let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0).unwrap(); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].server_id, "s1"); + assert_eq!(resp.artists[0].album_count, Some(3)); + } } diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index b18ab912..d4501586 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -77,7 +77,10 @@ function AlbumCard({ return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]); }); const psyDrag = useDragDrop(); - const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve }); + const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { + libraryResolve, + clusterSeedServerId: album.clusterSeedServerId, + }); const dragCoverKey = useMemo(() => { if (!coverRef) return ''; const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' }); diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index a3281cc1..1d9a15f2 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react'; import { CoverArtImage } from '../cover/CoverArtImage'; import { useAlbumCoverRef } from '../cover/useLibraryCoverRef'; +import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../cover/types'; import { useCoverLightboxSrc } from '../cover/lightbox'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../hooks/useIsMobile'; @@ -71,6 +72,8 @@ interface AlbumHeaderProps { headerArtistRefs: SubsonicOpenArtistRef[]; songs: SubsonicSong[]; coverArtId?: string; + /** Cluster / multi-server album detail — fetch cover from the seed member, not representative. */ + coverServerScope?: CoverServerScope; resolvedCoverUrl: string | null; isStarred: boolean; downloadProgress: number | null; @@ -98,6 +101,7 @@ export default function AlbumHeader({ headerArtistRefs, songs, coverArtId, + coverServerScope = COVER_SCOPE_ACTIVE, resolvedCoverUrl, isStarred, downloadProgress, @@ -124,7 +128,7 @@ export default function AlbumHeader({ const isMobile = useIsMobile(); const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); - const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true }); + const coverRef = useAlbumCoverRef(info.id, coverArtId, coverServerScope, { libraryResolve: true }); const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { alt: `${info.name} Cover`, }); diff --git a/src/components/ArtistCardLocal.tsx b/src/components/ArtistCardLocal.tsx index 5837349b..6c1d7006 100644 --- a/src/components/ArtistCardLocal.tsx +++ b/src/components/ArtistCardLocal.tsx @@ -18,7 +18,10 @@ interface Props { export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) { const { t } = useTranslation(); const navigateToArtist = useNavigateToArtist(); - const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve }); + const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { + libraryResolve, + clusterSeedServerId: artist.clusterSeedServerId, + }); return (
playAlbumShuffled(album.id), }); const navigate = useNavigate(); + const navigateToAlbum = useNavigateToAlbum(); const enqueue = usePlayerStore(s => s.enqueue); - const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false }); + const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { + libraryResolve: false, + clusterSeedServerId: album.clusterSeedServerId, + }); const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, { surface: 'dense', ensurePriority: 'high', }); const imgSrc = coverImgSrc(coverHandle.src); const bgResolved = coverHandle.src; - const handleOpen = () => navigate(`/album/${album.id}`); + const handleOpen = () => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId }); const handleEnqueue = async (e: React.MouseEvent) => { e.stopPropagation(); try { diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index 785b4517..f55d8b2c 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -5,12 +5,12 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { Check, ChevronDown, Layers } from 'lucide-react'; import { ConnectionStatus } from '../hooks/useConnectionStatus'; +import { useClusterConnectionLed } from '../hooks/useClusterConnectionLed'; import { useAuthStore } from '../store/authStore'; import { switchActiveCluster, switchActiveServer } from '../utils/server/switchActiveServer'; import { showToast } from '../utils/ui/toast'; import { serverListDisplayLabel } from '../utils/server/serverDisplayName'; import type { ServerCluster } from '../utils/serverCluster/types'; -import ClusterMergeBanner from './ClusterMergeBanner'; interface Props { status: ConnectionStatus; @@ -35,6 +35,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props const activeCluster = activeClusterId ? clusters.find(cluster => cluster.id === activeClusterId) ?? null : null; + const clusterLed = useClusterConnectionLed(activeCluster); + const effectiveStatus = + activeCluster && clusterLed.ledStatus !== null ? clusterLed.ledStatus : status; const updateMenuPosition = useCallback(() => { const el = hostRef.current; @@ -120,13 +123,19 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props }; const label = isLan ? 'LAN' : t('connection.extern'); - const tooltip = multi - ? t('connection.switchScopeHint') - : status === 'connected' + const metaTooltip = multi ? t('connection.switchScopeHint') : undefined; + const statusTooltip = + effectiveStatus === 'connected' ? t('connection.connectedTo', { server: serverName }) - : status === 'disconnected' + : effectiveStatus === 'disconnected' ? t('connection.disconnectedFrom', { server: serverName }) - : t('connection.checking'); + : effectiveStatus === 'degraded' + ? t('connection.connectedTo', { server: serverName }) + : t('connection.checking'); + const clusterActive = Boolean(activeCluster); + const ledTooltip = clusterActive + ? (clusterLed.ledTooltip ?? t('connection.checking')) + : statusTooltip; const renderMenuItem = (id: string, labelText: string, active: boolean, onClick: () => void, icon?: React.ReactNode) => ( {(() => { const albumForCover = topSongAlbumForCover(song, albums); - return albumForCover ? : null; + return albumForCover ? ( + + ) : null; })()}
{song.title}
diff --git a/src/components/artistDetail/ArtistTopTrackCover.tsx b/src/components/artistDetail/ArtistTopTrackCover.tsx index 128b0972..8a796f77 100644 --- a/src/components/artistDetail/ArtistTopTrackCover.tsx +++ b/src/components/artistDetail/ArtistTopTrackCover.tsx @@ -5,8 +5,17 @@ import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '../../cover/layoutSizes'; import type { TopSongAlbumCoverSource } from './topSongAlbumForCover'; /** 32px album thumb — same cover ref path as {@link AlbumCard} on artist pages. */ -export default function ArtistTopTrackCover({ album }: { album: TopSongAlbumCoverSource }) { - const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false }); +export default function ArtistTopTrackCover({ + album, + clusterSeedServerId, +}: { + album: TopSongAlbumCoverSource; + clusterSeedServerId?: string; +}) { + const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { + libraryResolve: false, + clusterSeedServerId, + }); if (!coverRef) return null; return ( diff --git a/src/components/artists/ArtistAvatars.tsx b/src/components/artists/ArtistAvatars.tsx index 3e8ea6e3..224fe38f 100644 --- a/src/components/artists/ArtistAvatars.tsx +++ b/src/components/artists/ArtistAvatars.tsx @@ -26,6 +26,7 @@ export function ArtistCardAvatar({ artist, showImages }: AvatarProps) { void; - onOpenArtist: (id: string) => void; + onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void; openContextMenu: PlayerState['openContextMenu']; t: TFunction; } @@ -29,7 +29,7 @@ function ArtistGridTile({ artist, ...rest }: TileProps) { if (rest.selectionMode) { rest.toggleSelect(artist.id); } else { - rest.onOpenArtist(artist.id); + rest.onOpenArtist(artist.id, { seedServerId: artist.clusterSeedServerId }); } }} onContextMenu={(e) => { @@ -68,7 +68,7 @@ interface Props { selectedArtists: SubsonicArtist[]; showArtistImages: boolean; toggleSelect: (id: string) => void; - onOpenArtist: (id: string) => void; + onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void; openContextMenu: PlayerState['openContextMenu']; t: TFunction; } diff --git a/src/components/artists/ArtistsListView.tsx b/src/components/artists/ArtistsListView.tsx index b80db2e5..693c0acf 100644 --- a/src/components/artists/ArtistsListView.tsx +++ b/src/components/artists/ArtistsListView.tsx @@ -13,7 +13,7 @@ interface RowProps { selectedArtists: SubsonicArtist[]; showArtistImages: boolean; toggleSelect: (id: string) => void; - onOpenArtist: (id: string) => void; + onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void; openContextMenu: PlayerState['openContextMenu']; t: TFunction; } @@ -37,7 +37,7 @@ function ArtistListRow({ if (selectionMode) { toggleSelect(artist.id); } else { - onOpenArtist(artist.id); + onOpenArtist(artist.id, { seedServerId: artist.clusterSeedServerId }); } }} onContextMenu={(e) => { @@ -78,7 +78,7 @@ interface Props { selectedArtists: SubsonicArtist[]; showArtistImages: boolean; toggleSelect: (id: string) => void; - onOpenArtist: (id: string) => void; + onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void; openContextMenu: PlayerState['openContextMenu']; t: TFunction; } diff --git a/src/components/contextMenu/QueueItemContextItems.tsx b/src/components/contextMenu/QueueItemContextItems.tsx index 1215ef8d..bea423be 100644 --- a/src/components/contextMenu/QueueItemContextItems.tsx +++ b/src/components/contextMenu/QueueItemContextItems.tsx @@ -111,7 +111,10 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')}
-
handleAction(() => openSongInfo(song.id))}> +
handleAction(() => openSongInfo( + song.id, + song.clusterBrowseServerId ?? queue[queueIndex ?? -1]?.serverId, + ))}> {t('contextMenu.songInfo')}
diff --git a/src/components/contextMenu/SongContextItems.tsx b/src/components/contextMenu/SongContextItems.tsx index 55821081..1c39cef2 100644 --- a/src/components/contextMenu/SongContextItems.tsx +++ b/src/components/contextMenu/SongContextItems.tsx @@ -103,7 +103,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) { )}
{song.albumId && ( -
handleAction(() => navigateToAlbum(song.albumId!))}> +
handleAction(() => navigateToAlbum(song.albumId!, { seedServerId: song.clusterBrowseServerId }))}> {t('contextMenu.openAlbum')}
)} @@ -161,7 +161,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')}
-
handleAction(() => openSongInfo(song.id))}> +
handleAction(() => openSongInfo(song.id, song.clusterBrowseServerId))}> {t('contextMenu.songInfo')}
{playlistId && playlistSongIndex !== undefined && ( @@ -246,7 +246,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
{song.albumId && ( -
handleAction(() => navigateToAlbum(song.albumId!))}> +
handleAction(() => navigateToAlbum(song.albumId!, { seedServerId: song.clusterBrowseServerId }))}> {t('contextMenu.openAlbum')}
)} @@ -298,7 +298,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')}
-
handleAction(() => openSongInfo(song.id))}> +
handleAction(() => openSongInfo(song.id, song.clusterBrowseServerId))}> {t('contextMenu.songInfo')}
diff --git a/src/components/contextMenu/contextMenuItemTypes.ts b/src/components/contextMenu/contextMenuItemTypes.ts index 3b2a7cc2..fe552eac 100644 --- a/src/components/contextMenu/contextMenuItemTypes.ts +++ b/src/components/contextMenu/contextMenuItemTypes.ts @@ -31,7 +31,7 @@ export interface ContextMenuItemsProps { setStarredOverride: (id: string, starred: boolean) => void; lastfmLovedCache: Record; setLastfmLovedForSong: (title: string, artist: string, loved: boolean) => void; - openSongInfo: (id: string) => void; + openSongInfo: (id: string, serverId?: string | null) => void; userRatingOverrides: Record; setKeyboardRating: React.Dispatch>; keyboardRating: KeyboardRating | null; diff --git a/src/components/playerBar/PlayerTrackInfo.tsx b/src/components/playerBar/PlayerTrackInfo.tsx index a1463cdf..6358305f 100644 --- a/src/components/playerBar/PlayerTrackInfo.tsx +++ b/src/components/playerBar/PlayerTrackInfo.tsx @@ -14,6 +14,7 @@ import MarqueeText from '../MarqueeText'; import { OpenArtistRefInline } from '../OpenArtistRefInline'; import StarRating from '../StarRating'; import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay'; +import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum'; import { usePlayerStore } from '../../store/playerStore'; import { usePlayerBarLayoutStore, @@ -54,6 +55,7 @@ export function PlayerTrackInfo({ navigate, openContextMenu, t, }: Props) { const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering); + const navigateToAlbum = useNavigateToAlbum(); const playbackCoverRef = usePlaybackTrackCoverRef( showPreviewMeta ? null : currentTrack ?? undefined, ); @@ -125,7 +127,7 @@ export function PlayerTrackInfo({ : displayTitle} className="player-track-name" style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.albumId ? 'pointer' : 'default' }} - onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} + onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigateToAlbum(currentTrack.albumId, { seedServerId: currentTrack.clusterBrowseServerId })} onContextMenu={!isRadio && !showPreviewMeta && currentTrack?.albumId ? (e) => { e.preventDefault(); diff --git a/src/components/queuePanel/QueueCurrentTrack.tsx b/src/components/queuePanel/QueueCurrentTrack.tsx index 0533af8e..70759d15 100644 --- a/src/components/queuePanel/QueueCurrentTrack.tsx +++ b/src/components/queuePanel/QueueCurrentTrack.tsx @@ -17,6 +17,7 @@ import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay'; import { CoverArtImage } from '../../cover/CoverArtImage'; import { OpenArtistRefInline } from '../OpenArtistRefInline'; import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef'; +import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum'; import { usePlayerStore } from '../../store/playerStore'; import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs'; @@ -53,6 +54,7 @@ export function QueueCurrentTrack({ lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t, }: Props) { const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering); + const navigateToAlbum = useNavigateToAlbum(); const coverRef = usePlaybackTrackCoverRef(currentTrack); const artistRefs = resolveTrackArtistRefs(currentTrack); const enrichment = useQueueTrackEnrichment(currentTrack.id); @@ -234,7 +236,7 @@ export function QueueCurrentTrack({
currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} + onClick={() => currentTrack.albumId && navigateToAlbum(currentTrack.albumId, { seedServerId: currentTrack.clusterBrowseServerId })} >{currentTrack.album}
{currentTrack.year && (
{currentTrack.year}
diff --git a/src/components/tracks/TracksPageChrome.tsx b/src/components/tracks/TracksPageChrome.tsx index 32b263ac..3c320a52 100644 --- a/src/components/tracks/TracksPageChrome.tsx +++ b/src/components/tracks/TracksPageChrome.tsx @@ -164,7 +164,7 @@ export default function TracksPageChrome({ hero.albumId && navigateToAlbum(hero.albumId)} + onClick={() => hero.albumId && navigateToAlbum(hero.albumId, { seedServerId: hero.clusterBrowseServerId })} >{hero.album} )} diff --git a/src/cover/AlbumCoverArtImage.tsx b/src/cover/AlbumCoverArtImage.tsx index 8422d48c..d0e7d33f 100644 --- a/src/cover/AlbumCoverArtImage.tsx +++ b/src/cover/AlbumCoverArtImage.tsx @@ -6,6 +6,7 @@ export type AlbumCoverArtImageProps = Omit & { albumId: string; coverArt?: string | null; serverScope?: CoverServerScope; + clusterSeedServerId?: string | null; /** Live search: use API `coverArt` ids only (avoids library IPC per row). */ libraryResolve?: boolean; }; @@ -14,6 +15,7 @@ export function AlbumCoverArtImage({ albumId, coverArt, serverScope, + clusterSeedServerId, libraryResolve = false, ...rest }: AlbumCoverArtImageProps) { @@ -21,7 +23,7 @@ export function AlbumCoverArtImage({ albumId, coverArt, serverScope ?? COVER_SCOPE_ACTIVE, - { libraryResolve }, + { libraryResolve, clusterSeedServerId }, ); if (!coverRef) return null; return ; diff --git a/src/cover/ArtistCoverArtImage.tsx b/src/cover/ArtistCoverArtImage.tsx index 3b9f0fe8..9d758739 100644 --- a/src/cover/ArtistCoverArtImage.tsx +++ b/src/cover/ArtistCoverArtImage.tsx @@ -6,6 +6,7 @@ export type ArtistCoverArtImageProps = Omit & { artistId: string; coverArt?: string | null; serverScope?: CoverServerScope; + clusterSeedServerId?: string | null; libraryResolve?: boolean; }; @@ -13,6 +14,7 @@ export function ArtistCoverArtImage({ artistId, coverArt, serverScope, + clusterSeedServerId, libraryResolve = false, ...rest }: ArtistCoverArtImageProps) { @@ -20,7 +22,7 @@ export function ArtistCoverArtImage({ artistId, coverArt, serverScope ?? COVER_SCOPE_ACTIVE, - { libraryResolve }, + { libraryResolve, clusterSeedServerId }, ); if (!coverRef) return null; return ; diff --git a/src/cover/TrackCoverArtImage.tsx b/src/cover/TrackCoverArtImage.tsx index 52e327de..0541e91e 100644 --- a/src/cover/TrackCoverArtImage.tsx +++ b/src/cover/TrackCoverArtImage.tsx @@ -4,7 +4,7 @@ import { useTrackCoverRef } from './useLibraryCoverRef'; import { COVER_SCOPE_ACTIVE, type CoverServerScope } from './types'; export type TrackCoverArtImageProps = Omit & { - song: Pick; + song: Pick; serverScope?: CoverServerScope; /** Default false for browse rails; true for queue/player rows needing per-disc art. */ libraryResolve?: boolean; diff --git a/src/cover/ref.ts b/src/cover/ref.ts index c1c060ec..22a5322b 100644 --- a/src/cover/ref.ts +++ b/src/cover/ref.ts @@ -1,8 +1,11 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { useAuthStore } from '../store/authStore'; -import { findServerByIdOrIndexKey } from '../utils/server/serverLookup'; +import { usePlayerStore } from '../store/playerStore'; +import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup'; import type { SubsonicSong } from '../api/subsonicTypes'; +import type { Track } from '../store/playerStoreTypes'; import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types'; +import { COVER_SCOPE_ACTIVE } from './types'; import { albumHasDistinctDiscCovers, coverEntryToRef, @@ -185,6 +188,23 @@ export function coverArtRef( return albumCoverRef(id, id, serverScope); } +/** Pin cover HTTP/disk scope to a specific library member (cluster browse rows). */ +export function coverScopeForServerProfileId( + serverId: string | undefined | null, + fallback: CoverServerScope = COVER_SCOPE_ACTIVE, +): CoverServerScope { + if (!serverId?.trim()) return fallback; + const server = findServerByIdOrIndexKey(serverId); + if (!server) return fallback; + return { + kind: 'server', + serverId: server.id, + url: server.url, + username: server.username, + password: server.password, + }; +} + export function resolvePlaybackCoverScope(): CoverServerScope { const playbackSid = getPlaybackServerId(); const activeSid = useAuthStore.getState().activeServerId; @@ -202,3 +222,27 @@ export function resolvePlaybackCoverScope(): CoverServerScope { } return { kind: 'playback' }; } + +/** Playback / queue cover scope — per-track cluster member when queue spans servers. */ +export function resolveCoverScopeForPlaybackTrack( + track: Pick | 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, + ); +} diff --git a/src/cover/useLibraryCoverRef.ts b/src/cover/useLibraryCoverRef.ts index 9aff1517..068489f5 100644 --- a/src/cover/useLibraryCoverRef.ts +++ b/src/cover/useLibraryCoverRef.ts @@ -7,14 +7,16 @@ import { albumCoverRefForPlayback, albumCoverRefForSong, artistCoverRef, + coverScopeForServerProfileId, + resolveCoverScopeForPlaybackTrack, resolveDistinctDiscCoversForAlbum, - resolvePlaybackCoverScope, } from './ref'; import { resolveAlbumCoverRefFromLibrary, resolveArtistCoverRefFromLibrary, resolveTrackCoverRefFromLibrary, } from './resolveEntryLibrary'; +import type { Track } from '../store/playerStoreTypes'; import { COVER_SCOPE_ACTIVE, coverScopeKey, type CoverArtRef, type CoverServerScope } from './types'; function coverRefsEqual(a: CoverArtRef, b: CoverArtRef): boolean { @@ -43,8 +45,18 @@ export type LibraryCoverRefOptions = { * detail headers and queue rows that need per-disc slots from SQLite. */ libraryResolve?: boolean; + /** Cluster browse row — pin cover HTTP/disk to this library member. */ + clusterSeedServerId?: string | null; }; +function coverScopeWithClusterSeed( + serverScope: CoverServerScope, + clusterSeedServerId?: string | null, +): CoverServerScope { + if (!clusterSeedServerId) return serverScope; + return coverScopeForServerProfileId(clusterSeedServerId, serverScope); +} + /** Album grid / card — sync fallback, then local library index when indexed. */ export function useAlbumCoverRef( albumId: string | null | undefined, @@ -53,7 +65,11 @@ export function useAlbumCoverRef( options?: LibraryCoverRefOptions, ): CoverArtRef | null { const libraryResolve = options?.libraryResolve !== false; - const scopeKey = coverScopeKey(serverScope); + const resolvedScope = useMemo( + () => coverScopeWithClusterSeed(serverScope, options?.clusterSeedServerId), + [serverScope, options?.clusterSeedServerId], + ); + const scopeKey = coverScopeKey(resolvedScope); const distinctDiscCovers = useMemo( () => resolveDistinctDiscCoversForAlbum(albumId ?? '', fallbackCoverArt), [albumId, fallbackCoverArt], @@ -61,8 +77,8 @@ export function useAlbumCoverRef( const syncRef = useMemo(() => { const id = albumId?.trim(); if (!id) return null; - return albumCoverRef(id, fallbackCoverArt, { serverScope, distinctDiscCovers }); - }, [albumId, fallbackCoverArt, scopeKey, serverScope, distinctDiscCovers]); + return albumCoverRef(id, fallbackCoverArt, { serverScope: resolvedScope, distinctDiscCovers }); + }, [albumId, fallbackCoverArt, scopeKey, resolvedScope, distinctDiscCovers]); const [ref, setRef] = useState(syncRef); @@ -72,7 +88,7 @@ export function useAlbumCoverRef( const id = albumId?.trim(); if (!id) return; let cancelled = false; - void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => { + void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, resolvedScope).then(next => { if (!cancelled) { setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next)); } @@ -80,7 +96,7 @@ export function useAlbumCoverRef( return () => { cancelled = true; }; - }, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]); + }, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve, resolvedScope]); return libraryResolve ? ref : syncRef; } @@ -93,12 +109,16 @@ export function useArtistCoverRef( options?: LibraryCoverRefOptions, ): CoverArtRef | null { const libraryResolve = options?.libraryResolve !== false; - const scopeKey = coverScopeKey(serverScope); + const resolvedScope = useMemo( + () => coverScopeWithClusterSeed(serverScope, options?.clusterSeedServerId), + [serverScope, options?.clusterSeedServerId], + ); + const scopeKey = coverScopeKey(resolvedScope); const syncRef = useMemo(() => { const id = artistId?.trim(); if (!id) return null; - return artistCoverRef(id, fallbackCoverArt, serverScope); - }, [artistId, fallbackCoverArt, scopeKey, serverScope]); + return artistCoverRef(id, fallbackCoverArt, resolvedScope); + }, [artistId, fallbackCoverArt, scopeKey, resolvedScope]); const [ref, setRef] = useState(syncRef); @@ -108,7 +128,7 @@ export function useArtistCoverRef( const id = artistId?.trim(); if (!id) return; let cancelled = false; - void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => { + void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, resolvedScope).then(next => { if (!cancelled) { setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next)); } @@ -116,19 +136,24 @@ export function useArtistCoverRef( return () => { cancelled = true; }; - }, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]); + }, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve, resolvedScope]); return libraryResolve ? ref : syncRef; } /** Track row / song card — album-scoped; multi-CD from library when indexed. */ export function useTrackCoverRef( - song: Pick | null | undefined, + song: Pick | null | undefined, serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, options?: LibraryCoverRefOptions, ): CoverArtRef | undefined { const libraryResolve = options?.libraryResolve !== false; - const scopeKey = coverScopeKey(serverScope); + const browseServerId = song?.clusterBrowseServerId; + const resolvedScope = useMemo( + () => (browseServerId ? coverScopeForServerProfileId(browseServerId, serverScope) : serverScope), + [browseServerId, serverScope], + ); + const scopeKey = coverScopeKey(resolvedScope); const songId = song?.id; const albumId = song?.albumId; const coverArt = song?.coverArt; @@ -151,8 +176,9 @@ export function useTrackCoverRef( return albumCoverRefForSong( { id: songId, albumId, coverArt, discNumber }, distinctDiscCovers, + resolvedScope, ); - }, [songId, albumId, coverArt, discNumber, distinctDiscCovers]); + }, [songId, albumId, coverArt, discNumber, distinctDiscCovers, resolvedScope]); const [ref, setRef] = useState(syncRef); @@ -165,7 +191,7 @@ export function useTrackCoverRef( let cancelled = false; void resolveTrackCoverRefFromLibrary( { ...song, id: trackId, albumId: al }, - serverScope, + resolvedScope, distinctDiscCovers, ).then(next => { if (!cancelled) { @@ -190,16 +216,18 @@ export function useTrackCoverRef( return () => { cancelled = true; }; - }, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers]); + }, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers, resolvedScope]); return libraryResolve ? ref : syncRef; } /** Now playing / queue — playback server scope + library-backed multi-CD. */ export function usePlaybackTrackCoverRef( - track: Parameters[0] | null | undefined, + track: (Parameters[0] & Pick) | null | undefined, ): CoverArtRef | undefined { const queueServerId = usePlayerStore(s => s.queueServerId); + const queueIndex = usePlayerStore(s => s.queueIndex); + const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId ?? null); const queueLength = usePlayerStore(s => s.queueItems.length); const activeServerId = useAuthStore(s => s.activeServerId); const serversFingerprint = useAuthStore(s => @@ -209,8 +237,17 @@ export function usePlaybackTrackCoverRef( ); const scope = useMemo( - () => resolvePlaybackCoverScope(), - [queueServerId, queueLength, activeServerId, serversFingerprint], + () => resolveCoverScopeForPlaybackTrack(track, queueRefServerId), + [ + track, + track?.clusterBrowseServerId, + queueRefServerId, + queueServerId, + queueIndex, + queueLength, + activeServerId, + serversFingerprint, + ], ); const scopeKey = coverScopeKey(scope); diff --git a/src/cover/usePlaybackCoverArt.ts b/src/cover/usePlaybackCoverArt.ts index e20a5002..c50600b4 100644 --- a/src/cover/usePlaybackCoverArt.ts +++ b/src/cover/usePlaybackCoverArt.ts @@ -1,16 +1,22 @@ import { useMemo } from 'react'; -import { resolvePlaybackCoverScope } from './ref'; +import { resolveCoverScopeForPlaybackTrack } from './ref'; import type { CoverArtHandle, CoverArtRef } from './types'; import { useCoverArt } from './useCoverArt'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; +import type { Track } from '../store/playerStoreTypes'; /** Cover art for playback queue — uses queue server when it differs from browsed server. */ export function usePlaybackCoverArt( coverRef: CoverArtRef | undefined, displayCssPx: number, + track?: Pick | null, ): CoverArtHandle { + const storeTrack = usePlayerStore(s => s.currentTrack); + const resolvedTrack = track ?? storeTrack ?? null; const queueServerId = usePlayerStore(s => s.queueServerId); + const queueIndex = usePlayerStore(s => s.queueIndex); + const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId ?? null); const queueLength = usePlayerStore(s => s.queueItems.length); const activeServerId = useAuthStore(s => s.activeServerId); const serversFingerprint = useAuthStore(s => @@ -20,8 +26,17 @@ export function usePlaybackCoverArt( ); const scope = useMemo( - () => resolvePlaybackCoverScope(), - [queueServerId, queueLength, activeServerId, serversFingerprint], + () => resolveCoverScopeForPlaybackTrack(resolvedTrack, queueRefServerId), + [ + resolvedTrack, + resolvedTrack?.clusterBrowseServerId, + queueRefServerId, + queueServerId, + queueIndex, + queueLength, + activeServerId, + serversFingerprint, + ], ); const refWithScope = useMemo( () => (coverRef ? { ...coverRef, serverScope: scope } : null), diff --git a/src/hooks/useArtistDetailData.ts b/src/hooks/useArtistDetailData.ts index 60f6bb64..0287b903 100644 --- a/src/hooks/useArtistDetailData.ts +++ b/src/hooks/useArtistDetailData.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { search } from '../api/subsonicSearch'; -import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists'; +import { getArtist, getArtistInfo, getArtistForServer, getTopSongs } from '../api/subsonicArtists'; import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong, } from '../api/subsonicTypes'; @@ -88,6 +88,15 @@ export function useArtistDetailData( } let nextAlbums = clusterData.albums; let nextSongs = clusterData.topSongs; + if (nextAlbums.length === 0 && seedServerId) { + const seeded = await getArtistForServer(seedServerId, id).catch(() => null); + if (seeded?.albums?.length) { + nextAlbums = seeded.albums.map(a => ({ + ...a, + clusterSeedServerId: a.clusterSeedServerId ?? seedServerId, + })); + } + } if (losslessOnly) { ({ albums: nextAlbums, songs: nextSongs } = filterNetworkArtistToLossless(nextAlbums, nextSongs)); } diff --git a/src/hooks/useClusterConnectionLed.ts b/src/hooks/useClusterConnectionLed.ts new file mode 100644 index 00000000..8f178a21 --- /dev/null +++ b/src/hooks/useClusterConnectionLed.ts @@ -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(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 }; +} diff --git a/src/hooks/useClusterMemberDisplayLabel.ts b/src/hooks/useClusterMemberDisplayLabel.ts new file mode 100644 index 00000000..027b2b3a --- /dev/null +++ b/src/hooks/useClusterMemberDisplayLabel.ts @@ -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]); +} diff --git a/src/hooks/useConnectionStatus.ts b/src/hooks/useConnectionStatus.ts index 495e05c2..66065ee0 100644 --- a/src/hooks/useConnectionStatus.ts +++ b/src/hooks/useConnectionStatus.ts @@ -13,7 +13,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags'; // Backward-compatible re-export for call sites that still import from the hook. export { isLanUrl }; -export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; +export type ConnectionStatus = 'connected' | 'degraded' | 'disconnected' | 'checking'; export function useConnectionStatus() { const perfFlags = usePerfProbeFlags(); diff --git a/src/hooks/useNowPlayingPrewarm.ts b/src/hooks/useNowPlayingPrewarm.ts index 62a0b255..5bc27e37 100644 --- a/src/hooks/useNowPlayingPrewarm.ts +++ b/src/hooks/useNowPlayingPrewarm.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache'; import { albumCoverRef } from '../cover/ref'; -import { resolvePlaybackCoverScope } from '../cover/ref'; +import { resolvePlaybackCoverScopeForCurrentTrack } from '../cover/ref'; import { resolveTrackCoverRefFromLibrary } from '../cover/resolveEntryLibrary'; import { getDiskSrc, rememberDiskSrc } from '../cover/diskSrcCache'; import { coverStorageKeyFromRef } from '../cover/storageKeys'; @@ -75,7 +75,7 @@ export function useNowPlayingPrewarm(): void { coverArt: currentTrack.coverArt, discNumber: (currentTrack as { discNumber?: number }).discNumber, }, - resolvePlaybackCoverScope(), + resolvePlaybackCoverScopeForCurrentTrack(), ).then(ref => { if (ref) void prewarmCoverRef(ref); }); diff --git a/src/hooks/usePlaybackServerId.ts b/src/hooks/usePlaybackServerId.ts index d4e0f968..769833cf 100644 --- a/src/hooks/usePlaybackServerId.ts +++ b/src/hooks/usePlaybackServerId.ts @@ -1,18 +1,24 @@ import { useMemo } from 'react'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; -import { getPlaybackServerId } from '../utils/playback/playbackServer'; +import { getPlaybackServerId, resolveStreamServerIdForTrack } from '../utils/playback/playbackServer'; /** * Subsonic server that owns the current queue / stream (may differ from the browsed - * server). Use for Now Playing metadata without calling `ensurePlaybackServerActive`. + * server). When a track is playing, resolves the cluster member for that track. + * Use for Now Playing metadata without calling `ensurePlaybackServerActive`. */ export function usePlaybackServerId(): string { const queueServerId = usePlayerStore(s => s.queueServerId); const queueLength = usePlayerStore(s => s.queueItems.length); + const queueIndex = usePlayerStore(s => s.queueIndex); + const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId); + const currentTrack = usePlayerStore(s => s.currentTrack); const activeServerId = useAuthStore(s => s.activeServerId); return useMemo( - () => getPlaybackServerId(), - [queueServerId, queueLength, activeServerId], + () => (currentTrack + ? resolveStreamServerIdForTrack(currentTrack, queueRefServerId) + : getPlaybackServerId()), + [currentTrack, queueRefServerId, queueServerId, queueLength, queueIndex, activeServerId], ); } diff --git a/src/locales/de/cluster.ts b/src/locales/de/cluster.ts index 505dc45d..1ace852c 100644 --- a/src/locales/de/cluster.ts +++ b/src/locales/de/cluster.ts @@ -3,6 +3,11 @@ export const cluster = { fanOutRatingFailed: 'Bewertungs-Synchronisierung fehlgeschlagen auf: {{servers}}.', fanOutScrobbleFailed: 'Scrobble-Synchronisierung fehlgeschlagen auf: {{servers}}.', mergeBanner: 'Cluster-Zusammenführung schließt aus: {{excluded}}.', + connectionTooltipPartial: '{{available}}/{{total}} Server erreichbar. {{details}}', + connectionTooltipNone: 'Keine Cluster-Server erreichbar. {{details}}', + memberStatusOffline: '{{name}}: offline', + memberStatusIndexing: '{{name}}: Index läuft', + memberStatusAvailable: '{{name}}: erreichbar', memberIncluded: 'Eingeschlossen', memberExcludedOffline: 'Ausgeschlossen (offline)', memberExcludedIndexing: 'Ausgeschlossen (Index läuft)', diff --git a/src/locales/de/nowPlayingInfo.ts b/src/locales/de/nowPlayingInfo.ts index 2c312298..03c6450c 100644 --- a/src/locales/de/nowPlayingInfo.ts +++ b/src/locales/de/nowPlayingInfo.ts @@ -16,6 +16,7 @@ export const nowPlayingInfo = { enableBandsintownPromptDesc: 'Optional. Lädt Tourdaten der aktuellen Künstler*in über die öffentliche Bandsintown-API.', enableBandsintownPrivacy: 'Beim Aktivieren wird der Name der aktuell gespielten Künstler*in an die Bandsintown-API übertragen, um Tourdaten abzurufen. Es werden keine Konto- oder persönlichen Daten gesendet.', enableBandsintownAction: 'Aktivieren', + clusterServer: 'Server', role: { artist: 'Hauptkünstler*in', albumArtist: 'Album-Künstler*in', diff --git a/src/locales/de/songInfo.ts b/src/locales/de/songInfo.ts index 8c321a33..b1a692c3 100644 --- a/src/locales/de/songInfo.ts +++ b/src/locales/de/songInfo.ts @@ -24,4 +24,5 @@ export const songInfo = { replayGainPeak: 'RG Track Peak', mono: 'Mono', stereo: 'Stereo', + clusterServer: 'Server', }; diff --git a/src/locales/en/cluster.ts b/src/locales/en/cluster.ts index ef5e849d..abf98f62 100644 --- a/src/locales/en/cluster.ts +++ b/src/locales/en/cluster.ts @@ -3,6 +3,11 @@ export const cluster = { fanOutRatingFailed: 'Rating sync failed on: {{servers}}.', fanOutScrobbleFailed: 'Scrobble sync failed on: {{servers}}.', mergeBanner: 'Cluster merge excludes: {{excluded}}.', + connectionTooltipPartial: '{{available}}/{{total}} servers available. {{details}}', + connectionTooltipNone: 'No cluster servers available. {{details}}', + memberStatusOffline: '{{name}}: offline', + memberStatusIndexing: '{{name}}: indexing', + memberStatusAvailable: '{{name}}: available', memberIncluded: 'Included', memberExcludedOffline: 'Excluded (offline)', memberExcludedIndexing: 'Excluded (indexing)', diff --git a/src/locales/en/nowPlayingInfo.ts b/src/locales/en/nowPlayingInfo.ts index e57559e6..cc4bd816 100644 --- a/src/locales/en/nowPlayingInfo.ts +++ b/src/locales/en/nowPlayingInfo.ts @@ -16,6 +16,7 @@ export const nowPlayingInfo = { enableBandsintownPromptDesc: 'Optional. Loads concerts for the current artist via the public Bandsintown API.', enableBandsintownPrivacy: 'When enabled, the name of the currently playing artist is sent to the Bandsintown API to fetch tour dates. No account or personal data leaves your device.', enableBandsintownAction: 'Enable', + clusterServer: 'Server', role: { artist: 'Artist', albumArtist: 'Album artist', diff --git a/src/locales/en/songInfo.ts b/src/locales/en/songInfo.ts index 427478bc..14862006 100644 --- a/src/locales/en/songInfo.ts +++ b/src/locales/en/songInfo.ts @@ -24,4 +24,5 @@ export const songInfo = { replayGainPeak: 'RG Track Peak', mono: 'Mono', stereo: 'Stereo', + clusterServer: 'Server', }; diff --git a/src/locales/es/cluster.ts b/src/locales/es/cluster.ts index 167e5ca0..3ce2feb3 100644 --- a/src/locales/es/cluster.ts +++ b/src/locales/es/cluster.ts @@ -3,6 +3,11 @@ export const cluster = { fanOutRatingFailed: 'La sincronización de valoraciones falló en: {{servers}}.', fanOutScrobbleFailed: 'La sincronización de scrobble falló en: {{servers}}.', mergeBanner: 'La fusión del clúster excluye: {{excluded}}.', + connectionTooltipPartial: '{{available}}/{{total}} servidores disponibles. {{details}}', + connectionTooltipNone: 'Ningún servidor del clúster disponible. {{details}}', + memberStatusOffline: '{{name}}: sin conexión', + memberStatusIndexing: '{{name}}: indexando', + memberStatusAvailable: '{{name}}: disponible', memberIncluded: 'Incluido', memberExcludedOffline: 'Excluido (sin conexión)', memberExcludedIndexing: 'Excluido (indexando)', diff --git a/src/locales/es/nowPlayingInfo.ts b/src/locales/es/nowPlayingInfo.ts index 689743ea..384f4e93 100644 --- a/src/locales/es/nowPlayingInfo.ts +++ b/src/locales/es/nowPlayingInfo.ts @@ -16,6 +16,7 @@ export const nowPlayingInfo = { enableBandsintownPromptDesc: 'Opcional. Carga conciertos del artista actual vía la API pública de Bandsintown.', enableBandsintownPrivacy: 'Al activar, el nombre del artista actual se envía a la API de Bandsintown para obtener fechas de gira. No se envían datos de cuenta ni personales.', enableBandsintownAction: 'Activar', + clusterServer: 'Servidor', role: { artist: 'Artista', albumArtist: 'Artista del álbum', diff --git a/src/locales/es/songInfo.ts b/src/locales/es/songInfo.ts index ff6af93e..1e22d66e 100644 --- a/src/locales/es/songInfo.ts +++ b/src/locales/es/songInfo.ts @@ -24,4 +24,5 @@ export const songInfo = { replayGainPeak: 'RG Pico de Pista', mono: 'Mono', stereo: 'Estéreo', + clusterServer: 'Servidor', }; diff --git a/src/locales/fr/cluster.ts b/src/locales/fr/cluster.ts index 137657f7..f9eab402 100644 --- a/src/locales/fr/cluster.ts +++ b/src/locales/fr/cluster.ts @@ -3,6 +3,11 @@ export const cluster = { fanOutRatingFailed: 'Échec de synchro des notes sur : {{servers}}.', fanOutScrobbleFailed: 'Échec de synchro des scrobbles sur : {{servers}}.', mergeBanner: 'La fusion de cluster exclut : {{excluded}}.', + connectionTooltipPartial: '{{available}}/{{total}} serveurs disponibles. {{details}}', + connectionTooltipNone: 'Aucun serveur du cluster disponible. {{details}}', + memberStatusOffline: '{{name}} : hors ligne', + memberStatusIndexing: '{{name}} : indexation', + memberStatusAvailable: '{{name}} : disponible', memberIncluded: 'Inclus', memberExcludedOffline: 'Exclu (hors ligne)', memberExcludedIndexing: 'Exclu (indexation)', diff --git a/src/locales/fr/nowPlayingInfo.ts b/src/locales/fr/nowPlayingInfo.ts index f6633db2..9cf289a8 100644 --- a/src/locales/fr/nowPlayingInfo.ts +++ b/src/locales/fr/nowPlayingInfo.ts @@ -16,6 +16,7 @@ export const nowPlayingInfo = { enableBandsintownPromptDesc: 'Optionnel. Charge les concerts de l\'artiste via l\'API publique Bandsintown.', enableBandsintownPrivacy: 'Lors de l\'activation, le nom de l\'artiste en cours de lecture est envoyé à l\'API Bandsintown pour récupérer les dates de tournée. Aucun compte ni données personnelles ne quittent votre appareil.', enableBandsintownAction: 'Activer', + clusterServer: 'Serveur', role: { artist: 'Artiste', albumArtist: 'Artiste de l\'album', diff --git a/src/locales/fr/songInfo.ts b/src/locales/fr/songInfo.ts index 14af5003..3e1ec565 100644 --- a/src/locales/fr/songInfo.ts +++ b/src/locales/fr/songInfo.ts @@ -24,4 +24,5 @@ export const songInfo = { replayGainPeak: 'RG Crête de piste', mono: 'Mono', stereo: 'Stéréo', + clusterServer: 'Serveur', }; diff --git a/src/locales/nb/cluster.ts b/src/locales/nb/cluster.ts index 40ea6223..78176fde 100644 --- a/src/locales/nb/cluster.ts +++ b/src/locales/nb/cluster.ts @@ -3,6 +3,11 @@ export const cluster = { fanOutRatingFailed: 'Vurderings-synk mislyktes på: {{servers}}.', fanOutScrobbleFailed: 'Scrobble-synk mislyktes på: {{servers}}.', mergeBanner: 'Cluster-fletting ekskluderer: {{excluded}}.', + connectionTooltipPartial: '{{available}}/{{total}} servere tilgjengelige. {{details}}', + connectionTooltipNone: 'Ingen cluster-servere tilgjengelige. {{details}}', + memberStatusOffline: '{{name}}: offline', + memberStatusIndexing: '{{name}}: indeksering', + memberStatusAvailable: '{{name}}: tilgjengelig', memberIncluded: 'Inkludert', memberExcludedOffline: 'Ekskludert (offline)', memberExcludedIndexing: 'Ekskludert (indeksering)', diff --git a/src/locales/nb/nowPlayingInfo.ts b/src/locales/nb/nowPlayingInfo.ts index 1b9fabf3..8517e255 100644 --- a/src/locales/nb/nowPlayingInfo.ts +++ b/src/locales/nb/nowPlayingInfo.ts @@ -16,6 +16,7 @@ export const nowPlayingInfo = { enableBandsintownPromptDesc: 'Valgfritt. Henter konserter for gjeldende artist via det offentlige Bandsintown-API-et.', enableBandsintownPrivacy: 'Ved aktivering sendes navnet på artisten som spilles av til Bandsintown-API-et for å hente turnédatoer. Ingen konto- eller personopplysninger forlater enheten din.', enableBandsintownAction: 'Aktiver', + clusterServer: 'Server', role: { artist: 'Artist', albumArtist: 'Albumartist', diff --git a/src/locales/nb/songInfo.ts b/src/locales/nb/songInfo.ts index 3a5e349a..01bb24b0 100644 --- a/src/locales/nb/songInfo.ts +++ b/src/locales/nb/songInfo.ts @@ -24,4 +24,5 @@ export const songInfo = { replayGainPeak: 'RG-sportopp', mono: 'Mono', stereo: 'Stereo', + clusterServer: 'Server', }; diff --git a/src/locales/nl/cluster.ts b/src/locales/nl/cluster.ts index b70b0403..c06cf3eb 100644 --- a/src/locales/nl/cluster.ts +++ b/src/locales/nl/cluster.ts @@ -3,6 +3,11 @@ export const cluster = { fanOutRatingFailed: 'Beoordelings-sync mislukt op: {{servers}}.', fanOutScrobbleFailed: 'Scrobble-sync mislukt op: {{servers}}.', mergeBanner: 'Cluster-samenvoeging sluit uit: {{excluded}}.', + connectionTooltipPartial: '{{available}}/{{total}} servers beschikbaar. {{details}}', + connectionTooltipNone: 'Geen clusterservers beschikbaar. {{details}}', + memberStatusOffline: '{{name}}: offline', + memberStatusIndexing: '{{name}}: indexeren', + memberStatusAvailable: '{{name}}: beschikbaar', memberIncluded: 'Inbegrepen', memberExcludedOffline: 'Uitgesloten (offline)', memberExcludedIndexing: 'Uitgesloten (indexeren)', diff --git a/src/locales/nl/nowPlayingInfo.ts b/src/locales/nl/nowPlayingInfo.ts index 16c30447..1c2fc616 100644 --- a/src/locales/nl/nowPlayingInfo.ts +++ b/src/locales/nl/nowPlayingInfo.ts @@ -16,6 +16,7 @@ export const nowPlayingInfo = { enableBandsintownPromptDesc: 'Optioneel. Laadt concerten van de huidige artiest via de openbare Bandsintown-API.', enableBandsintownPrivacy: 'Bij inschakelen wordt de naam van de huidige artiest naar de Bandsintown-API gestuurd om tourdata op te halen. Er worden geen account- of persoonlijke gegevens verzonden.', enableBandsintownAction: 'Inschakelen', + clusterServer: 'Server', role: { artist: 'Artiest', albumArtist: 'Albumartiest', diff --git a/src/locales/nl/songInfo.ts b/src/locales/nl/songInfo.ts index 897ac746..33ab88b8 100644 --- a/src/locales/nl/songInfo.ts +++ b/src/locales/nl/songInfo.ts @@ -24,4 +24,5 @@ export const songInfo = { replayGainPeak: 'RG Track Peak', mono: 'Mono', stereo: 'Stereo', + clusterServer: 'Server', }; diff --git a/src/locales/ro/cluster.ts b/src/locales/ro/cluster.ts index 41468152..4b4df6af 100644 --- a/src/locales/ro/cluster.ts +++ b/src/locales/ro/cluster.ts @@ -3,6 +3,11 @@ export const cluster = { fanOutRatingFailed: 'Sincronizarea rating-urilor a eșuat pe: {{servers}}.', fanOutScrobbleFailed: 'Sincronizarea scrobble a eșuat pe: {{servers}}.', mergeBanner: 'Îmbinarea clusterului exclude: {{excluded}}.', + connectionTooltipPartial: '{{available}}/{{total}} servere disponibile. {{details}}', + connectionTooltipNone: 'Niciun server din cluster disponibil. {{details}}', + memberStatusOffline: '{{name}}: offline', + memberStatusIndexing: '{{name}}: indexare', + memberStatusAvailable: '{{name}}: disponibil', memberIncluded: 'Inclus', memberExcludedOffline: 'Exclus (offline)', memberExcludedIndexing: 'Exclus (indexare)', diff --git a/src/locales/ro/nowPlayingInfo.ts b/src/locales/ro/nowPlayingInfo.ts index 4288d9ab..6cf076cd 100644 --- a/src/locales/ro/nowPlayingInfo.ts +++ b/src/locales/ro/nowPlayingInfo.ts @@ -16,6 +16,7 @@ export const nowPlayingInfo = { enableBandsintownPromptDesc: 'Opțional. Încarcă concertele pentru artistul curent prin API-ul public Bandsintown.', enableBandsintownPrivacy: 'Când este pornit, numele artistului piesei curente redate este trimis către API-ul Bandsintown pentru a prelua datele turneelor. Nimic din datele personale sau ale contului nu părăsesc dispozitivul.', enableBandsintownAction: 'Pornește', + clusterServer: 'Server', role: { artist: 'Artist', albumArtist: 'Artist album', diff --git a/src/locales/ro/songInfo.ts b/src/locales/ro/songInfo.ts index ce2c0e05..139f9c42 100644 --- a/src/locales/ro/songInfo.ts +++ b/src/locales/ro/songInfo.ts @@ -24,4 +24,5 @@ export const songInfo = { replayGainPeak: 'RG Vârf Piesă', mono: 'Mono', stereo: 'Stereo', + clusterServer: 'Server', }; diff --git a/src/locales/ru/cluster.ts b/src/locales/ru/cluster.ts index bff9baa7..0ca29c12 100644 --- a/src/locales/ru/cluster.ts +++ b/src/locales/ru/cluster.ts @@ -3,6 +3,11 @@ export const cluster = { fanOutRatingFailed: 'Не удалось синхронизировать рейтинг на: {{servers}}.', fanOutScrobbleFailed: 'Не удалось синхронизировать скроббл на: {{servers}}.', mergeBanner: 'Из объединения кластера исключены: {{excluded}}.', + connectionTooltipPartial: 'Доступно {{available}} из {{total}} серверов. {{details}}', + connectionTooltipNone: 'Нет доступных серверов кластера. {{details}}', + memberStatusOffline: '{{name}}: офлайн', + memberStatusIndexing: '{{name}}: индексация', + memberStatusAvailable: '{{name}}: доступен', memberIncluded: 'Включён', memberExcludedOffline: 'Исключён (офлайн)', memberExcludedIndexing: 'Исключён (индексация)', diff --git a/src/locales/ru/nowPlayingInfo.ts b/src/locales/ru/nowPlayingInfo.ts index 9662e12a..c3211941 100644 --- a/src/locales/ru/nowPlayingInfo.ts +++ b/src/locales/ru/nowPlayingInfo.ts @@ -18,6 +18,7 @@ export const nowPlayingInfo = { enableBandsintownPromptDesc: 'Опционально. Загружает концерты текущего исполнителя через публичный API Bandsintown.', enableBandsintownPrivacy: 'При включении имя текущего исполнителя отправляется в API Bandsintown для получения дат концертов. Аккаунт и личные данные не передаются.', enableBandsintownAction: 'Включить', + clusterServer: 'Сервер', role: { artist: 'Исполнитель', albumArtist: 'Исполнитель альбома', diff --git a/src/locales/ru/songInfo.ts b/src/locales/ru/songInfo.ts index 683b0c3e..97ca7057 100644 --- a/src/locales/ru/songInfo.ts +++ b/src/locales/ru/songInfo.ts @@ -24,4 +24,5 @@ export const songInfo = { replayGainPeak: 'Пик RG', mono: 'Моно', stereo: 'Стерео', + clusterServer: 'Сервер', }; diff --git a/src/locales/zh/cluster.ts b/src/locales/zh/cluster.ts index f9f8f7bb..29825af3 100644 --- a/src/locales/zh/cluster.ts +++ b/src/locales/zh/cluster.ts @@ -3,6 +3,11 @@ export const cluster = { fanOutRatingFailed: '以下服务器评分同步失败:{{servers}}。', fanOutScrobbleFailed: '以下服务器 scrobble 同步失败:{{servers}}。', mergeBanner: '集群合并已排除:{{excluded}}。', + connectionTooltipPartial: '{{available}}/{{total}} 个服务器可用。{{details}}', + connectionTooltipNone: '没有可用的集群服务器。{{details}}', + memberStatusOffline: '{{name}}:离线', + memberStatusIndexing: '{{name}}:索引中', + memberStatusAvailable: '{{name}}:可用', memberIncluded: '已纳入', memberExcludedOffline: '已排除(离线)', memberExcludedIndexing: '已排除(索引中)', diff --git a/src/locales/zh/nowPlayingInfo.ts b/src/locales/zh/nowPlayingInfo.ts index 5240935d..004f02b0 100644 --- a/src/locales/zh/nowPlayingInfo.ts +++ b/src/locales/zh/nowPlayingInfo.ts @@ -16,6 +16,7 @@ export const nowPlayingInfo = { enableBandsintownPromptDesc: '可选。通过公开的 Bandsintown API 加载当前艺术家的演出。', enableBandsintownPrivacy: '启用后,当前播放的艺术家名称将发送到 Bandsintown API 以获取巡演日期。不会发送任何账户或个人数据。', enableBandsintownAction: '启用', + clusterServer: '服务器', role: { artist: '艺术家', albumArtist: '专辑艺术家', diff --git a/src/locales/zh/songInfo.ts b/src/locales/zh/songInfo.ts index cb577f71..6de07c4a 100644 --- a/src/locales/zh/songInfo.ts +++ b/src/locales/zh/songInfo.ts @@ -24,4 +24,5 @@ export const songInfo = { replayGainPeak: 'RG 曲目峰值', mono: '单声道', stereo: '立体声', + clusterServer: '服务器', }; diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 194763cf..b9bbd7b7 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -6,7 +6,7 @@ import type { SubsonicSong } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import { shuffleArray } from '../utils/playback/shuffleArray'; import React, { useEffect, useState, useCallback, useMemo } from 'react'; -import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; +import { useParams, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { invoke } from '@tauri-apps/api/core'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; @@ -26,8 +26,10 @@ import { useCoverArt } from '../cover/useCoverArt'; import { forgetAlbumDistinctDiscCovers, rememberAlbumDistinctDiscCovers, + coverScopeForServerProfileId, } from '../cover/ref'; import { useAlbumCoverRef } from '../cover/useLibraryCoverRef'; +import { readClusterSeedServerId } from '../utils/navigation/albumDetailNavigation'; import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/ui/toast'; import { useSelectionStore } from '../store/selectionStore'; @@ -45,6 +47,7 @@ export default function AlbumDetail() { const perfFlags = usePerfProbeFlags(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const location = useLocation(); const [searchParams] = useSearchParams(); const losslessOnly = isLosslessMode(searchParams); const auth = useAuthStore(); @@ -104,8 +107,10 @@ export default function AlbumDetail() { const handlePlayAll = () => { if (!album || !effectiveSongs) return; const albumGenre = album.album.genre; + const seedSid = album.album.clusterSeedServerId; const tracks = effectiveSongs.map(s => { const t = songToTrack(s); + if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid; if (!t.genre && albumGenre) t.genre = albumGenre; return t; }); @@ -115,8 +120,10 @@ const handlePlayAll = () => { const handleEnqueueAll = () => { if (!album || !effectiveSongs) return; const albumGenre = album.album.genre; + const seedSid = album.album.clusterSeedServerId; const tracks = effectiveSongs.map(s => { const t = songToTrack(s); + if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid; if (!t.genre && albumGenre) t.genre = albumGenre; return t; }); @@ -126,8 +133,10 @@ const handleEnqueueAll = () => { const handleShuffleAll = () => { if (!album || !effectiveSongs) return; const albumGenre = album.album.genre; + const seedSid = album.album.clusterSeedServerId; const tracks = effectiveSongs.map(s => { const t = songToTrack(s); + if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid; if (!t.genre && albumGenre) t.genre = albumGenre; return t; }); @@ -141,12 +150,15 @@ const handleShuffleAll = () => { if (orbitActive) { queueHint(); return; } if (!album || !effectiveSongs) return; const albumGenre = album.album.genre; + const seedSid = album.album.clusterSeedServerId; const tracks = effectiveSongs.map(s => { const t = songToTrack(s); + if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid; if (!t.genre && albumGenre) t.genre = albumGenre; return t; }); const track = tracks.find(t => t.id === song.id) || songToTrack(song); + if (!track.clusterBrowseServerId && seedSid) track.clusterBrowseServerId = seedSid; playTrack(track, tracks); }; @@ -281,11 +293,17 @@ const handleShuffleAll = () => { userRatingOverrides, }); + const albumSeedServerId = album?.album.clusterSeedServerId ?? readClusterSeedServerId(location.state); + const albumCoverScope = useMemo( + () => coverScopeForServerProfileId(albumSeedServerId), + [albumSeedServerId], + ); + const albumCoverRefResolved = useAlbumCoverRef( album?.album.id, album?.album.coverArt, undefined, - { libraryResolve: true }, + { libraryResolve: true, clusterSeedServerId: albumSeedServerId }, ); const albumCover = useCoverArt(albumCoverRefResolved, 400, { surface: 'sparse' }); const resolvedCoverUrl = albumCover.src || null; @@ -318,6 +336,7 @@ const handleShuffleAll = () => { headerArtistRefs={headerArtistRefs} songs={songs} coverArtId={info.coverArt} + coverServerScope={albumCoverScope} resolvedCoverUrl={resolvedCoverUrl} isStarred={isStarred} downloadProgress={null} diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index e36ba406..c3df89ce 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -2,9 +2,7 @@ import { uploadArtistImage } from '../api/subsonicPlaylists'; import { useCoverArt } from '../cover/useCoverArt'; import { useArtistCoverRef } from '../cover/useLibraryCoverRef'; import { setRating, star, unstar } from '../api/subsonicStarRating'; -import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes'; -import { songToTrack } from '../utils/playback/songToTrack'; import { useEffect, useState, useRef, Fragment, useMemo } from 'react'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import AlbumCard from '../components/AlbumCard'; @@ -33,6 +31,8 @@ import { import { useArtistDetailData } from '../hooks/useArtistDetailData'; import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists'; import { + buildArtistTopSongPlayQueue, + fetchArtistCatalogTracks, runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio, } from '../utils/componentHelpers/runArtistDetailPlay'; import { @@ -137,29 +137,12 @@ export default function ArtistDetail() { }; const playTopSongWithContinuation = async (startIndex: number) => { - if (!artist || albums.length === 0) return; + if (!artist || topSongs.length === 0) return; setPlayAllLoading(true); try { - // Get all artist tracks ordered by album and track number - const results = await Promise.all(albums.map(a => getAlbum(a.id))); - const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0)); - const allTracks = sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack); - - // Top songs from clicked index onward - const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack); - - // Track IDs for deduplication - const topSongIds = new Set(topSongs.map(s => s.id)); - - // Filter remaining tracks to exclude top songs (prevent duplicates) - const remainingTracks = allTracks.filter(tr => !topSongIds.has(tr.id)); - - // Build queue: remaining top songs + rest of artist catalog - const queue = [...topTracksFromIndex, ...remainingTracks]; - - if (queue.length > 0) { - playTrack(queue[0], queue); - } + const catalogTracks = albums.length > 0 ? await fetchArtistCatalogTracks(albums) : []; + const queue = buildArtistTopSongPlayQueue(topSongs, startIndex, catalogTracks); + if (queue.length > 0) playTrack(queue[0], queue); } finally { setPlayAllLoading(false); } @@ -173,6 +156,7 @@ export default function ArtistDetail() { const coverId = artist ? (artist.coverArt || artist.id) : ''; const artistCoverRefResolved = useArtistCoverRef(artist?.id, artist?.coverArt, undefined, { libraryResolve: true, + clusterSeedServerId: artist?.clusterSeedServerId, }); const artistCoverFallback = useCoverArt(artistCoverRefResolved, 80, { surface: 'sparse' }); @@ -346,6 +330,7 @@ export default function ArtistDetail() { marginTop={sectionMt('topTracks')} playTopSongWithContinuation={playTopSongWithContinuation} losslessOnly={losslessOnly} + clusterSeedServerId={artist.clusterSeedServerId} /> ); diff --git a/src/store/audioListenerSetup/discordPresence.ts b/src/store/audioListenerSetup/discordPresence.ts index 3f958bca..e799a64c 100644 --- a/src/store/audioListenerSetup/discordPresence.ts +++ b/src/store/audioListenerSetup/discordPresence.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; -import { resolvePlaybackCoverScope } from '../../cover/ref'; +import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref'; import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary'; import { coverArtUrlForDiscord } from '../../cover/integrations/discord'; import { useAuthStore } from '../authStore'; @@ -96,7 +96,7 @@ export function setupDiscordPresence(): () => void { coverArt: currentTrack.coverArt, discNumber: (currentTrack as { discNumber?: number }).discNumber, }, - resolvePlaybackCoverScope(), + resolvePlaybackCoverScopeForCurrentTrack(), ).then(ref => { if (!ref) { sendPresence(null); diff --git a/src/store/audioListenerSetup/mprisSync.ts b/src/store/audioListenerSetup/mprisSync.ts index 5c913e61..6996e3ae 100644 --- a/src/store/audioListenerSetup/mprisSync.ts +++ b/src/store/audioListenerSetup/mprisSync.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; -import { resolvePlaybackCoverScope } from '../../cover/ref'; +import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref'; import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary'; import { coverArtUrlForMpris } from '../../cover/integrations/mpris'; import { usePlayerStore } from '../playerStore'; @@ -35,7 +35,7 @@ export function setupMprisSync(): () => void { coverArt: currentTrack.coverArt, discNumber: (currentTrack as { discNumber?: number }).discNumber, }, - resolvePlaybackCoverScope(), + resolvePlaybackCoverScopeForCurrentTrack(), ).then(ref => { if (!ref) return; coverArtUrlForMpris(ref) diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index 9e1ec7d9..8ad4426c 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -266,7 +266,10 @@ export function runPlayTrack( && getPlaybackCacheServerKey(), ); - const browseServerId = authState.activeServerId ?? ''; + const browseServerId = + track.clusterBrowseServerId + ?? authState.activeServerId + ?? ''; const browseTrackId = track.id; void (async () => { @@ -276,7 +279,11 @@ export function runPlayTrack( if (isClusterMode() && browseServerId) { const resolved = await resolveClusterPlaybackForTrack(browseServerId, track.id); if (resolved) { - playTrackResolved = { ...track, id: resolved.trackId }; + playTrackResolved = { + ...track, + id: resolved.trackId, + clusterBrowseServerId: resolved.serverId, + }; streamServerProfileId = resolved.serverId; } } diff --git a/src/store/playerStore.misc.test.ts b/src/store/playerStore.misc.test.ts index 3076efc2..1258ba2b 100644 --- a/src/store/playerStore.misc.test.ts +++ b/src/store/playerStore.misc.test.ts @@ -112,11 +112,25 @@ describe('openContextMenu / closeContextMenu', () => { describe('openSongInfo / closeSongInfo', () => { it('opens with the song id and clears on close', () => { + useAuthStore.setState({ activeServerId: 'a' }); usePlayerStore.getState().openSongInfo('song-1'); - expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: true, songId: 'song-1' }); + expect(usePlayerStore.getState().songInfoModal).toEqual({ + isOpen: true, + songId: 'song-1', + serverId: 'a', + }); usePlayerStore.getState().closeSongInfo(); - expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: false, songId: null }); + expect(usePlayerStore.getState().songInfoModal).toEqual({ + isOpen: false, + songId: null, + serverId: null, + }); + }); + + it('stores explicit cluster member server id', () => { + usePlayerStore.getState().openSongInfo('song-2', 'b'); + expect(usePlayerStore.getState().songInfoModal.serverId).toBe('b'); }); }); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index e676266c..67c96754 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -68,7 +68,7 @@ export const usePlayerStore = create()( scheduledResumeStartMs: null, repeatMode: initialPlayerPrefs.repeatMode, contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null }, - songInfoModal: { isOpen: false, songId: null }, + songInfoModal: { isOpen: false, songId: null, serverId: null }, ...createUiStateActions(set), ...createLastfmActions(set, get), diff --git a/src/store/playerStoreTypes.ts b/src/store/playerStoreTypes.ts index cc2053dc..d2562790 100644 --- a/src/store/playerStoreTypes.ts +++ b/src/store/playerStoreTypes.ts @@ -227,7 +227,7 @@ export interface PlayerState { ) => void; closeContextMenu: () => void; - songInfoModal: { isOpen: boolean; songId: string | null }; - openSongInfo: (songId: string) => void; + songInfoModal: { isOpen: boolean; songId: string | null; serverId: string | null }; + openSongInfo: (songId: string, serverId?: string | null) => void; closeSongInfo: () => void; } diff --git a/src/store/uiStateActions.ts b/src/store/uiStateActions.ts index f743a573..7a9e849d 100644 --- a/src/store/uiStateActions.ts +++ b/src/store/uiStateActions.ts @@ -5,7 +5,11 @@ import type { PlayerState } from './playerStoreTypes'; import { ensurePlaybackServerActive, playbackServerDiffersFromActive, + resolveStreamServerIdForTrack, } from '../utils/playback/playbackServer'; +import { useAuthStore } from './authStore'; +import { usePlayerStore } from './playerStore'; +import { resolveServerIdForIndexKey } from '../utils/server/serverLookup'; type SetState = ( partial: Partial | ((state: PlayerState) => Partial), @@ -77,8 +81,24 @@ export function createUiStateActions(set: SetState): Pick< contextMenu: { ...state.contextMenu, isOpen: false }, })), - openSongInfo: (songId) => set({ songInfoModal: { isOpen: true, songId } }), - closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null } }), + openSongInfo: (songId, serverId) => { + let sid: string | null = null; + if (serverId?.trim()) { + sid = resolveServerIdForIndexKey(serverId) || serverId; + } else { + const st = usePlayerStore.getState(); + if (st.currentTrack?.id === songId) { + sid = resolveStreamServerIdForTrack( + st.currentTrack, + st.queueItems[st.queueIndex]?.serverId, + ); + } else { + sid = useAuthStore.getState().activeServerId ?? null; + } + } + set({ songInfoModal: { isOpen: true, songId, serverId: sid } }); + }, + closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null, serverId: null } }), toggleQueue: () => set(state => { diff --git a/src/styles/components/connection-indicator.css b/src/styles/components/connection-indicator.css index 24376ccb..a607b554 100644 --- a/src/styles/components/connection-indicator.css +++ b/src/styles/components/connection-indicator.css @@ -38,6 +38,11 @@ box-shadow: 0 0 6px 2px rgba(166, 227, 161, 0.55); } +.connection-led--degraded { + background: #f9e2af; + box-shadow: 0 0 6px 2px rgba(249, 226, 175, 0.55); +} + .connection-led--disconnected { background: #f38ba8; box-shadow: 0 0 6px 2px rgba(243, 139, 168, 0.55); diff --git a/src/styles/components/mini-player-window.css b/src/styles/components/mini-player-window.css index b082d4fc..64b8fbb4 100644 --- a/src/styles/components/mini-player-window.css +++ b/src/styles/components/mini-player-window.css @@ -599,6 +599,12 @@ html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-c letter-spacing: -0.01em; text-align: left; } +.np-info-cluster-server { + font-size: 12px; + line-height: 1.4; + color: var(--text-secondary); + text-align: left; +} .np-info-artist-bio { margin: 0; font-size: 13px; diff --git a/src/utils/componentHelpers/runArtistDetailPlay.test.ts b/src/utils/componentHelpers/runArtistDetailPlay.test.ts new file mode 100644 index 00000000..a5579e97 --- /dev/null +++ b/src/utils/componentHelpers/runArtistDetailPlay.test.ts @@ -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']); + }); +}); diff --git a/src/utils/componentHelpers/runArtistDetailPlay.ts b/src/utils/componentHelpers/runArtistDetailPlay.ts index 4ef0927e..6f835f6b 100644 --- a/src/utils/componentHelpers/runArtistDetailPlay.ts +++ b/src/utils/componentHelpers/runArtistDetailPlay.ts @@ -1,15 +1,49 @@ import type { TFunction } from 'i18next'; -import { getAlbum } from '../../api/subsonicLibrary'; +import { getAlbum, getAlbumForServer } from '../../api/subsonicLibrary'; import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists'; -import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes'; +import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes'; import type { Track } from '../../store/playerStoreTypes'; import { songToTrack } from '../playback/songToTrack'; import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay'; -async function fetchAllTracks(albums: SubsonicAlbum[]): Promise { - const results = await Promise.all(albums.map(a => getAlbum(a.id))); - const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0)); - return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack); +async function fetchAlbumTracks(album: SubsonicAlbum): Promise<{ year: number; tracks: Track[] }> { + const serverId = album.clusterSeedServerId; + const data = serverId + ? await getAlbumForServer(serverId, album.id) + : await getAlbum(album.id); + const genre = data.album.genre ?? album.genre; + const tracks = [...data.songs] + .sort((a, b) => (a.track ?? 0) - (b.track ?? 0)) + .map(s => { + const track = songToTrack( + serverId ? { ...s, clusterBrowseServerId: s.clusterBrowseServerId ?? serverId } : s, + ); + if (!track.genre && genre) track.genre = genre; + return track; + }); + return { year: data.album.year ?? album.year ?? 0, tracks }; +} + +/** All album tracks for artist Play All / shuffle (cluster-aware per album). */ +export async function fetchArtistCatalogTracks(albums: SubsonicAlbum[]): Promise { + if (albums.length === 0) return []; + const parts = await Promise.all(albums.map(fetchAlbumTracks)); + return [...parts] + .sort((a, b) => a.year - b.year) + .flatMap(p => p.tracks); +} + +/** Top-song click queue: from index through top list, then rest of catalog without dupes. */ +export function buildArtistTopSongPlayQueue( + topSongs: SubsonicSong[], + startIndex: number, + catalogTracks: Track[], +): Track[] { + const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack); + if (catalogTracks.length === 0) return topTracksFromIndex; + const topSongIds = new Set(topSongs.map(s => s.id)); + const remaining = catalogTracks.filter(tr => !topSongIds.has(tr.id)); + return [...topTracksFromIndex, ...remaining]; } export interface RunArtistDetailPlayDeps { @@ -21,13 +55,21 @@ export interface RunArtistDetailPlayDeps { export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise { const { albums, setPlayAllLoading, playTrack } = deps; if (albums.length === 0) return; - await runBulkPlayAll({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack }); + await runBulkPlayAll({ + fetchTracks: () => fetchArtistCatalogTracks(albums), + setLoading: setPlayAllLoading, + playTrack, + }); } export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise { const { albums, setPlayAllLoading, playTrack } = deps; if (albums.length === 0) return; - await runBulkShuffle({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack }); + await runBulkShuffle({ + fetchTracks: () => fetchArtistCatalogTracks(albums), + setLoading: setPlayAllLoading, + playTrack, + }); } export interface RunArtistDetailStartRadioDeps { diff --git a/src/utils/miniPlayerBridge.ts b/src/utils/miniPlayerBridge.ts index 61e53607..f067f9ad 100644 --- a/src/utils/miniPlayerBridge.ts +++ b/src/utils/miniPlayerBridge.ts @@ -3,6 +3,7 @@ import { listen, emitTo } from '@tauri-apps/api/event'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { resolveQueueTrack } from './library/queueTrackView'; +import { getCurrentTrackStreamServerId } from './playback/playbackServer'; import type { SubsonicOpenArtistRef } from '../api/subsonicTypes'; export const MINI_WINDOW_LABEL = 'mini'; @@ -266,7 +267,9 @@ export function initMiniPlayerBridgeOnMain(): () => void { w.unminimize().catch(() => {}); w.show().catch(() => {}); w.setFocus().catch(() => {}); - usePlayerStore.getState().openSongInfo(id); + const st = usePlayerStore.getState(); + const serverId = st.currentTrack?.id === id ? getCurrentTrackStreamServerId() : undefined; + st.openSongInfo(id, serverId); }); return () => { diff --git a/src/utils/playback/playbackServer.test.ts b/src/utils/playback/playbackServer.test.ts index 8f3b6aa6..92f97a86 100644 --- a/src/utils/playback/playbackServer.test.ts +++ b/src/utils/playback/playbackServer.test.ts @@ -5,10 +5,12 @@ import { bindQueueServerForPlayback, clearQueueServerForPlayback, ensurePlaybackServerActive, + getCurrentTrackStreamServerId, getPlaybackServerId, playbackCoverArtForId, playbackServerDiffersFromActive, prepareActiveServerForNewMix, + resolveStreamServerIdForTrack, shouldBindQueueServerForPlay, shouldHandoffQueueToActiveServer, } from './playbackServer'; @@ -48,6 +50,28 @@ describe('playbackServer', () => { expect(getPlaybackServerId()).toBe('b'); }); + it('resolveStreamServerIdForTrack prefers clusterBrowseServerId over queue pin', () => { + usePlayerStore.setState({ + queueItems: [{ serverId: 'a', trackId: 't1' }], + queueServerId: 'a', + queueIndex: 0, + currentTrack: { + id: 't1', + title: 'T', + artist: 'A', + album: 'Al', + albumId: 'al1', + duration: 100, + clusterBrowseServerId: 'b', + }, + }); + expect(resolveStreamServerIdForTrack( + usePlayerStore.getState().currentTrack, + 'a', + )).toBe('b'); + expect(getCurrentTrackStreamServerId()).toBe('b'); + }); + it('bindQueueServerForPlayback pins active server as canonical index key', () => { useAuthStore.setState({ activeServerId: 'b' }); bindQueueServerForPlayback(); diff --git a/src/utils/playback/playbackServer.ts b/src/utils/playback/playbackServer.ts index 313b2f55..f8781409 100644 --- a/src/utils/playback/playbackServer.ts +++ b/src/utils/playback/playbackServer.ts @@ -1,5 +1,5 @@ import { buildCoverArtFetchUrl } from '../../cover/fetchUrl'; -import { resolvePlaybackCoverScope } from '../../cover/ref'; +import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref'; import { coverEntryToRef, resolveAlbumCoverEntry } from '../../cover/resolveEntry'; import { coverStorageKeyFromRef } from '../../cover/storageKeys'; import { resolveCoverDisplayTier } from '../../cover/tiers'; @@ -15,6 +15,31 @@ import { serverIndexKeyFromUrl, } from '../server/serverIndexKey'; +/** Per-track streaming server in cluster mixed queues (track ref → queue ref → queue pin). */ +export function resolveStreamServerIdForTrack( + track: Pick | null | undefined, + queueRefServerId?: string | null, +): string { + if (track?.clusterBrowseServerId?.trim()) { + const sid = resolveServerIdForIndexKey(track.clusterBrowseServerId); + if (sid) return sid; + } + if (queueRefServerId) { + const sid = resolveServerIdForIndexKey(queueRefServerId); + if (sid) return sid; + } + return getPlaybackServerId(); +} + +/** Subsonic server that owns the currently playing track (cluster-aware). */ +export function getCurrentTrackStreamServerId(): string { + const st = usePlayerStore.getState(); + return resolveStreamServerIdForTrack( + st.currentTrack, + st.queueItems[st.queueIndex]?.serverId, + ); +} + /** Server that owns the current queue / stream URLs (may differ from the browsed server). */ export function getPlaybackServerId(): string { const { queueServerId, queueItems } = usePlayerStore.getState(); @@ -132,7 +157,7 @@ export function playbackCoverArtForAlbum( if (!entry) { return playbackCoverArtForId(coverArt, displayCssPx); } - const ref = coverEntryToRef(entry, resolvePlaybackCoverScope()); + const ref = coverEntryToRef(entry, resolvePlaybackCoverScopeForCurrentTrack()); const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'sparse' }); return { src: buildCoverArtFetchUrl(ref, tier), diff --git a/src/utils/playback/songToTrack.test.ts b/src/utils/playback/songToTrack.test.ts index 705df1dc..e2f5dd2d 100644 --- a/src/utils/playback/songToTrack.test.ts +++ b/src/utils/playback/songToTrack.test.ts @@ -60,6 +60,11 @@ describe('songToTrack', () => { expect(t.coverArt).toBe('s2'); }); + it('preserves clusterBrowseServerId for cluster-mode playback and covers', () => { + const song = makeSubsonicSong({ id: 's3', clusterBrowseServerId: 'server-b' }); + expect(songToTrack(song).clusterBrowseServerId).toBe('server-b'); + }); + it('flattens replayGain into replayGainTrackDb / AlbumDb / Peak', () => { const song = makeSubsonicSong({ replayGain: { diff --git a/src/utils/playback/songToTrack.ts b/src/utils/playback/songToTrack.ts index f11e0e26..e34d3d43 100644 --- a/src/utils/playback/songToTrack.ts +++ b/src/utils/playback/songToTrack.ts @@ -25,5 +25,6 @@ export function songToTrack(song: SubsonicSong): Track { samplingRate: song.samplingRate, bitDepth: song.bitDepth, size: song.size, + clusterBrowseServerId: song.clusterBrowseServerId, }; } diff --git a/src/utils/serverCluster/clusterMergeStatus.test.ts b/src/utils/serverCluster/clusterMergeStatus.test.ts new file mode 100644 index 00000000..f089493e --- /dev/null +++ b/src/utils/serverCluster/clusterMergeStatus.test.ts @@ -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) => { + 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) => { + 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'); + }); +}); diff --git a/src/utils/serverCluster/clusterMergeStatus.ts b/src/utils/serverCluster/clusterMergeStatus.ts index 1bf1e315..de9fce95 100644 --- a/src/utils/serverCluster/clusterMergeStatus.ts +++ b/src/utils/serverCluster/clusterMergeStatus.ts @@ -1,8 +1,11 @@ import { libraryIsReady } from '../library/libraryReady'; +import { ensureConnectUrlResolved } from '../server/serverEndpoint'; import { serverListDisplayLabel } from '../server/serverDisplayName'; import { getClusterMemberProfiles } from './clusterScope'; import { isServerLikelyReachable } from './representative'; import type { ServerCluster } from './types'; +import type { TFunction } from 'i18next'; +import type { ConnectionStatus } from '../../hooks/useConnectionStatus'; export type ClusterMemberExcludeReason = 'unreachable' | 'indexing'; @@ -22,11 +25,15 @@ export interface ClusterMergeDiagnostics { /** Per-member merge eligibility (spec §4 exclusion rules). */ export async function getClusterMergeDiagnostics( cluster: ServerCluster, + options?: { probeMembers?: boolean }, ): Promise { const profiles = getClusterMemberProfiles(cluster); const members: ClusterMemberStatus[] = []; let mergeCount = 0; for (const p of profiles) { + if (options?.probeMembers) { + await ensureConnectUrlResolved(p); + } const label = serverListDisplayLabel(p, profiles); if (!isServerLikelyReachable(p.id)) { members.push({ serverId: p.id, label, included: false, reason: 'unreachable' }); @@ -53,3 +60,47 @@ export function formatExcludedMemberLabels( }) .join(', '); } + +/** LED color for cluster scope: green = all members merge-ready, yellow = partial, red = none. */ +export function clusterLedStatusFromDiagnostics(diag: ClusterMergeDiagnostics): ConnectionStatus { + if (diag.totalCount === 0 || diag.mergeCount === 0) return 'disconnected'; + if (diag.mergeCount < diag.totalCount) return 'degraded'; + return 'connected'; +} + +function memberStatusLine(t: TFunction, member: ClusterMemberStatus): string { + if (member.included) { + return t('cluster.memberStatusAvailable', { name: member.label }); + } + if (member.reason === 'indexing') { + return t('cluster.memberStatusIndexing', { name: member.label }); + } + return t('cluster.memberStatusOffline', { name: member.label }); +} + +/** Hover tooltip for the cluster connection LED — one line per member. */ +export function buildClusterMemberStatusTooltip( + t: TFunction, + diag: ClusterMergeDiagnostics, +): string { + if (diag.members.length === 0) return ''; + return diag.members.map(m => memberStatusLine(t, m)).join('\n'); +} + +/** @deprecated Prefer {@link buildClusterMemberStatusTooltip} for LED hover. */ +export function buildClusterConnectionTooltip( + t: TFunction, + diag: ClusterMergeDiagnostics, +): string { + const excluded = diag.members.filter(m => !m.included); + if (excluded.length === 0) return ''; + const details = excluded.map(m => memberStatusLine(t, m)).join(' · '); + if (diag.mergeCount === 0) { + return t('cluster.connectionTooltipNone', { details }); + } + return t('cluster.connectionTooltipPartial', { + available: diag.mergeCount, + total: diag.totalCount, + details, + }); +} diff --git a/src/utils/serverCluster/clusterPlaybackMonitor.ts b/src/utils/serverCluster/clusterPlaybackMonitor.ts index fd87f644..8bbdd6d0 100644 --- a/src/utils/serverCluster/clusterPlaybackMonitor.ts +++ b/src/utils/serverCluster/clusterPlaybackMonitor.ts @@ -30,14 +30,14 @@ export function useClusterPlaybackMonitor(): void { const streamSid = resolveServerIdForIndexKey(ref.serverId); if (isServerLikelyReachable(streamSid)) return; - const browseId = useAuthStore.getState().activeServerId ?? streamSid; + const track = st.currentTrack; + if (!track) return; + const browseId = track.clusterBrowseServerId ?? useAuthStore.getState().activeServerId ?? streamSid; void cascadeClusterPlayback(browseId, ref.trackId, streamSid).then(next => { if (!next) { usePlayerStore.getState().next(false); return; } - const track = st.currentTrack; - if (!track) return; usePlayerStore.getState().playTrack( { ...track, id: next.trackId, clusterBrowseServerId: next.serverId }, undefined, diff --git a/src/utils/serverCluster/representative.ts b/src/utils/serverCluster/representative.ts index 92ad0930..bcdef8a6 100644 --- a/src/utils/serverCluster/representative.ts +++ b/src/utils/serverCluster/representative.ts @@ -47,11 +47,13 @@ export async function getClusterMergeMemberIds(clusterId: string): Promise c.id === clusterId); if (!cluster) return []; - const out: string[] = []; - for (const server of getClusterMemberProfiles(cluster)) { - if (!isServerLikelyReachable(server.id)) continue; - if (!(await libraryIsReady(server.id))) continue; - out.push(server.id); - } - return out; + const members = getClusterMemberProfiles(cluster); + const ready = await Promise.all( + members.map(async server => { + if (!isServerLikelyReachable(server.id)) return null; + if (!(await libraryIsReady(server.id))) return null; + return server.id; + }), + ); + return ready.filter((id): id is string => id != null); }