From 884951fbc2e5902b125df118cf6de2d7570d7679 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 5 Jun 2026 17:36:53 +0300 Subject: [PATCH] feat(cluster): playback, browse, discovery, Tier 2/3, and infra Enqueue-time cluster resolve with mixed-server queue refs; play_session and scrobble target resolved server; mid-session cascade monitor. Queue share filters to representative with export count. Excluded-member banner and cluster settings diagnostics. Cluster advanced search, random tracks, native favorite albums/artists, extended player stats, sync rebuild hook. Discovery merge helper, filtered Tier 1, Tier 3 by-source UI, i18n, and tauri test mocks. --- .../crates/psysonic-library/src/commands.rs | 93 +++- src-tauri/crates/psysonic-library/src/dto.rs | 41 ++ .../src/server_cluster/advanced_search.rs | 392 ++++++++++++++++ .../src/server_cluster/list_favorites.rs | 262 ++++++++++- .../src/server_cluster/mod.rs | 9 +- .../src/server_cluster/play_stats.rs | 441 +++++++++++++++++- .../src/server_cluster/search.rs | 89 +++- src-tauri/src/lib.rs | 6 + src/api/library.ts | 135 ++++++ src/api/subsonicArtists.ts | 108 ++++- src/api/subsonicScrobble.ts | 9 +- src/api/subsonicTypes.ts | 2 + src/app/AppShell.tsx | 2 + src/components/ClusterMergeBanner.tsx | 32 ++ src/components/ConnectionIndicator.tsx | 5 + src/components/OrbitStartModal.tsx | 8 +- src/components/QueuePanel.tsx | 31 +- .../settings/ServerClustersSection.tsx | 32 ++ .../statistics/PlayerStatsRecentDays.tsx | 12 +- src/hooks/tauriBridge/useCliBridge.ts | 2 +- src/hooks/useClusterPlaybackMonitor.ts | 1 + src/locales/de/cluster.ts | 10 + src/locales/de/index.ts | 2 + src/locales/de/queue.ts | 3 + src/locales/en/cluster.ts | 10 + src/locales/en/index.ts | 2 + src/locales/en/queue.ts | 4 +- src/locales/es/cluster.ts | 10 + src/locales/es/index.ts | 2 + src/locales/es/queue.ts | 3 + src/locales/fr/cluster.ts | 10 + src/locales/fr/index.ts | 2 + src/locales/fr/queue.ts | 3 + src/locales/nb/cluster.ts | 10 + src/locales/nb/index.ts | 2 + src/locales/nb/queue.ts | 3 + src/locales/nl/cluster.ts | 10 + src/locales/nl/index.ts | 2 + src/locales/nl/queue.ts | 3 + src/locales/ro/cluster.ts | 10 + src/locales/ro/index.ts | 2 + src/locales/ro/queue.ts | 3 + src/locales/ru/cluster.ts | 10 + src/locales/ru/index.ts | 2 + src/locales/ru/queue.ts | 3 + src/locales/zh/cluster.ts | 10 + src/locales/zh/index.ts | 2 + src/locales/zh/queue.ts | 3 + src/pages/FolderBrowser.tsx | 83 ++++ src/pages/InternetRadio.tsx | 83 ++++ src/pages/MostPlayed.tsx | 22 + src/pages/Playlists.tsx | 101 +++- src/pages/SearchBrowsePage.tsx | 3 +- src/store/audioEventHandlers.ts | 17 +- src/store/playListenSession.ts | 7 +- src/store/playerStoreTypes.ts | 2 + src/store/queueMutationActions.ts | 175 ++++--- .../components/connection-indicator.css | 8 + src/test/mocks/tauri.ts | 42 ++ .../componentHelpers/contextMenuActions.ts | 2 +- src/utils/library/advancedSearchLocal.ts | 65 ++- src/utils/library/albumBrowseLocal.ts | 25 +- src/utils/library/browseTextSearch.ts | 3 + .../library/clusterAdvancedSearchLocal.ts | 23 + src/utils/library/genreAlbumBrowse.ts | 2 + src/utils/mix/luckyMix.ts | 3 +- src/utils/orbit/host.ts | 4 + src/utils/serverCluster/clusterBrowse.ts | 58 +-- .../serverCluster/clusterDiscoveryMerge.ts | 68 +++ .../serverCluster/clusterEnqueueResolve.ts | 43 ++ src/utils/serverCluster/clusterMergeStatus.ts | 55 +++ .../serverCluster/clusterPlaybackMonitor.ts | 54 +++ src/utils/serverCluster/clusterPlayerStats.ts | 32 ++ src/utils/serverCluster/clusterWriteFanout.ts | 78 +++- 74 files changed, 2750 insertions(+), 156 deletions(-) create mode 100644 src-tauri/crates/psysonic-library/src/server_cluster/advanced_search.rs create mode 100644 src/components/ClusterMergeBanner.tsx create mode 100644 src/hooks/useClusterPlaybackMonitor.ts create mode 100644 src/locales/de/cluster.ts create mode 100644 src/locales/en/cluster.ts create mode 100644 src/locales/es/cluster.ts create mode 100644 src/locales/fr/cluster.ts create mode 100644 src/locales/nb/cluster.ts create mode 100644 src/locales/nl/cluster.ts create mode 100644 src/locales/ro/cluster.ts create mode 100644 src/locales/ru/cluster.ts create mode 100644 src/locales/zh/cluster.ts create mode 100644 src/utils/library/clusterAdvancedSearchLocal.ts create mode 100644 src/utils/serverCluster/clusterDiscoveryMerge.ts create mode 100644 src/utils/serverCluster/clusterEnqueueResolve.ts create mode 100644 src/utils/serverCluster/clusterMergeStatus.ts create mode 100644 src/utils/serverCluster/clusterPlaybackMonitor.ts diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index c39b5754..e3e63dd4 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -21,14 +21,15 @@ use crate::cross_server; use crate::dto::{ count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto, FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, - LibraryClusterListTracksRequest, LibraryClusterResolveRequest, + LibraryClusterAdvancedSearchRequest, LibraryClusterListTracksRequest, LibraryClusterResolveRequest, LibraryClusterResolveResponse, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse, - LibraryClusterScopeRequest, LibraryClusterPlayerStatsRequest, + LibraryClusterScopeRequest, LibraryClusterPlayerStatsRequest, LibraryClusterPlayerStatsDayDetailRequest, LibraryClusterEntityDetailRequest, LibraryClusterAlbumDetailResponse, LibraryClusterArtistDetailResponse, LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto, LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto, - PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto, + PlaySessionInputDto, PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, + PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto, TrackArtifactDto, TrackFactDto, TrackRefDto, }; use crate::live_search; @@ -478,6 +479,16 @@ pub async fn library_advanced_search( library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await } +#[tauri::command] +pub async fn library_cluster_advanced_search( + runtime: State<'_, LibraryRuntime>, + request: LibraryClusterAdvancedSearchRequest, +) -> Result { + let store = Arc::clone(&runtime.store); + library_spawn_blocking(move || crate::server_cluster::run_cluster_advanced_search(&store, request)) + .await +} + #[tauri::command] pub async fn library_list_lossless_albums( runtime: State<'_, LibraryRuntime>, @@ -611,6 +622,36 @@ pub async fn library_cluster_list_favorites( .await } +#[tauri::command] +pub async fn library_cluster_list_favorite_albums( + runtime: State<'_, LibraryRuntime>, + request: LibraryClusterScopeRequest, +) -> Result { + let store = Arc::clone(&runtime.store); + let servers_ordered = request.servers_ordered; + let limit = request.limit.unwrap_or(500); + let offset = request.offset.unwrap_or(0); + library_spawn_blocking(move || { + crate::server_cluster::list_merged_favorite_albums(&store, &servers_ordered, limit, offset) + }) + .await +} + +#[tauri::command] +pub async fn library_cluster_list_favorite_artists( + runtime: State<'_, LibraryRuntime>, + request: LibraryClusterScopeRequest, +) -> Result { + let store = Arc::clone(&runtime.store); + let servers_ordered = request.servers_ordered; + let limit = request.limit.unwrap_or(500); + let offset = request.offset.unwrap_or(0); + library_spawn_blocking(move || { + crate::server_cluster::list_merged_favorite_artists(&store, &servers_ordered, limit, offset) + }) + .await +} + #[tauri::command] pub fn library_cluster_player_stats_year_summary( runtime: State<'_, LibraryRuntime>, @@ -635,6 +676,42 @@ pub fn library_cluster_player_stats_heatmap( ) } +#[tauri::command] +pub fn library_cluster_player_stats_day_detail( + runtime: State<'_, LibraryRuntime>, + request: LibraryClusterPlayerStatsDayDetailRequest, +) -> Result { + crate::server_cluster::cluster_day_detail( + &runtime.store, + &request.servers_ordered, + &request.date_iso, + ) +} + +#[tauri::command] +pub fn library_cluster_player_stats_recent_days( + runtime: State<'_, LibraryRuntime>, + request: LibraryClusterScopeRequest, +) -> Result, String> { + crate::server_cluster::cluster_recent_days( + &runtime.store, + &request.servers_ordered, + request.limit.unwrap_or(30), + ) +} + +#[tauri::command] +pub fn library_cluster_player_stats_most_played( + runtime: State<'_, LibraryRuntime>, + request: LibraryClusterScopeRequest, +) -> Result, String> { + crate::server_cluster::cluster_most_played( + &runtime.store, + &request.servers_ordered, + request.limit.unwrap_or(50), + ) +} + #[tauri::command] pub async fn library_cluster_resolve_candidates( runtime: State<'_, LibraryRuntime>, @@ -714,12 +791,14 @@ pub async fn library_search_cluster( runtime: State<'_, LibraryRuntime>, query: String, limit: Option, + offset: Option, servers_ordered: Vec, ) -> Result { let store = Arc::clone(&runtime.store); let limit = limit.unwrap_or(100); + let offset = offset.unwrap_or(0); library_spawn_blocking(move || { - crate::server_cluster::run_cluster_search(&store, &query, limit, &servers_ordered) + crate::server_cluster::run_cluster_search(&store, &query, limit, offset, &servers_ordered) }) .await } @@ -1091,6 +1170,12 @@ async fn library_sync_start_inner( }; if let Some(runtime) = app_for_emit.try_state::() { let _ = runtime.store.checkpoint_wal("sync.checkpoint"); + if outcome.ok { + let _ = crate::server_cluster::rebuild_cluster_keys_for_server( + &runtime.store, + &server_id_for_emit, + ); + } } let _ = app_for_emit.emit(LibrarySyncProgressPayload::IDLE_EVENT_NAME, &outcome); diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index 9b797b77..dac71204 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -365,6 +365,14 @@ pub struct PlaySessionRecentDayDto { pub partial_count: u32, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlaySessionMostPlayedDto { + pub track: LibraryTrackDto, + pub track_play_count: u32, + pub total_listened_sec: f64, +} + /// Earliest/latest calendar years with at least one session (local TZ). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -681,6 +689,32 @@ pub struct LibraryClusterListTracksRequest { pub offset: Option, } +/// `library_cluster_advanced_search` request. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryClusterAdvancedSearchRequest { + /// Ordered member server ids (index 0 = highest priority). + pub servers_ordered: Vec, + #[serde(default)] + pub query: Option, + pub entity_types: Vec, + #[serde(default)] + pub filters: Vec, + #[serde(default)] + pub starred_only: Option, + #[serde(default)] + pub restrict_album_ids: Option>, + #[serde(default)] + pub query_album_title_only: Option, + #[serde(default)] + pub sort: Vec, + pub limit: u32, + #[serde(default)] + pub offset: u32, + #[serde(default)] + pub skip_totals: bool, +} + /// Merged album browse response for cluster scope. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -715,6 +749,13 @@ pub struct LibraryClusterPlayerStatsRequest { pub year: i32, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryClusterPlayerStatsDayDetailRequest { + pub servers_ordered: Vec, + pub date_iso: String, +} + /// `library_cluster_resolve_candidates` request — provide cluster_key OR seed track. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/advanced_search.rs b/src-tauri/crates/psysonic-library/src/server_cluster/advanced_search.rs new file mode 100644 index 00000000..2feb16c3 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/server_cluster/advanced_search.rs @@ -0,0 +1,392 @@ +//! Cluster-scope advanced search: run per-server advanced search, then merge +//! winners by cluster identity keys with server-priority precedence. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use rusqlite::types::Value as SqlValue; + +use crate::advanced_search::run_advanced_search; +use crate::dto::{ + LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto, + LibraryClusterAdvancedSearchRequest, LibrarySearchTotals, LibraryTrackDto, +}; +use crate::search::PAGE_LIMIT_MAX; +use crate::store::LibraryStore; + +use super::db::ATTACH_ALIAS; + +pub fn run_cluster_advanced_search( + store: &LibraryStore, + req: LibraryClusterAdvancedSearchRequest, +) -> Result { + if req.servers_ordered.is_empty() { + return Ok(empty_response(req.skip_totals)); + } + + let page_limit = req.limit.clamp(1, PAGE_LIMIT_MAX); + let page_offset = req.offset as usize; + let per_server_limit = req + .limit + .saturating_add(req.offset) + .clamp(1, PAGE_LIMIT_MAX); + + let mut all_tracks: Vec = Vec::new(); + let mut all_albums: Vec = Vec::new(); + let mut all_artists: Vec = Vec::new(); + let mut applied_filters = BTreeSet::new(); + + for server_id in &req.servers_ordered { + let server_req = LibraryAdvancedSearchRequest { + server_id: server_id.clone(), + library_scope: None, + query: req.query.clone(), + entity_types: req.entity_types.clone(), + filters: req.filters.clone(), + starred_only: req.starred_only, + restrict_album_ids: req.restrict_album_ids.clone(), + query_album_title_only: req.query_album_title_only, + sort: req.sort.clone(), + limit: per_server_limit, + offset: 0, + skip_totals: true, + }; + let resp = run_advanced_search(store, &server_req)?; + all_tracks.extend(resp.tracks); + all_albums.extend(resp.albums); + all_artists.extend(resp.artists); + applied_filters.extend(resp.applied_filters); + } + + let merged_tracks = merge_tracks_by_cluster_key(store, all_tracks)?; + let merged_albums = merge_albums_by_album_key(store, all_albums)?; + let merged_artists = merge_artists_by_artist_key(store, all_artists)?; + + let totals = if req.skip_totals { + LibrarySearchTotals::default() + } else { + LibrarySearchTotals { + artists: merged_artists.len() as u32, + albums: merged_albums.len() as u32, + tracks: merged_tracks.len() as u32, + } + }; + + Ok(LibraryAdvancedSearchResponse { + artists: merged_artists + .into_iter() + .skip(page_offset) + .take(page_limit as usize) + .collect(), + albums: merged_albums + .into_iter() + .skip(page_offset) + .take(page_limit as usize) + .collect(), + tracks: merged_tracks + .into_iter() + .skip(page_offset) + .take(page_limit as usize) + .collect(), + totals, + applied_filters: applied_filters.into_iter().collect(), + source: "local".to_string(), + }) +} + +fn empty_response(skip_totals: bool) -> LibraryAdvancedSearchResponse { + LibraryAdvancedSearchResponse { + artists: Vec::new(), + albums: Vec::new(), + tracks: Vec::new(), + totals: if skip_totals { + LibrarySearchTotals::default() + } else { + LibrarySearchTotals { + artists: 0, + albums: 0, + tracks: 0, + } + }, + applied_filters: Vec::new(), + source: "local".to_string(), + } +} + +fn merge_tracks_by_cluster_key( + store: &LibraryStore, + tracks: Vec, +) -> Result, String> { + let refs: Vec<(String, String)> = tracks + .iter() + .map(|t| (t.server_id.clone(), t.id.clone())) + .collect(); + let key_map = lookup_track_cluster_keys(store, &refs)?; + + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for track in tracks { + let key = key_map + .get(&(track.server_id.clone(), track.id.clone())) + .and_then(|v| v.clone()) + .unwrap_or_else(|| format!("solo:{}:{}", track.server_id, track.id)); + if seen.insert(key) { + out.push(track); + } + } + Ok(out) +} + +fn merge_albums_by_album_key( + store: &LibraryStore, + albums: Vec, +) -> Result, String> { + let refs: Vec<(String, String)> = albums + .iter() + .map(|a| (a.server_id.clone(), a.id.clone())) + .collect(); + let key_map = lookup_album_keys(store, &refs)?; + + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for album in albums { + let key = key_map + .get(&(album.server_id.clone(), album.id.clone())) + .and_then(|v| v.clone()) + .unwrap_or_else(|| format!("solo:{}:{}", album.server_id, album.id)); + if seen.insert(key) { + out.push(album); + } + } + Ok(out) +} + +fn merge_artists_by_artist_key( + store: &LibraryStore, + artists: Vec, +) -> Result, String> { + let refs: Vec<(String, String)> = artists + .iter() + .map(|a| (a.server_id.clone(), a.id.clone())) + .collect(); + let key_map = lookup_artist_keys(store, &refs)?; + + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for artist in artists { + let key = key_map + .get(&(artist.server_id.clone(), artist.id.clone())) + .and_then(|v| v.clone()) + .unwrap_or_else(|| format!("solo:{}:{}", artist.server_id, artist.id)); + if seen.insert(key) { + out.push(artist); + } + } + Ok(out) +} + +fn lookup_track_cluster_keys( + store: &LibraryStore, + refs: &[(String, String)], +) -> Result>, String> { + lookup_keys_with_values( + store, + refs, + &format!( + "SELECT w.server_id, w.entity_id, k.cluster_key + FROM wanted w + LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + ON k.server_id = w.server_id AND k.track_id = w.entity_id" + ), + ) +} + +fn lookup_album_keys( + store: &LibraryStore, + refs: &[(String, String)], +) -> Result>, String> { + lookup_keys_with_values( + store, + refs, + &format!( + "SELECT w.server_id, w.entity_id, MIN(k.album_key) + FROM wanted w + LEFT JOIN track t + ON t.server_id = w.server_id AND t.album_id = w.entity_id AND t.deleted = 0 + LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + ON k.server_id = t.server_id AND k.track_id = t.id + GROUP BY w.server_id, w.entity_id" + ), + ) +} + +fn lookup_artist_keys( + store: &LibraryStore, + refs: &[(String, String)], +) -> Result>, String> { + lookup_keys_with_values( + store, + refs, + &format!( + "SELECT w.server_id, w.entity_id, MIN(k.artist_key) + FROM wanted w + LEFT JOIN track t + ON t.server_id = w.server_id + AND COALESCE(NULLIF(t.artist_id, ''), t.artist) = w.entity_id + AND t.deleted = 0 + LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + ON k.server_id = t.server_id AND k.track_id = t.id + GROUP BY w.server_id, w.entity_id" + ), + ) +} + +fn lookup_keys_with_values( + store: &LibraryStore, + refs: &[(String, String)], + query_sql: &str, +) -> Result>, String> { + if refs.is_empty() { + return Ok(HashMap::new()); + } + + let values_sql = std::iter::repeat_n("(?, ?)", refs.len()).collect::>().join(", "); + let sql = format!("WITH wanted(server_id, entity_id) AS (VALUES {values_sql}) {query_sql}"); + + let mut bind: Vec = Vec::with_capacity(refs.len() * 2); + for (server_id, entity_id) in refs { + bind.push(SqlValue::Text(server_id.clone())); + bind.push(SqlValue::Text(entity_id.clone())); + } + + store.with_read_conn(|conn| { + let mut stmt = conn.prepare(&sql)?; + let mut rows = stmt.query(rusqlite::params_from_iter(bind.iter()))?; + let mut out = HashMap::new(); + while let Some(row) = rows.next()? { + let server_id: String = row.get(0)?; + let entity_id: String = row.get(1)?; + let key: Option = row.get(2)?; + out.insert((server_id, entity_id), key); + } + Ok(out) + }) +} + +#[cfg(test)] +mod tests { + use crate::filter::EntityKind; + use crate::repos::{TrackRepository, TrackRow}; + use crate::server_cluster::rebuild::rebuild_all_cluster_keys; + + use super::*; + + fn track(server: &str, id: &str, artist: &str, artist_id: &str, album: &str, album_id: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: "Song".into(), + title_sort: None, + artist: Some(artist.into()), + artist_id: Some(artist_id.into()), + album: album.into(), + album_id: Some(album_id.into()), + album_artist: Some(artist.into()), + duration_sec: 200, + track_number: Some(1), + disc_number: Some(1), + year: Some(2024), + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + #[test] + fn merges_tracks_by_cluster_key_with_priority() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Band", "art-1", "LP", "alb-1"), + track("s2", "t2", "Band", "art-2", "LP", "alb-2"), + ]) + .unwrap(); + rebuild_all_cluster_keys(&store).unwrap(); + + let resp = run_cluster_advanced_search( + &store, + LibraryClusterAdvancedSearchRequest { + servers_ordered: vec!["s1".into(), "s2".into()], + query: None, + entity_types: vec![EntityKind::Track], + filters: Vec::new(), + starred_only: None, + restrict_album_ids: None, + query_album_title_only: None, + sort: Vec::new(), + limit: 50, + offset: 0, + skip_totals: false, + }, + ) + .unwrap(); + + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.tracks[0].server_id, "s1"); + assert_eq!(resp.totals.tracks, 1); + assert_eq!(resp.totals.albums, 0); + assert_eq!(resp.totals.artists, 0); + } + + #[test] + fn applies_offset_after_merge() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Band A", "art-a1", "LP A", "alb-a1"), + track("s2", "t2", "Band A", "art-a2", "LP A", "alb-a2"), + track("s1", "t3", "Band B", "art-b1", "LP B", "alb-b1"), + ]) + .unwrap(); + rebuild_all_cluster_keys(&store).unwrap(); + + let resp = run_cluster_advanced_search( + &store, + LibraryClusterAdvancedSearchRequest { + servers_ordered: vec!["s1".into(), "s2".into()], + query: None, + entity_types: vec![EntityKind::Track], + filters: Vec::new(), + starred_only: None, + restrict_album_ids: None, + query_album_title_only: None, + sort: Vec::new(), + limit: 1, + offset: 1, + skip_totals: false, + }, + ) + .unwrap(); + + assert_eq!(resp.tracks.len(), 1); + assert_eq!(resp.totals.tracks, 2); + } +} diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/list_favorites.rs b/src-tauri/crates/psysonic-library/src/server_cluster/list_favorites.rs index b3d198af..785fde8e 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/list_favorites.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/list_favorites.rs @@ -1,8 +1,12 @@ //! Merged favorites — starred on any member counts (spec §4 Tier 2). use rusqlite::types::Value as SqlValue; +use serde_json::Value; -use crate::dto::{LibraryTrackDto, LibraryTracksEnvelope}; +use crate::dto::{ + LibraryAlbumDto, LibraryArtistDto, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse, LibraryTrackDto, + LibraryTracksEnvelope, +}; use crate::repos; use crate::search::{aliased_track_columns, PAGE_LIMIT_MAX}; use crate::store::LibraryStore; @@ -108,6 +112,237 @@ pub fn list_merged_favorite_tracks( }) } +/// Merged favorite albums — one row per album merge group when any member is starred. +pub fn list_merged_favorite_albums( + store: &LibraryStore, + servers_ordered: &[String], + limit: u32, + offset: u32, +) -> Result { + if servers_ordered.is_empty() { + return Ok(LibraryClusterAlbumsResponse { + albums: vec![], + has_more: false, + }); + } + let limit = limit.clamp(1, PAGE_LIMIT_MAX); + let offset = offset.min(i32::MAX as u32) as i32; + let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); + let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + + let sql = format!( + "WITH candidates AS ( + SELECT + t.rowid AS tid, + t.server_id, + t.album_id, + k.album_key, + COALESCE(a.starred_at, t.starred_at) AS starred_at, + ({priority_sql}) AS priority_rank + FROM track t + LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + ON k.server_id = t.server_id AND k.track_id = t.id + LEFT JOIN album a + ON a.server_id = t.server_id AND a.id = t.album_id + WHERE t.deleted = 0 + AND t.server_id IN ({in_placeholders}) + AND t.album_id IS NOT NULL AND t.album_id != '' + ), + partitioned AS ( + SELECT c.tid, + CASE + WHEN c.album_key IS NULL THEN 'solo:' || c.server_id || ':' || c.album_id + ELSE c.album_key + END AS merge_key, + c.priority_rank, + c.starred_at + FROM candidates c + ), + starred_merge AS ( + SELECT DISTINCT merge_key + FROM partitioned + WHERE starred_at IS NOT NULL + ), + winners AS ( + SELECT p.tid, + ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn + FROM partitioned p + JOIN starred_merge s ON s.merge_key = p.merge_key + ) + SELECT + t.server_id, + t.album_id, + COALESCE(a.name, t.album), + COALESCE(a.artist, t.artist), + COALESCE(a.artist_id, t.artist_id), + COALESCE(a.song_count, ( + SELECT COUNT(*) FROM track c + WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0 + )), + COALESCE(a.duration_sec, ( + SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c + WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0 + )), + COALESCE(a.year, t.year), + COALESCE(a.genre, t.genre), + COALESCE(a.cover_art_id, t.cover_art_id), + COALESCE(a.starred_at, t.starred_at), + COALESCE(a.synced_at, t.synced_at), + a.raw_json + FROM winners w + JOIN track t ON t.rowid = w.tid + LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id + WHERE w.rn = 1 + ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE, t.server_id, t.album_id + LIMIT ? OFFSET ?", + ); + + let mut params: Vec = Vec::new(); + params.append(&mut priority_params); + params.append(&mut in_params); + params.push(SqlValue::Integer(limit as i64)); + params.push(SqlValue::Integer(offset as i64)); + + let albums: Vec = store.with_read_conn(|conn| { + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_album_row)?; + rows.collect::>>() + })?; + Ok(LibraryClusterAlbumsResponse { + has_more: albums.len() as u32 == limit, + albums, + }) +} + +/// Merged favorite artists — one row per artist merge group when any member track is starred. +pub fn list_merged_favorite_artists( + store: &LibraryStore, + servers_ordered: &[String], + limit: u32, + offset: u32, +) -> Result { + if servers_ordered.is_empty() { + return Ok(LibraryClusterArtistsResponse { + artists: vec![], + has_more: false, + }); + } + let limit = limit.clamp(1, PAGE_LIMIT_MAX); + let offset = offset.min(i32::MAX as u32) as i32; + let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); + let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + + let sql = format!( + "WITH candidates AS ( + SELECT + t.rowid AS tid, + t.server_id, + COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref, + k.artist_key, + t.starred_at, + ({priority_sql}) AS priority_rank + FROM track t + LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + ON k.server_id = t.server_id AND k.track_id = t.id + WHERE t.deleted = 0 + AND t.server_id IN ({in_placeholders}) + AND COALESCE(t.artist, '') != '' + ), + partitioned AS ( + SELECT c.tid, + CASE + WHEN c.artist_key IS NULL THEN 'solo:' || c.server_id || ':' || c.artist_ref + ELSE c.artist_key + END AS merge_key, + c.priority_rank, + c.starred_at + FROM candidates c + ), + starred_merge AS ( + SELECT DISTINCT merge_key + FROM partitioned + WHERE starred_at IS NOT NULL + ), + winners AS ( + SELECT p.tid, + ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn + FROM partitioned p + JOIN starred_merge s ON s.merge_key = p.merge_key + ) + SELECT + t.server_id, + COALESCE(NULLIF(t.artist_id, ''), t.artist), + COALESCE(ar.name, t.artist), + COALESCE(ar.album_count, ( + SELECT COUNT(DISTINCT c.album_id) FROM track c + WHERE c.server_id = t.server_id + AND c.deleted = 0 + AND c.album_id IS NOT NULL + AND (c.artist_id = t.artist_id OR c.artist = t.artist) + )), + COALESCE(ar.synced_at, t.synced_at), + ar.raw_json + FROM winners w + JOIN track t ON t.rowid = w.tid + LEFT JOIN artist ar ON ar.server_id = t.server_id + AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist) + WHERE w.rn = 1 + ORDER BY COALESCE(ar.name, t.artist) COLLATE NOCASE, t.server_id + LIMIT ? OFFSET ?", + ); + + let mut params: Vec = Vec::new(); + params.append(&mut priority_params); + params.append(&mut in_params); + params.push(SqlValue::Integer(limit as i64)); + params.push(SqlValue::Integer(offset as i64)); + + let artists: Vec = store.with_read_conn(|conn| { + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_artist_row)?; + rows.collect::>>() + })?; + Ok(LibraryClusterArtistsResponse { + has_more: artists.len() as u32 == limit, + artists, + }) +} + +fn map_album_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 map_artist_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let raw: Option = r.get(5)?; + Ok(LibraryArtistDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + album_count: r.get(3)?, + synced_at: r.get(4)?, + raw_json: raw + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(Value::Null), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -177,4 +412,29 @@ mod tests { let env = list_merged_favorite_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap(); assert!(env.tracks.is_empty()); } + + #[test] + fn favorite_albums_merge_when_any_member_starred() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)]) + .unwrap(); + rebuild_all_cluster_keys(&store).unwrap(); + let resp = list_merged_favorite_albums(&store, &["s1".into(), "s2".into()], 50, 0).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].server_id, "s1"); + } + + #[test] + fn favorite_artists_merge_when_any_member_starred() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)]) + .unwrap(); + rebuild_all_cluster_keys(&store).unwrap(); + let resp = + list_merged_favorite_artists(&store, &["s1".into(), "s2".into()], 50, 0).unwrap(); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].server_id, "s1"); + } } diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/mod.rs b/src-tauri/crates/psysonic-library/src/server_cluster/mod.rs index 25b2e980..895e5296 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/mod.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/mod.rs @@ -3,6 +3,7 @@ //! `repos/play_session/cluster.rs` (listening-session time-gap grouping). mod detail; +mod advanced_search; mod db; mod keys; mod list; @@ -18,6 +19,7 @@ mod resolve; mod search; pub use detail::{cluster_album_detail, cluster_artist_detail}; +pub use advanced_search::run_cluster_advanced_search; pub use db::{ attach_cluster_database, attach_cluster_database_uri, cluster_db_path, ensure_cluster_schema, init_cluster_meta, needs_norm_rebuild, ATTACH_ALIAS, CLUSTER_DB_FILENAME, NORM_VERSION, @@ -27,12 +29,15 @@ pub use list::list_merged_tracks; pub use list_albums::list_merged_albums; pub use list_artists::list_merged_artists; pub use list_favorites::list_merged_favorite_tracks; +pub use list_favorites::{list_merged_favorite_albums, list_merged_favorite_artists}; pub use merge::DURATION_TOLERANCE_SEC; -pub use play_stats::{cluster_heatmap, cluster_year_summary}; +pub use play_stats::{ + cluster_day_detail, cluster_heatmap, cluster_most_played, cluster_recent_days, cluster_year_summary, +}; pub use rebuild::{ rebuild_all_cluster_keys, rebuild_cluster_keys_for_server, rebuild_if_norm_version_stale, }; pub use resolve::{ cluster_key_for_track, resolve_candidates_by_cluster_key, resolve_candidates_for_track, }; -pub use search::run_cluster_search; +pub use search::{run_cluster_random_tracks, run_cluster_search}; diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/play_stats.rs b/src-tauri/crates/psysonic-library/src/server_cluster/play_stats.rs index 77e5e0bd..f52a1841 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/play_stats.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/play_stats.rs @@ -1,14 +1,33 @@ //! Cluster-scoped player statistics — aggregate `play_session` across members (spec §4 Tier 2). +use std::collections::HashMap; + use rusqlite::types::Value as SqlValue; use crate::dto::{ - PlaySessionHeatmapDayDto, PlaySessionYearSummaryDto, + PlaySessionDayDetailDto, PlaySessionDayTotalsDto, PlaySessionDayTrackDto, PlaySessionHeatmapDayDto, + PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearSummaryDto, }; +use crate::repos; +use crate::search::aliased_track_columns; use crate::store::LibraryStore; use super::db::ATTACH_ALIAS; +use super::merge::DURATION_TOLERANCE_SEC; use super::priority::in_list_sql; +use super::priority::priority_case_sql; + +const RECENT_DAYS_LIMIT_MAX: u32 = 90; +const MOST_PLAYED_LIMIT_MAX: u32 = 200; + +#[derive(Default)] +struct DayAgg { + total_listened_sec: f64, + track_play_count: u32, + full_count: u32, + partial_count: u32, + plays: Vec<(i64, f64)>, +} fn server_filter_sql(servers_ordered: &[String]) -> Result<(String, Vec), String> { if servers_ordered.is_empty() { @@ -41,6 +60,25 @@ fn count_listening_sessions(plays: &[(i64, f64)]) -> u32 { sessions } +fn validate_date_iso(date_iso: &str) -> Result<(), String> { + if date_iso.len() != 10 || date_iso.as_bytes()[4] != b'-' || date_iso.as_bytes()[7] != b'-' { + return Err("dateIso must be YYYY-MM-DD".into()); + } + let year: i32 = date_iso[0..4] + .parse() + .map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?; + let month: u32 = date_iso[5..7] + .parse() + .map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?; + let day: u32 = date_iso[8..10] + .parse() + .map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?; + if year < 1970 || !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return Err("dateIso must be YYYY-MM-DD".into()); + } + Ok(()) +} + pub fn cluster_year_summary( store: &LibraryStore, servers_ordered: &[String], @@ -154,3 +192,404 @@ pub fn cluster_heatmap( }) .map_err(|e| e.to_string()) } + +pub fn cluster_day_detail( + store: &LibraryStore, + servers_ordered: &[String], + date_iso: &str, +) -> Result { + validate_date_iso(date_iso)?; + let (server_sql, mut params) = server_filter_sql(servers_ordered)?; + params.push(SqlValue::Text(date_iso.to_string())); + + store + .with_read_conn(|conn| { + let totals_sql = format!( + "SELECT \ + COALESCE(SUM(ps.listened_sec), 0.0), \ + COUNT(*), \ + COALESCE(SUM(CASE WHEN ps.completion = 'full' THEN 1 ELSE 0 END), 0), \ + COALESCE(SUM(CASE WHEN ps.completion = 'partial' THEN 1 ELSE 0 END), 0) \ + FROM play_session ps \ + WHERE {server_sql} \ + AND date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?", + ); + let (total_listened_sec, track_play_count, full_count, partial_count) = conn.query_row( + &totals_sql, + rusqlite::params_from_iter(params.iter()), + |row| { + Ok(( + row.get::<_, f64>(0)?, + row.get::<_, i64>(1)? as u32, + row.get::<_, i64>(2)? as u32, + row.get::<_, i64>(3)? as u32, + )) + }, + )?; + + let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); + let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + let cols = aliased_track_columns("t"); + let rows_sql = format!( + "WITH sessions AS ( + SELECT + ps.started_at_ms, + ps.listened_sec, + ps.completion, + COALESCE(k.cluster_key, 'solo:' || ps.server_id || ':' || ps.track_id) AS merge_key + FROM play_session ps + LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + ON k.server_id = ps.server_id AND k.track_id = ps.track_id + WHERE {server_sql} + AND date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ? + ), + candidates AS ( + SELECT + t.rowid AS tid, + t.server_id, + t.id AS track_id, + k.cluster_key, + COALESCE(k.duration_sec, t.duration_sec) AS dur, + ({priority_sql}) AS priority_rank + FROM track t + LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + ON k.server_id = t.server_id AND k.track_id = t.id + WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders}) + ), + refs AS ( + SELECT cluster_key, MIN(priority_rank) AS best_rank + FROM candidates + WHERE cluster_key IS NOT NULL + GROUP BY cluster_key + ), + ref_dur AS ( + SELECT c.cluster_key, c.dur AS ref_dur + FROM candidates c + JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank + ), + partitioned AS ( + SELECT c.tid, + CASE + WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id + WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key + ELSE 'solo:' || c.server_id || ':' || c.track_id + END AS merge_key, + c.priority_rank + FROM candidates c + LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key + ), + winners AS ( + SELECT merge_key, tid, + ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn + FROM partitioned + ) + SELECT {cols}, s.listened_sec, s.completion, s.started_at_ms + FROM sessions s + JOIN winners w ON w.merge_key = s.merge_key AND w.rn = 1 + JOIN track t ON t.rowid = w.tid + ORDER BY s.started_at_ms DESC", + tol = DURATION_TOLERANCE_SEC, + ); + + let mut rows_params = params.clone(); + rows_params.append(&mut priority_params); + rows_params.append(&mut in_params); + + let track_col_count = repos::track_columns().split(',').count(); + let mut stmt = conn.prepare(&rows_sql)?; + let tracks = stmt + .query_map(rusqlite::params_from_iter(rows_params.iter()), |row| { + let track = repos::row_to_track_row(row).map(|r| crate::dto::LibraryTrackDto::from_row(&r))?; + Ok(PlaySessionDayTrackDto { + server_id: track.server_id, + track_id: track.id, + title: track.title, + artist: track.artist, + listened_sec: row.get(track_col_count)?, + completion: row.get(track_col_count + 1)?, + started_at_ms: row.get(track_col_count + 2)?, + }) + })? + .collect::>>()?; + + let plays: Vec<(i64, f64)> = tracks + .iter() + .map(|t| (t.started_at_ms, t.listened_sec)) + .collect(); + Ok(PlaySessionDayDetailDto { + totals: PlaySessionDayTotalsDto { + total_listened_sec, + session_count: count_listening_sessions(&plays), + track_play_count, + full_count, + partial_count, + }, + tracks, + }) + }) + .map_err(|e| e.to_string()) +} + +pub fn cluster_recent_days( + store: &LibraryStore, + servers_ordered: &[String], + limit: u32, +) -> Result, String> { + let limit = limit.clamp(1, RECENT_DAYS_LIMIT_MAX); + let (server_sql, params) = server_filter_sql(servers_ordered)?; + + store + .with_read_conn(|conn| { + let sql = format!( + "SELECT + date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') AS d, + ps.started_at_ms, + ps.listened_sec, + ps.completion + FROM play_session ps + WHERE {server_sql} + ORDER BY d DESC, ps.started_at_ms ASC", + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, f64>(2)?, + row.get::<_, String>(3)?, + )) + })?; + + let mut by_day: HashMap = HashMap::new(); + for row in rows { + let (date, started_at_ms, listened_sec, completion) = row?; + let agg = by_day.entry(date).or_default(); + agg.total_listened_sec += listened_sec; + agg.track_play_count += 1; + if completion == "full" { + agg.full_count += 1; + } else { + agg.partial_count += 1; + } + agg.plays.push((started_at_ms, listened_sec)); + } + + let mut out: Vec = by_day + .into_iter() + .map(|(date, agg)| PlaySessionRecentDayDto { + date, + total_listened_sec: agg.total_listened_sec, + session_count: count_listening_sessions(&agg.plays), + track_play_count: agg.track_play_count, + full_count: agg.full_count, + partial_count: agg.partial_count, + }) + .collect(); + out.sort_by(|a, b| b.date.cmp(&a.date)); + out.truncate(limit as usize); + Ok(out) + }) + .map_err(|e| e.to_string()) +} + +pub fn cluster_most_played( + store: &LibraryStore, + servers_ordered: &[String], + limit: u32, +) -> Result, String> { + let limit = limit.clamp(1, MOST_PLAYED_LIMIT_MAX); + let (server_sql, mut stats_params) = server_filter_sql(servers_ordered)?; + let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); + let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + let cols = aliased_track_columns("t"); + let col_count = repos::track_columns().split(',').count(); + + let sql = format!( + "WITH session_counts AS ( + SELECT + COALESCE(k.cluster_key, 'solo:' || ps.server_id || ':' || ps.track_id) AS merge_key, + COUNT(*) AS track_play_count, + COALESCE(SUM(ps.listened_sec), 0.0) AS total_listened_sec + FROM play_session ps + LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + ON k.server_id = ps.server_id AND k.track_id = ps.track_id + WHERE {server_sql} + GROUP BY merge_key + ), + candidates AS ( + SELECT + t.rowid AS tid, + t.server_id, + t.id AS track_id, + k.cluster_key, + COALESCE(k.duration_sec, t.duration_sec) AS dur, + ({priority_sql}) AS priority_rank + FROM track t + LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + ON k.server_id = t.server_id AND k.track_id = t.id + WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders}) + ), + refs AS ( + SELECT cluster_key, MIN(priority_rank) AS best_rank + FROM candidates + WHERE cluster_key IS NOT NULL + GROUP BY cluster_key + ), + ref_dur AS ( + SELECT c.cluster_key, c.dur AS ref_dur + FROM candidates c + JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank + ), + partitioned AS ( + SELECT c.tid, + CASE + WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id + WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key + ELSE 'solo:' || c.server_id || ':' || c.track_id + END AS merge_key, + c.priority_rank + FROM candidates c + LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key + ), + winners AS ( + SELECT merge_key, tid, + ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn + FROM partitioned + ) + SELECT {cols}, sc.track_play_count, sc.total_listened_sec + FROM session_counts sc + JOIN winners w ON w.merge_key = sc.merge_key AND w.rn = 1 + JOIN track t ON t.rowid = w.tid + ORDER BY sc.track_play_count DESC, sc.total_listened_sec DESC, t.title COLLATE NOCASE ASC + LIMIT ?", + tol = DURATION_TOLERANCE_SEC, + ); + + let mut params: Vec = Vec::new(); + params.append(&mut stats_params); + params.append(&mut priority_params); + params.append(&mut in_params); + params.push(SqlValue::Integer(limit as i64)); + + store + .with_read_conn(|conn| { + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| { + let track = + repos::row_to_track_row(row).map(|r| crate::dto::LibraryTrackDto::from_row(&r))?; + Ok(PlaySessionMostPlayedDto { + track, + track_play_count: row.get::<_, i64>(col_count)? as u32, + total_listened_sec: row.get(col_count + 1)?, + }) + })?; + rows.collect::>>() + }) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repos::{TrackRepository, TrackRow}; + use crate::server_cluster::rebuild::rebuild_all_cluster_keys; + + fn track(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: title.into(), + title_sort: None, + artist: Some(artist.into()), + artist_id: Some(format!("art-{server}")), + album: album.into(), + album_id: Some(format!("alb-{server}")), + album_artist: Some(artist.into()), + duration_sec: 200, + track_number: Some(1), + disc_number: Some(1), + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } + } + + #[test] + fn day_detail_merges_track_identity_to_cluster_winner() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Song", "Band", "LP"), + track("s2", "t2", "Song", "Band", "LP"), + ]) + .unwrap(); + rebuild_all_cluster_keys(&store).unwrap(); + + store + .with_conn_mut("test", |conn| { + conn.execute( + "INSERT INTO play_session (server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason) + VALUES (?1, ?2, ?3, 120.0, 120.0, 'full', 'ended')", + rusqlite::params!["s2", "t2", 1_000i64], + )?; + Ok(()) + }) + .unwrap(); + + let detail = cluster_day_detail(&store, &["s1".into(), "s2".into()], "1970-01-01").unwrap(); + assert_eq!(detail.tracks.len(), 1); + assert_eq!(detail.tracks[0].server_id, "s1"); + assert_eq!(detail.tracks[0].track_id, "t1"); + assert_eq!(detail.totals.track_play_count, 1); + } + + #[test] + fn most_played_aggregates_cluster_members() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Song", "Band", "LP"), + track("s2", "t2", "Song", "Band", "LP"), + ]) + .unwrap(); + rebuild_all_cluster_keys(&store).unwrap(); + + store + .with_conn_mut("test", |conn| { + conn.execute( + "INSERT INTO play_session (server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason) + VALUES + ('s1', 't1', 1700000000000, 60.0, 60.0, 'partial', 'ended'), + ('s2', 't2', 1700000100000, 90.0, 90.0, 'full', 'ended')", + [], + )?; + Ok(()) + }) + .unwrap(); + + let rows = cluster_most_played(&store, &["s1".into(), "s2".into()], 10).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].track.server_id, "s1"); + assert_eq!(rows[0].track_play_count, 2); + } +} diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/search.rs b/src-tauri/crates/psysonic-library/src/server_cluster/search.rs index 03186cfb..18d10b85 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/search.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/search.rs @@ -20,9 +20,11 @@ pub fn run_cluster_search( store: &LibraryStore, query: &str, limit: u32, + offset: u32, servers_ordered: &[String], ) -> Result { let limit = limit.clamp(1, PAGE_LIMIT_MAX); + let offset = offset as usize; if servers_ordered.is_empty() { return Ok(LibraryCrossServerSearchResponse::default()); } @@ -76,8 +78,12 @@ pub fn run_cluster_search( } } - let mut hits: Vec = best_by_key.into_values().map(|(t, _)| t).collect(); - hits.truncate(limit as usize); + let hits: Vec = best_by_key + .into_values() + .map(|(t, _)| t) + .skip(offset) + .take(limit as usize) + .collect(); let hit_keys: HashSet<(String, String)> = hits .iter() @@ -98,6 +104,85 @@ pub fn run_cluster_search( }) } +/// Random merged track sample across cluster scope. +pub fn run_cluster_random_tracks( + store: &LibraryStore, + servers_ordered: &[String], + limit: u32, +) -> Result, String> { + if servers_ordered.is_empty() { + return Ok(Vec::new()); + } + let limit = limit.clamp(1, PAGE_LIMIT_MAX); + let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); + let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + let cols = aliased_track_columns("t"); + + let sql = format!( + "WITH candidates AS ( + SELECT + t.rowid AS tid, + t.server_id, + t.id AS track_id, + k.cluster_key, + COALESCE(k.duration_sec, t.duration_sec) AS dur, + ({priority_sql}) AS priority_rank + FROM track t + LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k + ON k.server_id = t.server_id AND k.track_id = t.id + WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders}) + ), + refs AS ( + SELECT cluster_key, MIN(priority_rank) AS best_rank + FROM candidates + WHERE cluster_key IS NOT NULL + GROUP BY cluster_key + ), + ref_dur AS ( + SELECT c.cluster_key, c.dur AS ref_dur + FROM candidates c + JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank + ), + partitioned AS ( + SELECT c.tid, + CASE + WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id + WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key + ELSE 'solo:' || c.server_id || ':' || c.track_id + END AS merge_key, + c.priority_rank + FROM candidates c + LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key + ), + winners AS ( + SELECT tid, + ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn + FROM partitioned + ) + SELECT {cols} + FROM winners w + JOIN track t ON t.rowid = w.tid + WHERE w.rn = 1 + ORDER BY RANDOM() + LIMIT ?", + tol = super::merge::DURATION_TOLERANCE_SEC, + ); + + let mut params: Vec = Vec::new(); + params.append(&mut priority_params); + params.append(&mut in_params); + params.push(SqlValue::Integer(limit as i64)); + + store.with_read_conn(|conn| { + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row)) + })?; + rows.collect::>>() + }) + .map_err(|e| e.to_string()) +} + fn fuzzy_cluster_matches( store: &LibraryStore, targets: &[String], diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 297af3a0..1762fbb9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -708,6 +708,7 @@ pub fn run() { psysonic_library::commands::library_search, psysonic_library::commands::library_live_search, psysonic_library::commands::library_advanced_search, + psysonic_library::commands::library_cluster_advanced_search, psysonic_library::commands::library_list_lossless_albums, psysonic_library::commands::library_list_albums_by_genre, psysonic_library::commands::library_get_artist_lossless_browse, @@ -716,8 +717,13 @@ pub fn run() { psysonic_library::commands::library_cluster_list_albums, psysonic_library::commands::library_cluster_list_artists, psysonic_library::commands::library_cluster_list_favorites, + psysonic_library::commands::library_cluster_list_favorite_albums, + psysonic_library::commands::library_cluster_list_favorite_artists, psysonic_library::commands::library_cluster_player_stats_year_summary, psysonic_library::commands::library_cluster_player_stats_heatmap, + psysonic_library::commands::library_cluster_player_stats_day_detail, + psysonic_library::commands::library_cluster_player_stats_recent_days, + psysonic_library::commands::library_cluster_player_stats_most_played, psysonic_library::commands::library_cluster_resolve_candidates, psysonic_library::commands::library_cluster_album_detail, psysonic_library::commands::library_cluster_artist_detail, diff --git a/src/api/library.ts b/src/api/library.ts index addceca6..d80f3f78 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -668,11 +668,13 @@ export function libraryClusterArtistDetail(args: { export function librarySearchCluster(args: { query: string; limit?: number; + offset?: number; serversOrdered: string[]; }): Promise { return invoke('library_search_cluster', { query: args.query, limit: args.limit, + offset: args.offset, serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered), }).then(response => ({ ...response, @@ -682,6 +684,85 @@ export function librarySearchCluster(args: { })); } +export interface LibraryClusterAdvancedSearchRequest { + serversOrdered: string[]; + query?: string | null; + entityTypes: LibraryEntityType[]; + filters?: LibraryFilterClause[]; + starredOnly?: boolean | null; + restrictAlbumIds?: string[] | null; + queryAlbumTitleOnly?: boolean | null; + sort?: LibrarySortClause[]; + limit: number; + offset?: number; + skipTotals?: boolean; +} + +export function libraryClusterAdvancedSearch( + request: LibraryClusterAdvancedSearchRequest, +): Promise { + return invoke('library_cluster_advanced_search', { + request: { + serversOrdered: mapServersOrderedToIndexKeys(request.serversOrdered), + query: request.query ?? undefined, + entityTypes: request.entityTypes, + filters: request.filters ?? [], + starredOnly: request.starredOnly ?? undefined, + restrictAlbumIds: request.restrictAlbumIds ?? undefined, + queryAlbumTitleOnly: request.queryAlbumTitleOnly ?? undefined, + sort: request.sort ?? [], + limit: request.limit, + offset: request.offset ?? 0, + skipTotals: request.skipTotals ?? false, + }, + }).then(response => ({ + ...response, + artists: response.artists.map(artist => ({ + ...artist, + serverId: mapServerIdFromIndexKey(artist.serverId), + })), + albums: response.albums.map(album => ({ + ...album, + serverId: mapServerIdFromIndexKey(album.serverId), + })), + tracks: mapTracksServerId(response.tracks), + })); +} + +export function libraryClusterListFavoriteAlbums(args: { + serversOrdered: string[]; + limit?: number; + offset?: number; +}): Promise { + return invoke('library_cluster_list_favorite_albums', { + request: { + serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered), + limit: args.limit, + offset: args.offset, + }, + }).then(resp => ({ + ...resp, + albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })), + })); +} + +export function libraryClusterListFavoriteArtists(args: { + serversOrdered: string[]; + limit?: number; + offset?: number; +}): Promise { + return invoke('library_cluster_list_favorite_artists', { + request: { + serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered), + limit: args.limit, + offset: args.offset, + }, + }).then(resp => ({ + ...resp, + artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })), + })); +} + export function libraryGetTrack( serverId: string, trackId: string, @@ -1032,6 +1113,60 @@ export function libraryGetPlayerStatsRecentDays(limit = 30): Promise('library_get_player_stats_recent_days', { limit }); } +export interface PlaySessionMostPlayed { + track: LibraryTrackDto; + trackPlayCount: number; + totalListenedSec: number; +} + +export function libraryClusterPlayerStatsDayDetail(args: { + serversOrdered: string[]; + dateIso: string; +}): Promise { + return invoke('library_cluster_player_stats_day_detail', { + request: { + serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered), + dateIso: args.dateIso, + }, + }).then(detail => ({ + ...detail, + tracks: detail.tracks.map(track => ({ + ...track, + serverId: mapServerIdFromIndexKey(track.serverId), + })), + })); +} + +export function libraryClusterPlayerStatsRecentDays(args: { + serversOrdered: string[]; + limit?: number; +}): Promise { + return invoke('library_cluster_player_stats_recent_days', { + request: { + serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered), + limit: args.limit, + }, + }); +} + +export function libraryClusterPlayerStatsMostPlayed(args: { + serversOrdered: string[]; + limit?: number; +}): Promise { + return invoke('library_cluster_player_stats_most_played', { + request: { + serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered), + limit: args.limit, + }, + }).then(rows => rows.map(row => ({ + ...row, + track: { + ...row.track, + serverId: mapServerIdFromIndexKey(row.track.serverId), + }, + }))); +} + // ── Event subscriptions ─────────────────────────────────────────────── export interface LibrarySyncProgressPayload { diff --git a/src/api/subsonicArtists.ts b/src/api/subsonicArtists.ts index 6be79b79..5b5ac9be 100644 --- a/src/api/subsonicArtists.ts +++ b/src/api/subsonicArtists.ts @@ -8,6 +8,10 @@ import type { SubsonicArtistInfo, SubsonicSong, } from './subsonicTypes'; +import { isClusterMode } from '../utils/serverCluster/clusterScope'; +import { resolveClusterBrowseMembers } from '../utils/serverCluster/clusterBrowse'; +import { libraryClusterResolveCandidates } from './library'; +import { mergeClusterTracks, resolveClusterSeedIds } from '../utils/serverCluster/clusterDiscoveryMerge'; export async function getArtists(): Promise { const data = await api<{ artists: { index: any } }>('getArtists.view', { @@ -60,6 +64,9 @@ export async function getArtistInfoForServer( } export async function getTopSongs(artist: string): Promise { + if (isClusterMode()) { + return getTopSongsCluster(artist); + } const { activeServerId } = useAuthStore.getState(); if (!activeServerId) return []; return getTopSongsForServer(activeServerId, artist); @@ -83,7 +90,59 @@ export async function getTopSongsForServer(serverId: string, artist: string): Pr } } +async function getTopSongsCluster(artist: string): Promise { + const members = await resolveClusterBrowseMembers(); + if (!members?.length) return []; + const settled = await Promise.allSettled( + members.map(serverId => + apiForServer<{ topSongs: { song: SubsonicSong[] } }>( + serverId, + 'getTopSongs.view', + { artist, count: 20, ...libraryFilterParamsForServer(serverId) }, + ).then(data => ({ serverId, songs: data.topSongs?.song ?? [] })), + ), + ); + const merged = mergeClusterTracks( + settled.flatMap((row, idx) => + row.status === 'fulfilled' + ? row.value.songs.map(song => ({ + item: { ...song, clusterBrowseServerId: row.value.serverId }, + serverId: row.value.serverId, + priorityRank: idx, + })) + : [], + ), + ); + return merged.slice(0, 5); +} + export async function getSimilarSongs2(id: string, count = 50): Promise { + if (isClusterMode()) { + const members = await resolveClusterBrowseMembers(); + if (!members?.length) return []; + const requestCount = similarSongsRequestCount(count); + const settled = await Promise.allSettled( + members.map(serverId => + apiForServer<{ similarSongs2: { song: SubsonicSong[] } }>( + serverId, + 'getSimilarSongs2.view', + { id, count: requestCount, ...libraryFilterParamsForServer(serverId) }, + ).then(data => ({ serverId, songs: data.similarSongs2?.song ?? [] })), + ), + ); + const merged = mergeClusterTracks( + settled.flatMap((row, idx) => + row.status === 'fulfilled' + ? row.value.songs.map(song => ({ + item: { ...song, clusterBrowseServerId: row.value.serverId }, + serverId: row.value.serverId, + priorityRank: idx, + })) + : [], + ), + ); + return merged.filter(s => s.id !== id).slice(0, count); + } try { const requestCount = similarSongsRequestCount(count); const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() }); @@ -96,7 +155,54 @@ export async function getSimilarSongs2(id: string, count = 50): Promise { +export async function getSimilarSongs( + id: string, + count = 50, + browseServerId?: string, +): Promise { + if (isClusterMode()) { + const members = await resolveClusterBrowseMembers(); + if (!members?.length) return []; + const activeServerId = browseServerId ?? useAuthStore.getState().activeServerId ?? members[0] ?? ''; + const requestCount = similarSongsRequestCount(count); + const seedCandidates = await libraryClusterResolveCandidates({ + serversOrdered: members, + serverId: activeServerId, + trackId: id, + }).catch(() => null); + const seeds = resolveClusterSeedIds( + Object.fromEntries((seedCandidates?.candidates ?? []).map(c => [c.serverId, c.trackId])), + members, + ); + const resolvedSeeds = seeds.length > 0 + ? seeds + : members.map(serverId => ({ serverId, seedId: id })); + const settled = await Promise.allSettled( + resolvedSeeds.map(({ serverId, seedId }) => + apiForServer<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>( + serverId, + 'getSimilarSongs.view', + { id: seedId, count: requestCount, ...libraryFilterParamsForServer(serverId) }, + ).then(data => { + const raw = data.similarSongs?.song; + const songs = !raw ? [] : Array.isArray(raw) ? raw : [raw]; + return { serverId, songs }; + }), + ), + ); + const merged = mergeClusterTracks( + settled.flatMap((row, idx) => + row.status === 'fulfilled' + ? row.value.songs.map(song => ({ + item: { ...song, clusterBrowseServerId: row.value.serverId }, + serverId: row.value.serverId, + priorityRank: idx, + })) + : [], + ), + ); + return merged.filter(s => s.id !== id).slice(0, count); + } try { const requestCount = similarSongsRequestCount(count); const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() }); diff --git a/src/api/subsonicScrobble.ts b/src/api/subsonicScrobble.ts index a51f1a9d..eed164c2 100644 --- a/src/api/subsonicScrobble.ts +++ b/src/api/subsonicScrobble.ts @@ -16,11 +16,16 @@ async function scrobbleOnServer( await apiForServer(serverId, 'scrobble.view', params); } -export async function scrobbleSong(id: string, time: number, serverId: string): Promise { +export async function scrobbleSong( + id: string, + time: number, + serverId: string, + resolvedServerId?: string, +): Promise { if (!serverId) return; if (isClusterMode()) { const browseId = useAuthStore.getState().activeServerId ?? serverId; - await clusterFanOutScrobbleSubmission(browseId, id, time); + await clusterFanOutScrobbleSubmission(browseId, id, time, resolvedServerId ?? serverId); return; } try { diff --git a/src/api/subsonicTypes.ts b/src/api/subsonicTypes.ts index 1f830bec..6a27f4bc 100644 --- a/src/api/subsonicTypes.ts +++ b/src/api/subsonicTypes.ts @@ -100,6 +100,8 @@ export interface SubsonicSong { subRole?: string; artist: { id?: string; name: string }; }>; + /** Psysonic cluster: browse-origin server for fan-out resolution (client-only). */ + clusterBrowseServerId?: string; } export interface InternetRadioStation { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 64c8a4c1..0913dfd2 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -48,6 +48,7 @@ import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelect import { useAppActivityTracking } from '../hooks/useAppActivityTracking'; import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator'; import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'; +import { useClusterPlaybackMonitor } from '../hooks/useClusterPlaybackMonitor'; import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope'; import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm'; import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav'; @@ -102,6 +103,7 @@ export function AppShell() { const location = useLocation(); const prevPathnameRef = useRef(location.pathname); useCoverNavigationPriority(); + useClusterPlaybackMonitor(); useLiveSearchRouteScope(); useNowPlayingPrewarm(); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); diff --git a/src/components/ClusterMergeBanner.tsx b/src/components/ClusterMergeBanner.tsx new file mode 100644 index 00000000..aaf02bc8 --- /dev/null +++ b/src/components/ClusterMergeBanner.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ServerCluster } from '../utils/serverCluster/types'; +import { + formatExcludedMemberLabels, + getClusterMergeDiagnostics, + type ClusterMergeDiagnostics, +} from '../utils/serverCluster/clusterMergeStatus'; + +export default function ClusterMergeBanner({ cluster }: { cluster: ServerCluster }) { + const { t } = useTranslation(); + const [diag, setDiag] = useState(null); + + useEffect(() => { + let cancelled = false; + void getClusterMergeDiagnostics(cluster).then(res => { + if (!cancelled) setDiag(res); + }).catch(() => { + if (!cancelled) setDiag(null); + }); + return () => { cancelled = true; }; + }, [cluster]); + + if (!diag || diag.mergeCount >= diag.totalCount) return null; + return ( +
+ {t('cluster.mergeBanner', { + excluded: formatExcludedMemberLabels(diag.members), + })} +
+ ); +} diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index 6eaca0f7..785b4517 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -10,6 +10,7 @@ import { switchActiveCluster, switchActiveServer } from '../utils/server/switchA 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; @@ -31,6 +32,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props const menuPanelRef = useRef(null); const multi = servers.length > 1 || clusters.length > 0; + const activeCluster = activeClusterId + ? clusters.find(cluster => cluster.id === activeClusterId) ?? null + : null; const updateMenuPosition = useCallback(() => { const el = hostRef.current; @@ -170,6 +174,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props + {activeCluster && } {multi && menuOpen && typeof document !== 'undefined' && diff --git a/src/components/OrbitStartModal.tsx b/src/components/OrbitStartModal.tsx index 5a865537..81b065a8 100644 --- a/src/components/OrbitStartModal.tsx +++ b/src/components/OrbitStartModal.tsx @@ -15,6 +15,7 @@ import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import { isLanUrl, serverShareBaseUrl } from '../utils/server/serverEndpoint'; import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit'; +import { isClusterMode } from '../utils/serverCluster/clusterScope'; interface Props { onClose: () => void; } @@ -36,6 +37,7 @@ export default function OrbitStartModal({ onClose }: Props) { const [copied, setCopied] = useState(false); const [hasCopied, setHasCopied] = useState(false); const [clearQueue, setClearQueue] = useState(false); + const clusterBlocked = isClusterMode(); const server = useAuthStore.getState().getActiveServer(); // Orbit links go to remote guests — use the share URL (public by default @@ -70,6 +72,10 @@ export default function OrbitStartModal({ onClose }: Props) { const onStart = async () => { setError(null); + if (clusterBlocked) { + setError(t('orbit.clusterCreateBlocked')); + return; + } const trimmed = name.trim(); if (!trimmed) { setError(t('orbit.errNameRequired')); return; } @@ -222,7 +228,7 @@ export default function OrbitStartModal({ onClose }: Props) { type="button" className="btn btn-primary" onClick={onStart} - disabled={busy || !name.trim()} + disabled={busy || !name.trim() || clusterBlocked} > {busy ? t('orbit.btnStarting') diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 62e964e9..48593135 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -209,10 +209,35 @@ function QueuePanelHostOrSolo() { if (!active) return; if (isClusterMode()) { const repId = active.id; - const foreign = queueItems.some( - r => resolveServerIdForIndexKey(r.serverId) !== repId, + const exportRefs = queueItems.filter( + r => resolveServerIdForIndexKey(r.serverId) === repId, ); - if (foreign && !confirm(t('queue.shareClusterMixedWarning'))) return; + const foreignCount = queueItems.length - exportRefs.length; + if (foreignCount > 0) { + const msg = t('queue.shareClusterMixedWarning', { + exported: exportRefs.length, + total: queueItems.length, + }); + if (!confirm(msg)) return; + } + if (exportRefs.length === 0) { + showToast(t('queue.shareClusterNothingOnPrimary'), 4000, 'info'); + return; + } + const srv = serverShareBaseUrl(active); + if (!srv) return; + const ids = exportRefs.map(r => r.trackId); + const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids })); + if (ok) { + showToast( + foreignCount > 0 + ? t('queue.shareClusterExportedCount', { exported: exportRefs.length, total: queueItems.length }) + : t('contextMenu.shareCopied'), + ); + } else { + showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); + } + return; } const srv = serverShareBaseUrl(active); if (!srv) return; diff --git a/src/components/settings/ServerClustersSection.tsx b/src/components/settings/ServerClustersSection.tsx index c64bf1ba..fca3d041 100644 --- a/src/components/settings/ServerClustersSection.tsx +++ b/src/components/settings/ServerClustersSection.tsx @@ -6,6 +6,10 @@ import { useDragDrop, useDragSource } from '../../contexts/DragDropContext'; import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; import { switchActiveCluster } from '../../utils/server/switchActiveServer'; import type { ServerCluster } from '../../utils/serverCluster/types'; +import { + getClusterMergeDiagnostics, + type ClusterMergeDiagnostics, +} from '../../utils/serverCluster/clusterMergeStatus'; type MemberDropTarget = { idx: number; before: boolean } | null; @@ -19,6 +23,7 @@ export function ServerClustersSection() { const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const [memberDropTarget, setMemberDropTarget] = useState(null); + const [diagnosticsByCluster, setDiagnosticsByCluster] = useState>({}); const memberDropRef = useRef(null); const dragClusterIdRef = useRef(null); const containerRef = useRef(null); @@ -32,6 +37,20 @@ export function ServerClustersSection() { } }, [psyDragState.isDragging]); + useEffect(() => { + let cancelled = false; + void Promise.all(auth.clusters.map(async cluster => { + const diag = await getClusterMergeDiagnostics(cluster); + return [cluster.id, diag] as const; + })).then(entries => { + if (cancelled) return; + setDiagnosticsByCluster(Object.fromEntries(entries)); + }).catch(() => { + if (!cancelled) setDiagnosticsByCluster({}); + }); + return () => { cancelled = true; }; + }, [auth.clusters, auth.servers, auth.activeClusterId, auth.musicLibraryFilterVersion]); + const startCreate = () => { setCreating(true); setNewName(''); @@ -215,6 +234,14 @@ export function ServerClustersSection() { {cluster.serverIds.map((sid, memberIdx) => { const srv = auth.servers.find(s => s.id === sid); if (!srv) return null; + const status = diagnosticsByCluster[cluster.id]?.members.find(m => m.serverId === sid); + const statusLabel = !status + ? '' + : status.included + ? t('cluster.memberIncluded') + : status.reason === 'indexing' + ? t('cluster.memberExcludedIndexing') + : t('cluster.memberExcludedOffline'); const isBefore = psyDragState.isDragging && dragClusterIdRef.current === cluster.id && @@ -244,6 +271,11 @@ export function ServerClustersSection() { > {serverListDisplayLabel(srv, auth.servers)} + {statusLabel && ( + + {statusLabel} + + )} + ))} + + )} + + ))} + + )} + + ); + } + return (
diff --git a/src/pages/MostPlayed.tsx b/src/pages/MostPlayed.tsx index eb05bbf5..5e49eac8 100644 --- a/src/pages/MostPlayed.tsx +++ b/src/pages/MostPlayed.tsx @@ -12,6 +12,9 @@ import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum'; import { useLongPressAction } from '../hooks/useLongPressAction'; import { LongPressWaveOverlay } from '../components/LongPressWaveOverlay'; import { useTranslation } from 'react-i18next'; +import { isClusterMode } from '../utils/serverCluster/clusterScope'; +import { loadPlayerStatsMostPlayed } from '../utils/serverCluster/clusterPlayerStats'; +import { trackToSong } from '../utils/library/advancedSearchLocal'; const PAGE_SIZE = 50; @@ -113,6 +116,24 @@ export default function MostPlayed() { setAlbums([]); setHasMore(true); try { + if (isClusterMode()) { + const rows = await loadPlayerStatsMostPlayed(PAGE_SIZE); + const asAlbums = rows.map(row => { + const s = trackToSong(row.track); + return { + id: s.albumId || `${row.track.serverId}:${s.id}`, + name: s.album, + artist: s.artist, + artistId: s.artistId || '', + coverArt: s.coverArt, + playCount: row.trackPlayCount, + } as SubsonicAlbum; + }); + setAlbums(asAlbums); + setHasMore(false); + setLoading(false); + return; + } const result = await getAlbumList('frequent', PAGE_SIZE, 0); setAlbums(result); setHasMore(result.length === PAGE_SIZE); @@ -123,6 +144,7 @@ export default function MostPlayed() { useEffect(() => { load(); }, [load]); const loadMore = async () => { + if (isClusterMode()) return; if (loadingMore || !hasMore) return; setLoadingMore(true); try { diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 37b27116..b7bd8c72 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -1,7 +1,8 @@ import { getPlaylist } from '../api/subsonicPlaylists'; import { getGenres } from '../api/subsonicGenres'; +import { apiForServer } from '../api/subsonicClient'; import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; -import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes'; +import type { SubsonicPlaylist, SubsonicGenre, SubsonicSong } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import React, { useEffect, useState, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -29,6 +30,9 @@ import PlaylistsHeader from '../components/playlists/PlaylistsHeader'; import PlaylistCard from '../components/playlists/PlaylistCard'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; +import { isClusterMode } from '../utils/serverCluster/clusterScope'; +import { resolveClusterBrowseMembers } from '../utils/serverCluster/clusterBrowse'; +import { serverListDisplayLabel } from '../utils/server/serverDisplayName'; function formatDuration(seconds: number): string { return formatHumanHoursMinutes(seconds); @@ -65,6 +69,8 @@ export default function Playlists() { usePlaylistsLibraryScopeCounts(playlists, musicLibraryFilterVersion); const [playingId, setPlayingId] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [clusterGroups, setClusterGroups] = useState>([]); + const [clusterLoading, setClusterLoading] = useState(false); const nameInputRef = useRef(null); // ── Multi-selection ────────────────────────────────────────────────────── @@ -97,6 +103,44 @@ export default function Playlists() { getGenres().then(setGenres).catch(() => {}); }, [fetchPlaylists]); + useEffect(() => { + if (!isClusterMode()) return; + let cancelled = false; + setClusterLoading(true); + void (async () => { + const members = await resolveClusterBrowseMembers(); + if (!members || members.length === 0) { + if (!cancelled) { + setClusterGroups([]); + setClusterLoading(false); + } + return; + } + const all = useAuthStore.getState().servers; + const settled = await Promise.allSettled( + members.map(async (serverId: string) => { + const data = await apiForServer<{ playlists?: { playlist?: SubsonicPlaylist[] } }>( + serverId, + 'getPlaylists.view', + { _t: Date.now() }, + ); + const playlistsForServer = (data.playlists?.playlist ?? []).filter(p => !p.name.startsWith('__psyorbit_')); + const server = all.find(s => s.id === serverId); + const label = server ? serverListDisplayLabel(server, all) : serverId; + return { serverId, label, playlists: playlistsForServer }; + }), + ); + if (cancelled) return; + const groups: Array<{ serverId: string; label: string; playlists: SubsonicPlaylist[] }> = []; + settled.forEach((r) => { + if (r.status === 'fulfilled') groups.push(r.value); + }); + setClusterGroups(groups); + setClusterLoading(false); + })(); + return () => { cancelled = true; }; + }, [musicLibraryFilterVersion]); + useEffect(() => { if (creating) nameInputRef.current?.focus(); }, [creating]); @@ -169,6 +213,61 @@ export default function Playlists() { ); } + if (isClusterMode()) { + return ( +
+

{t('playlists.title')}

+ {clusterLoading ? ( +
+
+
+ ) : clusterGroups.length === 0 ? ( +
{t('playlists.empty')}
+ ) : ( +
+ {clusterGroups.map(group => ( +
+

{group.label}

+ {group.playlists.length === 0 ? ( +
{t('playlists.empty')}
+ ) : ( +
+ {group.playlists.map(pl => ( + + ))} +
+ )} +
+ ))} +
+ )} +
+ ); + } + return (