mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user