diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index e3e63dd4..caef0397 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -572,7 +572,13 @@ pub async fn library_cluster_list_tracks( let limit = request.limit.unwrap_or(100); let offset = request.offset.unwrap_or(0); library_spawn_blocking(move || { - crate::server_cluster::list_merged_tracks(&store, &servers_ordered, limit, offset) + crate::server_cluster::list_merged_tracks( + &store, + &servers_ordered, + limit, + offset, + &request.library_scopes, + ) }) .await } @@ -587,7 +593,13 @@ pub async fn library_cluster_list_albums( let limit = request.limit.unwrap_or(100); let offset = request.offset.unwrap_or(0); library_spawn_blocking(move || { - crate::server_cluster::list_merged_albums(&store, &servers_ordered, limit, offset) + crate::server_cluster::list_merged_albums( + &store, + &servers_ordered, + limit, + offset, + &request.library_scopes, + ) }) .await } @@ -602,7 +614,13 @@ pub async fn library_cluster_list_artists( let limit = request.limit.unwrap_or(100); let offset = request.offset.unwrap_or(0); library_spawn_blocking(move || { - crate::server_cluster::list_merged_artists(&store, &servers_ordered, limit, offset) + crate::server_cluster::list_merged_artists( + &store, + &servers_ordered, + limit, + offset, + &request.library_scopes, + ) }) .await } diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index dac71204..cc5c2bfa 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; use crate::filter::{EntityKind, FilterOp}; use crate::repos::TrackRow; @@ -687,6 +688,9 @@ pub struct LibraryClusterListTracksRequest { pub limit: Option, #[serde(default)] pub offset: Option, + /// Per-member music-folder scope (`server_id` → folder id). Omitted members = all libraries. + #[serde(default)] + pub library_scopes: HashMap, } /// `library_cluster_advanced_search` request. @@ -713,6 +717,9 @@ pub struct LibraryClusterAdvancedSearchRequest { pub offset: u32, #[serde(default)] pub skip_totals: bool, + /// Per-member music-folder scope (`server_id` → folder id). Omitted members = all libraries. + #[serde(default)] + pub library_scopes: HashMap, } /// Merged album browse response for cluster scope. 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 index 2feb16c3..cb844c6d 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/advanced_search.rs @@ -38,7 +38,7 @@ pub fn run_cluster_advanced_search( for server_id in &req.servers_ordered { let server_req = LibraryAdvancedSearchRequest { server_id: server_id.clone(), - library_scope: None, + library_scope: req.library_scopes.get(server_id).cloned(), query: req.query.clone(), entity_types: req.entity_types.clone(), filters: req.filters.clone(), @@ -345,6 +345,7 @@ mod tests { limit: 50, offset: 0, skip_totals: false, + library_scopes: HashMap::new(), }, ) .unwrap(); @@ -382,6 +383,7 @@ mod tests { limit: 1, offset: 1, skip_totals: false, + library_scopes: HashMap::new(), }, ) .unwrap(); 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 c0f3e4ab..e98f012d 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/detail.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/detail.rs @@ -367,7 +367,7 @@ fn list_related_albums( if artist_id.is_empty() && artist_name.is_empty() { return Ok(Vec::new()); } - let resp = list_merged_albums(store, servers_ordered, 500, 0)?; + let resp = list_merged_albums(store, servers_ordered, 500, 0, &std::collections::HashMap::new())?; Ok(resp .albums .into_iter() diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/library_scope.rs b/src-tauri/crates/psysonic-library/src/server_cluster/library_scope.rs new file mode 100644 index 00000000..e427cd11 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/server_cluster/library_scope.rs @@ -0,0 +1,57 @@ +//! Per-member music-folder (`library_scope`) filters for merged cluster queries. + +use std::collections::HashMap; + +use rusqlite::types::Value as SqlValue; + +use crate::search::library_scope_equals_sql; + +/// `(sql_suffix, bind_params)` — AND ( (server + optional scope) OR … ). +pub(crate) fn scope_filter_sql_and_params( + table_alias: &str, + servers_ordered: &[String], + scopes: &HashMap, +) -> (String, Vec) { + if scopes.is_empty() { + return (String::new(), Vec::new()); + } + let eq = library_scope_equals_sql(table_alias); + let mut parts = Vec::with_capacity(servers_ordered.len()); + let mut params = Vec::new(); + for sid in servers_ordered { + if let Some(scope) = scopes.get(sid) { + parts.push(format!("({table_alias}.server_id = ? AND {eq})")); + params.push(SqlValue::Text(sid.clone())); + params.push(SqlValue::Text(scope.clone())); + } else { + parts.push(format!("({table_alias}.server_id = ?)")); + params.push(SqlValue::Text(sid.clone())); + } + } + (format!(" AND ({})", parts.join(" OR ")), params) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_filter_empty_when_no_scopes() { + let (sql, params) = scope_filter_sql_and_params("t", &["s1".into()], &HashMap::new()); + assert!(sql.is_empty()); + assert!(params.is_empty()); + } + + #[test] + fn scope_filter_binds_scoped_and_unscoped_members() { + let mut scopes = HashMap::new(); + scopes.insert("s1".into(), "lib-a".into()); + let (sql, params) = scope_filter_sql_and_params( + "t", + &["s1".into(), "s2".into()], + &scopes, + ); + assert!(sql.contains("t.server_id = ?")); + assert_eq!(params.len(), 3); + } +} diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/list.rs b/src-tauri/crates/psysonic-library/src/server_cluster/list.rs index f3841ed8..99123633 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/list.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/list.rs @@ -8,6 +8,7 @@ use crate::search::{aliased_track_columns, PAGE_LIMIT_MAX}; use crate::store::LibraryStore; use super::db::ATTACH_ALIAS; +use super::library_scope::scope_filter_sql_and_params; use super::merge::DURATION_TOLERANCE_SEC; use super::priority::{in_list_sql, priority_case_sql}; @@ -18,6 +19,7 @@ pub fn list_merged_tracks( servers_ordered: &[String], limit: u32, offset: u32, + library_scopes: &std::collections::HashMap, ) -> Result { if servers_ordered.is_empty() { return Ok(LibraryTracksEnvelope { @@ -29,6 +31,7 @@ pub fn list_merged_tracks( let offset = offset.min(i32::MAX as u32) as i32; let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + let (scope_sql, mut scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes); let cols = aliased_track_columns("t"); let sql = format!( @@ -43,7 +46,7 @@ pub fn list_merged_tracks( 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}) + WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders}){scope_sql} ), refs AS ( SELECT cluster_key, MIN(priority_rank) AS best_rank @@ -84,6 +87,7 @@ pub fn list_merged_tracks( let mut params: Vec = Vec::new(); params.append(&mut priority_params); params.append(&mut in_params); + params.append(&mut scope_params); params.push(SqlValue::Integer(limit as i64)); params.push(SqlValue::Integer(offset as i64)); @@ -104,6 +108,7 @@ pub fn list_merged_tracks( #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; use crate::repos::{TrackRepository, TrackRow}; use crate::server_cluster::rebuild::rebuild_all_cluster_keys; @@ -158,7 +163,7 @@ mod tests { .unwrap(); rebuild_all_cluster_keys(&store).unwrap(); - let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap(); + let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0, &HashMap::new()).unwrap(); assert_eq!(env.tracks.len(), 1); assert_eq!(env.tracks[0].server_id, "s1"); } @@ -174,7 +179,7 @@ mod tests { .unwrap(); rebuild_all_cluster_keys(&store).unwrap(); - let env = list_merged_tracks(&store, &["s2".into()], 50, 0).unwrap(); + let env = list_merged_tracks(&store, &["s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap(); assert_eq!(env.tracks.len(), 1); assert_eq!(env.tracks[0].server_id, "s2"); } @@ -193,7 +198,7 @@ mod tests { }) .unwrap(); rebuild_all_cluster_keys(&store).unwrap(); - let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap(); + let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0, &HashMap::new()).unwrap(); assert_eq!(env.tracks.len(), 2); } } diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/list_albums.rs b/src-tauri/crates/psysonic-library/src/server_cluster/list_albums.rs index 39f8a265..e98adbcf 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/list_albums.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/list_albums.rs @@ -8,6 +8,7 @@ use crate::search::PAGE_LIMIT_MAX; use crate::store::LibraryStore; use super::db::ATTACH_ALIAS; +use super::library_scope::scope_filter_sql_and_params; use super::priority::{in_list_sql, priority_case_sql}; pub fn list_merged_albums( @@ -15,6 +16,7 @@ pub fn list_merged_albums( servers_ordered: &[String], limit: u32, offset: u32, + library_scopes: &std::collections::HashMap, ) -> Result { if servers_ordered.is_empty() { return Ok(LibraryClusterAlbumsResponse { @@ -26,6 +28,7 @@ pub fn list_merged_albums( let offset = offset.min(i32::MAX as u32) as i32; let (in_placeholders, mut in_params) = in_list_sql(servers_ordered); let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered); + let (scope_sql, mut scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes); let sql = format!( "WITH candidates AS ( @@ -40,7 +43,7 @@ pub fn list_merged_albums( ON k.server_id = t.server_id AND k.track_id = t.id WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders}) - AND t.album_id IS NOT NULL AND t.album_id != '' + AND t.album_id IS NOT NULL AND t.album_id != ''{scope_sql} ), partitioned AS ( SELECT c.tid, @@ -87,6 +90,7 @@ pub fn list_merged_albums( let mut params: Vec = Vec::new(); params.append(&mut priority_params); params.append(&mut in_params); + params.append(&mut scope_params); params.push(SqlValue::Integer(limit as i64)); params.push(SqlValue::Integer(offset as i64)); @@ -185,7 +189,14 @@ mod tests { .unwrap(); rebuild_all_cluster_keys(&store).unwrap(); - let resp = list_merged_albums(&store, &["s1".into(), "s2".into()], 50, 0).unwrap(); + let resp = list_merged_albums( + &store, + &["s1".into(), "s2".into()], + 50, + 0, + &std::collections::HashMap::new(), + ) + .unwrap(); assert_eq!(resp.albums.len(), 1); assert_eq!(resp.albums[0].server_id, "s1"); } 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 f89848ca..568d6d4a 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 @@ -8,6 +8,7 @@ use crate::search::PAGE_LIMIT_MAX; use crate::store::LibraryStore; use super::db::ATTACH_ALIAS; +use super::library_scope::scope_filter_sql_and_params; use super::priority::{in_list_sql, priority_case_sql}; pub fn list_merged_artists( @@ -15,6 +16,7 @@ pub fn list_merged_artists( servers_ordered: &[String], limit: u32, offset: u32, + library_scopes: &std::collections::HashMap, ) -> Result { if servers_ordered.is_empty() { return Ok(LibraryClusterArtistsResponse { @@ -26,6 +28,7 @@ pub fn list_merged_artists( let offset = offset.min(i32::MAX as u32) as i32; let (in_placeholders, in_params) = in_list_sql(servers_ordered); let (priority_sql, priority_params) = priority_case_sql("c.server_id", servers_ordered); + let (scope_sql, scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes); // Artist-first catalog: one row per artist (not per track), then merge by // `artist_key`. The previous track-scan + window over every row was O(tracks) @@ -40,7 +43,7 @@ pub fn list_merged_artists( 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 t.server_id IN ({in_placeholders}){scope_sql} AND k.artist_key IS NOT NULL GROUP BY t.server_id, artist_ref ), @@ -56,7 +59,7 @@ pub fn list_merged_artists( CAST(NULL AS TEXT) AS raw_json FROM track t WHERE t.deleted = 0 - AND t.server_id IN ({in_placeholders}) + AND t.server_id IN ({in_placeholders}){scope_sql} AND COALESCE(t.artist, '') != '' AND NOT EXISTS ( SELECT 1 FROM artist ar @@ -110,7 +113,9 @@ pub fn list_merged_artists( let mut params: Vec = Vec::new(); params.extend(in_params.iter().cloned()); + params.extend(scope_params.iter().cloned()); params.extend(in_params.iter().cloned()); + params.extend(scope_params.iter().cloned()); params.extend(in_params.iter().cloned()); params.extend(priority_params); params.push(SqlValue::Integer(limit as i64)); @@ -194,7 +199,7 @@ mod tests { .unwrap(); rebuild_all_cluster_keys(&store).unwrap(); - let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0).unwrap(); + let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap(); assert_eq!(resp.artists.len(), 1); assert_eq!(resp.artists[0].server_id, "s1"); } @@ -217,7 +222,7 @@ mod tests { }) .unwrap(); - let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0).unwrap(); + let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap(); assert_eq!(resp.artists.len(), 1); assert_eq!(resp.artists[0].server_id, "s1"); assert_eq!(resp.artists[0].album_count, Some(3)); 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 895e5296..05c55de3 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/mod.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/mod.rs @@ -6,6 +6,7 @@ mod detail; mod advanced_search; mod db; mod keys; +mod library_scope; mod list; mod list_albums; mod list_artists; diff --git a/src/api/library.ts b/src/api/library.ts index d80f3f78..7c6f4055 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -481,17 +481,30 @@ function mapServersOrderedToIndexKeys(serverIds: string[]): string[] { return serverIds.map(serverIndexKeyForId); } +function mapClusterLibraryScopesToIndexKeys( + scopes: Record | undefined, +): Record | undefined { + if (!scopes) return undefined; + const out: Record = {}; + for (const [sid, scope] of Object.entries(scopes)) { + out[serverIndexKeyForId(sid)] = scope; + } + return Object.keys(out).length > 0 ? out : undefined; +} + /** Merged track list for cluster scope (ordered members = priority). */ export function libraryClusterListTracks(args: { serversOrdered: string[]; limit?: number; offset?: number; + libraryScopes?: Record; }): Promise { return invoke('library_cluster_list_tracks', { request: { serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered), limit: args.limit, offset: args.offset, + libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {}, }, }).then(env => ({ ...env, @@ -513,12 +526,14 @@ export function libraryClusterListAlbums(args: { serversOrdered: string[]; limit?: number; offset?: number; + libraryScopes?: Record; }): Promise { return invoke('library_cluster_list_albums', { request: { serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered), limit: args.limit, offset: args.offset, + libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {}, }, }).then(resp => ({ ...resp, @@ -530,12 +545,14 @@ export function libraryClusterListArtists(args: { serversOrdered: string[]; limit?: number; offset?: number; + libraryScopes?: Record; }): Promise { return invoke('library_cluster_list_artists', { request: { serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered), limit: args.limit, offset: args.offset, + libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {}, }, }).then(resp => ({ ...resp, @@ -696,6 +713,7 @@ export interface LibraryClusterAdvancedSearchRequest { limit: number; offset?: number; skipTotals?: boolean; + libraryScopes?: Record; } export function libraryClusterAdvancedSearch( @@ -714,6 +732,7 @@ export function libraryClusterAdvancedSearch( limit: request.limit, offset: request.offset ?? 0, skipTotals: request.skipTotals ?? false, + libraryScopes: mapClusterLibraryScopesToIndexKeys(request.libraryScopes) ?? {}, }, }).then(response => ({ ...response, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index da36c8c1..43df4ce9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -25,8 +25,17 @@ import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown'; import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible'; import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers'; import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe'; +import { useClusterMusicFolders } from '../hooks/useClusterMusicFolders'; import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal'; import SidebarNavBody from './sidebar/SidebarNavBody'; +import { getActiveClusterMemberIds, isClusterMode } from '../utils/serverCluster/clusterScope'; +import { + clusterLibraryPickerEntryId, + clusterLibraryScopeSubtitle, + clusterPickerFilterId, + parseClusterLibraryPickerEntryId, +} from '../utils/serverCluster/clusterLibraryScopes'; +import { getCachedMusicFolders } from '../utils/musicFoldersCache'; export default function Sidebar({ @@ -82,11 +91,31 @@ export default function Sidebar({ }, [playlistsRaw]); const [sidebarViewportEl, setSidebarViewportEl] = useState(null); const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl); - const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1; + const clusterMode = isClusterMode(); + const clusterMemberIds = getActiveClusterMemberIds(); + const { entries: clusterMusicFolders } = useClusterMusicFolders(); + const effectiveMusicFolders = + musicFolders.length > 0 ? musicFolders : (getCachedMusicFolders(serverId) ?? []); + const pickerFolders = clusterMode + ? clusterMusicFolders.map(e => ({ + id: clusterLibraryPickerEntryId(e.serverId, e.folderId), + name: `${e.serverLabel} — ${e.folderName}`, + })) + : effectiveMusicFolders; + const showLibraryPicker = !isCollapsed && isLoggedIn && ( + clusterMode ? clusterMusicFolders.length > 0 : effectiveMusicFolders.length > 1 + ); - const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all'; - const selectedFolderName = - filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null; + const filterId = clusterMode + ? clusterPickerFilterId(clusterMemberIds, clusterMusicFolders) + : serverId + ? (musicLibraryFilterByServer[serverId] ?? 'all') + : 'all'; + const selectedFolderName = clusterMode + ? clusterLibraryScopeSubtitle(clusterMemberIds, clusterMusicFolders) + : filterId === 'all' + ? null + : effectiveMusicFolders.find(f => f.id === filterId)?.name ?? null; const libraryItemsForReorder = useMemo( () => getLibraryItemsForReorder(sidebarItems, randomNavMode), @@ -140,6 +169,14 @@ export default function Sidebar({ const pickLibrary = (id: 'all' | string) => { + if (clusterMode && id !== 'all') { + const parsed = parseClusterLibraryPickerEntryId(id); + if (parsed) { + setMusicLibraryFilter(parsed.folderId, parsed.serverId); + setLibraryDropdownOpen(false); + return; + } + } setMusicLibraryFilter(id); setLibraryDropdownOpen(false); }; @@ -212,7 +249,7 @@ export default function Sidebar({ setLibraryDropdownOpen={setLibraryDropdownOpen} dropdownRect={dropdownRect} libraryTriggerRef={libraryTriggerRef} - musicFolders={musicFolders} + musicFolders={pickerFolders} pickLibrary={pickLibrary} visibleLibraryConfigs={visibleLibraryConfigs} libraryItemsForReorder={libraryItemsForReorder} diff --git a/src/components/sidebar/SidebarLibraryPicker.tsx b/src/components/sidebar/SidebarLibraryPicker.tsx index f7b0c188..1e762bc3 100644 --- a/src/components/sidebar/SidebarLibraryPicker.tsx +++ b/src/components/sidebar/SidebarLibraryPicker.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Check, ChevronDown, Music2 } from 'lucide-react'; @@ -22,6 +22,39 @@ export default function SidebarLibraryPicker({ }: Props) { const { t } = useTranslation(); const libraryTriggerPlain = filterId === 'all'; + const panelRef = useRef(null); + const [panelWidth, setPanelWidth] = useState(null); + const allLibrariesLabel = t('sidebar.allLibraries'); + + useLayoutEffect(() => { + if (!libraryDropdownOpen) { + setPanelWidth(null); + return; + } + const measure = () => { + const panel = panelRef.current; + if (!panel) return; + const minW = dropdownRect.width; + const maxW = Math.max(minW, window.innerWidth - dropdownRect.left - 8); + panel.dataset.measure = 'true'; + panel.style.width = 'max-content'; + panel.style.minWidth = `${minW}px`; + const measured = panel.offsetWidth; + delete panel.dataset.measure; + panel.style.width = ''; + panel.style.minWidth = ''; + setPanelWidth(Math.min(Math.max(minW, measured), maxW)); + }; + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, [ + libraryDropdownOpen, + dropdownRect.left, + dropdownRect.width, + musicFolders, + allLibrariesLabel, + ]); return ( <> @@ -52,6 +85,7 @@ export default function SidebarLibraryPicker({ {libraryDropdownOpen && createPortal(
10 ? ' nav-library-dropdown-panel--many-libraries' : ''}`} role="listbox" aria-label={t('sidebar.libraryScope')} @@ -59,9 +93,8 @@ export default function SidebarLibraryPicker({ position: 'fixed', top: dropdownRect.top, left: dropdownRect.left, - width: dropdownRect.width, minWidth: dropdownRect.width, - maxWidth: dropdownRect.width, + width: panelWidth ?? 'max-content', boxSizing: 'border-box', }} > diff --git a/src/hooks/useClusterMusicFolders.ts b/src/hooks/useClusterMusicFolders.ts new file mode 100644 index 00000000..4a961ca3 --- /dev/null +++ b/src/hooks/useClusterMusicFolders.ts @@ -0,0 +1,95 @@ +import { useEffect, useMemo, useState } from 'react'; +import { apiForServer, libraryFilterParamsForServer } from '../api/subsonicClient'; +import { useAuthStore } from '../store/authStore'; +import { getCachedMusicFolders, setCachedMusicFolders } from '../utils/musicFoldersCache'; +import { getActiveClusterMemberIds, isClusterMode } from '../utils/serverCluster/clusterScope'; +import { isServerLikelyReachable } from '../utils/serverCluster/representative'; +import { serverListDisplayLabel } from '../utils/server/serverDisplayName'; + +export interface ClusterMusicFolderEntry { + serverId: string; + serverLabel: string; + folderId: string; + folderName: string; +} + +function buildEntriesForMembers(memberIds: string[]): ClusterMusicFolderEntry[] { + const all = useAuthStore.getState().servers; + const flat: ClusterMusicFolderEntry[] = []; + for (const serverId of memberIds) { + const folders = getCachedMusicFolders(serverId); + if (!folders?.length) continue; + const server = all.find(s => s.id === serverId); + const label = server ? serverListDisplayLabel(server, all) : serverId; + for (const f of folders) { + flat.push({ + serverId, + serverLabel: label, + folderId: f.id, + folderName: f.name?.trim() || f.id, + }); + } + } + return flat; +} + +async function fetchMusicFoldersForServer(serverId: string): Promise { + const data = await apiForServer<{ + musicFolders?: { musicFolder?: Array<{ id: string; name: string }> }; + }>( + serverId, + 'getMusicFolders.view', + libraryFilterParamsForServer(serverId), + ); + const folders = (data.musicFolders?.musicFolder ?? []).map(f => ({ + id: String(f.id), + name: f.name?.trim() || String(f.id), + })); + setCachedMusicFolders(serverId, folders); + const all = useAuthStore.getState().servers; + const server = all.find(s => s.id === serverId); + const label = server ? serverListDisplayLabel(server, all) : serverId; + return folders.map(f => ({ + serverId, + serverLabel: label, + folderId: f.id, + folderName: f.name, + })); +} + +/** Navidrome/Subsonic music folders for cluster members (sidebar Library picker). */ +export function useClusterMusicFolders(): { entries: ClusterMusicFolderEntry[]; loading: boolean } { + const clusterId = useAuthStore(s => s.activeClusterId); + const memberIds = useMemo( + () => (clusterId && isClusterMode() ? getActiveClusterMemberIds() : []), + [clusterId], + ); + const [entries, setEntries] = useState(() => buildEntriesForMembers(memberIds)); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!clusterId || !isClusterMode() || memberIds.length === 0) { + setEntries([]); + setLoading(false); + return; + } + setEntries(buildEntriesForMembers(memberIds)); + let cancelled = false; + setLoading(true); + const targets = memberIds.filter(isServerLikelyReachable); + const fetchIds = targets.length > 0 ? targets : memberIds; + void (async () => { + const settled = await Promise.allSettled(fetchIds.map(fetchMusicFoldersForServer)); + if (cancelled) return; + const flat: ClusterMusicFolderEntry[] = []; + for (const r of settled) { + if (r.status === 'fulfilled') flat.push(...r.value); + } + setEntries(flat); + setLoading(false); + })(); + return () => { cancelled = true; }; + }, [clusterId, memberIds.join('|')]); + + return { entries, loading }; +} diff --git a/src/hooks/useServerCapabilitiesProbe.ts b/src/hooks/useServerCapabilitiesProbe.ts index e14b2f14..76b5040d 100644 --- a/src/hooks/useServerCapabilitiesProbe.ts +++ b/src/hooks/useServerCapabilitiesProbe.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { getMusicFolders } from '../api/subsonicLibrary'; import { probeEntityRatingSupport } from '../api/subsonicStarRating'; import { useAuthStore } from '../store/authStore'; +import { setCachedMusicFolders } from '../utils/musicFoldersCache'; import { cleanupOrphanedOrbitPlaylists } from '../utils/orbit'; /** @@ -29,8 +30,10 @@ export function useServerCapabilitiesProbe(): void { const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart; try { const folders = await getMusicFolders(); + setCachedMusicFolders(serverAtStart, folders); if (stillThisServer()) setMusicFolders(folders); } catch { + setCachedMusicFolders(serverAtStart, []); if (stillThisServer()) setMusicFolders([]); } try { diff --git a/src/hooks/useSidebarLibraryDropdown.ts b/src/hooks/useSidebarLibraryDropdown.ts index 57a42e76..6fd4a0ef 100644 --- a/src/hooks/useSidebarLibraryDropdown.ts +++ b/src/hooks/useSidebarLibraryDropdown.ts @@ -25,6 +25,7 @@ export function useSidebarLibraryDropdown(): Result { setDropdownRect({ top: r.bottom + 4, left: r.left, + /** Minimum width (trigger); panel grows to fit labels via `max-content`. */ width: r.width, }); }, []); diff --git a/src/store/authMusicLibraryActions.ts b/src/store/authMusicLibraryActions.ts index 6ad7b5fe..7c882209 100644 --- a/src/store/authMusicLibraryActions.ts +++ b/src/store/authMusicLibraryActions.ts @@ -35,8 +35,28 @@ export function createMusicLibraryActions(set: SetState, get: GetState): Pick< }); }, - setMusicLibraryFilter: (folderId) => { - const sid = get().activeServerId; + setMusicLibraryFilter: (folderId, targetServerId) => { + const { activeClusterId, clusters, activeServerId } = get(); + if (activeClusterId) { + const cluster = clusters.find(c => c.id === activeClusterId); + if (!cluster) return; + set(s => { + const next = { ...s.musicLibraryFilterByServer }; + if (folderId === 'all' && !targetServerId) { + for (const sid of cluster.serverIds) next[sid] = 'all'; + } else if (targetServerId) { + next[targetServerId] = folderId; + } else { + return s; + } + return { + musicLibraryFilterByServer: next, + musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1, + }; + }); + return; + } + const sid = activeServerId; if (!sid) return; set(s => ({ musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: folderId }, diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index 7ec00837..d7465fd8 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -371,7 +371,7 @@ export interface AuthState { setRandomMixSize: (v: number) => void; setShowLuckyMixMenu: (v: boolean) => void; setMusicFolders: (folders: Array<{ id: string; name: string }>) => void; - setMusicLibraryFilter: (folderId: 'all' | string) => void; + setMusicLibraryFilter: (folderId: 'all' | string, serverId?: string) => void; /** Navigation style for Mix pages: single hub ('hub') or separate sidebar entries ('separate'). */ randomNavMode: 'hub' | 'separate'; diff --git a/src/styles/layout/sidebar.css b/src/styles/layout/sidebar.css index eef5e4e6..143bac79 100644 --- a/src/styles/layout/sidebar.css +++ b/src/styles/layout/sidebar.css @@ -147,10 +147,15 @@ transform: rotate(180deg); } +.nav-library-dropdown-panel[data-measure='true'] .nav-library-dropdown-item { + width: max-content; +} + .nav-library-dropdown-panel { z-index: 10050; display: flex; flex-direction: column; + align-items: stretch; padding: var(--space-1); border-radius: var(--radius-md); border: 1px solid var(--border-dropdown, rgba(255, 255, 255, 0.12)); @@ -198,11 +203,12 @@ } .nav-library-dropdown-item-label { - flex: 1; + flex: 0 1 auto; min-width: 0; + line-height: 1.35; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; } .nav-library-dropdown-check { diff --git a/src/utils/library/clusterAdvancedSearchLocal.ts b/src/utils/library/clusterAdvancedSearchLocal.ts index 069e734a..c2c97429 100644 --- a/src/utils/library/clusterAdvancedSearchLocal.ts +++ b/src/utils/library/clusterAdvancedSearchLocal.ts @@ -4,6 +4,7 @@ import { type LibraryClusterAdvancedSearchRequest, } from '../../api/library'; import { resolveClusterBrowseMembers } from '../serverCluster/clusterBrowse'; +import { buildClusterLibraryScopes } from '../serverCluster/clusterLibraryScopes'; import { isClusterMode } from '../serverCluster/clusterScope'; export async function clusterAdvancedSearchLocal( @@ -16,6 +17,7 @@ export async function clusterAdvancedSearchLocal( return await libraryClusterAdvancedSearch({ ...request, serversOrdered: members, + libraryScopes: buildClusterLibraryScopes(members), }); } catch { return null; diff --git a/src/utils/musicFoldersCache.ts b/src/utils/musicFoldersCache.ts new file mode 100644 index 00000000..4716bf7e --- /dev/null +++ b/src/utils/musicFoldersCache.ts @@ -0,0 +1,16 @@ +/** Session cache of Subsonic `getMusicFolders` per server (not persisted). */ + +export interface MusicFolderEntry { + id: string; + name: string; +} + +const byServer = new Map(); + +export function setCachedMusicFolders(serverId: string, folders: MusicFolderEntry[]): void { + byServer.set(serverId, folders); +} + +export function getCachedMusicFolders(serverId: string): MusicFolderEntry[] | undefined { + return byServer.get(serverId); +} diff --git a/src/utils/serverCluster/clusterBrowse.ts b/src/utils/serverCluster/clusterBrowse.ts index 3c641550..a774dafb 100644 --- a/src/utils/serverCluster/clusterBrowse.ts +++ b/src/utils/serverCluster/clusterBrowse.ts @@ -15,6 +15,7 @@ import { dedupeById } from '../dedupeById'; import { albumToAlbum, artistToArtist, trackToSong } from '../library/advancedSearchLocal'; import { albumBrowseHasServerFilters } from '../library/albumBrowseFilters'; import type { AlbumBrowsePageResult, AlbumBrowseQuery } from '../library/albumBrowseTypes'; +import { buildClusterLibraryScopes } from './clusterLibraryScopes'; import { getActiveClusterId, isClusterMode } from './clusterScope'; import { getClusterMergeMemberIds } from './representative'; @@ -48,6 +49,7 @@ export async function clusterBrowseTracksPage( serversOrdered: members, limit: pageSize, offset, + libraryScopes: buildClusterLibraryScopes(members), }); return env.tracks.map(trackToSong); } catch { @@ -66,6 +68,7 @@ export async function clusterBrowseAlbumsPage( serversOrdered: members, limit: pageSize, offset, + libraryScopes: buildClusterLibraryScopes(members), }); return { albums: resp.albums.map(albumToAlbum), @@ -87,6 +90,7 @@ export async function clusterBrowseArtistsPage( serversOrdered: members, limit: pageSize, offset, + libraryScopes: buildClusterLibraryScopes(members), }); const artists = resp.artists.map(artistToArtist); return { artists, hasMore: resp.hasMore }; diff --git a/src/utils/serverCluster/clusterLibraryScopes.ts b/src/utils/serverCluster/clusterLibraryScopes.ts new file mode 100644 index 00000000..d5389847 --- /dev/null +++ b/src/utils/serverCluster/clusterLibraryScopes.ts @@ -0,0 +1,75 @@ +import { libraryScopeForServer } from '../../api/subsonicClient'; +import { useAuthStore } from '../../store/authStore'; +import { resolveServerIdForIndexKey } from '../server/serverLookup'; + +/** Per-member Navidrome music-folder scopes for cluster index reads (omit = all libraries). */ +export function buildClusterLibraryScopes(memberIds: string[]): Record | undefined { + const scopes: Record = {}; + for (const sid of memberIds) { + const scope = libraryScopeForServer(sid); + if (scope) scopes[sid] = scope; + } + return Object.keys(scopes).length > 0 ? scopes : undefined; +} + +export function isClusterAllLibrariesSelected(memberIds: string[]): boolean { + const filters = useAuthStore.getState().musicLibraryFilterByServer; + return memberIds.every(sid => { + const resolved = resolveServerIdForIndexKey(sid) || sid; + const f = filters[resolved] ?? filters[sid]; + return f === undefined || f === 'all'; + }); +} + +/** Label for the sidebar scope subtitle when one member is narrowed. */ +export function clusterLibraryScopeSubtitle( + memberIds: string[], + entries: Array<{ serverId: string; serverLabel: string; folderId: string; folderName: string }>, +): string | null { + if (isClusterAllLibrariesSelected(memberIds)) return null; + const filters = useAuthStore.getState().musicLibraryFilterByServer; + for (const entry of entries) { + const resolved = resolveServerIdForIndexKey(entry.serverId) || entry.serverId; + const f = filters[resolved] ?? filters[entry.serverId]; + if (f && f !== 'all' && f === entry.folderId) { + return `${entry.serverLabel} — ${entry.folderName}`; + } + } + return null; +} + +export function isClusterLibraryFolderSelected( + serverId: string, + folderId: string, +): boolean { + const filters = useAuthStore.getState().musicLibraryFilterByServer; + const resolved = resolveServerIdForIndexKey(serverId) || serverId; + const f = filters[resolved] ?? filters[serverId]; + return f === folderId; +} + +export function clusterLibraryPickerEntryId(serverId: string, folderId: string): string { + return `${serverId}::${folderId}`; +} + +/** Sidebar picker `filterId` in cluster mode (`all` or `serverId::folderId`). */ +export function clusterPickerFilterId( + memberIds: string[], + entries: Array<{ serverId: string; folderId: string }>, +): string { + if (isClusterAllLibrariesSelected(memberIds)) return 'all'; + for (const entry of entries) { + if (isClusterLibraryFolderSelected(entry.serverId, entry.folderId)) { + return clusterLibraryPickerEntryId(entry.serverId, entry.folderId); + } + } + return 'all'; +} + +export function parseClusterLibraryPickerEntryId( + entryId: string, +): { serverId: string; folderId: string } | null { + const sep = entryId.indexOf('::'); + if (sep <= 0) return null; + return { serverId: entryId.slice(0, sep), folderId: entryId.slice(sep + 2) }; +}