feat(cluster): per-member library scope in sidebar picker and merge reads

Cluster mode lists music folders from every member (cached for instant
picker), applies per-server musicLibraryFilter scopes in merged browse,
and passes libraryScopes through Rust list/advanced-search SQL filters.
Dropdown width sizes to content; single-server picker unchanged.
This commit is contained in:
Maxim Isaev
2026-06-05 19:11:40 +03:00
parent 0a09d29b06
commit 558f42dba6
22 changed files with 445 additions and 28 deletions
@@ -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
}
@@ -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<u32>,
#[serde(default)]
pub offset: Option<u32>,
/// Per-member music-folder scope (`server_id` → folder id). Omitted members = all libraries.
#[serde(default)]
pub library_scopes: HashMap<String, String>,
}
/// `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<String, String>,
}
/// Merged album browse response for cluster scope.
@@ -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();
@@ -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()
@@ -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, String>,
) -> (String, Vec<SqlValue>) {
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);
}
}
@@ -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<String, String>,
) -> Result<LibraryTracksEnvelope, String> {
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<SqlValue> = 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);
}
}
@@ -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<String, String>,
) -> Result<LibraryClusterAlbumsResponse, String> {
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<SqlValue> = 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");
}
@@ -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<String, String>,
) -> Result<LibraryClusterArtistsResponse, String> {
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<SqlValue> = 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));
@@ -6,6 +6,7 @@ mod detail;
mod advanced_search;
mod db;
mod keys;
mod library_scope;
mod list;
mod list_albums;
mod list_artists;
+19
View File
@@ -481,17 +481,30 @@ function mapServersOrderedToIndexKeys(serverIds: string[]): string[] {
return serverIds.map(serverIndexKeyForId);
}
function mapClusterLibraryScopesToIndexKeys(
scopes: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!scopes) return undefined;
const out: Record<string, string> = {};
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<string, string>;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('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<string, string>;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('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<string, string>;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('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<string, string>;
}
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,
+42 -5
View File
@@ -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<HTMLDivElement | null>(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}
@@ -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<HTMLDivElement>(null);
const [panelWidth, setPanelWidth] = useState<number | null>(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(
<div
ref={panelRef}
className={`nav-library-dropdown-panel${musicFolders.length > 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',
}}
>
+95
View File
@@ -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<ClusterMusicFolderEntry[]> {
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<ClusterMusicFolderEntry[]>(() => 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 };
}
+3
View File
@@ -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 {
+1
View File
@@ -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,
});
}, []);
+22 -2
View File
@@ -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 },
+1 -1
View File
@@ -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';
+8 -2
View File
@@ -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 {
@@ -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;
+16
View File
@@ -0,0 +1,16 @@
/** Session cache of Subsonic `getMusicFolders` per server (not persisted). */
export interface MusicFolderEntry {
id: string;
name: string;
}
const byServer = new Map<string, MusicFolderEntry[]>();
export function setCachedMusicFolders(serverId: string, folders: MusicFolderEntry[]): void {
byServer.set(serverId, folders);
}
export function getCachedMusicFolders(serverId: string): MusicFolderEntry[] | undefined {
return byServer.get(serverId);
}
+4
View File
@@ -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 };
@@ -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<string, string> | undefined {
const scopes: Record<string, string> = {};
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) };
}