feat(library): add unified multi-server library scope (#1309)

* feat(library): add multi-server scope foundation

* feat(library): wire unified multi-server browse

* feat(library): complete multi-server ownership flows

* fix(library): harden multi-server ownership

* fix(library): close multi-server edge cases

* docs: add multi-server library release notes
This commit is contained in:
cucadmuh
2026-07-16 08:10:29 +03:00
committed by GitHub
parent 16aee64d66
commit 599ac31306
421 changed files with 12078 additions and 2027 deletions
+8
View File
@@ -139,6 +139,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view.
### Multi-server Library — one ordered catalog across your servers
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1309](https://github.com/Psychotoxical/psysonic/pull/1309)**
* Select and prioritise multiple servers from the sidebar Library picker. Albums, artists, tracks, search, Home, genres, lossless, random mixes, composers, statistics and detail pages merge their ready online indexes into one de-duplicated catalog; unavailable sources are clearly excluded without hiding downloaded music in **Offline**.
* Playback, playlists, radio and folders keep their real source ownership. Failed streams offer an explicit **Play from server** choice instead of silently switching accounts, mixed-server queue sharing asks which source to export, and playlist/radio edits always target the owning server.
* Favorites and ratings fan out durably to matching copies on selected servers, including offline retry and deferred sync while an index is still being built. Play counts and scrobbles remain attached only to the concrete source that actually played.
## Changed
+1 -1
View File
@@ -11,7 +11,7 @@ psysonic-core = { path = "../psysonic-core" }
psysonic-integration = { path = "../psysonic-integration" }
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
@@ -194,14 +194,24 @@ pub fn run_advanced_search(
&req.server_id,
req.library_scope.as_deref(),
req.library_scopes.as_deref(),
);
)?;
// Any >1-library scope dedups album/artist rows via cluster keys, including
// the Layer-1 same-server path — build keys first so dedup works on a cold
// index (idempotent; only rebuilds when needed).
if multi_library_merge_enabled(&scope_pairs) {
crate::identity::ensure_cluster_keys_built(store, &req.server_id)?;
let pair_reader_required = multi_library_merge_enabled(&scope_pairs)
|| scope_pairs
.first()
.is_some_and(|pair| pair.server_id != req.server_id);
if pair_reader_required {
for server_id in scope_pairs.iter().map(|p| p.server_id.as_str()).collect::<HashSet<_>>() {
crate::identity::ensure_cluster_keys_built(store, server_id)?;
}
}
if scoped_layer1_eligible(&scope_pairs) {
if scoped_layer1_eligible(&scope_pairs)
&& scope_pairs
.first()
.is_some_and(|pair| pair.server_id == req.server_id)
{
return run_advanced_search_layer1_scope(
store,
req,
@@ -213,7 +223,7 @@ pub fn run_advanced_search(
skip_totals,
);
}
if multi_library_merge_enabled(&scope_pairs) {
if pair_reader_required {
return run_advanced_search_multi_scope(
store,
req,
@@ -229,7 +239,7 @@ pub fn run_advanced_search(
let mut legacy = req.clone();
if legacy.library_scope.is_none() {
if let Some(pair) = scope_pairs.first() {
legacy.library_scope = Some(pair.library_id.clone());
legacy.library_scope = pair.library_id.clone();
}
}
@@ -1140,11 +1150,12 @@ fn push_artist_library_scope_pairs(
pairs: &[LibraryScopePair],
applied: &mut BTreeSet<String>,
) {
// Pairs may carry profile or index `server_id`; this query is already pinned to
// one server via `ar.server_id = ?`, so only drop empty library ids.
if pairs.iter().any(|p| p.library_id.is_none()) {
return;
}
let scoped: Vec<&LibraryScopePair> = pairs
.iter()
.filter(|p| !p.library_id.trim().is_empty())
.filter(|p| p.server_id == _server_id)
.collect();
if scoped.is_empty() {
return;
@@ -1155,7 +1166,7 @@ fn push_artist_library_scope_pairs(
let clause = library_scope_sargable_equals_sql("t");
w.push_params(
&format!("{exists_prefix}{clause})"),
vec![SqlValue::Text(scoped[0].library_id.clone())],
vec![SqlValue::Text(scoped[0].library_id.clone().unwrap_or_default())],
);
} else {
let in_clause = library_scope_in_sql("t", scoped.len());
@@ -1163,7 +1174,7 @@ fn push_artist_library_scope_pairs(
&format!("{exists_prefix}{in_clause})"),
scoped
.iter()
.map(|p| SqlValue::Text(p.library_id.clone()))
.map(|p| SqlValue::Text(p.library_id.clone().unwrap_or_default()))
.collect(),
);
}
@@ -1175,7 +1186,7 @@ fn push_artist_library_scope(w: &mut WhereBuilder, req: &LibraryAdvancedSearchRe
&req.server_id,
req.library_scope.as_deref(),
req.library_scopes.as_deref(),
);
).unwrap_or_default();
push_artist_library_scope_pairs(w, &req.server_id, &pairs, applied);
}
@@ -2114,6 +2125,7 @@ struct AlbumOrderCols {
name: &'static str,
artist: String,
year: &'static str,
synced: &'static str,
}
impl AlbumOrderCols {
@@ -2124,6 +2136,7 @@ impl AlbumOrderCols {
name: "MAX(t.album) COLLATE NOCASE",
artist: sql_display_artist_from("MAX(t.artist)", "MAX(t.album_artist)"),
year: "MAX(t.year)",
synced: "MAX(t.synced_at)",
}
}
@@ -2133,6 +2146,7 @@ impl AlbumOrderCols {
name: "album COLLATE NOCASE",
artist: sql_display_artist_from("artist", "album_artist"),
year: "year",
synced: "synced_at",
}
}
}
@@ -2144,6 +2158,7 @@ fn album_order_sql(sort: &[LibrarySortClause], cols: &AlbumOrderCols) -> Option<
"name" => cols.name.to_string(),
"artist" => format!("{} COLLATE NOCASE", cols.artist),
"year" => cols.year.to_string(),
"synced" => cols.synced.to_string(),
"random" => "RANDOM()".to_string(),
_ => continue,
};
@@ -2179,6 +2194,7 @@ pub(crate) fn sort_column(field: &str, entity: EntityKind) -> Option<&'static st
("name", EntityKind::Album) => Some("a.name COLLATE NOCASE"),
("year", EntityKind::Album) => Some("a.year"),
("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"),
("synced", EntityKind::Album) => Some("a.synced_at"),
("name", EntityKind::Artist) => Some("COALESCE(ar.name_sort, ar.name) COLLATE NOCASE"),
// SQLite built-in: ORDER BY RANDOM() LIMIT N — fast pseudo-random sample,
// no index scan needed beyond the row-id range. Direction is ignored.
@@ -3595,7 +3611,7 @@ mod tests {
fn scope_pair(server: &str, lib: &str) -> crate::dto::LibraryScopePair {
crate::dto::LibraryScopePair {
server_id: server.into(),
library_id: lib.into(),
library_id: Some(lib.into()),
}
}
@@ -4082,4 +4098,27 @@ mod tests {
assert_eq!(legacy_resp.albums, scoped_resp.albums);
assert_eq!(legacy_resp.totals, scoped_resp.totals);
}
#[test]
fn single_exact_pair_on_another_server_uses_pair_server() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
scoped_track(
"s1", "fallback", "Song", "Artist", "Fallback", "al1", "lib",
None, None, None,
),
scoped_track(
"s2", "selected", "Song", "Artist", "Selected", "al2", "lib",
None, None, None,
),
])
.unwrap();
let mut request = req("s1", &[EntityKind::Track]);
request.library_scopes = Some(vec![scope_pair("s2", "lib")]);
let response = run_advanced_search(&store, &request).unwrap();
assert_eq!(response.tracks.len(), 1);
assert_eq!(response.tracks[0].id, "selected");
assert_eq!(response.tracks[0].server_id, "s2");
}
}
@@ -2,7 +2,8 @@
use crate::dto::{
LibraryAlbumDto, LibraryArtistLosslessBrowseRequest, LibraryArtistLosslessBrowseResponse,
LibraryTrackDto,
LibraryScopeArtistDetailRequest, LibraryTrackDto, multi_library_merge_enabled,
ordered_library_scope_pairs,
};
use crate::lossless_formats::track_is_lossless_sql;
use crate::search::{
@@ -39,9 +40,61 @@ pub fn get_artist_lossless_browse(
store: &LibraryStore,
req: &LibraryArtistLosslessBrowseRequest,
) -> Result<LibraryArtistLosslessBrowseResponse, String> {
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
let scope_pairs = ordered_library_scope_pairs(
&req.server_id,
req.library_scope.as_deref(),
req.library_scopes.as_deref(),
)?;
if scope_pairs.is_empty() && !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(empty_response());
}
let use_pair_reader = scope_pairs.len() > 1
|| scope_pairs.first().is_some_and(|pair| {
pair.server_id != req.server_id || pair.library_id.is_none()
});
if use_pair_reader {
if multi_library_merge_enabled(&scope_pairs) {
for server_id in scope_pairs
.iter()
.map(|p| p.server_id.as_str())
.collect::<std::collections::HashSet<_>>()
{
crate::identity::ensure_cluster_keys_built(store, server_id)?;
}
}
let detail = crate::scope_merge::artist_detail(
store,
&LibraryScopeArtistDetailRequest {
scopes: scope_pairs,
artist_id: req.artist_id.clone(),
server_id: req.server_id.clone(),
},
)?;
let tracks: Vec<_> = detail
.tracks
.into_iter()
.filter(|track| {
track
.suffix
.as_deref()
.is_some_and(|suffix| crate::lossless_formats::LOSSLESS_SUFFIXES.contains(&suffix.to_ascii_lowercase().as_str()))
})
.collect();
let album_ids: std::collections::HashSet<(&str, &str)> = tracks
.iter()
.filter_map(|track| track.album_id.as_deref().map(|id| (track.server_id.as_str(), id)))
.collect();
let albums = detail
.albums
.into_iter()
.filter(|album| album_ids.contains(&(album.server_id.as_str(), album.id.as_str())))
.collect();
return Ok(LibraryArtistLosslessBrowseResponse {
albums,
tracks,
source: "local".to_string(),
});
}
let lossless_sql = track_is_lossless_sql("t");
let mut track_where = vec![
@@ -55,8 +108,11 @@ pub fn get_artist_lossless_browse(
SqlValue::Text(req.artist_id.clone()),
];
let scope_ids =
combined_scope_library_ids(req.library_scope.as_deref(), req.library_scopes.as_deref());
let scope_ids = scope_pairs
.first()
.and_then(|pair| pair.library_id.clone())
.map(|library_id| vec![library_id])
.unwrap_or_else(|| combined_scope_library_ids(req.library_scope.as_deref(), None));
push_library_scope_filter(&mut track_where, &mut track_params, &scope_ids);
let track_where_sql = track_where.join(" AND ");
@@ -7,6 +7,7 @@ use tauri::State;
use crate::dto::CatalogYearBoundsDto;
use crate::dto::GenreAlbumCountDto;
use crate::dto::LibraryAlbumDto;
use crate::dto::LibraryScopePair;
use crate::runtime::LibraryRuntime;
use crate::store::LibraryStore;
use crate::sync::mapping::format_iso_ms_z;
@@ -257,6 +258,39 @@ pub fn library_get_catalog_year_bounds(
result
}
pub(crate) fn catalog_year_bounds_for_scopes(
store: &LibraryStore,
scopes: &[LibraryScopePair],
) -> Result<CatalogYearBoundsDto, String> {
let scopes = crate::scope_merge::normalize_scope_pairs(scopes)?;
if scopes.is_empty() {
return Ok(CatalogYearBoundsDto { min_year: None, max_year: None });
}
let (cte, binds) = crate::scope_merge::scope_cte_sql(&scopes);
let sql = format!(
"{cte} SELECT MIN(t.year), MAX(t.year) \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
WHERE t.deleted = 0 AND t.year IS NOT NULL AND t.year > 0"
);
store.with_read_conn(|conn| {
conn.query_row(&sql, rusqlite::params_from_iter(binds.iter()), |row| {
Ok(CatalogYearBoundsDto {
min_year: row.get::<_, Option<i64>>(0)?.map(|year| year as i32),
max_year: row.get::<_, Option<i64>>(1)?.map(|year| year as i32),
})
})
}).map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
pub fn library_scope_catalog_year_bounds(
runtime: State<'_, LibraryRuntime>,
scopes: Vec<LibraryScopePair>,
) -> Result<CatalogYearBoundsDto, String> {
catalog_year_bounds_for_scopes(&runtime.store, &scopes)
}
pub(crate) fn genre_album_counts_for_server(
store: &LibraryStore,
server_id: &str,
@@ -304,6 +338,55 @@ pub(crate) fn genre_album_counts_for_server(
.map_err(|e| e.to_string())
}
pub(crate) fn genre_album_counts_for_scopes(
store: &LibraryStore,
scopes: &[LibraryScopePair],
) -> Result<Vec<GenreAlbumCountDto>, String> {
let scopes = crate::scope_merge::normalize_scope_pairs(scopes)?;
if scopes.is_empty() {
return Ok(Vec::new());
}
if crate::dto::multi_library_merge_enabled(&scopes) {
for server_id in scopes
.iter()
.map(|pair| pair.server_id.as_str())
.collect::<std::collections::HashSet<_>>()
{
crate::identity::ensure_cluster_keys_built(store, server_id)?;
}
}
let (cte, binds) = crate::scope_merge::scope_cte_sql(&scopes);
let album_key = crate::scope_merge::ALBUM_DEDUP_KEY;
let track_key = crate::scope_merge::TRACK_DEDUP_KEY;
let sql = format!(
"{cte}, genre_rows AS ( \
SELECT tg.genre, {album_key} AS album_dedup, {track_key} AS track_dedup \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
INNER JOIN track_genre tg ON tg.server_id = t.server_id AND tg.track_id = t.id \
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
WHERE t.deleted = 0 AND tg.album_id IS NOT NULL AND tg.album_id != '' \
) \
SELECT genre, COUNT(DISTINCT album_dedup), COUNT(DISTINCT track_dedup) \
FROM genre_rows GROUP BY genre COLLATE NOCASE HAVING COUNT(DISTINCT album_dedup) > 0 \
ORDER BY 2 DESC, genre COLLATE NOCASE ASC"
);
store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(binds.iter()), |r| {
Ok(GenreAlbumCountDto {
value: r.get(0)?,
album_count: r.get::<_, i64>(1)?.max(0) as u32,
song_count: r.get::<_, i64>(2)?.max(0) as u32,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())
}
/// Distinct album counts per track genre — same grouping as genre album browse.
#[tauri::command]
#[specta::specta]
@@ -311,19 +394,21 @@ pub fn library_get_genre_album_counts(
runtime: State<'_, LibraryRuntime>,
server_id: String,
library_scope: Option<String>,
library_scopes: Option<Vec<String>>,
library_scopes: Option<Vec<LibraryScopePair>>,
) -> Result<Vec<GenreAlbumCountDto>, String> {
let trace = psysonic_core::logging::should_log_albums_browse_trace();
let scopes = if let Some(scopes) = library_scopes {
normalized_library_scopes(&scopes)
} else if let Some(scope) = library_scope.as_deref().filter(|s| !s.trim().is_empty()) {
vec![scope.to_string()]
} else {
vec![]
};
let scopes = crate::dto::ordered_library_scope_pairs(
&server_id,
library_scope.as_deref(),
library_scopes.as_deref(),
)?;
let trace_scopes = scopes.clone();
let t0 = std::time::Instant::now();
let result = genre_album_counts_for_server(&runtime.store, &server_id, &scopes);
let result = if scopes.is_empty() {
genre_album_counts_for_server(&runtime.store, &server_id, &[])
} else {
genre_album_counts_for_scopes(&runtime.store, &scopes)
};
if trace {
let step_ms = t0.elapsed().as_millis();
let genre_count = result.as_ref().map(|rows| rows.len()).unwrap_or(0);
@@ -354,7 +439,8 @@ mod tests {
use crate::store::LibraryStore;
use super::{
apply_album_patch, catalog_year_bounds_for_server, genre_album_counts_for_server,
apply_album_patch, catalog_year_bounds_for_server, genre_album_counts_for_scopes,
genre_album_counts_for_server, catalog_year_bounds_for_scopes,
overlay_album_level_starred_at, reconcile_album_stars, StarredAlbumReconcileItem,
};
use crate::dto::LibraryAlbumDto;
@@ -452,6 +538,27 @@ mod tests {
assert!(!raw.contains("starred"));
}
#[test]
fn catalog_year_bounds_respect_cross_server_scope() {
let store = LibraryStore::open_in_memory();
let mut old = make_row("s1", "t1", "al1", 1);
old.library_id = Some("a".into());
old.year = Some(1990);
let mut recent = make_row("s2", "t2", "al2", 1);
recent.library_id = Some("b".into());
recent.year = Some(2024);
let mut excluded = make_row("s2", "t3", "al3", 1);
excluded.library_id = Some("other".into());
excluded.year = Some(2030);
TrackRepository::new(&store).upsert_batch(&[old, recent, excluded]).unwrap();
let bounds = catalog_year_bounds_for_scopes(&store, &[
crate::dto::LibraryScopePair { server_id: "s1".into(), library_id: Some("a".into()) },
crate::dto::LibraryScopePair { server_id: "s2".into(), library_id: Some("b".into()) },
]).unwrap();
assert_eq!(bounds.min_year, Some(1990));
assert_eq!(bounds.max_year, Some(2024));
}
#[test]
fn apply_album_patch_clears_stale_starred_in_raw_json() {
let store = Arc::new(LibraryStore::open_in_memory());
@@ -630,6 +737,34 @@ mod tests {
assert_eq!(counts[1].song_count, 1);
}
#[test]
fn genre_album_counts_include_cross_server_whole_sources_and_empty_library_rows() {
let store = LibraryStore::open_in_memory();
let mut first = make_row("s1", "t1", "al1", 1);
first.library_id = Some("lib-a".into());
first.genre = Some("Rock".into());
first.title = "Shared Track".into();
first.album = "Shared Album".into();
let mut second = make_row("s2", "t2", "al2", 1);
second.library_id = Some(String::new());
second.genre = Some("Rock".into());
second.title = "Shared Track".into();
second.album = "Shared Album".into();
TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap();
let counts = genre_album_counts_for_scopes(
&store,
&[
crate::dto::LibraryScopePair { server_id: "s1".into(), library_id: None },
crate::dto::LibraryScopePair { server_id: "s2".into(), library_id: None },
],
)
.unwrap();
let rock = counts.iter().find(|row| row.value == "Rock").unwrap();
assert_eq!(rock.album_count, 1);
assert_eq!(rock.song_count, 1);
}
#[test]
fn genre_album_counts_respect_library_scope() {
let store = Arc::new(LibraryStore::open_in_memory());
@@ -23,6 +23,7 @@ use crate::dto::{
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse,
LibraryEntitySourceDto, LibraryResolveEntitySourcesRequest,
LibraryScopeAlbumDetailRequest, LibraryScopeAlbumDetailResponse, LibraryScopeArtistDetailRequest,
LibraryScopeArtistDetailResponse, LibraryScopeListRequest, LibraryScopeSearchRequest,
LibraryTrackDto,
@@ -686,6 +687,16 @@ pub fn library_cluster_rebuild(
crate::identity::rebuild_cluster_keys(&runtime.store, server_id)
}
#[tauri::command]
#[specta::specta]
pub async fn library_resolve_entity_sources(
runtime: State<'_, LibraryRuntime>,
request: LibraryResolveEntitySourcesRequest,
) -> Result<Vec<LibraryEntitySourceDto>, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || scope_merge::resolve_entity_sources(&store, &request)).await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_scope_list_albums(
@@ -736,6 +747,36 @@ pub async fn library_scope_artist_detail(
library_spawn_blocking(move || scope_merge::artist_detail(&store, &request)).await
}
#[tauri::command]
#[specta::specta]
pub async fn library_scope_catalog_statistics(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryScopeCatalogStatisticsRequest,
) -> Result<crate::dto::LibraryScopeCatalogStatisticsDto, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::scope_statistics::catalog_statistics(&store, &request)).await
}
// NOT specta-collected: nested album DTO carries `raw_json: Value`.
#[tauri::command]
pub async fn library_scope_most_played_albums(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryScopeMostPlayedRequest,
) -> Result<Vec<crate::dto::LibraryScopeMostPlayedAlbumDto>, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::scope_statistics::most_played_albums(&store, &request)).await
}
// NOT specta-collected: artist DTO carries `raw_json: Value`.
#[tauri::command]
pub async fn library_scope_list_artists_by_role(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryScopeArtistRoleRequest,
) -> Result<Vec<crate::dto::LibraryArtistDto>, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::scope_statistics::artists_by_role(&store, &request)).await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_get_artist_lossless_browse(
+109 -22
View File
@@ -391,6 +391,59 @@ pub struct CatalogYearBoundsDto {
pub max_year: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeCatalogStatisticsRequest {
pub scopes: Vec<LibraryScopePair>,
#[serde(default)]
pub format_sample_limit: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeFormatCountDto {
pub format: String,
pub count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeCatalogStatisticsDto {
pub artist_count: u32,
pub album_count: u32,
pub track_count: u32,
pub duration_sec: i64,
pub genres: Vec<GenreAlbumCountDto>,
pub formats: Vec<LibraryScopeFormatCountDto>,
pub format_sample_size: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeMostPlayedRequest {
pub scopes: Vec<LibraryScopePair>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeMostPlayedAlbumDto {
pub album: LibraryAlbumDto,
pub play_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeArtistRoleRequest {
pub scopes: Vec<LibraryScopePair>,
pub role: String,
#[serde(default)]
pub limit: Option<u32>,
}
/// Per-genre album/track totals from the local track catalog (Genres cloud + browse).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
@@ -642,10 +695,10 @@ pub struct LibraryLosslessAlbumsRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Ordered library ids for a multi-library selection; takes precedence over
/// the legacy single `library_scope` when present.
/// Ordered server/library sources; takes precedence over the legacy single
/// `library_scope` when present. `library_id: None` means the whole server.
#[serde(default)]
pub library_scopes: Option<Vec<String>>,
pub library_scopes: Option<Vec<LibraryScopePair>>,
#[serde(default = "default_lossless_limit")]
pub limit: u32,
#[serde(default)]
@@ -673,10 +726,10 @@ pub struct LibraryArtistLosslessBrowseRequest {
pub artist_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Ordered library ids for a multi-library selection; takes precedence over
/// the legacy single `library_scope` when present.
/// Ordered server/library sources; takes precedence over the legacy single
/// `library_scope` when present. `library_id: None` means the whole server.
#[serde(default)]
pub library_scopes: Option<Vec<String>>,
pub library_scopes: Option<Vec<LibraryScopePair>>,
}
/// Lossless albums + tracks for one artist (local index).
@@ -693,37 +746,73 @@ pub struct LibraryArtistLosslessBrowseResponse {
// ──────────────────────────────────────────────────────────────────────
/// One `(server_id, library_id)` pair in priority order (index 0 = highest).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopePair {
pub server_id: String,
/// `None` means every indexed library on this server. `Some("")` is the
/// concrete implicit library id and must not be treated as whole-server.
pub library_id: Option<String>,
}
/// Entity kind accepted by `library_resolve_entity_sources`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum LibrarySourceEntityType {
Track,
Album,
Artist,
}
/// Resolve one concrete browse entity to every matching concrete source in an
/// explicitly ordered scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryResolveEntitySourcesRequest {
pub entity_type: LibrarySourceEntityType,
pub anchor_server_id: String,
pub anchor_id: String,
pub scopes: Vec<LibraryScopePair>,
}
/// Concrete source metadata for one browse identity partition. Identity keys
/// remain internal so the frontend contract cannot persist raw cluster hashes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryEntitySourceDto {
pub server_id: String,
pub id: String,
pub library_id: String,
pub priority: u32,
pub duration_sec: Option<i64>,
pub suffix: Option<String>,
pub bit_rate: Option<i64>,
pub size_bytes: Option<i64>,
pub starred_at: Option<i64>,
pub user_rating: Option<i64>,
}
/// Derive ordered `(server_id, library_id)` pairs from request fields.
/// List order is merge priority (index 0 wins). Empty = all libraries on the server.
/// List order is merge priority (index 0 wins). Duplicate pairs keep their first
/// occurrence; mixing whole-server and exact-library sources for one server is rejected.
pub(crate) fn ordered_library_scope_pairs(
server_id: &str,
library_scope: Option<&str>,
library_scopes: Option<&[LibraryScopePair]>,
) -> Vec<LibraryScopePair> {
) -> Result<Vec<LibraryScopePair>, String> {
if let Some(scopes) = library_scopes {
let pairs: Vec<LibraryScopePair> = scopes
.iter()
.filter(|p| !p.server_id.trim().is_empty() && !p.library_id.trim().is_empty())
.cloned()
.collect();
let pairs = crate::scope_merge::normalize_scope_pairs(scopes)?;
if !pairs.is_empty() {
return pairs;
return Ok(pairs);
}
}
if let Some(scope) = library_scope.map(str::trim).filter(|s| !s.is_empty()) {
return vec![LibraryScopePair {
return Ok(vec![LibraryScopePair {
server_id: server_id.to_string(),
library_id: scope.to_string(),
}];
library_id: Some(scope.to_string()),
}]);
}
Vec::new()
Ok(Vec::new())
}
/// Layer-2 dedup runs only when the ordered scope has more than one pair.
@@ -736,9 +825,7 @@ pub(crate) fn scoped_layer1_eligible(scopes: &[LibraryScopePair]) -> bool {
let Some(first) = scopes.first() else {
return false;
};
scopes
.iter()
.all(|p| p.server_id == first.server_id && !p.library_id.trim().is_empty())
scopes.iter().all(|p| p.server_id == first.server_id)
}
/// Paginated album/artist browse over an ordered multi-library scope.
@@ -88,15 +88,6 @@ pub fn list_albums_by_genre(
store: &LibraryStore,
req: &LibraryGenreAlbumsRequest,
) -> Result<LibraryGenreAlbumsResponse, String> {
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(LibraryGenreAlbumsResponse {
albums: Vec::new(),
has_more: false,
total: None,
source: "local".to_string(),
});
}
let genre = req.genre.trim();
if genre.is_empty() {
return Ok(LibraryGenreAlbumsResponse {
@@ -114,13 +105,23 @@ pub fn list_albums_by_genre(
&req.server_id,
req.library_scope.as_deref(),
req.library_scopes.as_deref(),
);
)?;
if scope_pairs.is_empty() && !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(LibraryGenreAlbumsResponse {
albums: Vec::new(),
has_more: false,
total: None,
source: "local".to_string(),
});
}
// Any >1-library scope collapses duplicates via cluster keys — including the
// Layer-1 same-server path, whose genre `EXISTS` sets `merge_by_album_key`.
// Build keys first so dedup works on a cold index (not only after a prior
// search / sync-idle rebuild happened to populate them).
if multi_library_merge_enabled(&scope_pairs) {
crate::identity::ensure_cluster_keys_built(store, &req.server_id)?;
for server_id in scope_pairs.iter().map(|p| p.server_id.as_str()).collect::<std::collections::HashSet<_>>() {
crate::identity::ensure_cluster_keys_built(store, server_id)?;
}
}
if scoped_layer1_eligible(&scope_pairs) {
return list_albums_by_genre_layer1_scope(store, req, &scope_pairs, genre, limit, offset);
@@ -132,7 +133,7 @@ pub fn list_albums_by_genre(
let mut legacy = req.clone();
if legacy.library_scope.is_none() {
if let Some(pair) = scope_pairs.first() {
legacy.library_scope = Some(pair.library_id.clone());
legacy.library_scope = pair.library_id.clone();
}
}
@@ -367,6 +368,40 @@ mod tests {
}
}
#[test]
fn cross_server_whole_scope_genre_browse_merges_duplicate_albums() {
let store = LibraryStore::open_in_memory();
let mut first = track("s1", "t1", "al1", "Rock");
first.library_id = Some("lib-a".into());
first.album = "Shared Album".into();
let mut second = track("s2", "t2", "al2", "Rock");
second.library_id = Some(String::new());
second.album = "Shared Album".into();
TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap();
crate::identity::rebuild_cluster_keys(&store, None).unwrap();
let response = list_albums_by_genre(
&store,
&LibraryGenreAlbumsRequest {
server_id: "s1".into(),
genre: "Rock".into(),
library_scope: None,
library_scopes: Some(vec![
LibraryScopePair { server_id: "s2".into(), library_id: None },
LibraryScopePair { server_id: "s1".into(), library_id: None },
]),
sort: vec![],
limit: 50,
offset: 0,
include_total: true,
},
)
.unwrap();
assert_eq!(response.albums.len(), 1);
assert_eq!(response.albums[0].server_id, "s2");
assert_eq!(response.total, Some(1));
}
#[test]
fn list_albums_by_genre_respects_library_scope_and_total() {
let store = LibraryStore::open_in_memory();
@@ -11,6 +11,7 @@ pub use attach::{
remove_cluster_files_for_library, CLUSTER_DB_FILENAME, CLUSTER_SCHEMA,
};
pub use norm::NORM_VERSION;
pub(crate) use norm::norm_part;
pub use rebuild::{cluster_rebuild_needed, ensure_cluster_keys_built, rebuild_cluster_keys};
pub use keys::{build_track_cluster_keys, TrackClusterKeys};
@@ -39,6 +39,7 @@ pub mod payload;
pub mod repos;
pub mod runtime;
pub mod scope_merge;
pub mod scope_statistics;
pub mod search;
pub mod store;
pub mod sync;
@@ -46,9 +46,15 @@ pub fn run_live_search(
});
}
let scope_pairs = ordered_library_scope_pairs(server_id, library_scope, library_scopes);
if multi_library_merge_enabled(&scope_pairs) {
crate::identity::ensure_cluster_keys_built(store, server_id)?;
let scope_pairs = ordered_library_scope_pairs(server_id, library_scope, library_scopes)?;
let pair_reader_required = multi_library_merge_enabled(&scope_pairs)
|| scope_pairs
.first()
.is_some_and(|pair| pair.server_id != server_id);
if pair_reader_required {
for scope_server in scope_pairs.iter().map(|p| p.server_id.as_str()).collect::<HashSet<_>>() {
crate::identity::ensure_cluster_keys_built(store, scope_server)?;
}
return run_live_search_multi_scope(
store,
&scope_pairs,
@@ -63,7 +69,7 @@ pub fn run_live_search(
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| scope_pairs.first().map(|p| p.library_id.clone()));
.or_else(|| scope_pairs.first().and_then(|p| p.library_id.clone()));
store.with_read_conn(|conn| {
let scopes = scopes_from_option(effective_scope.as_deref());
@@ -869,11 +875,11 @@ mod tests {
let scopes = vec![
LibraryScopePair {
server_id: "s1".into(),
library_id: "lib-a".into(),
library_id: Some("lib-a".into()),
},
LibraryScopePair {
server_id: "s1".into(),
library_id: "lib-b".into(),
library_id: Some("lib-b".into()),
},
];
let resp = run_live_search(
@@ -895,6 +901,50 @@ mod tests {
assert_eq!(resp.tracks[0].id, "t-a");
}
#[test]
fn cross_server_whole_scope_live_search_keeps_priority_and_empty_library_rows() {
use crate::dto::LibraryScopePair;
use crate::identity::rebuild_cluster_keys;
let store = LibraryStore::open_in_memory();
let mut first = track("s1", "t-a", "Shared Song", "Shared Artist", "Shared Album", "al-a", "ar-a");
first.library_id = Some("lib-a".into());
let mut second = track("s2", "t-b", "Shared Song", "Shared Artist", "Shared Album", "al-b", "ar-b");
second.library_id = Some(String::new());
TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap();
rebuild_cluster_keys(&store, None).unwrap();
let scopes = vec![
LibraryScopePair { server_id: "s2".into(), library_id: None },
LibraryScopePair { server_id: "s1".into(), library_id: None },
];
let resp = run_live_search(&store, "s1", "shared", None, Some(&scopes), 5, 5, 10).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].server_id, "s2");
assert_eq!(resp.tracks[0].id, "t-b");
}
#[test]
fn single_exact_pair_on_another_server_does_not_use_fallback_server() {
use crate::dto::LibraryScopePair;
let store = LibraryStore::open_in_memory();
let mut fallback = track("s1", "fallback", "Shared Song", "Artist", "Album", "al1", "ar1");
fallback.library_id = Some("lib".into());
let mut selected = track("s2", "selected", "Shared Song", "Artist", "Album", "al2", "ar2");
selected.library_id = Some("lib".into());
TrackRepository::new(&store).upsert_batch(&[fallback, selected]).unwrap();
let scopes = vec![LibraryScopePair {
server_id: "s2".into(),
library_id: Some("lib".into()),
}];
let response = run_live_search(&store, "s1", "shared", None, Some(&scopes), 5, 5, 10).unwrap();
assert_eq!(response.tracks.len(), 1);
assert_eq!(response.tracks[0].id, "selected");
assert_eq!(response.tracks[0].server_id, "s2");
}
/// Manual: `cargo test -p psysonic-library bench_disk_live_search --release -- --ignored --nocapture`
#[test]
#[ignore]
@@ -2,7 +2,10 @@
//!
//! Mirrors the frontend allowlist in `src/utils/library/losslessFormats.ts`.
use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse};
use crate::dto::{
LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse,
multi_library_merge_enabled, ordered_library_scope_pairs,
};
use crate::lossless_formats::track_is_lossless_sql;
use crate::search::{combined_scope_library_ids, library_scope_in_sql, library_scope_sargable_equals_sql};
use crate::store::LibraryStore;
@@ -38,13 +41,47 @@ pub fn list_lossless_albums(
store: &LibraryStore,
req: &LibraryLosslessAlbumsRequest,
) -> Result<LibraryLosslessAlbumsResponse, String> {
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(empty_response());
}
let limit = req.limit.max(1);
let offset = req.offset;
let lossless_sql = track_is_lossless_sql("t");
let scope_pairs = ordered_library_scope_pairs(
&req.server_id,
req.library_scope.as_deref(),
req.library_scopes.as_deref(),
)?;
if scope_pairs.is_empty() && !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(empty_response());
}
let use_pair_reader = scope_pairs.len() > 1
|| scope_pairs.first().is_some_and(|pair| {
pair.server_id != req.server_id || pair.library_id.is_none()
});
if use_pair_reader {
if multi_library_merge_enabled(&scope_pairs) {
for server_id in scope_pairs
.iter()
.map(|p| p.server_id.as_str())
.collect::<std::collections::HashSet<_>>()
{
crate::identity::ensure_cluster_keys_built(store, server_id)?;
}
}
let (albums, _) = crate::scope_merge::list_albums_filtered(
store,
&scope_pairs,
&lossless_sql,
&[],
"ORDER BY album COLLATE NOCASE ASC, album_id ASC",
limit,
offset,
true,
)?;
return Ok(LibraryLosslessAlbumsResponse {
has_more: albums.len() as u32 == limit,
albums,
source: "local".to_string(),
});
}
let mut where_clauses = vec![
"t.deleted = 0".to_string(),
@@ -54,8 +91,11 @@ pub fn list_lossless_albums(
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids =
combined_scope_library_ids(req.library_scope.as_deref(), req.library_scopes.as_deref());
let scope_ids = scope_pairs
.first()
.and_then(|pair| pair.library_id.clone())
.map(|library_id| vec![library_id])
.unwrap_or_else(|| combined_scope_library_ids(req.library_scope.as_deref(), None));
push_library_scope_filter(&mut where_clauses, &mut params, &scope_ids);
let where_sql = where_clauses.join(" AND ");
@@ -304,7 +344,16 @@ mod tests {
.unwrap();
let mut scoped = req("s1", 50, 0);
scoped.library_scopes = Some(vec!["lib1".into(), "lib2".into()]);
scoped.library_scopes = Some(vec![
crate::dto::LibraryScopePair {
server_id: "s1".into(),
library_id: Some("lib1".into()),
},
crate::dto::LibraryScopePair {
server_id: "s1".into(),
library_id: Some("lib2".into()),
},
]);
let resp = list_lossless_albums(&store, &scoped).unwrap();
let mut ids: Vec<_> = resp.albums.iter().map(|a| a.id.clone()).collect();
ids.sort();
@@ -330,4 +379,24 @@ mod tests {
assert_eq!(page2.albums.len(), 1);
assert!(!page2.has_more);
}
#[test]
fn cross_server_whole_scope_lossless_browse_uses_priority_owner() {
let store = LibraryStore::open_in_memory();
let mut first = track_with_suffix("s1", "t1", "al1", "Shared", "flac", 16);
first.library_id = Some("lib-a".into());
let mut second = track_with_suffix("s2", "t2", "al2", "Shared", "flac", 24);
second.library_id = Some(String::new());
TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap();
crate::identity::rebuild_cluster_keys(&store, None).unwrap();
let mut request = req("s1", 50, 0);
request.library_scopes = Some(vec![
crate::dto::LibraryScopePair { server_id: "s2".into(), library_id: None },
crate::dto::LibraryScopePair { server_id: "s1".into(), library_id: None },
]);
let response = list_lossless_albums(&store, &request).unwrap();
assert_eq!(response.albums.len(), 1);
assert_eq!(response.albums[0].server_id, "s2");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,425 @@
//! Scope-aware catalog aggregates and play-session identity views.
use rusqlite::types::Value as SqlValue;
use rusqlite::params_from_iter;
use serde_json::Value;
use crate::album_compilation_filter::pick_album_group_artist;
use crate::browse_support::overlay_album_starred_at_rows;
use crate::dto::{
LibraryAlbumDto, LibraryScopeCatalogStatisticsDto,
LibraryScopeCatalogStatisticsRequest, LibraryScopeFormatCountDto,
LibraryScopeMostPlayedAlbumDto, LibraryScopeMostPlayedRequest,
LibraryArtistDto, LibraryScopeArtistRoleRequest,
};
use crate::identity::norm_part;
use crate::scope_merge::{
ensure_cluster_keys_for_scopes, normalize_scope_pairs, scope_cte_sql, ALBUM_DEDUP_KEY,
TRACK_DEDUP_KEY,
};
use crate::store::LibraryStore;
const ARTIST_DEDUP_KEY: &str = "CASE WHEN ck.artist_key IS NOT NULL THEN ck.artist_key \
ELSE ('null:' || t.server_id || ':' || COALESCE(NULLIF(t.artist_id, ''), t.id)) END";
fn validated_scopes(
scopes: &[crate::dto::LibraryScopePair],
) -> Result<Vec<crate::dto::LibraryScopePair>, String> {
let scopes = normalize_scope_pairs(scopes)?;
if scopes.is_empty() {
return Err("scopes must not be empty".into());
}
Ok(scopes)
}
pub fn catalog_statistics(
store: &LibraryStore,
request: &LibraryScopeCatalogStatisticsRequest,
) -> Result<LibraryScopeCatalogStatisticsDto, String> {
let scopes = validated_scopes(&request.scopes)?;
ensure_cluster_keys_for_scopes(store, &scopes)?;
let sample_limit = request.format_sample_limit.unwrap_or(500).clamp(1, 5_000);
let (cte, binds) = scope_cte_sql(&scopes);
let sql = format!(
"{cte}, base AS ( \
SELECT t.rowid, t.server_id, t.id, t.duration_sec, t.suffix, t.album_id, t.artist_id, \
s.pr, {TRACK_DEDUP_KEY} AS track_dedup, {ALBUM_DEDUP_KEY} AS album_dedup, \
{ARTIST_DEDUP_KEY} AS artist_dedup \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
WHERE t.deleted = 0 \
), winners AS ( \
SELECT rowid, server_id, id, duration_sec, suffix, album_id, artist_id, \
album_dedup, artist_dedup, MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _pick \
FROM base GROUP BY track_dedup \
) \
SELECT COUNT(*), COALESCE(SUM(duration_sec), 0), \
COUNT(DISTINCT CASE WHEN album_id IS NOT NULL AND album_id != '' THEN album_dedup END), \
COUNT(DISTINCT CASE WHEN artist_id IS NOT NULL AND artist_id != '' THEN artist_dedup END) \
FROM winners"
);
let (track_count, duration_sec, album_count, artist_count) = store
.with_read_conn(|conn| {
conn.query_row(&sql, params_from_iter(binds.iter()), |row| {
Ok((
row.get::<_, i64>(0)?.max(0) as u32,
row.get::<_, i64>(1)?.max(0),
row.get::<_, i64>(2)?.max(0) as u32,
row.get::<_, i64>(3)?.max(0) as u32,
))
})
})
.map_err(|e| e.to_string())?;
let genres = crate::browse_support::genre_album_counts_for_scopes(store, &scopes)?;
let format_sql = format!(
"{cte}, base AS ( \
SELECT t.server_id, t.id, t.suffix, s.pr, {TRACK_DEDUP_KEY} AS track_dedup \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
WHERE t.deleted = 0 \
), winners AS ( \
SELECT suffix, MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _pick \
FROM base GROUP BY track_dedup ORDER BY RANDOM() LIMIT ? \
) \
SELECT COALESCE(NULLIF(UPPER(TRIM(suffix)), ''), 'UNKNOWN'), COUNT(*) \
FROM winners GROUP BY 1 ORDER BY 2 DESC, 1 ASC"
);
let mut format_binds = binds;
format_binds.push(SqlValue::Integer(i64::from(sample_limit)));
let formats = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&format_sql)?;
let rows = stmt.query_map(params_from_iter(format_binds.iter()), |row| {
Ok(LibraryScopeFormatCountDto {
format: row.get(0)?,
count: row.get::<_, i64>(1)?.max(0) as u32,
})
})?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())?;
let format_sample_size = formats.iter().map(|row| row.count).sum();
Ok(LibraryScopeCatalogStatisticsDto {
artist_count,
album_count,
track_count,
duration_sec,
genres,
formats,
format_sample_size,
})
}
pub fn most_played_albums(
store: &LibraryStore,
request: &LibraryScopeMostPlayedRequest,
) -> Result<Vec<LibraryScopeMostPlayedAlbumDto>, String> {
let scopes = validated_scopes(&request.scopes)?;
ensure_cluster_keys_for_scopes(store, &scopes)?;
let limit = request.limit.unwrap_or(50).clamp(1, 500);
let offset = request.offset.unwrap_or(0);
let (cte, mut binds) = scope_cte_sql(&scopes);
let sql = format!(
"{cte}, base AS ( \
SELECT t.server_id, t.id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \
t.year, t.genre, t.cover_art_id, t.synced_at, t.duration_sec, s.pr, \
{ALBUM_DEDUP_KEY} AS album_dedup, {TRACK_DEDUP_KEY} AS track_dedup \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
WHERE t.deleted = 0 AND t.album_id IS NOT NULL AND t.album_id != '' \
), track_winners AS ( \
SELECT server_id, id, album_id, album, artist, artist_id, album_artist, year, genre, \
cover_art_id, synced_at, duration_sec, pr, album_dedup, track_dedup, \
MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _track_pick \
FROM base GROUP BY album_dedup, track_dedup \
), album_totals AS ( \
SELECT album_dedup, COUNT(*) AS song_count, SUM(duration_sec) AS duration_total \
FROM track_winners GROUP BY album_dedup \
), played AS ( \
SELECT b.*, ps.id AS session_id \
FROM base b INNER JOIN play_session ps ON ps.server_id = b.server_id AND ps.track_id = b.id \
), played_albums AS ( \
SELECT server_id, album_id, album, artist, artist_id, album_artist, year, genre, cover_art_id, \
synced_at, album_dedup, COUNT(session_id) AS play_count, \
MIN(printf('%08d|%s|%s', pr, server_id, album_id)) AS _pick \
FROM played GROUP BY album_dedup \
) \
SELECT p.server_id, p.album_id, p.album, p.artist, p.artist_id, p.album_artist, \
a.song_count, a.duration_total, p.year, p.genre, p.cover_art_id, p.synced_at, \
p.play_count, \
p._pick \
FROM played_albums p INNER JOIN album_totals a ON a.album_dedup = p.album_dedup \
ORDER BY p.play_count DESC, p.album COLLATE NOCASE ASC, p.album_id ASC LIMIT ? OFFSET ?"
);
binds.push(SqlValue::Integer(i64::from(limit)));
binds.push(SqlValue::Integer(i64::from(offset)));
let mut rows = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_from_iter(binds.iter()), |row| {
let track_artist: Option<String> = row.get(3)?;
let album_artist: Option<String> = row.get(5)?;
Ok(LibraryScopeMostPlayedAlbumDto {
album: LibraryAlbumDto {
server_id: row.get(0)?,
id: row.get(1)?,
name: row.get(2)?,
artist: pick_album_group_artist(track_artist, album_artist),
artist_id: row.get(4)?,
song_count: Some(row.get(6)?),
duration_sec: Some(row.get(7)?),
year: row.get(8)?,
genre: row.get(9)?,
cover_art_id: row.get(10)?,
starred_at: None,
synced_at: row.get(11)?,
raw_json: Value::Null,
},
play_count: row.get::<_, i64>(12)?.max(0) as u32,
})
})?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())?;
store
.with_read_conn(|conn| {
let mut albums = rows.iter_mut().map(|row| &mut row.album).collect::<Vec<_>>();
for album in albums.iter_mut() {
let one = std::slice::from_mut(&mut **album);
overlay_album_starred_at_rows(conn, one);
}
Ok(())
})
.map_err(|e| e.to_string())?;
Ok(rows)
}
pub fn artists_by_role(
store: &LibraryStore,
request: &LibraryScopeArtistRoleRequest,
) -> Result<Vec<LibraryArtistDto>, String> {
let scopes = validated_scopes(&request.scopes)?;
ensure_cluster_keys_for_scopes(store, &scopes)?;
let role = request.role.trim().to_ascii_lowercase();
if role.is_empty() {
return Err("role must not be empty".into());
}
let limit = request.limit.unwrap_or(10_000).clamp(1, 20_000);
let (cte, mut binds) = scope_cte_sql(&scopes);
let rows_sql = format!(
"{cte}, role_rows AS ( \
SELECT t.server_id, json_extract(j.value, '$.artist.id') AS artist_id, \
json_extract(j.value, '$.artist.name') AS artist_name, \
COALESCE(ck.album_key, 'null:' || t.server_id || ':' || COALESCE(t.album_id, t.id)) AS album_dedup, \
t.synced_at, s.pr, \
MIN(printf('%08d|%s|%s', s.pr, t.server_id, json_extract(j.value, '$.artist.id'))) AS _pick \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
JOIN json_each(CASE WHEN json_valid(t.raw_json) THEN t.raw_json ELSE '{{}}' END, '$.contributors') j \
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
WHERE t.deleted = 0 AND LOWER(COALESCE(json_extract(j.value, '$.role'), '')) = ? \
AND json_extract(j.value, '$.artist.id') IS NOT NULL \
GROUP BY t.server_id, artist_id, artist_name, album_dedup, t.synced_at, s.pr \
) \
SELECT server_id, artist_id, artist_name, album_dedup, synced_at, pr \
FROM role_rows ORDER BY pr ASC, server_id ASC, artist_id ASC"
);
binds.push(SqlValue::Text(role));
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&rows_sql)?;
let rows = stmt.query_map(params_from_iter(binds.iter()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, i64>(4)?,
))
})?.collect::<rusqlite::Result<Vec<_>>>()?;
let mut grouped: std::collections::HashMap<String, (LibraryArtistDto, std::collections::HashSet<String>)> =
std::collections::HashMap::new();
for (server_id, artist_id, name, album_dedup, synced_at) in rows {
let key = norm_part(&name)
.unwrap_or_else(|| format!("null:{server_id}:{artist_id}"));
let entry = grouped.entry(key).or_insert_with(|| {
(LibraryArtistDto {
server_id,
id: artist_id,
name: name.clone(),
name_sort: Some(crate::artist_sort::sort_key_for_display_name(
&name,
crate::artist_sort::DEFAULT_IGNORED_ARTICLES,
)),
album_count: Some(0),
synced_at,
raw_json: Value::Null,
}, std::collections::HashSet::new())
});
if let Some(album_dedup) = album_dedup {
entry.1.insert(album_dedup);
}
}
let mut artists = grouped.into_values().map(|(mut artist, album_ids)| {
artist.album_count = Some(album_ids.len() as i64);
artist
}).collect::<Vec<_>>();
artists.sort_by(|a, b| a.name_sort.cmp(&b.name_sort).then_with(|| a.id.cmp(&b.id)));
artists.truncate(limit as usize);
Ok(artists)
}).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use crate::dto::{
LibraryScopeArtistRoleRequest, LibraryScopeCatalogStatisticsRequest,
LibraryScopeMostPlayedRequest, LibraryScopePair,
};
use crate::repos::{PlaySessionRepository, TrackRepository, TrackRow};
use crate::store::LibraryStore;
use super::{artists_by_role, catalog_statistics, most_played_albums};
fn row(server: &str, id: &str, album: &str, library: &str, duration: i64) -> TrackRow {
TrackRow {
server_id: server.into(), id: id.into(), title: "Song".into(), title_sort: None,
artist: Some("Artist".into()), artist_id: Some("artist".into()), album: album.into(),
album_id: Some(album.into()), album_artist: Some("Artist".into()), duration_sec: duration,
track_number: Some(1), disc_number: Some(1), year: Some(2024), genre: Some("Rock".into()),
suffix: Some("flac".into()), bit_rate: None, size_bytes: None, cover_art_id: Some(album.into()),
starred_at: None, user_rating: None, play_count: None, played_at: None, server_path: None,
library_id: Some(library.into()), isrc: Some("same-isrc".into()), mbid_recording: None,
bpm: None, replay_gain_track_db: None, replay_gain_album_db: None, replay_gain_peak: None,
content_hash: None, server_updated_at: None, server_created_at: None, deleted: false,
synced_at: 1, raw_json: "{}".into(),
}
}
fn contributor_row(
server: &str,
id: &str,
album: &str,
library: &str,
contributors: serde_json::Value,
) -> TrackRow {
let mut track = row(server, id, album, library, 200);
track.raw_json = serde_json::json!({ "contributors": contributors }).to_string();
track
}
fn scopes() -> Vec<LibraryScopePair> {
vec![
LibraryScopePair { server_id: "s1".into(), library_id: Some("a".into()) },
LibraryScopePair { server_id: "s2".into(), library_id: Some("b".into()) },
]
}
#[test]
fn catalog_totals_dedup_shared_recording_and_album_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store).upsert_batch(&[
row("s1", "t1", "shared-album", "a", 200),
row("s2", "t2", "shared-album", "b", 201),
]).unwrap();
let stats = catalog_statistics(&store, &LibraryScopeCatalogStatisticsRequest {
scopes: scopes(), format_sample_limit: Some(500),
}).unwrap();
assert_eq!(stats.track_count, 1);
assert_eq!(stats.album_count, 1);
assert_eq!(stats.artist_count, 1);
assert_eq!(stats.duration_sec, 200);
assert_eq!(stats.format_sample_size, 1);
}
#[test]
fn most_played_counts_concrete_sessions_once_but_displays_priority_identity() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store).upsert_batch(&[
row("s1", "t1", "shared-album", "a", 200),
row("s2", "t2", "shared-album", "b", 201),
]).unwrap();
let repo = PlaySessionRepository::new(&store);
for (server_id, track_id, started_at_ms) in [("s1", "t1", 1000), ("s2", "t2", 2000)] {
repo.insert(&crate::dto::PlaySessionInputDto {
server_id: server_id.into(), track_id: track_id.into(), started_at_ms,
listened_sec: 30.0, position_max_sec: 30.0, end_reason: "next".into(),
duration_sec_hint: None,
}).unwrap();
}
let rows = most_played_albums(&store, &LibraryScopeMostPlayedRequest {
scopes: scopes(), limit: Some(10), offset: None,
}).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].play_count, 2);
assert_eq!(rows[0].album.server_id, "s1");
assert_eq!(rows[0].album.id, "shared-album");
assert_eq!(rows[0].album.song_count, Some(1));
assert_eq!(rows[0].album.duration_sec, Some(200));
}
#[test]
fn most_played_song_count_does_not_collide_equal_raw_ids_across_servers() {
let store = LibraryStore::open_in_memory();
let mut first = row("s1", "same-id", "shared-album", "a", 200);
first.title = "First".into();
first.isrc = Some("first-isrc".into());
let mut second = row("s2", "same-id", "shared-album", "b", 240);
second.title = "Second".into();
second.isrc = Some("second-isrc".into());
TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap();
let repo = PlaySessionRepository::new(&store);
for (server_id, started_at_ms) in [("s1", 1000), ("s2", 2000)] {
repo.insert(&crate::dto::PlaySessionInputDto {
server_id: server_id.into(), track_id: "same-id".into(), started_at_ms,
listened_sec: 30.0, position_max_sec: 30.0, end_reason: "next".into(),
duration_sec_hint: None,
}).unwrap();
}
let rows = most_played_albums(&store, &LibraryScopeMostPlayedRequest {
scopes: scopes(), limit: Some(10), offset: None,
}).unwrap();
assert_eq!(rows[0].album.song_count, Some(2));
assert_eq!(rows[0].album.duration_sec, Some(440));
}
#[test]
fn contributor_roles_keep_distinct_people_on_one_track() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store).upsert_batch(&[contributor_row(
"s1", "t1", "album-one", "a",
serde_json::json!([
{ "role": "composer", "artist": { "id": "c1", "name": "Composer One" } },
{ "role": "composer", "artist": { "id": "c2", "name": "Composer Two" } }
]),
)]).unwrap();
let artists = artists_by_role(&store, &LibraryScopeArtistRoleRequest {
scopes: vec![LibraryScopePair { server_id: "s1".into(), library_id: Some("a".into()) }],
role: "composer".into(), limit: Some(10),
}).unwrap();
assert_eq!(artists.iter().map(|artist| artist.name.as_str()).collect::<Vec<_>>(),
vec!["Composer One", "Composer Two"]);
}
#[test]
fn contributor_roles_merge_same_person_across_servers_by_contributor_identity() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store).upsert_batch(&[
contributor_row(
"s1", "t1", "shared-album", "a",
serde_json::json!([{ "role": "composer", "artist": { "id": "composer-a", "name": "Béla Bartók" } }]),
),
contributor_row(
"s2", "t2", "shared-album", "b",
serde_json::json!([{ "role": "composer", "artist": { "id": "composer-b", "name": "Bela Bartok" } }]),
),
]).unwrap();
let artists = artists_by_role(&store, &LibraryScopeArtistRoleRequest {
scopes: scopes(), role: "composer".into(), limit: Some(10),
}).unwrap();
assert_eq!(artists.len(), 1);
assert_eq!(artists[0].server_id, "s1");
assert_eq!(artists[0].id, "composer-a");
assert_eq!(artists[0].album_count, Some(1));
}
}
+10
View File
@@ -106,6 +106,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
.commands(tauri_specta::collect_commands![
crate::lib_commands::app_api::core::greet,
psysonic_library::browse_support::library_get_catalog_year_bounds,
psysonic_library::browse_support::library_scope_catalog_year_bounds,
psysonic_library::browse_support::library_get_genre_album_counts,
// psysonic-library — remaining typeable commands. Excluded (stay on
// generate_handler! only): the 10 search/browse/track reads whose envelopes
@@ -123,6 +124,8 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
psysonic_library::commands::library_genre_tags_inspect,
psysonic_library::commands::library_genre_tags_run,
psysonic_library::commands::library_cluster_rebuild,
psysonic_library::commands::library_resolve_entity_sources,
psysonic_library::commands::library_scope_catalog_statistics,
psysonic_library::commands::library_sync_bind_session,
psysonic_library::commands::library_sync_clear_session,
psysonic_library::commands::library_set_playback_hint,
@@ -1043,11 +1046,15 @@ pub fn run() {
psysonic_library::commands::library_genre_tags_inspect,
psysonic_library::commands::library_genre_tags_run,
psysonic_library::commands::library_cluster_rebuild,
psysonic_library::commands::library_resolve_entity_sources,
psysonic_library::commands::library_scope_list_albums,
psysonic_library::commands::library_scope_list_artists,
psysonic_library::commands::library_scope_search_tracks,
psysonic_library::commands::library_scope_album_detail,
psysonic_library::commands::library_scope_artist_detail,
psysonic_library::commands::library_scope_catalog_statistics,
psysonic_library::commands::library_scope_most_played_albums,
psysonic_library::commands::library_scope_list_artists_by_role,
psysonic_library::commands::library_get_artist_lossless_browse,
psysonic_library::commands::library_search_cross_server,
psysonic_library::commands::library_get_track,
@@ -1070,6 +1077,7 @@ pub fn run() {
psysonic_library::browse_support::library_patch_album,
psysonic_library::browse_support::library_reconcile_album_stars,
psysonic_library::browse_support::library_get_catalog_year_bounds,
psysonic_library::browse_support::library_scope_catalog_year_bounds,
psysonic_library::browse_support::library_get_genre_album_counts,
psysonic_library::commands::library_put_artifact,
psysonic_library::commands::library_put_fact,
@@ -1293,6 +1301,8 @@ mod specta_export {
"library_scope_artist_detail",
"library_scope_list_albums",
"library_scope_list_artists",
"library_scope_list_artists_by_role",
"library_scope_most_played_albums",
"library_scope_search_tracks",
"library_search",
"library_search_cross_server",
+2
View File
@@ -17,6 +17,7 @@ import AppRoutes from './AppRoutes';
import FullscreenPlayer, { FullscreenPlayerImmersive, FullscreenPlayerPrism } from '@/features/fullscreenPlayer';
import ContextMenu from '@/features/contextMenu/components/ContextMenu';
import SongInfoModal from '@/features/playback/components/SongInfoModal';
import PlaybackAlternativeModal from '@/features/playback/components/PlaybackAlternativeModal';
import { DownloadFolderModal, OfflineBanner } from '@/features/offline/ui';
import GlobalConfirmModal from '@/ui/GlobalConfirmModal';
import ThemeMigrationNotice from '@/ui/ThemeMigrationNotice';
@@ -346,6 +347,7 @@ export function AppShell() {
)}
<ContextMenu />
<SongInfoModal />
<PlaybackAlternativeModal />
<DownloadFolderModal />
<GlobalConfirmModal />
<ThemeMigrationNotice />
+9 -5
View File
@@ -26,8 +26,9 @@ import { runLegacyOfflineFileMigration } from '@/features/offline/utils/legacyOf
import { reconcileLibraryTierForServer } from '@/features/offline/utils/libraryTierReconcile';
import { initMiniPlayerBridgeOnMain } from '@/features/miniPlayer';
import { runAdvancedModeMigration } from '@/app/migrations/advancedModeMigration';
import { bootstrapAllIndexedServers } from '@/lib/library/librarySession';
import { hydrateQueueFromIndex } from '@/features/playback/store/queueRestore';
import { initPendingEntityMutationSync } from '@/features/playback/store/pendingStarSync';
import { useLibraryIndexSync } from '@/lib/library/hooks/useLibraryIndexSync';
import { useLibraryAnalysisBackfill } from '@/lib/library/hooks/useLibraryAnalysisBackfill';
import { useCoverArtPrefetch } from '../cover/useCoverArtPrefetch';
import { useLibraryCoverBackfill } from '@/cover/useLibraryCoverBackfill';
@@ -68,12 +69,10 @@ export default function MainApp() {
const migrationPhase = useMigrationStore(s => s.phase);
const migrationReady = migrationPhase === 'completed';
useMigrationOrchestrator();
useLibraryIndexSync(migrationReady);
useEffect(() => {
if (!migrationReady) return;
void (async () => {
await bootstrapAllIndexedServers();
void hydrateQueueFromIndex();
})();
void hydrateQueueFromIndex();
}, [activeServerId, serverIdsKey, masterEnabled, migrationReady]);
useLibraryAnalysisBackfill(migrationReady);
@@ -105,6 +104,11 @@ export default function MainApp() {
return initAudioListeners();
}, [migrationReady]);
useEffect(() => {
if (!migrationReady) return undefined;
return initPendingEntityMutationSync();
}, [migrationReady]);
useEffect(() => {
if (!migrationReady) return undefined;
return initHotCachePrefetch();
+6 -8
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { getMusicFolders } from '@/lib/api/subsonicLibrary';
import { getMusicFoldersForServer } from '@/lib/api/subsonicLibrary';
import { probeEntityRatingSupport } from '@/lib/api/subsonicStarRating';
import { useAuthStore } from '@/store/authStore';
import { cleanupOrphanedOrbitPlaylists } from '@/features/orbit';
@@ -18,7 +18,7 @@ import { cleanupOrphanedOrbitPlaylists } from '@/features/orbit';
export function useServerCapabilitiesProbe(): void {
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const activeServerId = useAuthStore(s => s.activeServerId);
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
const setMusicFoldersForServer = useAuthStore(s => s.setMusicFoldersForServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
useEffect(() => {
@@ -28,11 +28,9 @@ export function useServerCapabilitiesProbe(): void {
(async () => {
const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart;
try {
const folders = await getMusicFolders();
if (stillThisServer()) setMusicFolders(folders);
} catch {
if (stillThisServer()) setMusicFolders([]);
}
const folders = await getMusicFoldersForServer(serverAtStart);
if (stillThisServer()) setMusicFoldersForServer(serverAtStart, folders);
} catch { /* Keep the persisted last-known folders on transient failure. */ }
try {
const level = await probeEntityRatingSupport();
if (stillThisServer()) setEntityRatingSupport(serverAtStart, level);
@@ -43,7 +41,7 @@ export function useServerCapabilitiesProbe(): void {
return () => {
cancelled = true;
};
}, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
}, [isLoggedIn, activeServerId, setMusicFoldersForServer, setEntityRatingSupport]);
useEffect(() => {
if (!isLoggedIn || !activeServerId) return;
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { flushMusicLibraryFilterVersionBumpForTests } from '@/store/musicLibraryFilterNotify';
import { resetAuthStore } from '@/test/helpers/storeReset';
vi.mock('@/lib/library/artistBrowseInflight', () => ({
clearArtistBrowseCatalogCache: vi.fn(),
}));
vi.mock('@/lib/library/albumBrowseCatalogPrefetch', () => ({
prefetchAlbumBrowseCatalogAfterFilterChange: vi.fn(),
}));
vi.mock('@/lib/library/artistBrowseCatalogPrefetch', () => ({
prefetchArtistBrowseCatalogAfterFilterChange: vi.fn(),
}));
vi.mock('@/store/offlineLocalLibrarySyncRevision', () => ({
offlineLocalLibrarySyncRevision: () => 0,
}));
import './musicLibraryCatalogReloadBridge';
function readyStatus(serverId: string) {
return {
serverId,
libraryScope: '',
syncPhase: 'ready',
capabilityFlags: 0,
libraryTier: '',
};
}
describe('musicLibraryCatalogReloadBridge runtime scope updates', () => {
beforeEach(() => {
resetAuthStore();
useLibraryIndexStore.setState({ statusByServer: {}, connectionByServer: {} });
useLibraryIndexStore.getState().replaceConnections({});
});
it('coalesces readiness and reachability transitions into one version bump', () => {
const serverId = useAuthStore.getState().addServer({
name: 'Server', url: 'https://music.example', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(serverId);
useLibraryIndexStore.getState().replaceStatuses({
'music.example': readyStatus('music.example'),
});
useLibraryIndexStore.getState().replaceConnections({ 'music.example': 'online' });
expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(0);
flushMusicLibraryFilterVersionBumpForTests();
expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(1);
});
it('bumps again when reconnect restores browse membership', () => {
const serverId = useAuthStore.getState().addServer({
name: 'Server', url: 'https://music.example', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(serverId);
useLibraryIndexStore.getState().replaceStatuses({
'music.example': readyStatus('music.example'),
});
useLibraryIndexStore.getState().replaceConnections({ 'music.example': 'offline' });
flushMusicLibraryFilterVersionBumpForTests();
const versionBeforeReconnect = useAuthStore.getState().musicLibraryFilterVersion;
useLibraryIndexStore.getState().replaceConnections({ 'music.example': 'online' });
flushMusicLibraryFilterVersionBumpForTests();
expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(versionBeforeReconnect + 1);
});
});
+52 -1
View File
@@ -1,8 +1,20 @@
import { clearArtistBrowseCatalogCache } from '@/lib/library/artistBrowseInflight';
import { prefetchAlbumBrowseCatalogAfterFilterChange } from '@/lib/library/albumBrowseCatalogPrefetch';
import { prefetchArtistBrowseCatalogAfterFilterChange } from '@/lib/library/artistBrowseCatalogPrefetch';
import { registerMusicLibraryCatalogReloadHandler } from '@/store/musicLibraryFilterNotify';
import {
registerMusicLibraryCatalogReloadHandler,
runMusicLibraryCatalogReloadHandler,
scheduleMusicLibraryFilterVersionBump,
} from '@/store/musicLibraryFilterNotify';
import { offlineLocalLibrarySyncRevision } from '@/store/offlineLocalLibrarySyncRevision';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import {
buildBrowseLibraryScopePairs,
libraryScopeFingerprint,
} from '@/lib/library/libraryBrowseScope';
import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint';
import { registerLibraryServerConnectionPublisher } from '@/lib/network/libraryServerReachability';
/**
* App-layer seam wiring the store's music-library filter/selection change to the
@@ -21,3 +33,42 @@ registerMusicLibraryCatalogReloadHandler((serverId, indexEnabled, version) => {
prefetchAlbumBrowseCatalogAfterFilterChange(serverId, version, indexEnabled, syncRevision);
prefetchArtistBrowseCatalogAfterFilterChange(serverId, version, indexEnabled, syncRevision);
});
registerLibraryServerConnectionPublisher((indexKey, connection) => {
useLibraryIndexStore.getState().mergeConnections({ [indexKey]: connection });
});
function currentBrowseFingerprint(): string {
const runtime = useLibraryIndexStore.getState();
return libraryScopeFingerprint(buildBrowseLibraryScopePairs(
useAuthStore.getState(),
runtime,
{ navigatorOffline: isNavigatorOfflineHint() },
));
}
let previousBrowseFingerprint = currentBrowseFingerprint();
function scheduleReloadIfBrowseScopeChanged(): void {
const next = currentBrowseFingerprint();
if (next === previousBrowseFingerprint) return;
previousBrowseFingerprint = next;
scheduleMusicLibraryFilterVersionBump(() => {
useAuthStore.setState(state => ({
musicLibraryFilterVersion: state.musicLibraryFilterVersion + 1,
}));
const auth = useAuthStore.getState();
const serverId = auth.activeServerId ?? auth.musicLibraryServerIds[0];
if (!serverId) return;
runMusicLibraryCatalogReloadHandler(
serverId,
useLibraryIndexStore.getState().isIndexEnabled(serverId),
auth.musicLibraryFilterVersion,
);
});
}
useAuthStore.subscribe(scheduleReloadIfBrowseScopeChanged);
useLibraryIndexStore.subscribe(scheduleReloadIfBrowseScopeChanged);
window.addEventListener('online', scheduleReloadIfBrowseScopeChanged);
window.addEventListener('offline', scheduleReloadIfBrowseScopeChanged);
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { entityOverrideKey } from '@/lib/media/entityOverrideKey';
import { useAuthStore } from '@/store/authStore';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
@@ -27,10 +28,14 @@ export function usePlayerSnapshotPublisher() {
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
const ct = s.currentTrack;
const currentTrackUserRating =
ct != null ? (s.userRatingOverrides[ct.id] ?? ct.userRating ?? null) : null;
ct != null
? (s.userRatingOverrides[entityOverrideKey(ct.serverId ?? s.queueServerId, ct.id)] ?? ct.userRating ?? null)
: null;
const currentTrackStarred =
ct != null
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
? (entityOverrideKey(ct.serverId ?? s.queueServerId, ct.id) in s.starredOverrides
? s.starredOverrides[entityOverrideKey(ct.serverId ?? s.queueServerId, ct.id)]
: Boolean(ct.starred))
: null;
// Thin-state: resolve only a window around the playing track (resolver
// cache → placeholder) instead of the whole 50k queue. `queue_length`
+1
View File
@@ -208,6 +208,7 @@ const CONTRIBUTOR_ENTRIES = [
'Playlist and radio custom covers — preserve Navidrome pl-/ra-* getCoverArt ids (fixes blank uploaded covers; PR #1295)',
'Playlist cards — Play next and Add to queue from the right-click menu, matching album cards (PR #1307)',
'Playlists browse — scoped header search by playlist name (PR #1308)',
'Unified multi-server Library scope with source-safe playback, mutations, playlists, radio, folders, Offline and sharing (PR #1309)',
],
},
{
+1 -1
View File
@@ -97,7 +97,7 @@ export function executeCliPlayerCommand(ctx: CliContext): void | Promise<void> {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
queueSongRating(track.id, stars);
queueSongRating(track.id, stars, track.serverId);
return;
}
// no-op for unknown command
+9 -3
View File
@@ -1,6 +1,6 @@
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '@/features/album/hooks/useNavigateToAlbum';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -24,6 +24,8 @@ import { isAlbumRecentlyAdded } from '@/features/album/utils/albumRecency';
import { albumArtistDisplayName, deriveAlbumArtistRefs } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
import { coverServerScopeForServerId } from '@/cover/serverScope';
import { appendServerQuery } from '@/lib/navigation/detailServerScope';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
import { navigateToArtistDetail } from '@/lib/navigation/albumDetailNavigation';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -68,6 +70,7 @@ function AlbumCard({
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
});
const navigate = useNavigate();
const location = useLocation();
const navigateToAlbum = useNavigateToAlbum();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
@@ -96,7 +99,7 @@ function AlbumCard({
const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]);
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
if (selectionMode) { onToggleSelect?.(libraryEntityKey(album), opts); return; }
navigateToAlbum(album.id, { search: albumLinkQuery });
};
@@ -215,7 +218,10 @@ function AlbumCard({
<OpenArtistRefInline
refs={artistRefs}
fallbackName={artistLabel}
onGoArtist={id => navigate(`/artist/${id}`)}
onGoArtist={id => {
const search = appendServerQuery(undefined, album.serverId);
navigateToArtistDetail(navigate, location, id, search ? { search } : undefined);
}}
as="none"
linkTag="span"
linkClassName="track-artist-link"
@@ -145,6 +145,7 @@ interface AlbumInfo {
coverArt?: string;
recordLabel?: string;
created?: string;
serverId?: string;
}
interface AlbumHeaderProps {
@@ -230,7 +231,7 @@ export default function AlbumHeader({
const handleShareAlbum = async () => {
try {
const ok = await copyEntityShareLink('album', info.id);
const ok = await copyEntityShareLink('album', info.id, info.serverId);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
} catch {
+3 -2
View File
@@ -6,6 +6,7 @@ import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { dedupeById } from '@/lib/util/dedupeById';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
interface Props {
title: string;
@@ -137,7 +138,7 @@ export default function AlbumRow({
// Reset when the rows identity changes (new data / server), not when the list grows via
// “load more” — reusing albums.length would shrink the budget mid-scroll and flash placeholders.
const rowArtworkResetKey = uniqueAlbums[0]?.id ?? '';
const rowArtworkResetKey = uniqueAlbums[0] ? libraryEntityKey(uniqueAlbums[0]) : '';
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -261,7 +262,7 @@ export default function AlbumRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueAlbums.map((a, idx) => (
<AlbumCard
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
key={libraryEntityKey(a)}
album={a}
showRating={showRating}
linkQuery={albumLinkQuery}
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { runLocalLosslessAlbums } from '@/lib/library/browseTextSearch';
import { LOSSLESS_MODE_QUERY } from '@/lib/library/losslessMode';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
interface Props {
disableArtwork?: boolean;
@@ -25,20 +26,31 @@ export default function LosslessAlbumsRail({
}: Props) {
const { t } = useTranslation();
const activeServerId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId ?? ''));
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId || activeServerId || '';
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(browseServerId));
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
if (indexEnabled && activeServerId) {
const local = await runLocalLosslessAlbums(activeServerId, TARGET_ALBUMS, 0);
if (indexEnabled && browseServerId) {
const local = await runLocalLosslessAlbums(
browseServerId,
TARGET_ALBUMS,
0,
browseScope.pairs,
);
if (cancelled) return;
if (local && local.albums.length > 0) {
setAlbums(local.albums);
return;
}
}
if (browseScope.multiServer) {
setAlbums([]);
return;
}
try {
const page = await ndListLosslessAlbumsPage({ targetNewAlbums: TARGET_ALBUMS });
if (cancelled) return;
@@ -48,7 +60,7 @@ export default function LosslessAlbumsRail({
}
})();
return () => { cancelled = true; };
}, [activeServerId, indexEnabled]);
}, [browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId, indexEnabled]);
if (albums.length === 0) return null;
@@ -5,10 +5,6 @@ const libraryScopeAlbumDetailMock = vi.fn();
vi.mock('@/lib/api/library/scopeReads', () => ({
libraryScopeAlbumDetail: (...args: unknown[]) => libraryScopeAlbumDetailMock(...args),
scopePairsFromLibrarySelection: (serverId: string) => [
{ serverId: `${serverId}-idx`, libraryId: 'lib-a' },
{ serverId: `${serverId}-idx`, libraryId: 'lib-b' },
],
}));
import { tryLoadAlbumDetailMultiScope } from './loadAlbumDetailMultiScope';
@@ -53,7 +49,10 @@ describe('tryLoadAlbumDetailMultiScope', () => {
tracks: [trackDto(), trackDto({ id: 'trk-2', title: 'Track Two', trackNumber: 2 })],
});
const result = await tryLoadAlbumDetailMultiScope('srv-1', 'alb-1');
const result = await tryLoadAlbumDetailMultiScope('srv-1', 'alb-1', [
{ serverId: 'srv-1-idx', libraryId: 'lib-a' },
{ serverId: 'srv-1-idx', libraryId: 'lib-b' },
]);
expect(libraryScopeAlbumDetailMock).toHaveBeenCalledWith('srv-1', {
scopes: [
@@ -74,12 +73,12 @@ describe('tryLoadAlbumDetailMultiScope', () => {
tracks: [],
});
await expect(tryLoadAlbumDetailMultiScope('srv-1', 'alb-1')).resolves.toBeNull();
await expect(tryLoadAlbumDetailMultiScope('srv-1', 'alb-1', [])).resolves.toBeNull();
});
it('returns null when the scope command throws', async () => {
libraryScopeAlbumDetailMock.mockRejectedValue(new Error('ipc fail'));
await expect(tryLoadAlbumDetailMultiScope('srv-1', 'alb-1')).resolves.toBeNull();
await expect(tryLoadAlbumDetailMultiScope('srv-1', 'alb-1', [])).resolves.toBeNull();
});
});
@@ -1,7 +1,7 @@
import {
libraryScopeAlbumDetail,
scopePairsFromLibrarySelection,
} from '@/lib/api/library/scopeReads';
import type { LibraryScopePair } from '@/lib/api/library';
import { albumToAlbum, trackToSong } from '@/lib/library/advancedSearchLocal';
import type { ResolvedAlbum } from '@/features/offline';
@@ -12,10 +12,11 @@ import type { ResolvedAlbum } from '@/features/offline';
export async function tryLoadAlbumDetailMultiScope(
serverId: string,
albumId: string,
scopes: LibraryScopePair[],
): Promise<ResolvedAlbum | null> {
try {
const response = await libraryScopeAlbumDetail(serverId, {
scopes: scopePairsFromLibrarySelection(serverId),
scopes,
albumId,
serverId,
});
+34 -10
View File
@@ -64,6 +64,7 @@ import {
storeAlbumBrowseCatalogCache,
} from '@/lib/library/albumBrowseInflight';
import { librarySelectionForServer } from '@/lib/api/subsonicClient';
import type { LibraryScopePair } from '@/lib/api/library';
const PAGE_SIZE = 30;
const CLIENT_SLICE_PAGE_SIZE = 60;
@@ -92,6 +93,9 @@ export type UseAlbumBrowseDataArgs = {
scrollRootEl?: HTMLElement | null;
/** Bootstrap visible slice size when restoring scroll after album-detail back. */
restoreDisplayCount?: number;
scopePairs?: LibraryScopePair[];
scopeFingerprint?: string;
localOnly?: boolean;
};
function resolveHasMoreAfterPage(
@@ -120,6 +124,9 @@ export function useAlbumBrowseData({
getScrollRoot,
scrollRootEl,
restoreDisplayCount,
scopePairs,
scopeFingerprint = '',
localOnly = false,
}: UseAlbumBrowseDataArgs) {
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
@@ -172,13 +179,14 @@ export function useAlbumBrowseData({
browseQuery,
offlineBrowseActive,
);
const scopedBase = `${base}\0${scopeFingerprint}`;
// Online index browse re-keys on the library sync revision so a completed
// resync surfaces renamed/pruned albums without an app restart; offline
// browse already re-keys via its own (sync-driven) reload key.
if (!offlineBrowseActive) return albumBrowseOnlineCatalogKey(base, librarySyncRevision);
return `${base}\0${offlineLocalBrowseReloadKey}`;
if (!offlineBrowseActive) return albumBrowseOnlineCatalogKey(scopedBase, librarySyncRevision);
return `${scopedBase}\0${offlineLocalBrowseReloadKey}`;
},
[serverId, musicLibraryFilterVersion, browseQuery, offlineBrowseActive, offlineLocalBrowseReloadKey, librarySyncRevision],
[serverId, musicLibraryFilterVersion, browseQuery, offlineBrowseActive, offlineLocalBrowseReloadKey, librarySyncRevision, scopeFingerprint],
);
const compFilterActive = compFilter !== 'all';
@@ -206,6 +214,7 @@ export function useAlbumBrowseData({
compFilter,
musicLibraryFilterVersion,
serverId,
scopeFingerprint,
],
getScrollRoot,
scrollRootEl,
@@ -294,7 +303,8 @@ export function useAlbumBrowseData({
query,
offset,
CATALOG_CHUNK_SIZE,
starredOverrides,
starredOverrides,
scopePairs,
);
if (generation !== loadGenerationRef.current || chunk == null) return;
setAlbums(prev => {
@@ -313,7 +323,7 @@ export function useAlbumBrowseData({
// reloads from the right source when offline browse toggles; the loader reads
// the active mode internally rather than referencing the flag directly here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [indexEnabled, offlineBrowseActive, serverId, starredOverrides]);
}, [indexEnabled, offlineBrowseActive, scopePairs, serverId, starredOverrides]);
const loadBrowse = useCallback(async (
query: AlbumBrowseQuery,
@@ -363,6 +373,8 @@ export function useAlbumBrowseData({
}
},
},
scopePairs,
localOnly,
),
{ offset, pageSize: PAGE_SIZE, append },
);
@@ -377,7 +389,7 @@ export function useAlbumBrowseData({
else setLoading(false);
}
}
}, [indexEnabled, serverId]);
}, [indexEnabled, localOnly, scopePairs, serverId]);
useLayoutEffect(() => {
const cached = readAlbumBrowseCatalogCache(catalogLoadKey);
@@ -501,8 +513,9 @@ export function useAlbumBrowseData({
serverId,
indexEnabled,
browseQuery,
0,
ALBUM_BROWSE_BOOTSTRAP_CHUNK,
0,
ALBUM_BROWSE_BOOTSTRAP_CHUNK,
scopePairs,
),
{ chunkSize: ALBUM_BROWSE_BOOTSTRAP_CHUNK },
),
@@ -543,6 +556,7 @@ export function useAlbumBrowseData({
browseQuery,
tailOffset,
tailSize,
scopePairs,
),
{ offset: tailOffset, chunkSize: tailSize },
),
@@ -589,6 +603,7 @@ export function useAlbumBrowseData({
browseQuery,
0,
CATALOG_CHUNK_SIZE,
scopePairs,
),
),
);
@@ -614,6 +629,14 @@ export function useAlbumBrowseData({
}
}
if (cancelled) return;
if (localOnly) {
setBrowseMode('slice');
setAlbums([]);
setCatalogHasMore(false);
setLoading(false);
emitAlbumBrowseDebug('load_effect_done', { browseMode: 'slice', localUnavailable: true });
return;
}
emitAlbumBrowseDebug('load_branch', { mode: 'page' });
setBrowseMode('page');
await loadBrowse(browseQuery, 0, false);
@@ -628,7 +651,7 @@ export function useAlbumBrowseData({
// starredOverrides is read to seed star state during the load, but the browse
// list must not reload on every star toggle — it is intentionally excluded.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [catalogLoadKey, browseQuery, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, loadBrowse, musicLibraryFilterVersion]);
}, [catalogLoadKey, browseQuery, indexEnabled, localOnly, offlineBrowseActive, offlineBrowseReloadTs, scopePairs, serverId, loadBrowse, musicLibraryFilterVersion]);
useEffect(() => {
if (!genreCatalogActive) {
@@ -645,7 +668,7 @@ export function useAlbumBrowseData({
'genre_options',
() => offlineBrowseActive && serverId && offlineLocalBrowseEnabled(serverId)
? fetchOfflineLocalAlbumGenreOptions(serverId, browseQueryWithoutGenre, starredOverrides)
: fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre),
: fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre, scopePairs),
).then(options => {
if (!cancelled) {
setGenreCatalogOptions(options);
@@ -663,6 +686,7 @@ export function useAlbumBrowseData({
browseQueryWithoutGenre,
musicLibraryFilterVersion,
offlineBrowseActive,
scopePairs,
starredOverrides,
]);
@@ -7,11 +7,26 @@ import { useAuthStore } from '@/store/authStore';
const tryLoadAlbumDetailMultiScopeMock = vi.fn();
const resolveAlbumMock = vi.fn();
const librarySelectionForServerMock = vi.fn();
let browseScope = {
pairs: [{ serverId: 'srv-1', libraryId: 'lib-a' }, { serverId: 'srv-2', libraryId: null }],
fingerprint: 'multi',
anchorServerId: 'srv-1',
configuredServerIds: ['srv-1', 'srv-2'],
multiServer: true,
};
vi.mock('@/features/album/hooks/loadAlbumDetailMultiScope', () => ({
tryLoadAlbumDetailMultiScope: (...args: unknown[]) => tryLoadAlbumDetailMultiScopeMock(...args),
}));
vi.mock('@/lib/library/loadArtistDetailMultiScope', () => ({
tryLoadArtistDetailMultiScope: vi.fn().mockResolvedValue(null),
}));
vi.mock('@/store/useBrowseLibraryScope', () => ({
useBrowseLibraryScope: () => browseScope,
}));
vi.mock('@/lib/api/subsonicClient', async importOriginal => {
const actual = await importOriginal<typeof import('@/lib/api/subsonicClient')>();
return {
@@ -49,6 +64,13 @@ describe('useAlbumDetailData — multi-library selection', () => {
tryLoadAlbumDetailMultiScopeMock.mockReset();
resolveAlbumMock.mockReset();
librarySelectionForServerMock.mockReset();
browseScope = {
pairs: [{ serverId: 'srv-1', libraryId: 'lib-a' }, { serverId: 'srv-2', libraryId: null }],
fingerprint: 'multi',
anchorServerId: 'srv-1',
configuredServerIds: ['srv-1', 'srv-2'],
multiServer: true,
};
useAuthStore.setState({
activeServerId: 'srv-1',
servers: [{ id: 'srv-1', name: 'S', url: 'https://s.test', username: 'u', password: 'p' }],
@@ -72,7 +94,7 @@ describe('useAlbumDetailData — multi-library selection', () => {
const { result } = renderHook(() => useAlbumDetailData('alb-1'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'alb-1');
expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'alb-1', browseScope.pairs);
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(result.current.album?.album).toMatchObject({ id: 'alb-1', name: 'Merged' });
expect(result.current.album?.songs).toHaveLength(1);
@@ -88,12 +110,19 @@ describe('useAlbumDetailData — multi-library selection', () => {
const { result } = renderHook(() => useAlbumDetailData('alb-1'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'alb-1');
expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'alb-1', browseScope.pairs);
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(result.current.album?.album).toMatchObject({ name: 'Scoped' });
});
it('does not call tryLoadAlbumDetailMultiScope when all libraries are selected', async () => {
browseScope = {
pairs: [],
fingerprint: 'single',
anchorServerId: 'srv-1',
configuredServerIds: ['srv-1'],
multiServer: false,
};
librarySelectionForServerMock.mockReturnValue([]);
resolveAlbumMock.mockResolvedValue({
album: { id: 'alb-1', name: 'Single' },
@@ -108,7 +137,7 @@ describe('useAlbumDetailData — multi-library selection', () => {
expect(result.current.album?.album).toMatchObject({ id: 'alb-1', name: 'Single' });
});
it('falls through to resolveAlbum when multi-scope load returns null', async () => {
it('does not fall through to network when multi-server scope load returns null', async () => {
librarySelectionForServerMock.mockReturnValue(['lib-a', 'lib-b']);
tryLoadAlbumDetailMultiScopeMock.mockResolvedValue(null);
resolveAlbumMock.mockResolvedValue({
@@ -120,7 +149,7 @@ describe('useAlbumDetailData — multi-library selection', () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadAlbumDetailMultiScopeMock).toHaveBeenCalled();
expect(resolveAlbumMock).toHaveBeenCalled();
expect(result.current.album?.album).toMatchObject({ name: 'Fallback' });
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(result.current.album).toBeNull();
});
});
+22 -3
View File
@@ -22,8 +22,9 @@ import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '@/lib/network/subsonicNetworkGuard';
import { librarySelectionForServer } from '@/lib/api/subsonicClient';
import { tryLoadAlbumDetailMultiScope } from '@/features/album/hooks/loadAlbumDetailMultiScope';
import { tryLoadArtistDetailMultiScope } from '@/lib/library/loadArtistDetailMultiScope';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
type AlbumPayload = ResolvedAlbum;
@@ -52,6 +53,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [searchParams] = useSearchParams();
const detailServerId = readDetailServerId(searchParams, activeServerId);
const browseScope = useBrowseLibraryScope();
const offlineBrowseActive = useOfflineBrowseContext().active && !!detailServerId;
useEffect(() => {
@@ -77,6 +79,15 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
) => {
if (!artistId) return;
try {
if (browseScope.multiServer) {
const scoped = await tryLoadArtistDetailMultiScope(
serverId ?? browseScope.anchorServerId,
artistId,
browseScope.pairs,
);
if (scoped) setRelatedAlbums(scoped.albums.filter(a => a.id !== id));
return;
}
if (useLocalArtist && serverId) {
const artistLocal = localBytesOnly
? await loadArtistFromLocalPlayback(serverId, artistId)
@@ -114,13 +125,17 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
return;
}
if (detailServerId && librarySelectionForServer(detailServerId).length > 0) {
const multi = await tryLoadAlbumDetailMultiScope(detailServerId, id);
if (detailServerId && browseScope.pairs.length > 0) {
const multi = await tryLoadAlbumDetailMultiScope(detailServerId, id, browseScope.pairs);
if (multi) {
applyAlbumPayload(multi);
await loadRelatedAlbums(detailServerId, multi.album.artistId, true, false);
return;
}
if (browseScope.multiServer) {
setLoading(false);
return;
}
}
// Index-first when the local SQLite index is ready, not only when the
@@ -188,6 +203,10 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
})();
}, [
activeServerId,
browseScope.anchorServerId,
browseScope.fingerprint,
browseScope.multiServer,
browseScope.pairs,
detailServerId,
favoritesOfflineEnabled,
id,
@@ -11,6 +11,7 @@ describe('useAlbumOfflineState', () => {
it('reports queued when the album waits in the pin queue', () => {
useOfflineJobStore.setState({
pinQueue: [{
serverId: 'srv',
albumId: 'alb-1',
albumName: 'One',
pinKind: 'album',
@@ -27,6 +28,7 @@ describe('useAlbumOfflineState', () => {
it('prefers downloading over queued when jobs are active', () => {
useOfflineJobStore.setState({
pinQueue: [{
serverId: 'srv',
albumId: 'alb-1',
albumName: 'One',
pinKind: 'album',
@@ -36,23 +36,23 @@ export function useAlbumOfflineState(
const isPinQueued = useOfflineJobStore(s =>
!pinComplete
&& !!albumId
&& s.pinQueue.some(p => p.albumId === albumId && p.status === 'queued'),
&& s.pinQueue.some(p => p.albumId === albumId && p.serverId === serverId && p.status === 'queued'),
);
const isOfflineDownloading = useOfflineJobStore(s =>
!pinComplete
&& !!albumId
&& (
s.pinQueue.some(p => p.albumId === albumId && p.status === 'downloading')
|| s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading'))
s.pinQueue.some(p => p.albumId === albumId && p.serverId === serverId && p.status === 'downloading')
|| s.jobs.some(j => j.albumId === albumId && (!j.serverId || j.serverId === serverId) && (j.status === 'queued' || j.status === 'downloading'))
),
);
const offlineProgressDone = useOfflineJobStore(s => {
if (!albumId || pinComplete) return 0;
return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length;
return s.jobs.filter(j => j.albumId === albumId && (!j.serverId || j.serverId === serverId) && (j.status === 'done' || j.status === 'error')).length;
});
const offlineProgressTotal = useOfflineJobStore(s => {
if (!albumId || pinComplete) return 0;
return s.jobs.filter(j => j.albumId === albumId).length;
return s.jobs.filter(j => j.albumId === albumId && (!j.serverId || j.serverId === serverId)).length;
});
const resolvedOfflineStatus = pinComplete
? 'cached'
@@ -10,6 +10,7 @@ import {
} from '@/lib/library/browseTextSearch';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineLocalBrowseEnabled, searchOfflineLocalAlbums } from '@/features/offline';
import type { LibraryScopePair } from '@/lib/api/library';
/**
* Debounced album title search with local-vs-network race when the
@@ -20,6 +21,8 @@ export function useBrowseAlbumTextSearch(
indexEnabled: boolean,
serverId: string | null | undefined,
losslessOnly = false,
scopePairs?: LibraryScopePair[],
localOnly = false,
) {
const offlineBrowseActive = useOfflineBrowseContext().active;
const [debouncedFilter, setDebouncedFilter] = useState('');
@@ -57,7 +60,7 @@ export function useBrowseAlbumTextSearch(
setTextSearchLoading(false);
return;
}
if (!indexEnabled) {
if (!indexEnabled && !localOnly) {
const albums = await runNetworkBrowseAlbums(q);
if (isStale()) return;
setTextSearchAlbums(albums);
@@ -65,9 +68,17 @@ export function useBrowseAlbumTextSearch(
return;
}
if (localOnly) {
const albums = await runLocalBrowseAlbums(serverId, q, undefined, losslessOnly, scopePairs);
if (isStale()) return;
setTextSearchAlbums(albums ?? []);
setTextSearchLoading(false);
return;
}
const outcome = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly),
() => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly, scopePairs),
() => runNetworkBrowseAlbums(q),
{
surface: 'albums_browse',
@@ -80,7 +91,7 @@ export function useBrowseAlbumTextSearch(
setTextSearchAlbums(outcome?.result ?? null);
setTextSearchLoading(false);
})();
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, losslessOnly]);
}, [debouncedFilter, indexEnabled, localOnly, offlineBrowseActive, scopePairs, serverId, losslessOnly]);
const effectiveFilter = textSearchAlbums != null ? '' : filter;
return { textSearchAlbums, textSearchLoading, effectiveFilter };
+12 -52
View File
@@ -1,6 +1,5 @@
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
import { queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync';
import { queueEntityRating, queueEntityStar, queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync';
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
import { getArtistInfo } from '@/lib/api/subsonicArtists';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
@@ -36,7 +35,6 @@ import {
} from '@/cover/ref';
import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef';
import { useTranslation } from 'react-i18next';
import { showToast } from '@/lib/dom/toast';
import { useSelectionStore } from '@/store/selectionStore';
import { sanitizeFilename } from '@/features/album/utils/albumDetailHelpers';
import { albumArtistDisplayName, deriveAlbumHeaderArtistRefs } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
@@ -79,7 +77,6 @@ export default function AlbumDetail() {
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const serverId = readDetailServerId(searchParams, auth.activeServerId) ?? '';
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
const offlineCtx = useOfflineBrowseContext();
const albumActionPolicy = offlineActionPolicy('albumDetail', offlineCtx.active);
@@ -217,16 +214,13 @@ const handleShuffleAll = () => {
const handleRate = (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
// F4: optimistic override + retried server sync via the central helper.
queueSongRating(songId, rating);
const song = album?.songs.find(candidate => candidate.id === songId);
queueSongRating(songId, rating, song?.serverId ?? serverId);
};
const handleAlbumEntityRating = async (rating: number) => {
if (!album || album.album.id !== id) return;
const albumId = album.album.id;
const ratingAtStart = albumId in userRatingOverrides
? userRatingOverrides[albumId]
: (album.album.userRating ?? 0);
userMetadataMutationRef.current = true;
setUserRatingOverride(albumId, rating);
@@ -235,24 +229,13 @@ const handleShuffleAll = () => {
return;
}
try {
await setRating(albumId, rating);
setAlbum(cur =>
cur && cur.album.id === albumId
? { ...cur, album: { ...cur.album, userRating: rating } }
: cur,
);
} catch (err) {
setUserRatingOverride(albumId, ratingAtStart);
setEntityRatingSupport(serverId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
'error',
);
} finally {
userMetadataMutationRef.current = false;
}
queueEntityRating('album', albumId, rating, album.album.serverId ?? serverId);
setAlbum(cur =>
cur && cur.album.id === albumId
? { ...cur, album: { ...cur.album, userRating: rating } }
: cur,
);
userMetadataMutationRef.current = false;
};
const handleBio = async () => {
@@ -289,7 +272,6 @@ const handleShuffleAll = () => {
const toggleStar = async () => {
if (!album) return;
const wasStarred = isStarred;
const previousStarred = album.album.starred;
const nextStarred = !wasStarred;
userMetadataMutationRef.current = true;
setStarredOverride(album.album.id, nextStarred);
@@ -300,30 +282,8 @@ const handleShuffleAll = () => {
starred: nextStarred ? (prev.album.starred ?? new Date().toISOString()) : undefined,
},
} : prev);
try {
const meta = {
serverId: serverId || album.album.serverId,
name: album.album.name,
artist: album.album.artist,
artistId: album.album.artistId,
coverArtId: album.album.coverArt,
year: album.album.year,
};
if (wasStarred) await unstar(album.album.id, 'album', meta);
else await star(album.album.id, 'album', meta);
} catch (e) {
console.error('Failed to toggle star', e);
setStarredOverride(album.album.id, wasStarred);
setAlbum(prev => prev ? {
...prev,
album: {
...prev.album,
starred: wasStarred ? previousStarred : undefined,
},
} : prev);
} finally {
userMetadataMutationRef.current = false;
}
queueEntityStar('album', album.album.id, nextStarred, album.album.serverId ?? serverId);
userMetadataMutationRef.current = false;
};
const toggleSongStar = (song: SubsonicSong, e: React.MouseEvent) => {
+23 -12
View File
@@ -1,4 +1,4 @@
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl';
import { resolveAlbum } from '@/features/offline';
import { songToTrack } from '@/lib/media/songToTrack';
import { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react';
@@ -59,6 +59,8 @@ import {
emitAlbumBrowseDebug,
} from '@/lib/library/albumBrowseDebug';
import { librarySelectionForServer } from '@/lib/api/subsonicClient';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
type SortType = AlbumBrowseSort;
@@ -73,6 +75,8 @@ export default function Albums() {
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId || serverId;
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
useLayoutEffect(() => {
@@ -90,7 +94,7 @@ export default function Albums() {
const scrollSnapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
const restoreDisplayCountRef = useRef<number | undefined>(
peekAlbumBrowseScrollRestore(serverId, 'albums')?.displayCount,
peekAlbumBrowseScrollRestore(`${serverId}\0${browseScope.fingerprint}`, 'albums')?.displayCount,
);
const {
@@ -108,14 +112,16 @@ export default function Albums() {
setStarredOnly,
losslessOnly,
setLosslessOnly,
} = useAlbumBrowseFilters(serverId, scrollSnapshotRef);
} = useAlbumBrowseFilters(`${serverId}\0${browseScope.fingerprint}`, scrollSnapshotRef);
const albumsSearchQuery = useScopedBrowseSearchQuery('albums');
const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch(
albumsSearchQuery,
indexEnabled,
serverId,
browseServerId,
losslessOnly,
browseScope.pairs,
browseScope.multiServer,
);
const {
@@ -126,7 +132,7 @@ export default function Albums() {
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const browseData = useAlbumBrowseData({
serverId,
serverId: browseServerId,
indexEnabled,
musicLibraryFilterVersion,
sort,
@@ -142,6 +148,9 @@ export default function Albums() {
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
restoreDisplayCount: restoreDisplayCountRef.current,
scopePairs: browseScope.pairs,
scopeFingerprint: browseScope.fingerprint,
localOnly: browseScope.multiServer,
});
const textSearchActive = textSearchAlbums != null;
@@ -280,7 +289,7 @@ export default function Albums() {
resetSelection();
};
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(libraryEntityKey(a)));
const enqueue = usePlayerStore(state => state.enqueue);
const handleEnqueueSelected = async () => {
@@ -288,7 +297,7 @@ export default function Albums() {
try {
// Parallel album resolves — Navidrome handles concurrent requests fine.
const results = await Promise.all(
selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
selectedAlbums.map(a => resolveAlbum(a.serverId ?? serverId, a.id).catch(() => null)),
);
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
if (tracks.length > 0) {
@@ -314,7 +323,8 @@ export default function Albums() {
const downloadId = crypto.randomUUID();
const filename = `${sanitizeFilename(album.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(album.id);
const ownerServerId = album.serverId ?? serverId;
const url = buildDownloadUrlForServer(ownerServerId, album.id);
start(downloadId, filename);
try {
await downloadZip({ id: downloadId, url, destPath });
@@ -332,9 +342,10 @@ export default function Albums() {
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await resolveAlbum(serverId, album.id);
const ownerServerId = album.serverId ?? serverId;
const detail = await resolveAlbum(ownerServerId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, ownerServerId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
@@ -550,7 +561,7 @@ export default function Albums() {
<div ref={gridMeasureRef}>
<VirtualCardGrid
items={displayAlbums}
itemKey={(a, _i) => a.id}
itemKey={(a, _i) => libraryEntityKey(a)}
rowVariant="album"
disableVirtualization={albumBrowsePlainLayout}
layoutSignal={displayAlbums.length}
@@ -566,7 +577,7 @@ export default function Albums() {
observeScrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
selected={selectedIds.has(libraryEntityKey(a))}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
+12 -4
View File
@@ -9,6 +9,8 @@ import { useAuthStore } from '@/store/authStore';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { albumGridWarmCovers } from '@/cover/layoutSizes';
import { VirtualCardGrid } from '@/ui/VirtualCardGrid';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
import { runLocalBrowseAlbums } from '@/lib/library/browseTextSearch';
export default function LabelAlbums() {
const { t } = useTranslation();
@@ -18,6 +20,8 @@ export default function LabelAlbums() {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId;
useEffect(() => {
if (!name) return;
@@ -26,21 +30,25 @@ export default function LabelAlbums() {
setLoading(true);
// Search for the label name and ask for a large number of albums
search(name, { albumCount: 200, artistCount: 0, songCount: 0 })
const loadAlbums = browseScope.multiServer
? runLocalBrowseAlbums(browseServerId, name, 200, false, browseScope.pairs)
.then(rows => rows ?? [])
: search(name, { albumCount: 200, artistCount: 0, songCount: 0 }).then(res => res.albums);
loadAlbums
.then(res => {
// Filter out albums that don't match the record label exactly if possible,
// to avoid unrelated search hits. We do case-insensitive comparison.
const matches = res.albums.filter(a =>
const matches = res.filter(a =>
a.recordLabel?.toLowerCase() === name.toLowerCase()
);
// Fallback: if Navidrome's search doesn't return the exact label in the recordLabel field
// (or it's not indexed exactly as typed), just show all album matches
// as a decent best-effort if our strict filter yields nothing.
setAlbums(matches.length > 0 ? matches : res.albums);
setAlbums(matches.length > 0 ? matches : res);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [name, musicLibraryFilterVersion]);
}, [name, musicLibraryFilterVersion, browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId]);
return (
<div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}>
+26 -12
View File
@@ -1,4 +1,4 @@
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl';
import { resolveAlbum } from '@/features/offline';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
@@ -13,6 +13,7 @@ import { useDownloadModalStore } from '@/features/offline';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useZipDownloadStore } from '@/features/offline';
import { useRangeSelection } from '@/lib/hooks/useRangeSelection';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
import { useMainstageInpageHeaderTight } from '@/lib/hooks/useMainstageInpageHeaderTight';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { showToast } from '@/lib/dom/toast';
@@ -39,6 +40,7 @@ import {
sortSubsonicAlbums,
type AlbumBrowseSort,
} from '@/lib/library/browseTextSearch';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
/** Local index page size — SQLite is cheap; larger pages than the network walk. */
const LOCAL_PAGE_SIZE = 30;
@@ -59,6 +61,8 @@ export default function LosslessAlbums() {
const auth = useAuthStore();
const activeServerId = useAuthStore(s => s.activeServerId);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId || serverId;
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
const setBrowseSort = useAlbumBrowseSessionStore(s => s.setSort);
@@ -80,7 +84,7 @@ export default function LosslessAlbums() {
}, [albums, sort, useLocalIndex]);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(libraryEntityKey(a)));
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
@@ -125,17 +129,18 @@ export default function LosslessAlbums() {
const loadMoreLocal = useCallback(async () => {
const data = await runLocalAlbumBrowsePage(
serverId,
browseServerId,
sort,
localOffset.current,
LOCAL_PAGE_SIZE,
undefined,
true,
browseScope.pairs,
);
if (data == null) return null;
localOffset.current += data.length;
return { albums: data, hasMore: data.length === LOCAL_PAGE_SIZE };
}, [serverId, sort]);
}, [browseScope.pairs, browseServerId, sort]);
const loadMore = useCallback(async () => {
if (inFlight.current || useLocalIndex === null) return;
@@ -187,12 +192,13 @@ export default function LosslessAlbums() {
try {
if (indexEnabled && serverId) {
const data = await runLocalAlbumBrowsePage(
serverId,
browseServerId,
sort,
0,
LOCAL_PAGE_SIZE,
undefined,
true,
browseScope.pairs,
);
if (cancelled) return;
if (data != null) {
@@ -205,6 +211,12 @@ export default function LosslessAlbums() {
}
if (cancelled) return;
if (browseScope.multiServer) {
setUseLocalIndex(true);
setAlbums([]);
setHasMore(false);
return;
}
setUseLocalIndex(false);
const page = await loadMoreNetwork(albums => {
if (!cancelled) setAlbums(prev => [...prev, ...albums]);
@@ -224,7 +236,7 @@ export default function LosslessAlbums() {
})();
return () => { cancelled = true; };
}, [activeServerId, indexEnabled, loadMoreNetwork, serverId, sort]);
}, [activeServerId, browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId, indexEnabled, loadMoreNetwork, serverId, sort]);
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: hasMore && useLocalIndex !== null,
@@ -238,7 +250,7 @@ export default function LosslessAlbums() {
if (selectedAlbums.length === 0) return;
try {
const results = await Promise.all(
selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
selectedAlbums.map(a => resolveAlbum(a.serverId ?? serverId, a.id).catch(() => null)),
);
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
if (tracks.length > 0) {
@@ -255,9 +267,10 @@ export default function LosslessAlbums() {
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await resolveAlbum(serverId, album.id);
const ownerServerId = album.serverId ?? serverId;
const detail = await resolveAlbum(ownerServerId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, ownerServerId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
@@ -277,7 +290,8 @@ export default function LosslessAlbums() {
const downloadId = crypto.randomUUID();
const filename = `${sanitizeFilename(album.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(album.id);
const ownerServerId = album.serverId ?? serverId;
const url = buildDownloadUrlForServer(ownerServerId, album.id);
start(downloadId, filename);
try {
await downloadZip({ id: downloadId, url, destPath });
@@ -376,7 +390,7 @@ export default function LosslessAlbums() {
<>
<VirtualCardGrid
items={displayAlbums}
itemKey={(a, _i) => a.id}
itemKey={(a, _i) => libraryEntityKey(a)}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={displayAlbums.length}
@@ -388,7 +402,7 @@ export default function LosslessAlbums() {
observeScrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
linkQuery={LOSSLESS_MODE_QUERY}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
selected={selectedIds.has(libraryEntityKey(a))}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
+33 -14
View File
@@ -14,6 +14,10 @@ import { useLongPressAction } from '@/lib/hooks/useLongPressAction';
import { LongPressWaveOverlay } from '@/ui/LongPressWaveOverlay';
import { useTranslation } from 'react-i18next';
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
import { libraryScopeMostPlayedAlbums } from '@/lib/api/library';
import { albumToAlbum } from '@/lib/library/advancedSearchLocal';
import { appendServerQuery } from '@/lib/navigation/detailServerScope';
const PAGE_SIZE = 50;
@@ -60,11 +64,11 @@ function formatPlays(n: number, t: ReturnType<typeof import('react-i18next').use
/** Most-played list row cover layout px. */
const MOST_PLAYED_COVER_CSS_PX = 80;
function MostPlayedPlayButton({ albumId }: { albumId: string }) {
function MostPlayedPlayButton({ albumId, serverId }: { albumId: string; serverId?: string }) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(albumId),
onLongPress: () => playAlbumShuffled(albumId),
onShortPress: () => playAlbum(albumId, { serverId }),
onLongPress: () => playAlbumShuffled(albumId, { serverId }),
});
return (
@@ -90,13 +94,16 @@ export default function MostPlayed() {
const navigate = useNavigate();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const activeServerId = useAuthStore(s => s.activeServerId);
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId || activeServerId || '';
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const handleEnqueueAlbum = useCallback(async (albumId: string) => {
if (!activeServerId) return;
const handleEnqueueAlbum = useCallback(async (albumId: string, ownerServerId?: string) => {
const resolvedServerId = ownerServerId ?? activeServerId;
if (!resolvedServerId) return;
try {
const data = await resolveAlbum(activeServerId, albumId);
const data = await resolveAlbum(resolvedServerId, albumId);
if (!data) return;
enqueue(data.songs.map(songToTrack));
} catch {
@@ -118,7 +125,13 @@ export default function MostPlayed() {
setAlbums([]);
setHasMore(true);
try {
const result = await getAlbumList('frequent', PAGE_SIZE, 0);
const result = browseScope.pairs.length
? (await libraryScopeMostPlayedAlbums(browseServerId, {
scopes: browseScope.pairs,
limit: PAGE_SIZE,
offset: 0,
})).map(row => ({ ...albumToAlbum(row.album), playCount: row.playCount }))
: await getAlbumList('frequent', PAGE_SIZE, 0);
setAlbums(result);
setHasMore(result.length === PAGE_SIZE);
} catch { /* ignore: best-effort */ }
@@ -127,7 +140,7 @@ export default function MostPlayed() {
// reads the active library filter internally, so `load` must refresh (and the
// mount effect re-run) when that version bumps even though it is unused here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [musicLibraryFilterVersion]);
}, [musicLibraryFilterVersion, browseScope.fingerprint, browseScope.pairs, browseServerId]);
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -137,7 +150,13 @@ export default function MostPlayed() {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
try {
const result = await getAlbumList('frequent', PAGE_SIZE, albums.length);
const result = browseScope.pairs.length
? (await libraryScopeMostPlayedAlbums(browseServerId, {
scopes: browseScope.pairs,
limit: PAGE_SIZE,
offset: albums.length,
})).map(row => ({ ...albumToAlbum(row.album), playCount: row.playCount }))
: await getAlbumList('frequent', PAGE_SIZE, albums.length);
setAlbums(prev => [...prev, ...result]);
setHasMore(result.length === PAGE_SIZE);
} catch { /* ignore: best-effort */ }
@@ -190,7 +209,7 @@ export default function MostPlayed() {
<button
key={artist.id}
className="mp-artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
onClick={() => navigate({ pathname: `/artist/${artist.id}`, search: appendServerQuery(undefined, browseServerId) })}
onContextMenu={e => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
@@ -234,7 +253,7 @@ export default function MostPlayed() {
<div
key={album.id}
className="mp-album-row"
onClick={() => navigate(`/album/${album.id}`)}
onClick={() => navigate({ pathname: `/album/${album.id}`, search: appendServerQuery(undefined, album.serverId) })}
onContextMenu={e => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
@@ -263,16 +282,16 @@ export default function MostPlayed() {
</div>
<span
className="mp-album-artist truncate track-artist-link"
onClick={e => { e.stopPropagation(); navigate(`/artist/${album.artistId}`); }}
onClick={e => { e.stopPropagation(); navigate({ pathname: `/artist/${album.artistId}`, search: appendServerQuery(undefined, album.serverId) }); }}
>
{albumArtistDisplayName(album)}
</span>
</div>
<div className="mp-album-actions">
<MostPlayedPlayButton albumId={album.id} />
<MostPlayedPlayButton albumId={album.id} serverId={album.serverId} />
<button
className="mp-album-action-btn"
onClick={e => { e.stopPropagation(); void handleEnqueueAlbum(album.id); }}
onClick={e => { e.stopPropagation(); void handleEnqueueAlbum(album.id, album.serverId); }}
data-tooltip={t('contextMenu.enqueueAlbum')}
data-tooltip-pos="top"
aria-label={t('contextMenu.enqueueAlbum')}
+56 -15
View File
@@ -1,4 +1,4 @@
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl';
import { getAlbumsByGenre } from '@/lib/api/subsonicGenres';
import { getAlbumList } from '@/lib/api/subsonicLibrary';
import { resolveAlbum } from '@/features/offline';
@@ -19,6 +19,7 @@ import { join } from '@tauri-apps/api/path';
import { showToast } from '@/lib/dom/toast';
import { useZipDownloadStore } from '@/features/offline';
import { useRangeSelection } from '@/lib/hooks/useRangeSelection';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { useMainstageInpageHeaderTight } from '@/lib/hooks/useMainstageInpageHeaderTight';
import { albumGridWarmCovers } from '@/cover/layoutSizes';
@@ -39,6 +40,9 @@ import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeader
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { filterAlbumsByGenres } from '@/lib/library/albumBrowseFilters';
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
import { libraryAdvancedSearch } from '@/lib/api/library';
import { albumToAlbum } from '@/lib/library/advancedSearchLocal';
const PAGE_SIZE = 30;
@@ -58,6 +62,9 @@ export default function NewReleases() {
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId || serverId;
const sessionScopeKey = `${serverId}\0${browseScope.fingerprint}`;
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
@@ -71,14 +78,17 @@ export default function NewReleases() {
setSelectedGenres,
initialAlbums,
initialHasMore,
} = useAlbumGridBrowseFilters(serverId, 'new-releases', scrollSnapshotRef, gridSnapshotRef);
} = useAlbumGridBrowseFilters(sessionScopeKey, 'new-releases', scrollSnapshotRef, gridSnapshotRef);
const restoringSessionRef = useRef(initialAlbums != null);
const newReleasesSearchQuery = useScopedBrowseSearchQuery('newReleases');
const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch(
newReleasesSearchQuery,
indexEnabled,
serverId,
browseServerId,
false,
browseScope.pairs,
browseScope.multiServer,
);
const textSearchActive = textSearchAlbums != null;
const scopedSearchQuery = newReleasesSearchQuery.trim();
@@ -133,7 +143,7 @@ export default function NewReleases() {
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(libraryEntityKey(a)));
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
@@ -145,7 +155,8 @@ export default function NewReleases() {
const downloadId = crypto.randomUUID();
const filename = `${sanitizeFilename(album.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(album.id);
const ownerServerId = album.serverId ?? serverId;
const url = buildDownloadUrlForServer(ownerServerId, album.id);
start(downloadId, filename);
try {
await downloadZip({ id: downloadId, url, destPath });
@@ -163,9 +174,10 @@ export default function NewReleases() {
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await resolveAlbum(serverId, album.id);
const ownerServerId = album.serverId ?? serverId;
const detail = await resolveAlbum(ownerServerId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, ownerServerId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
@@ -177,17 +189,46 @@ export default function NewReleases() {
const load = useCallback(async (offset: number, append = false) => {
await runLoad(async () => {
const data = await getAlbumList('newest', PAGE_SIZE, offset);
const local = await libraryAdvancedSearch({
serverId: browseServerId,
libraryScopes: browseScope.pairs,
entityTypes: ['album'],
sort: [
{ field: 'synced', dir: 'desc' },
{ field: 'year', dir: 'desc' },
{ field: 'name', dir: 'asc' },
],
limit: PAGE_SIZE,
offset,
skipTotals: true,
}).then(response => response.albums.map(albumToAlbum)).catch(() => null);
const data = local ?? (browseScope.multiServer ? [] : await getAlbumList('newest', PAGE_SIZE, offset));
if (append) setAlbums(prev => [...prev, ...data]);
else setAlbums(data);
setHasMore(data.length === PAGE_SIZE);
});
}, [runLoad]);
}, [runLoad, browseScope.multiServer, browseScope.pairs, browseServerId]);
const loadFiltered = useCallback(async (genres: string[]) => {
setLoading(true);
try {
setAlbums(await fetchByGenres(genres));
const local = await Promise.all(genres.map(genre => libraryAdvancedSearch({
serverId: browseServerId,
libraryScopes: browseScope.pairs,
entityTypes: ['album'],
filters: [{ field: 'genre', op: 'eq', value: genre }],
sort: [
{ field: 'synced', dir: 'desc' },
{ field: 'year', dir: 'desc' },
{ field: 'name', dir: 'asc' },
],
limit: 500,
offset: 0,
skipTotals: true,
}))).then(responses => dedupeById(
responses.flatMap(response => response.albums.map(albumToAlbum)),
).sort((a, b) => (b.year ?? 0) - (a.year ?? 0))).catch(() => null);
setAlbums(local ?? (browseScope.multiServer ? [] : await fetchByGenres(genres)));
setHasMore(false);
} finally {
setLoading(false);
@@ -196,7 +237,7 @@ export default function NewReleases() {
// reads the active library filter internally); the setters are stable. The
// loader must refresh when that version bumps even though it is unused here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [musicLibraryFilterVersion]);
}, [musicLibraryFilterVersion, browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId]);
useEffect(() => {
if (restoringSessionRef.current || scopedSearchQuery) return;
@@ -220,7 +261,7 @@ export default function NewReleases() {
});
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
serverId,
serverId: sessionScopeKey,
surface: 'new-releases',
scrollBodyEl,
displayAlbumsLength: displayAlbums.length,
@@ -234,7 +275,7 @@ export default function NewReleases() {
scrollSnapshotRef,
getScrollRoot,
isScrollRestorePending,
resetKey: [newReleasesSearchQuery, selectedGenres.join('\u0001'), serverId].join('|'),
resetKey: [newReleasesSearchQuery, selectedGenres.join('\u0001'), browseScope.fingerprint].join('|'),
});
useLayoutEffect(() => {
@@ -316,7 +357,7 @@ export default function NewReleases() {
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
<VirtualCardGrid
items={displayAlbums}
itemKey={(a, _i) => a.id}
itemKey={(a, _i) => libraryEntityKey(a)}
rowVariant="album"
disableVirtualization={albumBrowsePlainLayout}
layoutSignal={displayAlbums.length}
@@ -327,7 +368,7 @@ export default function NewReleases() {
album={a}
observeScrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
selected={selectedIds.has(libraryEntityKey(a))}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
+44 -17
View File
@@ -1,4 +1,4 @@
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl';
import { getAlbumsByGenre } from '@/lib/api/subsonicGenres';
import { getAlbumList } from '@/lib/api/subsonicLibrary';
import { resolveAlbum } from '@/features/offline';
@@ -23,6 +23,7 @@ import { join } from '@tauri-apps/api/path';
import { showToast } from '@/lib/dom/toast';
import { useZipDownloadStore } from '@/features/offline';
import { useRangeSelection } from '@/lib/hooks/useRangeSelection';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { albumGridWarmCovers, COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
import {
@@ -38,6 +39,8 @@ import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrow
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters';
import { readAlbumBrowseRestore } from '@/lib/navigation/albumDetailNavigation';
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
import type { LibraryScopePair } from '@/lib/api/library';
const ALBUM_COUNT = 30;
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
@@ -58,17 +61,20 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
}
/** Shared fetch logic — used by both `load` and the background reserve fill. */
async function doFetchRandomAlbums(genres: string[]): Promise<SubsonicAlbum[]> {
async function doFetchRandomAlbums(
genres: string[],
scope?: { serverId: string; pairs: LibraryScopePair[]; multiServer: boolean },
): Promise<SubsonicAlbum[]> {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMixActive = mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
const serverId = useAuthStore.getState().activeServerId ?? '';
const serverId = scope?.serverId ?? useAuthStore.getState().activeServerId ?? '';
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
if (genres.length === 0 && indexEnabled && serverId) {
// Local path: SQLite ORDER BY RANDOM() LIMIT N — no network, effectively instant.
const local = await runLocalRandomAlbums(serverId, randomSize);
const local = await runLocalRandomAlbums(serverId, randomSize, scope?.pairs);
if (local && local.length > 0) {
return (await filterAlbumsByMixRatings(local, mixCfg)).slice(0, ALBUM_COUNT);
}
@@ -76,13 +82,14 @@ async function doFetchRandomAlbums(genres: string[]): Promise<SubsonicAlbum[]> {
if (genres.length > 0 && indexEnabled && serverId) {
// Genre path: local index union + JS shuffle (avoids per-genre network requests).
const allLocal = await runLocalAlbumsByGenres(serverId, genres, 'alphabeticalByName', GENRE_UNION_PREFILTER_CAP);
const allLocal = await runLocalAlbumsByGenres(serverId, genres, 'alphabeticalByName', GENRE_UNION_PREFILTER_CAP, false, scope?.pairs);
if (allLocal && allLocal.length > 0) {
const pool = shuffleArray(dedupeById(allLocal)).slice(0, GENRE_UNION_PREFILTER_CAP);
return (await filterAlbumsByMixRatings(pool, mixCfg)).slice(0, ALBUM_COUNT);
}
}
if (scope?.multiServer) return [];
// Network fallback when local index is unavailable or returned nothing.
return genres.length > 0
? fetchByGenres(genres)
@@ -123,11 +130,15 @@ function takeReserve(filterId: string): SubsonicAlbum[] | null {
* Covers are warmed lazily via primeAlbumCoversForDisplay when the reserve is
* actually consumed.
*/
async function fillReserve(filterId: string, genres: string[]): Promise<void> {
async function fillReserve(
filterId: string,
genres: string[],
scope?: { serverId: string; pairs: LibraryScopePair[]; multiServer: boolean },
): Promise<void> {
if (_reserveFilling) return;
_reserveFilling = true;
try {
const albums = await doFetchRandomAlbums(genres);
const albums = await doFetchRandomAlbums(genres, scope);
_nextReserve = { filterId, albums };
} catch {
// Network or cache failure — next Refresh falls back to a fresh fetch.
@@ -145,6 +156,9 @@ export default function RandomAlbums() {
const mixMinRatingAlbum = auth.mixMinRatingAlbum;
const mixMinRatingArtist = auth.mixMinRatingArtist;
const serverId = auth.activeServerId ?? '';
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId || serverId;
const sessionScopeKey = `${serverId}\0${browseScope.fingerprint}`;
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
@@ -156,7 +170,7 @@ export default function RandomAlbums() {
selectedGenres,
setSelectedGenres,
initialAlbums,
} = useAlbumGridBrowseFilters(serverId, 'random-albums', scrollSnapshotRef, gridSnapshotRef);
} = useAlbumGridBrowseFilters(sessionScopeKey, 'random-albums', scrollSnapshotRef, gridSnapshotRef);
const restoringSessionRef = useRef(initialAlbums != null);
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
@@ -173,7 +187,7 @@ export default function RandomAlbums() {
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const selectedAlbums = albums.filter(a => selectedIds.has(libraryEntityKey(a)));
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
@@ -185,7 +199,8 @@ export default function RandomAlbums() {
const downloadId = crypto.randomUUID();
const filename = `${sanitizeFilename(album.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(album.id);
const ownerServerId = album.serverId ?? serverId;
const url = buildDownloadUrlForServer(ownerServerId, album.id);
start(downloadId, filename);
try {
await downloadZip({ id: downloadId, url, destPath });
@@ -203,9 +218,10 @@ export default function RandomAlbums() {
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await resolveAlbum(serverId, album.id);
const ownerServerId = album.serverId ?? serverId;
const detail = await resolveAlbum(ownerServerId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, ownerServerId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
@@ -229,12 +245,20 @@ export default function RandomAlbums() {
await primeAlbumCoversForDisplay(reserved, COVER_DENSE_GRID_MIN_CELL_CSS_PX);
setAlbums(reserved);
} else {
const data = await doFetchRandomAlbums(genres);
const data = await doFetchRandomAlbums(genres, {
serverId: browseServerId,
pairs: browseScope.pairs,
multiServer: browseScope.multiServer,
});
await primeAlbumCoversForDisplay(data, COVER_DENSE_GRID_MIN_CELL_CSS_PX);
setAlbums(data);
}
// Pre-fetch + disk-warm the next batch so the next Refresh is instant.
void fillReserve(filterId, genres);
void fillReserve(filterId, genres, {
serverId: browseServerId,
pairs: browseScope.pairs,
multiServer: browseScope.multiServer,
});
} catch (e) {
console.error(e);
} finally {
@@ -246,6 +270,9 @@ export default function RandomAlbums() {
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
browseScope.multiServer,
browseScope.pairs,
browseServerId,
]);
const loadRef = useRef(load);
@@ -276,7 +303,7 @@ export default function RandomAlbums() {
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, albums.length);
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
serverId,
serverId: sessionScopeKey,
surface: 'random-albums',
scrollBodyEl,
displayAlbumsLength: albums.length,
@@ -377,7 +404,7 @@ export default function RandomAlbums() {
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
itemKey={(a, _i) => libraryEntityKey(a)}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
@@ -388,7 +415,7 @@ export default function RandomAlbums() {
album={a}
observeScrollRootId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
selected={selectedIds.has(libraryEntityKey(a))}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
ensurePriority="high"
@@ -53,7 +53,7 @@ export const DEFAULT_ALBUM_BROWSE_RETURN_FILTERS: AlbumBrowseReturnFilters = {
interface AlbumBrowseSessionStore {
/** Session-lifetime sort per server (sidebar ↔ album detail). */
/** Session-lifetime sort per scope key (server id for legacy single-server callers). */
sortByServer: Record<string, AlbumBrowseSort>;
/** Stashed when leaving a browse surface → album detail; consumed after scroll restore. */
returnStashByKey: Record<string, AlbumBrowseReturnFilters>;
@@ -67,8 +67,8 @@ interface AlbumBrowseSessionStore {
peekReturnStash: (serverId: string, surface: AlbumBrowseSurface) => AlbumBrowseReturnFilters | null;
}
function returnStashKey(serverId: string, surface: AlbumBrowseSurface): string {
return `${serverId}:${surface}`;
function returnStashKey(scopeKey: string, surface: AlbumBrowseSurface): string {
return JSON.stringify([scopeKey, surface]);
}
function genreDetailStashKey(serverId: string, genreName: string): string {
@@ -4,6 +4,7 @@ import { isOfflineBrowseActive } from '@/features/offline';
import { loadOfflineAlbumCatalogChunk } from '@/features/offline';
import type { AlbumBrowseQuery } from '@/lib/library/albumBrowseTypes';
import { fetchLocalAlbumCatalogChunk } from '@/lib/library/albumBrowseLoad';
import type { LibraryScopePair } from '@/lib/api/library';
export type AlbumCatalogChunk = {
albums: SubsonicAlbum[];
@@ -30,6 +31,7 @@ export async function fetchAlbumBrowseCatalogChunk(
offset: number,
chunkSize: number,
starredOverrides: Record<string, boolean>,
scopePairs?: LibraryScopePair[],
): Promise<AlbumCatalogChunk | null> {
if (isOfflineBrowseActive()) {
return loadOfflineAlbumCatalogChunk(
@@ -40,5 +42,5 @@ export async function fetchAlbumBrowseCatalogChunk(
starredOverrides,
);
}
return fetchLocalAlbumCatalogChunk(serverId, indexEnabled, query, offset, chunkSize);
return fetchLocalAlbumCatalogChunk(serverId, indexEnabled, query, offset, chunkSize, scopePairs);
}
+3 -2
View File
@@ -3,6 +3,7 @@ import React, { useRef, useState, useEffect, useLayoutEffect } from 'react';
import ArtistCardLocal from '@/features/artist/components/ArtistCardLocal';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
interface Props {
title: string;
@@ -29,7 +30,7 @@ export default function ArtistRow({
const [showRight, setShowRight] = useState(true);
const scrollRestoreTargetRef = useRef(restoreScrollLeft);
const scrollRestoreDoneRef = useRef(false);
const rowResetKey = artists[0]?.id ?? '';
const rowResetKey = artists[0] ? libraryEntityKey(artists[0]) : '';
const handleScroll = () => {
if (!scrollRef.current) return;
@@ -123,7 +124,7 @@ export default function ArtistRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => (
<ArtistCardLocal
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
key={libraryEntityKey(a)}
artist={a}
linkQuery={artistLinkQuery}
libraryResolve={libraryResolve}
@@ -6,6 +6,7 @@ import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
import { ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { VirtualCardGrid } from '@/ui/VirtualCardGrid';
import { ArtistCardAvatar } from '@/features/artist/components/ArtistAvatars';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
interface TileProps {
artist: SubsonicArtist;
@@ -13,8 +14,8 @@ interface TileProps {
selectedIds: Set<string>;
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
toggleSelect: (artist: SubsonicArtist) => void;
onOpenArtist: (artist: SubsonicArtist) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -24,12 +25,12 @@ type TilePropsShared = Omit<TileProps, 'artist'>;
function ArtistGridTile({ artist, ...rest }: TileProps) {
return (
<div
className={`artist-card${rest.selectionMode ? ' artist-card--selectable' : ''}${rest.selectionMode && rest.selectedIds.has(artist.id) ? ' artist-card--selected' : ''}`}
className={`artist-card${rest.selectionMode ? ' artist-card--selectable' : ''}${rest.selectionMode && rest.selectedIds.has(libraryEntityKey(artist)) ? ' artist-card--selected' : ''}`}
onClick={() => {
if (rest.selectionMode) {
rest.toggleSelect(artist.id);
rest.toggleSelect(artist);
} else {
rest.onOpenArtist(artist.id);
rest.onOpenArtist(artist);
}
}}
onContextMenu={(e) => {
@@ -42,8 +43,8 @@ function ArtistGridTile({ artist, ...rest }: TileProps) {
}}
>
{rest.selectionMode && (
<div className={`artist-card-select-check${rest.selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
{rest.selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
<div className={`artist-card-select-check${rest.selectedIds.has(libraryEntityKey(artist)) ? ' artist-card-select-check--on' : ''}`}>
{rest.selectedIds.has(libraryEntityKey(artist)) && <Check size={14} strokeWidth={3} />}
</div>
)}
<ArtistCardAvatar artist={artist} showImages={rest.showArtistImages} />
@@ -67,8 +68,8 @@ interface Props {
selectedIds: Set<string>;
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
toggleSelect: (artist: SubsonicArtist) => void;
onOpenArtist: (artist: SubsonicArtist) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -104,14 +105,14 @@ export function ArtistsGridView({
<VirtualCardGrid
key={layoutKey}
items={visible}
itemKey={(artist) => artist.id}
itemKey={libraryEntityKey}
rowVariant="artist"
disableVirtualization={disableVirtualization}
layoutSignal={visible.length}
scrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
wrapClassName={disableVirtualization ? 'album-grid-wrap album-grid-wrap--plain' : 'album-grid-wrap'}
renderItem={artist => (
<ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} />
<ArtistGridTile key={libraryEntityKey(artist)} artist={artist} {...tilePropsShared} />
)}
/>
);
@@ -5,6 +5,7 @@ import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
import { OTHER_BUCKET, type ArtistListFlatRow } from '@/features/artist/utils/artistsHelpers';
import { ArtistRowAvatar } from '@/features/artist/components/ArtistAvatars';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
interface RowProps {
artist: SubsonicArtist;
@@ -12,8 +13,8 @@ interface RowProps {
selectedIds: Set<string>;
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
toggleSelect: (artist: SubsonicArtist) => void;
onOpenArtist: (artist: SubsonicArtist) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -32,12 +33,12 @@ function ArtistListRow({
return (
<button
type="button"
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
className={`artist-row${selectionMode && selectedIds.has(libraryEntityKey(artist)) ? ' selected' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(artist.id);
toggleSelect(artist);
} else {
onOpenArtist(artist.id);
onOpenArtist(artist);
}
}}
onContextMenu={(e) => {
@@ -48,8 +49,8 @@ function ArtistListRow({
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}
}}
id={`artist-${artist.id}`}
style={selectionMode && selectedIds.has(artist.id) ? {
id={`artist-${encodeURIComponent(libraryEntityKey(artist))}`}
style={selectionMode && selectedIds.has(libraryEntityKey(artist)) ? {
background: 'var(--accent-dim)',
color: 'var(--accent)',
} : {}}
@@ -77,8 +78,8 @@ interface Props {
selectedIds: Set<string>;
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
toggleSelect: (artist: SubsonicArtist) => void;
onOpenArtist: (artist: SubsonicArtist) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -126,7 +127,7 @@ export function ArtistsListView({
<h3 className="letter-heading">{letter === OTHER_BUCKET ? t('artists.other') : letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => (
<ArtistListRow key={artist.id} artist={artist} {...rowCommonProps} />
<ArtistListRow key={libraryEntityKey(artist)} artist={artist} {...rowCommonProps} />
))}
</div>
</div>
@@ -6,11 +6,22 @@ import { useAuthStore } from '@/store/authStore';
const tryLoadArtistDetailMultiScopeMock = vi.fn();
const librarySelectionForServerMock = vi.fn();
let browseScope = {
pairs: [{ serverId: 'srv-1', libraryId: 'lib-a' }, { serverId: 'srv-2', libraryId: null }],
fingerprint: 'multi',
anchorServerId: 'srv-1',
configuredServerIds: ['srv-1', 'srv-2'],
multiServer: true,
};
vi.mock('@/features/artist/hooks/loadArtistDetailMultiScope', () => ({
vi.mock('@/lib/library/loadArtistDetailMultiScope', () => ({
tryLoadArtistDetailMultiScope: (...args: unknown[]) => tryLoadArtistDetailMultiScopeMock(...args),
}));
vi.mock('@/store/useBrowseLibraryScope', () => ({
useBrowseLibraryScope: () => browseScope,
}));
vi.mock('@/lib/api/subsonicClient', async importOriginal => {
const actual = await importOriginal<typeof import('@/lib/api/subsonicClient')>();
return {
@@ -45,6 +56,13 @@ describe('useArtistDetailData — multi-library selection', () => {
beforeEach(() => {
tryLoadArtistDetailMultiScopeMock.mockReset();
librarySelectionForServerMock.mockReset();
browseScope = {
pairs: [{ serverId: 'srv-1', libraryId: 'lib-a' }, { serverId: 'srv-2', libraryId: null }],
fingerprint: 'multi',
anchorServerId: 'srv-1',
configuredServerIds: ['srv-1', 'srv-2'],
multiServer: true,
};
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(getArtistInfo).mockResolvedValue({} as Awaited<ReturnType<typeof getArtistInfo>>);
vi.mocked(search).mockResolvedValue({ songs: [], albums: [], artists: [] });
@@ -73,7 +91,7 @@ describe('useArtistDetailData — multi-library selection', () => {
const { result } = renderHook(() => useArtistDetailData('art-1'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'art-1');
expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'art-1', browseScope.pairs);
expect(getArtistForServer).not.toHaveBeenCalled();
expect(getArtist).not.toHaveBeenCalled();
expect(result.current.artist).toMatchObject({ id: 'art-1', name: 'Merged' });
@@ -92,12 +110,19 @@ describe('useArtistDetailData — multi-library selection', () => {
const { result } = renderHook(() => useArtistDetailData('art-1'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'art-1');
expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalledWith('srv-1', 'art-1', browseScope.pairs);
expect(getArtistForServer).not.toHaveBeenCalled();
expect(result.current.albums).toHaveLength(1);
});
it('does not call tryLoadArtistDetailMultiScope when all libraries are selected', async () => {
browseScope = {
pairs: [],
fingerprint: 'single',
anchorServerId: 'srv-1',
configuredServerIds: ['srv-1'],
multiServer: false,
};
librarySelectionForServerMock.mockReturnValue([]);
vi.mocked(getArtistForServer).mockResolvedValue({
artist: { id: 'art-1', name: 'Network' },
@@ -114,6 +139,13 @@ describe('useArtistDetailData — multi-library selection', () => {
});
it('falls back to the local library index when network getArtist fails', async () => {
browseScope = {
pairs: [],
fingerprint: 'single',
anchorServerId: 'srv-1',
configuredServerIds: ['srv-1'],
multiServer: false,
};
// Random Albums links an album-artist id that `getArtist` 404s on, but the
// artist row exists in the local index the album came from → resolve there
// instead of showing "Artist not found".
@@ -134,6 +166,13 @@ describe('useArtistDetailData — multi-library selection', () => {
});
it('shows nothing to resolve when both network and local index miss', async () => {
browseScope = {
pairs: [],
fingerprint: 'single',
anchorServerId: 'srv-1',
configuredServerIds: ['srv-1'],
multiServer: false,
};
librarySelectionForServerMock.mockReturnValue([]);
vi.mocked(getArtistForServer).mockRejectedValue(new Error('artist not found'));
vi.mocked(loadArtistFromLibraryIndex).mockResolvedValue(null);
@@ -145,7 +184,7 @@ describe('useArtistDetailData — multi-library selection', () => {
expect(result.current.artist).toBeNull();
});
it('falls through to getArtist when multi-scope load returns null', async () => {
it('does not fall through to getArtist when multi-server scope load returns null', async () => {
librarySelectionForServerMock.mockReturnValue(['lib-a', 'lib-b']);
tryLoadArtistDetailMultiScopeMock.mockResolvedValue(null);
vi.mocked(getArtistForServer).mockResolvedValue({
@@ -157,7 +196,7 @@ describe('useArtistDetailData — multi-library selection', () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadArtistDetailMultiScopeMock).toHaveBeenCalled();
expect(getArtistForServer).toHaveBeenCalled();
expect(result.current.artist).toMatchObject({ name: 'Fallback' });
expect(getArtistForServer).not.toHaveBeenCalled();
expect(result.current.artist).toBeNull();
});
});
@@ -13,8 +13,8 @@ import { loadArtistFromLocalPlayback, offlineLocalBrowseEnabled } from '@/featur
import { readDetailServerId } from '@/lib/navigation/detailServerScope';
import { runLocalArtistLosslessBrowse } from '@/lib/library/browseTextSearch';
import { isLosslessSuffix } from '@/lib/library/losslessFormats';
import { librarySelectionForServer } from '@/lib/api/subsonicClient';
import { tryLoadArtistDetailMultiScope } from '@/features/artist/hooks/loadArtistDetailMultiScope';
import { tryLoadArtistDetailMultiScope } from '@/lib/library/loadArtistDetailMultiScope';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
export interface UseArtistDetailDataOptions {
/** When true, albums and top tracks are limited to lossless containers (local index preferred). */
@@ -56,6 +56,7 @@ export function useArtistDetailData(
const activeServerId = useAuthStore(s => s.activeServerId);
const [searchParams] = useSearchParams();
const serverId = readDetailServerId(searchParams, activeServerId);
const browseScope = useBrowseLibraryScope();
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const { status: connStatus } = useConnectionStatus();
const audiomuseNavidromeEnabled = useAuthStore(
@@ -94,14 +95,21 @@ export function useArtistDetailData(
setLoading(false);
return;
}
if (serverId && librarySelectionForServer(serverId).length > 0) {
const multi = await tryLoadArtistDetailMultiScope(serverId, id);
if (serverId && browseScope.pairs.length > 0) {
const multi = await tryLoadArtistDetailMultiScope(serverId, id, browseScope.pairs);
if (cancelled) return;
if (multi) {
const scoped = losslessOnly
? filterNetworkArtistToLossless(multi.albums, multi.topSongs)
: { albums: multi.albums, songs: multi.topSongs };
setArtist(multi.artist);
setIsStarred(!!multi.artist.starred);
setAlbums(multi.albums);
setTopSongs(multi.topSongs);
setAlbums(scoped.albums);
setTopSongs(scoped.songs);
setLoading(false);
return;
}
if (browseScope.multiServer) {
setLoading(false);
return;
}
@@ -193,6 +201,9 @@ export function useArtistDetailData(
return () => { cancelled = true; };
}, [
id,
browseScope.fingerprint,
browseScope.multiServer,
browseScope.pairs,
losslessOnly,
musicLibraryFilterVersion,
musicLibrarySelectionByServer,
@@ -204,7 +215,7 @@ export function useArtistDetailData(
]);
useEffect(() => {
if (!id || preferLocalArtist) return;
if (!id || preferLocalArtist || browseScope.multiServer) return;
let cancelled = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -220,10 +231,10 @@ export function useArtistDetailData(
if (!cancelled) setArtistInfoLoading(false);
});
return () => { cancelled = true; };
}, [id, audiomuseNavidromeEnabled, preferLocalArtist]);
}, [id, audiomuseNavidromeEnabled, browseScope.multiServer, preferLocalArtist]);
useEffect(() => {
if (!id || !artist || preferLocalArtist) return;
if (!id || !artist || preferLocalArtist || browseScope.multiServer) return;
const ownAlbumIds = new Set(albums.map(a => a.id));
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -265,7 +276,7 @@ export function useArtistDetailData(
setFeaturedLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums, preferLocalArtist]);
}, [artist?.id, browseScope.multiServer, musicLibraryFilterVersion, losslessOnly, albums, preferLocalArtist]);
const info = infoEntry && infoEntry.id === id ? infoEntry.value : null;
@@ -18,6 +18,7 @@ import {
offlineLocalBrowseEnabled,
} from '@/features/offline';
import { librarySelectionForServer } from '@/lib/api/subsonicClient';
import type { LibraryScopePair } from '@/lib/api/library';
import { scheduleAlbumBrowseBackgroundWork } from '@/lib/library/albumBrowseBackground';
import {
artistBrowseTimed,
@@ -50,6 +51,9 @@ export type UseArtistsBrowseCatalogArgs = {
libraryScopeKey: string;
/** Server `ignoredArticles` for offline letter buckets (Navidrome parity). */
ignoredArticles?: string | null;
scopePairs?: LibraryScopePair[];
scopeFingerprint?: string;
localOnly?: boolean;
};
export function useArtistsBrowseCatalog({
@@ -61,6 +65,9 @@ export function useArtistsBrowseCatalog({
musicLibraryFilterVersion,
libraryScopeKey,
ignoredArticles,
scopePairs,
scopeFingerprint = '',
localOnly = false,
}: UseArtistsBrowseCatalogArgs) {
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
@@ -84,7 +91,7 @@ export function useArtistsBrowseCatalog({
const base = artistBrowseInitialLoadKey(
serverId,
musicLibraryFilterVersion,
libraryScopeKey,
`${libraryScopeKey}\0${scopeFingerprint}`,
creditMode,
letterFilter,
starredOnly,
@@ -95,7 +102,7 @@ export function useArtistsBrowseCatalog({
// resync surfaces renamed/pruned artists without an app restart.
if (!offlineBrowseActive) return artistBrowseOnlineCatalogKey(base, librarySyncRevision);
return `${base}\0${offlineLocalBrowseReloadKey}`;
}, [serverId, musicLibraryFilterVersion, libraryScopeKey, creditMode, letterFilter, starredOnly, offlineBrowseActive, offlineLocalBrowseReloadKey, librarySyncRevision]);
}, [serverId, musicLibraryFilterVersion, libraryScopeKey, scopeFingerprint, creditMode, letterFilter, starredOnly, offlineBrowseActive, offlineLocalBrowseReloadKey, librarySyncRevision]);
useLayoutEffect(() => {
const cached = readArtistBrowseCatalogCache(catalogLoadKey);
@@ -166,8 +173,10 @@ export function useArtistsBrowseCatalog({
serverId,
catalogOffsetRef.current,
ARTIST_CATALOG_CHUNK_SIZE,
creditMode,
letterFilter,
creditMode,
letterFilter,
scopePairs,
starredOnly,
),
{ append, offset: catalogOffsetRef.current, creditMode, letterFilter },
);
@@ -200,7 +209,7 @@ export function useArtistsBrowseCatalog({
setCatalogLoadingMore(false);
}
}
}, [creditMode, ignoredArticles, letterFilter, offlineBrowseActive, serverId]);
}, [creditMode, ignoredArticles, letterFilter, offlineBrowseActive, scopePairs, serverId, starredOnly]);
useEffect(() => {
let cancelled = false;
@@ -287,7 +296,7 @@ export function useArtistsBrowseCatalog({
}
return;
}
if (starredOnly) {
if (starredOnly && !localOnly) {
emitArtistsBrowseDebug('load_branch', { mode: 'starred' });
if (!cancelled && generation === loadGenerationRef.current) {
const starred = await artistBrowseTimed(
@@ -317,7 +326,9 @@ export function useArtistsBrowseCatalog({
0,
ARTIST_BROWSE_BOOTSTRAP_CHUNK,
creditMode,
letterFilter,
letterFilter,
scopePairs,
false,
),
{ creditMode, letterFilter, chunkSize: ARTIST_BROWSE_BOOTSTRAP_CHUNK },
),
@@ -356,7 +367,9 @@ export function useArtistsBrowseCatalog({
tailOffset,
tailSize,
creditMode,
letterFilter,
letterFilter,
scopePairs,
false,
),
{ creditMode, letterFilter, chunkSize: tailSize, offset: tailOffset },
),
@@ -393,6 +406,8 @@ export function useArtistsBrowseCatalog({
ARTIST_CATALOG_CHUNK_SIZE,
creditMode,
letterFilter,
scopePairs,
starredOnly,
),
{ creditMode, letterFilter, chunkSize: ARTIST_CATALOG_CHUNK_SIZE },
),
@@ -413,7 +428,7 @@ export function useArtistsBrowseCatalog({
}
emitArtistsBrowseDebug('slice_fallback', { reason: 'local_chunk_null' });
}
if (!cancelled && generation === loadGenerationRef.current && !indexEnabled) {
if (!cancelled && generation === loadGenerationRef.current && !indexEnabled && !localOnly) {
emitArtistsBrowseDebug('load_branch', { mode: 'network' });
const network = await artistBrowseTimed(
'network_catalog',
@@ -455,7 +470,7 @@ export function useArtistsBrowseCatalog({
return () => {
cancelled = true;
};
}, [catalogLoadKey, creditMode, ignoredArticles, letterFilter, musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
}, [catalogLoadKey, creditMode, ignoredArticles, letterFilter, musicLibraryFilterVersion, indexEnabled, localOnly, offlineBrowseActive, offlineBrowseReloadTs, scopePairs, serverId, starredOnly]);
return {
catalogArtists,
@@ -16,6 +16,7 @@ import {
artistBrowseTimed,
emitArtistsBrowseDebug,
} from '@/lib/library/artistBrowseDebug';
import type { LibraryScopePair } from '@/lib/api/library';
/**
* Debounced artist/composer name search with local-vs-network race when the
@@ -30,6 +31,8 @@ export function useBrowseArtistTextSearch(
surface: LibrarySearchSurface = 'artists_browse',
creditMode: ArtistCreditMode = 'album',
starredOnly = false,
scopePairs?: LibraryScopePair[],
localOnly = false,
) {
const offlineBrowseActive = useOfflineBrowseContext().active;
const [debouncedFilter, setDebouncedFilter] = useState('');
@@ -45,7 +48,7 @@ export function useBrowseArtistTextSearch(
useEffect(() => {
const q = debouncedFilter;
if (starredOnly || !q || !indexEnabled || !serverId) {
if (starredOnly || !q || (!indexEnabled && !localOnly) || !serverId) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTextSearchArtists(null);
@@ -73,11 +76,26 @@ export function useBrowseArtistTextSearch(
emitArtistsBrowseDebug('text_search_done', { source: 'offline', artistCount: artists?.length ?? 0 });
return;
}
if (localOnly) {
const artists = await artistBrowseTimed(
'text_search_local',
() => runLocalBrowseArtists(serverId, q, creditMode, undefined, scopePairs),
{ query: q, creditMode },
);
if (isStale()) return;
setTextSearchArtists(artists ?? []);
setTextSearchLoading(false);
emitArtistsBrowseDebug('text_search_done', {
source: 'local',
artistCount: artists?.length ?? 0,
});
return;
}
const outcome = await artistBrowseTimed(
'text_search_race',
() => raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseArtists(serverId, q, creditMode),
() => runLocalBrowseArtists(serverId, q, creditMode, undefined, scopePairs),
() => runNetworkBrowseArtists(q, creditMode),
{
surface,
@@ -96,7 +114,7 @@ export function useBrowseArtistTextSearch(
artistCount: outcome?.result?.length ?? 0,
});
})();
}, [creditMode, debouncedFilter, indexEnabled, offlineBrowseActive, serverId, starredOnly, surface]);
}, [creditMode, debouncedFilter, indexEnabled, localOnly, offlineBrowseActive, scopePairs, serverId, starredOnly, surface]);
const effectiveFilter = textSearchArtists != null ? '' : filter;
return { textSearchArtists, textSearchLoading, effectiveFilter };
+25 -9
View File
@@ -47,12 +47,18 @@ import {
beginArtistsBrowseTrace,
emitArtistsBrowseDebug,
} from '@/lib/library/artistBrowseDebug';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
import { appendServerQuery } from '@/lib/navigation/detailServerScope';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
export default function Artists() {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId || serverId;
const sessionScopeKey = `${serverId}\0${browseScope.fingerprint}`;
const libraryScopeKey = useAuthStore(s => {
if (!serverId) return 'all';
const resolved = resolveServerIdForIndexKey(serverId);
@@ -68,7 +74,7 @@ export default function Artists() {
const scrollSnapshotRef = useRef<ArtistBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
const restoreVisibleCountRef = useRef<number | undefined>(
peekArtistBrowseScrollRestore(serverId)?.visibleCount,
peekArtistBrowseScrollRestore(sessionScopeKey)?.visibleCount,
);
const {
@@ -80,7 +86,7 @@ export default function Artists() {
setCreditMode,
viewMode,
setViewMode,
} = useArtistsBrowseFilters(serverId, scrollSnapshotRef);
} = useArtistsBrowseFilters(sessionScopeKey, scrollSnapshotRef);
useLayoutEffect(() => {
beginArtistsBrowseTrace({
@@ -121,7 +127,7 @@ export default function Artists() {
loadCatalogChunk,
catalogLoadingRef,
} = useArtistsBrowseCatalog({
serverId,
serverId: browseServerId,
indexEnabled,
starredOnly,
creditMode,
@@ -129,15 +135,20 @@ export default function Artists() {
musicLibraryFilterVersion,
libraryScopeKey,
ignoredArticles,
scopePairs: browseScope.pairs,
scopeFingerprint: browseScope.fingerprint,
localOnly: browseScope.multiServer,
});
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
artistsSearchQuery,
indexEnabled,
serverId,
browseServerId,
'artists_browse',
creditMode,
starredOnly,
browseScope.pairs,
browseScope.multiServer,
);
const artists = starredOnly ? catalogArtists : (textSearchArtists ?? catalogArtists);
const loading = starredOnly ? catalogLoading : (catalogLoading || textSearchLoading);
@@ -154,7 +165,7 @@ export default function Artists() {
loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({
pageSize: PAGE_SIZE,
resetDeps: [artistsSearchQuery, letterFilter, starredOnly, creditMode, viewMode, musicLibraryFilterVersion, serverId],
resetDeps: [artistsSearchQuery, letterFilter, starredOnly, creditMode, viewMode, musicLibraryFilterVersion, browseScope.fingerprint],
getScrollRoot: getArtistsScrollRoot,
scrollRootEl: artistsScrollBodyEl,
restoreDisplayCount: restoreVisibleCountRef.current,
@@ -169,7 +180,8 @@ export default function Artists() {
setSelectedIds(new Set());
};
const toggleSelect = useCallback((id: string) => {
const toggleSelect = useCallback((artist: Parameters<typeof libraryEntityKey>[0]) => {
const id = libraryEntityKey(artist);
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
@@ -177,7 +189,11 @@ export default function Artists() {
});
}, []);
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
const selectedArtists = artists.filter(a => selectedIds.has(libraryEntityKey(a)));
const openArtist = useCallback((artist: Parameters<typeof libraryEntityKey>[0]) => {
const search = appendServerQuery(undefined, artist.serverId ?? undefined);
navigateToArtist(artist.id, search ? { search } : undefined);
}, [navigateToArtist]);
const {
filtered, visible, hasMore, groups, letters, artistListFlatRows,
@@ -517,7 +533,7 @@ export default function Artists() {
selectedArtists={selectedArtists}
showArtistImages={showArtistImages}
toggleSelect={toggleSelect}
onOpenArtist={navigateToArtist}
onOpenArtist={openArtist}
openContextMenu={openContextMenu}
t={t}
/>
@@ -537,7 +553,7 @@ export default function Artists() {
selectedArtists={selectedArtists}
showArtistImages={showArtistImages}
toggleSelect={toggleSelect}
onOpenArtist={navigateToArtist}
onOpenArtist={openArtist}
openContextMenu={openContextMenu}
t={t}
/>
@@ -32,15 +32,20 @@ interface ArtistBrowseSessionStore {
peekReturnStash: (serverId: string) => ArtistBrowseReturnState | null;
}
function returnStashKey(scopeKey: string): string {
return JSON.stringify([scopeKey]);
}
export const useArtistBrowseSessionStore = create<ArtistBrowseSessionStore>((set, get) => ({
returnStashByServer: {},
stashReturnState: (serverId, state) => {
if (!serverId) return;
const key = returnStashKey(serverId);
set((s) => ({
returnStashByServer: {
...s.returnStashByServer,
[serverId]: {
[key]: {
filter: state.filter,
letterFilter: state.letterFilter,
starredOnly: state.starredOnly,
@@ -57,13 +62,13 @@ export const useArtistBrowseSessionStore = create<ArtistBrowseSessionStore>((set
clearReturnStash: (serverId) => {
if (!serverId) return;
const next = { ...get().returnStashByServer };
delete next[serverId];
delete next[returnStashKey(serverId)];
set({ returnStashByServer: next });
},
peekReturnStash: (serverId) => {
if (!serverId) return null;
const stash = get().returnStashByServer[serverId];
const stash = get().returnStashByServer[returnStashKey(serverId)];
if (!stash) return null;
return {
filter: stash.filter,
@@ -1,9 +1,8 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { uploadArtistImage } from '@/lib/api/subsonicArtists';
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
import { queueEntityRating, queueEntityStar } from '@/features/playback';
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
import { invalidateCoverArt } from '@/cover';
import { showToast } from '@/lib/dom/toast';
@@ -20,27 +19,15 @@ export interface RunArtistEntityRatingDeps {
}
export async function runArtistEntityRating(deps: RunArtistEntityRatingDeps): Promise<void> {
const { artist, id, rating, artistEntityRatingSupport, activeServerId, t, setArtistEntityRating, setArtist } = deps;
const { artist, id, rating, artistEntityRatingSupport, activeServerId, setArtistEntityRating, setArtist } = deps;
if (!artist || artist.id !== id) return;
const artistId = artist.id;
const ratingAtStart = artist.userRating ?? 0;
setArtistEntityRating(rating);
if (artistEntityRatingSupport !== 'full') return;
try {
await setRating(artistId, rating);
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
} catch (err) {
setArtistEntityRating(ratingAtStart);
useAuthStore.getState().setEntityRatingSupport(activeServerId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
'error',
);
}
queueEntityRating('artist', artistId, rating, artist.serverId ?? activeServerId);
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
}
export interface RunArtistToggleStarDeps {
@@ -54,18 +41,7 @@ export async function runArtistToggleStar(deps: RunArtistToggleStarDeps): Promis
if (!artist) return;
const currentlyStarred = isStarred;
setIsStarred(!currentlyStarred);
try {
const meta = {
serverId: artist.serverId,
name: artist.name,
albumCount: artist.albumCount,
};
if (currentlyStarred) await unstar(artist.id, 'artist', meta);
else await star(artist.id, 'artist', meta);
} catch (e) {
console.error('Failed to toggle star', e);
setIsStarred(currentlyStarred);
}
queueEntityStar('artist', artist.id, !currentlyStarred, artist.serverId);
}
export interface RunArtistShareDeps {
@@ -76,7 +52,7 @@ export interface RunArtistShareDeps {
export async function runArtistShare(deps: RunArtistShareDeps): Promise<void> {
const { artist, t } = deps;
try {
const ok = await copyEntityShareLink('artist', artist.id);
const ok = await copyEntityShareLink('artist', artist.id, artist.serverId);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
} catch {
@@ -1,4 +1,4 @@
import { star, unstar } from '@/lib/api/subsonicStarRating';
import { queueEntityStar } from '@/features/playback';
import { getArtist, getArtistInfo } from '@/lib/api/subsonicArtists';
import type { SubsonicArtist, SubsonicAlbum, SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
import { useEffect, useState, useMemo } from 'react';
@@ -11,7 +11,6 @@ import { useCoverLightboxSrc } from '@/cover/lightbox';
import { ArrowLeft, Users, Heart, Feather, Share2 } from 'lucide-react';
import WikipediaIcon from '@/ui/WikipediaIcon';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { useTranslation } from 'react-i18next';
import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
@@ -35,7 +34,6 @@ export default function ComposerDetail() {
const [headerCoverFailed, setHeaderCoverFailed] = useState(false);
const [openedLink, setOpenedLink] = useState<string | null>(null);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
// Subsonic `getArtist.view` only follows AlbumArtist relations, so for a
@@ -104,20 +102,7 @@ export default function ComposerDetail() {
if (!artist) return;
const next = !isStarred;
setIsStarred(next);
setStarredOverride(artist.id, next);
try {
const meta = {
serverId: artist.serverId,
name: artist.name,
albumCount: artist.albumCount,
};
if (next) await star(artist.id, 'artist', meta);
else await unstar(artist.id, 'artist', meta);
} catch (err) {
console.warn('[psysonic] composer star failed:', err);
setIsStarred(!next);
setStarredOverride(artist.id, !next);
}
queueEntityStar('artist', artist.id, next, artist.serverId);
};
const openLink = (url: string, key: string) => {
@@ -129,7 +114,7 @@ export default function ComposerDetail() {
const handleShareComposer = async () => {
if (!id || !artist) return;
try {
const ok = await copyEntityShareLink('composer', artist.id);
const ok = await copyEntityShareLink('composer', artist.id, artist.serverId);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
} catch {
+20 -2
View File
@@ -20,6 +20,8 @@ import { peekComposerBrowseScrollRestore } from '@/features/composers/store/comp
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
import { readComposerBrowseRestore } from '@/lib/navigation/albumDetailNavigation';
import { filterArtistsWithRoleAlbumCredits } from '@/lib/library/composerBrowse';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
import { libraryScopeListArtistsByRole } from '@/lib/api/library';
import { ALL_SENTINEL, artistLetterBucket } from '@/features/artist';
import { useLibraryIgnoredArticles } from '@/lib/library/hooks/useLibraryIgnoredArticles';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
@@ -88,6 +90,8 @@ export default function Composers() {
const scrollSnapshotRef = useRef<ComposerBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId || serverId;
const restoreVisibleCountRef = useRef<number | undefined>(
peekComposerBrowseScrollRestore(serverId)?.visibleCount,
);
@@ -152,7 +156,21 @@ export default function Composers() {
// an option but Symfonium-style classical libs rarely exceed a few thousand
// composers, and a single round-trip beats N infinite-scroll calls when the
// list is alphabetised + filtered locally.
ndListArtistsByRole('composer', 0, 10000)
const loadComposers = browseScope.multiServer
? libraryScopeListArtistsByRole(browseServerId, {
scopes: browseScope.pairs,
role: 'composer',
limit: 10000,
}).then(rows => rows.map(row => ({
id: row.id,
name: row.name,
nameSort: row.nameSort ?? undefined,
albumCount: row.albumCount ?? undefined,
coverArt: row.id,
serverId: row.serverId,
})))
: ndListArtistsByRole('composer', 0, 10000);
loadComposers
.then(data => {
if (cancelled) return;
setComposers(filterArtistsWithRoleAlbumCredits(data));
@@ -170,7 +188,7 @@ export default function Composers() {
setLoading(false);
});
return () => { cancelled = true; };
}, [musicLibraryFilterVersion, reloadTick]);
}, [musicLibraryFilterVersion, reloadTick, browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId]);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const ignoredArticles = useLibraryIgnoredArticles(serverId);
@@ -6,9 +6,13 @@ import { usePlaylistStore } from '@/features/playlist';
import { addTracksToPlaylistWithDedup, showAddTracksDedupToast } from '@/features/playlist';
import { showToast } from '@/lib/dom/toast';
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
import type { Track } from '@/lib/media/trackTypes';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
import { resolvePlaylistTargetTrackIds } from '@/features/playlist';
interface Props {
songIds: string[];
tracks?: Track[];
/** When set (bulk toolbar pickers), read IDs at action time — avoids stale props if selection changes after open. */
resolveSongIds?: () => readonly string[];
onDone: () => void;
@@ -16,7 +20,7 @@ interface Props {
triggerId?: string;
}
export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown, triggerId }: Props) {
export function AddToPlaylistSubmenu({ songIds, tracks, resolveSongIds, onDone, dropDown, triggerId }: Props) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
@@ -34,6 +38,7 @@ export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
const browseScope = useBrowseLibraryScope();
useEffect(() => {
if (storePlaylists.length === 0) fetchPlaylists();
@@ -68,12 +73,15 @@ export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown
const idsForAction = () => [...(resolveSongIds?.() ?? songIdsRef.current)];
const handleAdd = async (pl: SubsonicPlaylist) => {
const ids = idsForAction();
if (!pl.serverId) return;
const ids = tracks?.length
? await resolvePlaylistTargetTrackIds(pl.serverId, tracks, browseScope.pairs)
: idsForAction();
setAdding(pl.id);
try {
const result = await addTracksToPlaylistWithDedup(pl.id, pl.name, ids, t);
const result = await addTracksToPlaylistWithDedup(pl.id, pl.name, ids, t, pl.serverId);
showAddTracksDedupToast(t, pl.name, result);
if (result.outcome !== 'skipped') touchPlaylist(pl.id);
if (result.outcome !== 'skipped') touchPlaylist(pl.id, pl.serverId);
} catch {
showToast(t('playlists.addError'), 3000, 'error');
}
@@ -85,7 +93,8 @@ export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown
const ids = idsForAction();
const name = newName.trim() || t('playlists.unnamed');
try {
const pl = await createPlaylist(name, ids);
const targetServerId = storePlaylists[0]?.serverId;
const pl = await createPlaylist(name, ids, targetServerId);
if (pl?.id) {
showToast(t('playlists.createAndAddSuccess', { count: ids.length, playlist: pl.name || name }));
}
@@ -144,7 +153,7 @@ export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown
)}
{playlists.map((pl: SubsonicPlaylist) => (
<div
key={pl.id}
key={`${pl.serverId ?? ''}:${pl.id}`}
className="context-menu-item"
onClick={() => handleAdd(pl)}
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Heart, Download, ChevronRight, ChevronsRight, User, ListMusic, Star, Share2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
import { star, unstar } from '@/lib/api/subsonicStarRating';
import { queueEntityStar } from '@/features/playback';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import StarRating from '@/ui/StarRating';
@@ -13,7 +13,7 @@ import type { ContextMenuItemsProps } from '@/features/contextMenu/components/co
export default function AlbumContextItems(props: ContextMenuItemsProps) {
const {
type, item, playNext, enqueue, closeContextMenu,
setStarredOverride, userRatingOverrides, setKeyboardRating, keyboardRating,
userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
entityRatingSupport, applyAlbumRating,
@@ -61,16 +61,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
{offlinePolicy.canFavorite && (
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(album.id, album.starred);
setStarredOverride(album.id, !starred);
const meta = {
serverId: album.serverId,
name: album.name,
artist: album.artist,
artistId: album.artistId,
coverArtId: album.coverArt,
year: album.year,
};
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
queueEntityStar('album', album.id, !starred, album.serverId);
})}>
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
@@ -96,7 +87,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id, album.serverId))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
{offlinePolicy.canDownload && (
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Radio, Heart, ChevronRight, ListMusic, Star, Share2 } from 'lucide-react';
import { star, unstar } from '@/lib/api/subsonicStarRating';
import { queueEntityStar } from '@/features/playback';
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import StarRating from '@/ui/StarRating';
import { ArtistToPlaylistSubmenu } from '@/features/contextMenu/components/AlbumArtistToPlaylistSubmenu';
@@ -10,7 +10,7 @@ import type { ContextMenuItemsProps } from '@/features/contextMenu/components/co
export default function ArtistContextItems(props: ContextMenuItemsProps) {
const {
type, item, shareKindOverride, closeContextMenu,
setStarredOverride, userRatingOverrides, setKeyboardRating, keyboardRating,
userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
entityRatingSupport, applyArtistRating,
@@ -43,7 +43,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
)}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id, artist.serverId))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
{(offlinePolicy.canFavorite || offlinePolicy.canRate) && (
@@ -52,15 +52,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
{offlinePolicy.canFavorite && (
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(artist.id, artist.starred);
setStarredOverride(artist.id, !starred);
const meta = {
serverId: artist.serverId,
name: artist.name,
albumCount: artist.albumCount,
};
return starred
? unstar(artist.id, 'artist', meta)
: star(artist.id, 'artist', meta);
queueEntityStar('artist', artist.id, !starred, artist.serverId);
})}>
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
@@ -15,6 +15,7 @@ import {
} from '@/features/contextMenu/utils/contextMenuActions';
import { useContextMenuKeyboardNav } from '@/features/contextMenu/hooks/useContextMenuKeyboardNav';
import { useContextMenuRating } from '@/features/contextMenu/hooks/useContextMenuRating';
import { entityOverrideKey } from '@/lib/media/entityOverrideKey';
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
import { useNavigate } from 'react-router-dom';
import { useOfflineBrowseContext } from '@/features/offline';
@@ -177,8 +178,11 @@ export default function ContextMenu() {
? navigatePlaybackLibrary
: (path: string) => { navigate(path); };
const isStarred = (id: string, itemStarred?: string) =>
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
const isStarred = (id: string, itemStarred?: string) => {
const itemServerId = (item as { serverId?: string } | null)?.serverId ?? auth.activeServerId;
const key = entityOverrideKey(itemServerId, id);
return key in starredOverrides ? starredOverrides[key] : !!itemStarred;
};
const { applySongRating, applyAlbumRating, applyArtistRating, getRatingValueByKind, commitRatingByKind } =
useContextMenuRating({ type, item, userRatingOverrides, setUserRatingOverride, entityRatingSupport, t });
@@ -204,7 +208,8 @@ export default function ContextMenu() {
};
const copyShareLink = useCallback(
(kind: EntityShareKind, id: string) => copyShareLinkAction(kind, id, t),
(kind: EntityShareKind, id: string, representativeServerId?: string) =>
copyShareLinkAction(kind, id, t, representativeServerId),
[t],
);
@@ -1,11 +1,11 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, Folder, FolderMinus, Plus } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '@/features/playlist';
interface Props {
playlistId: string;
serverId?: string;
onDone: () => void;
triggerId?: string;
}
@@ -16,7 +16,7 @@ interface Props {
* hover machinery. Folder assignment is purely local state, so it stays
* available offline.
*/
export default function MoveToFolderSubmenu({ playlistId, onDone, triggerId }: Props) {
export default function MoveToFolderSubmenu({ playlistId, serverId, onDone, triggerId }: Props) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const [creating, setCreating] = useState(false);
@@ -25,7 +25,6 @@ export default function MoveToFolderSubmenu({ playlistId, onDone, triggerId }: P
const [flipLeft, setFlipLeft] = useState(false);
const [flipUp, setFlipUp] = useState(false);
const serverId = useAuthStore(s => s.activeServerId);
const bucket =
usePlaylistFolderStore(s => (serverId ? s.byServer[serverId] : undefined)) ?? EMPTY_SERVER_FOLDERS;
const createFolder = usePlaylistFolderStore(s => s.createFolder);
@@ -24,21 +24,21 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
return (
<>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const tracks = await resolvePlaylistTracks(playlist.id);
const tracks = await resolvePlaylistTracks(playlist.id, playlist.serverId);
if (tracks.length === 0) return;
playTrack(tracks[0], tracks);
})}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const tracks = await resolvePlaylistTracks(playlist.id);
const tracks = await resolvePlaylistTracks(playlist.id, playlist.serverId);
if (tracks.length === 0) return;
playNext(tracks);
})}>
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const tracks = await resolvePlaylistTracks(playlist.id);
const tracks = await resolvePlaylistTracks(playlist.id, playlist.serverId);
if (tracks.length === 0) return;
enqueue(tracks);
})}>
@@ -69,7 +69,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
<FolderTree size={14} /> {t('playlists.folders.moveToFolder')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `folder:${playlist.id}` && (
<MoveToFolderSubmenu playlistId={playlist.id} triggerId={`folder:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
<MoveToFolderSubmenu playlistId={playlist.id} serverId={playlist.serverId} triggerId={`folder:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{offlinePolicy.canEditPlaylist && (
@@ -77,14 +77,15 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-divider" />
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('@/lib/dom/toast');
const { deletePlaylist } = await import('@/lib/api/subsonicPlaylists');
const { deletePlaylistForServer } = await import('@/lib/api/subsonicPlaylists');
const { removeId } = usePlaylistStore.getState();
try {
await deletePlaylist(playlist.id);
removeId(playlist.id);
if (!playlist.serverId) throw new Error('Playlist owner unavailable');
await deletePlaylistForServer(playlist.serverId, playlist.id);
removeId(playlist.id, playlist.serverId);
// Update local playlist state without page reload to preserve audio playback state
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => p.id !== playlist.id),
playlists: s.playlists.filter((p) => p.id !== playlist.id || p.serverId !== playlist.serverId),
}));
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
} catch {
@@ -125,24 +126,25 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
{offlinePolicy.canEditPlaylist && (
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('@/lib/dom/toast');
const { deletePlaylist } = await import('@/lib/api/subsonicPlaylists');
const { deletePlaylistForServer } = await import('@/lib/api/subsonicPlaylists');
const { removeId } = usePlaylistStore.getState();
const deletedIds: string[] = [];
const deletedIds = new Set<string>();
for (const pl of selectedPlaylists) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
deletedIds.push(pl.id);
if (!pl.serverId) throw new Error('Playlist owner unavailable');
await deletePlaylistForServer(pl.serverId, pl.id);
removeId(pl.id, pl.serverId);
deletedIds.add(`${pl.serverId}:${pl.id}`);
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
}
if (deletedIds.length > 0) {
if (deletedIds.size > 0) {
// Update local playlist state without page reload to preserve audio playback state
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !deletedIds.includes(p.id)),
playlists: s.playlists.filter((p) => !deletedIds.has(`${p.serverId ?? ''}:${p.id}`)),
}));
showToast(t('playlists.deleteSuccess', { count: deletedIds.length }), 3000, 'info');
showToast(t('playlists.deleteSuccess', { count: deletedIds.size }), 3000, 'info');
}
})}>
<Trash2 size={14} /> {t('playlists.deleteSelected')}
@@ -110,7 +110,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
/>
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id, song.serverId))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
@@ -171,7 +171,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id, song.serverId))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
@@ -179,17 +179,18 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { removePlaylistSongsAtIndices } = await import('@/lib/api/subsonicPlaylists');
const { removePlaylistSongsAtIndicesForServer } = await import('@/lib/api/subsonicPlaylists');
const { showToast } = await import('@/lib/dom/toast');
const { touchPlaylist } = usePlaylistStore.getState();
const membership = usePlaylistMembershipStore.getState();
try {
await removePlaylistSongsAtIndices(playlistId, [playlistSongIndex]);
membership.removePlaylistSongIdsAtIndices(playlistId, [playlistSongIndex]);
touchPlaylist(playlistId);
if (!song.serverId) throw new Error('Playlist owner unavailable');
await removePlaylistSongsAtIndicesForServer(song.serverId, playlistId, [playlistSongIndex]);
membership.removePlaylistSongIdsAtIndices(playlistId, [playlistSongIndex], song.serverId);
touchPlaylist(playlistId, song.serverId);
showToast(t('playlists.removeSuccess'), 3000, 'info');
} catch {
membership.invalidatePlaylistSongIds(playlistId);
membership.invalidatePlaylistSongIds(playlistId, song.serverId);
showToast(t('playlists.removeError'), 4000, 'error');
}
})}>
@@ -311,7 +312,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id, song.serverId))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
@@ -52,7 +52,7 @@ export interface ContextMenuItemsProps {
startRadio: (artistId: string, artistName: string, seedTrack?: Track) => void;
startInstantMix: (song: Track) => void;
downloadAlbum: (albumName: string, albumId: string) => Promise<void>;
copyShareLink: (kind: EntityShareKind, id: string) => void;
copyShareLink: (kind: EntityShareKind, id: string, representativeServerId?: string) => void;
isStarred: (id: string, itemStarred?: string) => boolean;
/** When true, album/artist links switch to the queue server before routing. */
pinToPlaybackServer: boolean;
@@ -1,10 +1,9 @@
import { useCallback } from 'react';
import { setRating } from '@/lib/api/subsonicStarRating';
import { queueSongRating } from '@/features/playback/store/pendingStarSync';
import { queueEntityRating, queueSongRating } from '@/features/playback';
import type { SubsonicAlbum, SubsonicArtist } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
import { showToast } from '@/lib/dom/toast';
import { entityOverrideKey } from '@/lib/media/entityOverrideKey';
type RatingKind = 'song' | 'album' | 'artist';
@@ -26,75 +25,59 @@ interface Result {
}
export function useContextMenuRating({
type, item, userRatingOverrides, setUserRatingOverride, entityRatingSupport, t,
type, item, userRatingOverrides, entityRatingSupport,
}: Args): Result {
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const activeServerId = useAuthStore(s => s.activeServerId);
const applySongRating = useCallback((songId: string, rating: number) => {
// F4: optimistic override + retry-with-backoff sync via the central helper.
queueSongRating(songId, rating);
}, []);
const song = item as Track;
queueSongRating(songId, rating, song.serverId ?? activeServerId ?? undefined);
}, [item, activeServerId]);
const applyAlbumRating = useCallback((album: SubsonicAlbum, rating: number) => {
setUserRatingOverride(album.id, rating);
if (entityRatingSupport !== 'full') return;
setRating(album.id, rating).catch(err => {
if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
'error',
);
});
}, [setUserRatingOverride, entityRatingSupport, activeServerId, setEntityRatingSupport, t]);
queueEntityRating('album', album.id, rating, album.serverId ?? activeServerId ?? undefined);
}, [entityRatingSupport, activeServerId]);
const applyArtistRating = useCallback((artist: SubsonicArtist, rating: number) => {
setUserRatingOverride(artist.id, rating);
if (entityRatingSupport !== 'full') return;
setRating(artist.id, rating).catch(err => {
if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
'error',
);
});
}, [setUserRatingOverride, entityRatingSupport, activeServerId, setEntityRatingSupport, t]);
queueEntityRating('artist', artist.id, rating, artist.serverId ?? activeServerId ?? undefined);
}, [entityRatingSupport, activeServerId]);
const getRatingValueByKind = useCallback((kind: RatingKind, id: string): number => {
if (kind === 'song' && (type === 'song' || type === 'album-song' || type === 'queue-item')) {
const song = item as Track;
if (song.id === id) return userRatingOverrides[id] ?? song.userRating ?? 0;
if (song.id === id) return userRatingOverrides[entityOverrideKey(song.serverId ?? activeServerId, id)] ?? song.userRating ?? 0;
}
if (kind === 'album' && type === 'album') {
const album = item as SubsonicAlbum;
if (album.id === id) return userRatingOverrides[id] ?? album.userRating ?? 0;
if (album.id === id) return userRatingOverrides[entityOverrideKey(album.serverId ?? activeServerId, id)] ?? album.userRating ?? 0;
}
if (kind === 'album' && type === 'multi-album') {
const albums = item as SubsonicAlbum[];
const compositeId = [...albums.map(a => a.id)].sort().join('\x1e');
if (id !== compositeId) return userRatingOverrides[id] ?? 0;
if (albums.length === 0) return 0;
const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
const vals = albums.map(a => userRatingOverrides[entityOverrideKey(a.serverId ?? activeServerId, a.id)] ?? a.userRating ?? 0);
const first = vals[0];
return vals.every(v => v === first) ? first : 0;
}
if (kind === 'artist' && type === 'artist') {
const artist = item as SubsonicArtist;
if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0;
if (artist.id === id) return userRatingOverrides[entityOverrideKey(artist.serverId ?? activeServerId, id)] ?? artist.userRating ?? 0;
}
if (kind === 'artist' && type === 'multi-artist') {
const artists = item as SubsonicArtist[];
const compositeId = [...artists.map(a => a.id)].sort().join('\x1e');
if (id !== compositeId) return userRatingOverrides[id] ?? 0;
if (artists.length === 0) return 0;
const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
const vals = artists.map(a => userRatingOverrides[entityOverrideKey(a.serverId ?? activeServerId, a.id)] ?? a.userRating ?? 0);
const first = vals[0];
return vals.every(v => v === first) ? first : 0;
}
return userRatingOverrides[id] ?? 0;
}, [type, item, userRatingOverrides]);
}, [type, item, userRatingOverrides, activeServerId]);
const commitRatingByKind = useCallback((kind: RatingKind, id: string, rating: number) => {
if (kind === 'song') {
@@ -19,8 +19,9 @@ export async function copyShareLink(
kind: EntityShareKind,
id: string,
t: (key: string) => string,
representativeServerId?: string,
) {
const ok = await copyEntityShareLink(kind, id);
const ok = await copyEntityShareLink(kind, id, representativeServerId);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
}
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { getMusicDirectory, getMusicIndexes } from '@/lib/api/subsonicLibrary';
import { getMusicDirectoryForServer, getMusicIndexesForServer } from '@/lib/api/subsonicLibrary';
import type { SubsonicDirectoryEntry } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import type { Column, NavPos } from '@/features/folderBrowser/utils/folderBrowserHelpers';
let persistedPlayingPathIds: string[] = [];
let persistedPlayingPath: { serverId: string; ids: string[] } | null = null;
interface Args {
columns: Column[];
@@ -24,7 +24,10 @@ interface Result {
export function useFolderBrowserNowPlayingPath({
columns, currentTrack, isPlaying, setColumns, setKeyboardPos,
}: Args): Result {
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
const [playingPathIds, setPlayingPathIds] = useState<string[]>(() => {
const persisted = persistedPlayingPath;
return persisted && persisted.serverId === currentTrack?.serverId ? persisted.ids : [];
});
const autoResolvedTrackRef = useRef<string | null>(null);
const prevTrackIdRef = useRef<string | null>(null);
const lastHotkeyRevealTsRef = useRef<number | null>(null);
@@ -38,7 +41,7 @@ export function useFolderBrowserNowPlayingPath({
return;
}
setPlayingPathIds(prev => (prev[prev.length - 1] === currentTrack.id ? prev : []));
}, [currentTrack?.id]);
}, [currentTrack?.id, currentTrack?.serverId]);
useEffect(() => {
if (!isPlaying || !currentTrack?.id) return;
@@ -66,17 +69,21 @@ export function useFolderBrowserNowPlayingPath({
}, [columns, currentTrack?.id, isPlaying]);
useEffect(() => {
persistedPlayingPathIds = playingPathIds;
}, [playingPathIds]);
persistedPlayingPath = currentTrack?.serverId
? { serverId: currentTrack.serverId, ids: playingPathIds }
: null;
}, [playingPathIds, currentTrack?.serverId]);
const resolveColumnsForTrack = useCallback(async (
track: Track,
roots: SubsonicDirectoryEntry[],
): Promise<Column[] | null> => {
for (const root of roots) {
const serverId = root.serverId ?? track.serverId;
if (!serverId || (track.serverId && track.serverId !== serverId)) continue;
let indexes: SubsonicDirectoryEntry[];
try {
indexes = await getMusicIndexes(root.id);
indexes = await getMusicIndexesForServer(serverId, root.id);
} catch {
continue;
}
@@ -88,7 +95,7 @@ export function useFolderBrowserNowPlayingPath({
let artistChildren: SubsonicDirectoryEntry[];
try {
artistChildren = (await getMusicDirectory(artistEntry.id)).child;
artistChildren = (await getMusicDirectoryForServer(serverId, artistEntry.id)).child;
} catch {
continue;
}
@@ -104,7 +111,7 @@ export function useFolderBrowserNowPlayingPath({
let albumChildren: SubsonicDirectoryEntry[];
try {
albumChildren = (await getMusicDirectory(albumEntry.id)).child;
albumChildren = (await getMusicDirectoryForServer(serverId, albumEntry.id)).child;
} catch {
continue;
}
@@ -112,10 +119,10 @@ export function useFolderBrowserNowPlayingPath({
if (!songEntry) continue;
return [
{ id: 'root', name: '', items: roots, selectedId: root.id, loading: false, error: false, kind: 'roots' },
{ id: root.id, name: root.title, items: indexes, selectedId: artistEntry.id, loading: false, error: false, kind: 'indexes' },
{ id: artistEntry.id, name: artistEntry.title, items: artistChildren, selectedId: albumEntry.id, loading: false, error: false, kind: 'directory' },
{ id: albumEntry.id, name: albumEntry.title, items: albumChildren, selectedId: songEntry.id, loading: false, error: false, kind: 'directory' },
{ id: 'root', serverId: '', name: '', items: roots, selectedId: root.id, loading: false, error: false, kind: 'roots' },
{ id: root.id, serverId, name: root.title, items: indexes, selectedId: artistEntry.id, loading: false, error: false, kind: 'indexes' },
{ id: artistEntry.id, serverId, name: artistEntry.title, items: artistChildren, selectedId: albumEntry.id, loading: false, error: false, kind: 'directory' },
{ id: albumEntry.id, serverId, name: albumEntry.title, items: albumChildren, selectedId: songEntry.id, loading: false, error: false, kind: 'directory' },
];
}
return null;
@@ -1,4 +1,8 @@
import { getMusicFolders, getMusicDirectory, getMusicIndexes } from '@/lib/api/subsonicLibrary';
import {
getMusicDirectoryForServer,
getMusicFoldersForServer,
getMusicIndexesForServer,
} from '@/lib/api/subsonicLibrary';
import type { SubsonicDirectoryEntry, SubsonicArtist } from '@/lib/api/subsonicTypes';
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
@@ -11,6 +15,7 @@ import FolderBrowserColumn from '@/features/folderBrowser/components/FolderBrows
import { useFolderBrowserNowPlayingPath } from '@/features/folderBrowser/hooks/useFolderBrowserNowPlayingPath';
import { useFolderBrowserScrolling } from '@/features/folderBrowser/hooks/useFolderBrowserScrolling';
import { useFolderBrowserKeyboardNav } from '@/features/folderBrowser/hooks/useFolderBrowserKeyboardNav';
import { useReachableLibrarySources } from '@/store/useReachableLibrarySources';
export default function FolderBrowser() {
const { t } = useTranslation();
@@ -27,6 +32,7 @@ export default function FolderBrowser() {
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const sources = useReachableLibrarySources();
const { wrapperRef, columnsViewportWidth } = useFolderBrowserScrolling({
columns, keyboardPos, keyboardNavActive, setKeyboardNavActive,
@@ -38,6 +44,7 @@ export default function FolderBrowser() {
useEffect(() => {
const placeholder: Column = {
id: 'root',
serverId: '',
name: '',
items: [],
selectedId: null,
@@ -48,19 +55,23 @@ export default function FolderBrowser() {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setColumns([placeholder]);
getMusicFolders()
.then(folders => {
const items: SubsonicDirectoryEntry[] = folders.map(f => ({
id: f.id,
title: f.name,
isDir: true,
}));
Promise.all(sources.map(async source => {
const folders = await getMusicFoldersForServer(source.serverId);
return folders.map(f => ({
id: f.id,
title: sources.length > 1 ? `${source.name} · ${f.name}` : f.name,
isDir: true,
serverId: source.serverId,
} satisfies SubsonicDirectoryEntry));
}))
.then(groups => {
const items = groups.flat();
setColumns([{ ...placeholder, items, loading: false }]);
})
.catch(() => {
setColumns([{ ...placeholder, items: [], loading: false, error: true }]);
});
}, []);
}, [sources]);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
@@ -171,6 +182,7 @@ export default function FolderBrowser() {
),
{
id: item.id,
serverId: item.serverId ?? '',
name: item.title,
items: [],
selectedId: null,
@@ -180,8 +192,11 @@ export default function FolderBrowser() {
},
]);
const fetchItems =
colIndex === 0 ? getMusicIndexes(item.id) : getMusicDirectory(item.id).then(d => d.child);
const ownerServerId = item.serverId ?? columns[colIndex]?.serverId;
if (!ownerServerId) return;
const fetchItems = colIndex === 0
? getMusicIndexesForServer(ownerServerId, item.id)
: getMusicDirectoryForServer(ownerServerId, item.id).then(d => d.child);
fetchItems
.then(items => {
@@ -202,7 +217,7 @@ export default function FolderBrowser() {
return next;
});
});
}, [clearFiltersRightOf]);
}, [clearFiltersRightOf, columns]);
const handleFileClick = useCallback(
(colIndex: number, item: SubsonicDirectoryEntry) => {
@@ -332,7 +347,7 @@ export default function FolderBrowser() {
>
{columns.map((col, colIndex) => (
<FolderBrowserColumn
key={`${col.id}-${colIndex}`}
key={`${col.serverId}:${col.id}-${colIndex}`}
col={col}
colIndex={colIndex}
isCompact={isColumnCompact(col, colIndex)}
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { entryToTrack } from './folderBrowserHelpers';
describe('folder browser source routing', () => {
it('carries the directory source into playback and actions', () => {
expect(entryToTrack({
id: 'same', title: 'Track', isDir: false, serverId: 'server-b',
}).serverId).toBe('server-b');
});
});
@@ -7,6 +7,7 @@ export type NavPos = { colIndex: number; rowIndex: number };
export type Column = {
id: string;
serverId: string;
name: string;
items: SubsonicDirectoryEntry[];
selectedId: string | null;
@@ -32,6 +33,7 @@ export function entryToAlbumIfPresent(item: SubsonicDirectoryEntry): SubsonicAlb
userRating: item.userRating,
songCount: 0,
duration: 0,
serverId: item.serverId,
};
}
@@ -52,6 +54,7 @@ export function entryToTrack(e: SubsonicDirectoryEntry): Track {
genre: e.genre,
starred: e.starred,
userRating: e.userRating,
serverId: e.serverId,
};
}
@@ -1,4 +1,5 @@
import { queueSongStar, playbackCoverArtForAlbum, usePlayerStore } from '@/features/playback';
import { entityOverrideKey } from '@/lib/media/entityOverrideKey';
import { usePlaybackCoverArt } from '@/cover/usePlaybackCoverArt';
import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
@@ -39,7 +40,8 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const isStarred = usePlayerStore(s => {
const track = s.currentTrack;
if (!track) return false;
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
const key = entityOverrideKey(track.serverId ?? s.queueServerId, track.id);
return key in s.starredOverrides ? s.starredOverrides[key] : !!track.starred;
});
const toggleStar = useCallback(() => {
@@ -5,7 +5,8 @@ import {
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync';
import { queueSongStar, queueSongRating } from '@/features/playback';
import { entityOverrideKey } from '@/lib/media/entityOverrideKey';
import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef';
import { usePlaybackCoverArt } from '@/cover/usePlaybackCoverArt';
import { useCachedUrl } from '@/ui/CachedImage';
@@ -68,7 +69,8 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
const isStarred = usePlayerStore(s => {
const track = s.currentTrack;
if (!track) return false;
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
const key = entityOverrideKey(track.serverId ?? s.queueServerId, track.id);
return key in s.starredOverrides ? s.starredOverrides[key] : !!track.starred;
});
const toggleStar = useCallback(() => {
if (!currentTrack) return;
@@ -117,14 +119,15 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
const rating = usePlayerStore(s => {
const track = s.currentTrack;
if (!track) return 0;
return track.id in s.userRatingOverrides ? s.userRatingOverrides[track.id] : (track.userRating ?? 0);
const key = entityOverrideKey(track.serverId ?? s.queueServerId, track.id);
return key in s.userRatingOverrides ? s.userRatingOverrides[key] : (track.userRating ?? 0);
});
// Hover preview for the clickable rating stars (0 = no preview).
const [hoverRating, setHoverRating] = useState(0);
const applyRating = useCallback((stars: number) => {
if (!currentTrack) return;
// Click the current rating again to clear it (matches StarRating's toggle-off).
queueSongRating(currentTrack.id, rating === stars ? 0 : stars);
queueSongRating(currentTrack.id, rating === stars ? 0 : stars, currentTrack.serverId);
}, [currentTrack, rating]);
return (
+13 -5
View File
@@ -7,13 +7,13 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { fetchGenreCatalog, filterGenresWithContent } from '@/features/playback/utils/playback/genreBrowsePlayback';
import { libraryScopeCacheKeyForServer } from '@/lib/api/subsonicClient';
import { peekGenreCatalogCache } from '@/lib/library/genreCatalogCountsCache';
import { genreColor } from '@/lib/library/genreColor';
import { useOfflineBrowseContext, offlineLocalBrowseEnabled } from '@/features/offline';
import { useOfflineLocalBrowseReloadKey } from '@/store/localPlaybackBrowseRevision';
import { useOfflineLocalLibrarySyncRevision } from '@/store/offlineLocalLibrarySyncRevision';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
const SCROLL_KEY = 'genres-scroll';
const FONT_MIN_REM = 0.78;
@@ -23,9 +23,11 @@ export default function Genres() {
const { t } = useTranslation();
const navigate = useNavigate();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId || serverId;
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const libraryScopeKey = libraryScopeCacheKeyForServer(serverId);
const libraryScopeKey = browseScope.fingerprint;
const offlineBrowseActive = useOfflineBrowseContext().active;
const localPlaybackEntries = useLocalPlaybackStore(s => s.entries);
const librarySyncRevision = useOfflineLocalLibrarySyncRevision(serverId || null);
@@ -43,7 +45,7 @@ export default function Genres() {
useEffect(() => {
let cancelled = false;
const scopeKey = libraryScopeCacheKeyForServer(serverId);
const scopeKey = browseScope.fingerprint;
const cached = serverId && !skipGenreCatalogCache
? peekGenreCatalogCache(serverId, scopeKey, true)
: null;
@@ -55,7 +57,13 @@ export default function Genres() {
} else {
setLoading(true);
}
void fetchGenreCatalog(serverId, indexEnabled)
void fetchGenreCatalog(
browseServerId,
indexEnabled,
browseScope.pairs,
browseScope.multiServer,
browseScope.fingerprint,
)
.then(data => {
if (!cancelled) setRawGenres(data);
})
@@ -65,7 +73,7 @@ export default function Genres() {
return () => {
cancelled = true;
};
}, [serverId, indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, skipGenreCatalogCache, librarySyncRevision, offlineLocalBrowseReloadKey]);
}, [browseScope.fingerprint, browseScope.multiServer, browseScope.pairs, browseServerId, serverId, indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, skipGenreCatalogCache, librarySyncRevision, offlineLocalBrowseReloadKey]);
const genres = useMemo(
() => filterGenresWithContent([...rawGenres]).sort((a, b) => b.albumCount - a.albumCount),
@@ -1,5 +1,5 @@
import { getArtist, getArtistInfo } from '@/lib/api/subsonicArtists';
import { filterAlbumsToActiveLibrary } from '@/lib/api/subsonicLibrary';
import { getArtistForServer, getArtistInfoForServer } from '@/lib/api/subsonicArtists';
import { filterAlbumsToServerLibrary } from '@/lib/api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
@@ -26,6 +26,9 @@ import { LongPressWaveOverlay } from '@/ui/LongPressWaveOverlay';
import { formatHumanHoursMinutes } from '@/lib/format/formatHumanDuration';
import { AlbumRow } from '@/features/album';
import { albumArtistDisplayName } from '@/features/album';
import { appendServerQuery } from '@/lib/navigation/detailServerScope';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
import { coverServerScopeForServerId } from '@/cover/serverScope';
const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:';
const PICKS_HISTORY_KEY_PREFIX = 'psysonic_because_picks:';
@@ -57,7 +60,8 @@ const ROW_STAGGER_MS = 150;
// ── Module-level reserve: next batch pre-fetched in background after each display ──
type BecauseReserve = {
serverId: string;
sourceKey: string;
sourceServerId: string;
filterVersion: number;
// poolKey intentionally omitted — reserve is valid for any pool state on the
// same server. Pool (top-played artists) changes slowly; showing a slightly-off
@@ -89,18 +93,20 @@ function readJsonArray(key: string | null): string[] {
async function resolvePicks(
candidate: BecauseYouLikeAnchor,
recentPicks: Set<string>,
sourceServerId: string,
): Promise<SubsonicAlbum[] | null> {
const info = await getArtistInfo(candidate.id, { similarArtistCount: SIMILAR_FETCH });
const info = await getArtistInfoForServer(sourceServerId, candidate.id, { similarArtistCount: SIMILAR_FETCH });
const similar = (info.similarArtist ?? []).filter(s => s.id);
if (similar.length === 0) return null;
const sampled = shuffleArray(similar).slice(0, SIMILAR_PICK);
const results = await Promise.all(sampled.map(s => getArtist(s.id).catch(() => null)));
const results = await Promise.all(sampled.map(s => getArtistForServer(sourceServerId, s.id).catch(() => null)));
const picks: SubsonicAlbum[] = [];
for (const r of results) {
if (!r) continue;
const albums = await filterAlbumsToActiveLibrary(r.albums);
const albums = (await filterAlbumsToServerLibrary(r.albums, sourceServerId))
.map(album => ({ ...album, serverId: sourceServerId }));
if (albums.length === 0) continue;
const fresh = albums.filter(a => !recentPicks.has(a.id));
const choice = fresh.length > 0 ? fresh : albums;
@@ -127,6 +133,7 @@ async function fetchBecauseYouLike(
pool: BecauseYouLikeAnchor[],
anchorHistKey: string | null,
picksHistKey: string | null,
sourceServerId: string,
): Promise<FetchBecauseResult | null> {
const anchorHistory = readJsonArray(anchorHistKey);
const picksHistory = readJsonArray(picksHistKey);
@@ -153,7 +160,7 @@ async function fetchBecauseYouLike(
const raced = await Promise.all(
tryList.slice(0, 2).map(async candidate => {
try {
const picks = await resolvePicks(candidate, recentPicks);
const picks = await resolvePicks(candidate, recentPicks, sourceServerId);
return picks ? { candidate, picks } : null;
} catch {
return null;
@@ -166,7 +173,7 @@ async function fetchBecauseYouLike(
for (const candidate of tryList) {
try {
const picks = await resolvePicks(candidate, recentPicks);
const picks = await resolvePicks(candidate, recentPicks, sourceServerId);
if (!picks) continue;
return buildResult(candidate, picks);
} catch {
@@ -186,7 +193,8 @@ async function fetchBecauseYouLike(
*/
async function fillBecauseReserve(
pool: BecauseYouLikeAnchor[],
serverId: string,
sourceKey: string,
sourceServerId: string,
filterVersion: number,
anchorHistKey: string | null,
picksHistKey: string | null,
@@ -194,12 +202,12 @@ async function fillBecauseReserve(
if (_becauseReserveFilling) return;
_becauseReserveFilling = true;
try {
const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey);
const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey, sourceServerId);
if (result) {
_becauseReserve = { serverId, filterVersion, ...result };
_becauseReserve = { sourceKey, sourceServerId, filterVersion, ...result };
// Also refresh the session snapshot so a quick leave→return can pick up
// newer cards even before the reserve is explicitly consumed.
writeBecauseYouLikeCache({ serverId, filterVersion, anchor: result.anchor, recs: result.recs });
writeBecauseYouLikeCache({ sourceKey, filterVersion, anchor: result.anchor, recs: result.recs });
}
} catch {
/* Network failure — next visit falls back to a fresh fetch. */
@@ -283,6 +291,8 @@ function BecauseYouLikeSkeleton({ title, slotCount }: { title: string; slotCount
}
interface Props {
sourceKey: string;
sourceServerId?: string | null;
mostPlayed: SubsonicAlbum[];
recentlyPlayed?: SubsonicAlbum[];
starred?: SubsonicAlbum[];
@@ -320,22 +330,24 @@ function picksHistoryKey(serverId: string | null): string | null {
return serverId ? `${PICKS_HISTORY_KEY_PREFIX}${serverId}` : null;
}
function hasValidReserve(serverId: string | null, filterVersion: number): boolean {
function hasValidReserve(sourceKey: string | null, sourceServerId: string | null, filterVersion: number): boolean {
return (
_becauseReserve != null &&
_becauseReserve.serverId === (serverId ?? '') &&
_becauseReserve.sourceKey === (sourceKey ?? '') &&
_becauseReserve.sourceServerId === (sourceServerId ?? '') &&
_becauseReserve.filterVersion === filterVersion
);
}
export default function BecauseYouLikeRail({
sourceKey,
sourceServerId,
mostPlayed,
recentlyPlayed,
starred,
disableArtwork = false,
}: Props) {
const { t } = useTranslation();
const activeServerId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const pool = useMemo(
() => buildAnchorPool([mostPlayed, recentlyPlayed ?? [], starred ?? []], TOP_ARTIST_POOL),
@@ -349,18 +361,18 @@ export default function BecauseYouLikeRail({
// revalidate) > skeleton. Both checks work without poolKey so they fire correctly on the
// first render when pool is still [] (Home.tsx loads mostPlayed asynchronously).
const [anchor, setAnchor] = useState<BecauseYouLikeAnchor | null>(() => {
if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return _becauseReserve!.anchor;
return readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion)?.anchor ?? null;
if (hasValidReserve(sourceKey, sourceServerId ?? null, musicLibraryFilterVersion)) return _becauseReserve!.anchor;
return readBecauseYouLikeCache(sourceKey, musicLibraryFilterVersion)?.anchor ?? null;
});
const [recs, setRecs] = useState<SubsonicAlbum[]>(() => {
if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return _becauseReserve!.recs;
return readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion)?.recs ?? [];
if (hasValidReserve(sourceKey, sourceServerId ?? null, musicLibraryFilterVersion)) return _becauseReserve!.recs;
return readBecauseYouLikeCache(sourceKey, musicLibraryFilterVersion)?.recs ?? [];
});
const containerRef = useRef<HTMLDivElement>(null);
const [narrow, setNarrow] = useState(false);
const [refreshing, setRefreshing] = useState(() => {
if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return false;
const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion);
if (hasValidReserve(sourceKey, sourceServerId ?? null, musicLibraryFilterVersion)) return false;
const snap = readBecauseYouLikeCache(sourceKey, musicLibraryFilterVersion);
return !snap || snap.recs.length === 0;
});
const skeletonSlots = useBecauseRowSlotCount(refreshing, SHOW_COUNT);
@@ -371,14 +383,14 @@ export default function BecauseYouLikeRail({
* (synchronous, before browser paint) or fall back to session cache (stale-
* while-revalidate), only clearing to skeleton when nothing is available. */
useLayoutEffect(() => {
if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) {
if (hasValidReserve(sourceKey, sourceServerId ?? null, musicLibraryFilterVersion)) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setAnchor(_becauseReserve!.anchor);
setRecs(_becauseReserve!.recs);
setRefreshing(false);
} else {
const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion);
const snap = readBecauseYouLikeCache(sourceKey, musicLibraryFilterVersion);
if (snap && snap.recs.length > 0) {
setAnchor(snap.anchor);
setRecs(snap.recs);
@@ -389,7 +401,7 @@ export default function BecauseYouLikeRail({
setRecs([]);
}
}
}, [activeServerId, musicLibraryFilterVersion, poolKey]);
}, [sourceKey, sourceServerId, musicLibraryFilterVersion, poolKey]);
// 696px ≙ exactly 2 BecauseCards side-by-side (2*340 + 16 gap). Below that
// the hero-style cards stretch full-width and dwarf the rest of the page,
@@ -416,7 +428,7 @@ export default function BecauseYouLikeRail({
// cache content. The effect will re-run once pool is populated.
return;
}
if (!activeServerId) {
if (!sourceServerId) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setAnchor(null);
@@ -425,12 +437,12 @@ export default function BecauseYouLikeRail({
return;
}
const anchorHistKey = anchorHistoryKey(activeServerId);
const picksHistKey = picksHistoryKey(activeServerId);
const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion);
const anchorHistKey = anchorHistoryKey(sourceServerId);
const picksHistKey = picksHistoryKey(sourceServerId);
const snap = readBecauseYouLikeCache(sourceKey, musicLibraryFilterVersion);
// Consume module-level reserve (keyed by server + library scope).
const reserved = hasValidReserve(activeServerId, musicLibraryFilterVersion) ? _becauseReserve : null;
const reserved = hasValidReserve(sourceKey, sourceServerId, musicLibraryFilterVersion) ? _becauseReserve : null;
_becauseReserve = null;
(async () => {
@@ -448,9 +460,9 @@ export default function BecauseYouLikeRail({
} catch { /* ignore */ }
setAnchor(reserved.anchor);
setRecs(reserved.recs);
if (activeServerId) {
if (sourceServerId) {
writeBecauseYouLikeCache({
serverId: activeServerId,
sourceKey,
filterVersion: musicLibraryFilterVersion,
anchor: reserved.anchor,
recs: reserved.recs,
@@ -458,7 +470,7 @@ export default function BecauseYouLikeRail({
}
setRefreshing(false);
// Pre-fetch the next batch so the next visit is also instant.
void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey);
void fillBecauseReserve(pool, sourceKey, sourceServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey);
return;
}
@@ -467,7 +479,7 @@ export default function BecauseYouLikeRail({
// for the next mount instead of swapping cards mid-visit.
if (snap && snap.recs.length > 0) {
setRefreshing(false);
void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey);
void fillBecauseReserve(pool, sourceKey, sourceServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey);
return;
}
@@ -481,7 +493,7 @@ export default function BecauseYouLikeRail({
setRecs([]);
}
const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey);
const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey, sourceServerId);
if (cancelled) return;
if (result) {
@@ -496,9 +508,9 @@ export default function BecauseYouLikeRail({
} catch { /* ignore */ }
setAnchor(result.anchor);
setRecs(result.recs);
if (activeServerId) {
if (sourceServerId) {
writeBecauseYouLikeCache({
serverId: activeServerId,
sourceKey,
filterVersion: musicLibraryFilterVersion,
anchor: result.anchor,
recs: result.recs,
@@ -506,7 +518,7 @@ export default function BecauseYouLikeRail({
}
setRefreshing(false);
// Pre-fetch next batch so the next visit is instant.
void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey);
void fillBecauseReserve(pool, sourceKey, sourceServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey);
} else {
// Network failed — restore session cache if available.
if (snap) {
@@ -532,7 +544,7 @@ export default function BecauseYouLikeRail({
// swap the cards — a height blip above the row that scroll anchoring turns into
// an upward viewport jump. The sibling reserve effect already keys on poolKey.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poolKey, activeServerId, musicLibraryFilterVersion, disableArtwork]);
}, [poolKey, sourceKey, sourceServerId, musicLibraryFilterVersion, disableArtwork]);
useLibraryCoverPrefetch(
disableArtwork || recs.length === 0 ? [] : [{ albums: recs, priority: 'high' }],
@@ -570,7 +582,7 @@ export default function BecauseYouLikeRail({
<div className="because-card-grid because-card-grid--stagger">
{recs.slice(0, contentSlots).map((album, index) => (
<BecauseCard
key={album.id}
key={libraryEntityKey(album)}
album={album}
anchor={anchor.name}
disableArtwork={disableArtwork}
@@ -594,12 +606,12 @@ interface CardProps {
const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, enter }: CardProps) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
});
const navigate = useNavigate();
const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScopeForServerId(album.serverId), { libraryResolve: false });
const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
@@ -607,7 +619,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
const imgSrc = coverImgSrc(coverHandle.src);
const bgResolved = coverHandle.src;
const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]);
const handleOpen = () => navigate(`/album/${album.id}`);
const handleOpen = () => navigate({ pathname: `/album/${album.id}`, search: appendServerQuery(undefined, album.serverId) });
const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
+23 -12
View File
@@ -23,6 +23,9 @@ import { playAlbum, playAlbumShuffled } from '@/features/playback/utils/playback
import { useLongPressAction } from '@/lib/hooks/useLongPressAction';
import { LongPressWaveOverlay } from '@/ui/LongPressWaveOverlay';
import { albumArtistDisplayName, deriveAlbumArtistRefs } from '@/features/album';
import { appendServerQuery } from '@/lib/navigation/detailServerScope';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
import { coverServerScopeForServerId } from '@/cover/serverScope';
const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
@@ -119,9 +122,10 @@ function HeroBg({ url, position }: { url: string; position?: string }) {
interface HeroProps {
albums?: SubsonicAlbum[];
fallbackToNetwork?: boolean;
}
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
export default function Hero({ albums: albumsProp, fallbackToNetwork = true }: HeroProps = {}) {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const navigateToAlbum = useNavigateToAlbum();
@@ -244,6 +248,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (albumsProp?.length) { setAlbums(albumsProp); return; }
if (!fallbackToNetwork) { setAlbums([]); return; }
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT;
@@ -257,6 +262,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
.catch(() => {});
}, [
albumsProp,
fallbackToNetwork,
musicLibraryFilterVersion,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
@@ -319,27 +325,32 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
// Lazily fetch format label for the currently-visible album (cached by id)
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
const albumKey = album ? libraryEntityKey(album) : '';
useEffect(() => {
if (!album || albumFormats[album.id] !== undefined) return;
if (!album || albumFormats[albumKey] !== undefined) return;
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
resolveAlbum(serverId, album.id).then(data => {
if (!data) {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
setAlbumFormats(prev => ({ ...prev, [albumKey]: '' }));
return;
}
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
setAlbumFormats(prev => ({ ...prev, [albumKey]: fmts.map(f => f.toUpperCase()).join(' / ') }));
}).catch(() => {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
setAlbumFormats(prev => ({ ...prev, [albumKey]: '' }));
});
// Intentionally keyed on album?.id only: the format label is fetched once per
// album id and cached in albumFormats. Depending on the album object or the
// albumFormats map would re-run on every render / cache write.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [album?.id]);
}, [albumKey]);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
const heroCoverRef = useAlbumCoverRef(
album?.id,
album?.coverArt,
coverServerScopeForServerId(album?.serverId),
);
const albumId = album?.id;
// Mainstage hero backdrop — the album artist's fanart (banner → 16:9 fanart),
@@ -377,8 +388,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
!perfFlags.disableMainstageHeroBackdrop &&
heroInView;
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
onShortPress: () => { if (albumId) playAlbum(albumId, album.serverId ? { serverId: album.serverId } : undefined); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId, album.serverId ? { serverId: album.serverId } : undefined); },
});
if (!album) return <div className="hero-placeholder" />;
@@ -389,14 +400,14 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
className="hero"
role="banner"
aria-label={t('hero.eyebrow')}
onClick={() => navigateToAlbum(album.id)}
onClick={() => navigateToAlbum(album.id, { search: appendServerQuery(undefined, album.serverId) })}
style={{ cursor: 'pointer' }}
>
{showHeroBackdrop && <HeroBg url={heroBackdrop.url} position={heroBackdrop.position} />}
{showHeroBackdrop && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content" key={album.id}>
<div className="hero-content" key={albumKey}>
{heroCoverRef && !isMobile && (
<CoverArtImage
coverRef={heroCoverRef}
@@ -415,7 +426,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{album.year && <span className="badge">{album.year}</span>}
{album.genre && <span className="badge">{album.genre}</span>}
{!isMobile && album.songCount && <span className="badge">{album.songCount} Tracks</span>}
{!isMobile && albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
{!isMobile && albumFormats[albumKey] && <span className="badge">{albumFormats[albumKey]}</span>}
</div>
{isMobile ? (
<div className="hero-actions-mobile" onClick={e => e.stopPropagation()}>
+8 -2
View File
@@ -15,6 +15,7 @@ import { useNavigateToAlbum } from '@/features/album';
import { useNavigateToArtist } from '@/features/artist';
import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
import { appendServerQuery } from '@/lib/navigation/detailServerScope';
interface SongCardProps {
song: SubsonicSong;
@@ -45,6 +46,7 @@ function SongCard({
const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
const navigateToAlbum = useNavigateToAlbum();
const serverSearch = appendServerQuery(undefined, song.serverId);
const handlePlay = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
@@ -62,7 +64,8 @@ function SongCard({
const handleAlbumClick = (e: React.MouseEvent) => {
if (!song.albumId) return;
e.stopPropagation();
navigateToAlbum(song.albumId);
if (serverSearch) navigateToAlbum(song.albumId, { search: serverSearch });
else navigateToAlbum(song.albumId);
};
return (
@@ -143,7 +146,10 @@ function SongCard({
<OpenArtistRefInline
refs={artistRefs}
fallbackName={song.artist}
onGoArtist={id => navigateToArtist(id)}
onGoArtist={id => {
if (serverSearch) navigateToArtist(id, { search: serverSearch });
else navigateToArtist(id);
}}
as="none"
linkTag="span"
linkClassName="track-artist-link"
+3 -2
View File
@@ -4,6 +4,7 @@ import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import SongCard from '@/features/home/components/SongCard';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { dedupeById } from '@/lib/util/dedupeById';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
interface Props {
title: string;
@@ -90,7 +91,7 @@ export default function SongRail({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uniqueSongs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
const rowArtworkResetKey = uniqueSongs[0]?.id ?? '';
const rowArtworkResetKey = uniqueSongs[0] ? libraryEntityKey(uniqueSongs[0]) : '';
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -151,7 +152,7 @@ export default function SongRail({
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueSongs.map((s, idx) => (
<SongCard
key={s.id}
key={libraryEntityKey(s)}
song={s}
disableArtwork={
artworkDisabled ||
+90 -83
View File
@@ -1,8 +1,5 @@
import { getArtists } from '@/lib/api/subsonicArtists';
import { getAlbumList, getRandomSongs } from '@/lib/api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { runLocalRandomSongs } from '@/lib/library/browseTextSearch';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import Hero from '@/features/home/components/Hero';
import { AlbumRow } from '@/features/album';
import SongRail from '@/features/home/components/SongRail';
@@ -13,11 +10,9 @@ import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '@/features/home/store/homeStore';
import { useAuthStore } from '@/store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { bumpPerfCounter } from '@/lib/perf/perfTelemetry';
import { dedupeById } from '@/lib/util/dedupeById';
import { shuffleArray } from '@/lib/util/shuffleArray';
import { libraryEntityKey } from '@/lib/library/libraryEntityKey';
import { useLibraryCoverPrefetch } from '@/cover/useLibraryCoverPrefetch';
import { primeAlbumCoversForDisplay, warmHomeMainstageCovers } from '@/cover/warmDiskPeek';
import { readBecauseYouLikeCache } from '@/features/home/store/becauseYouLikeCache';
@@ -32,12 +27,16 @@ import { useConnectionStatus } from '@/lib/hooks/useConnectionStatus';
import { useOfflineBrowseContext } from '@/features/offline';
import { useOfflineBrowseReloadToken } from '@/features/offline';
import { useDevOfflineBrowseStore } from '@/features/offline';
import { useBrowseLibraryScope } from '@/store/useBrowseLibraryScope';
import {
appendHomeAlbumPage,
loadHomeAlbumPage,
loadHomeFeed,
type HomeAlbumListType,
type HomeFeedScope,
} from '@/features/home/utils/homeFeedLoader';
import { appendServerQuery } from '@/lib/navigation/detailServerScope';
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
const HOME_RANDOM_FETCH = 100;
const HOME_HERO_COUNT = 8;
const HOME_DISCOVER_SLICE = 20;
const HOME_DISCOVER_SONGS_SIZE = 18;
const HOME_ALBUM_ROW_ARTWORK_SIZE = 300;
const HOME_SONG_RAIL_ARTWORK_SIZE = 200;
const HOME_ARTWORK_WINDOWING = true;
@@ -55,11 +54,10 @@ const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
* fully rehydrated and activeServerId is set, so on every return visit the
* first render already has data, eliminating the empty-state flash.
*/
function getInitialHomeFeed(): HomeFeedSnapshot | null {
const { activeServerId, musicLibraryFilterVersion } = useAuthStore.getState();
if (!activeServerId) return null;
return readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
?? readHomeFeedCacheStale(activeServerId);
function getInitialHomeFeed(scopeFingerprint: string, filterVersion: number): HomeFeedSnapshot | null {
if (!scopeFingerprint) return null;
return readHomeFeedCache(scopeFingerprint, filterVersion)
?? readHomeFeedCacheStale(scopeFingerprint);
}
export default function Home() {
@@ -70,6 +68,27 @@ export default function Home() {
const homeSections = useHomeStore(s => s.sections);
const activeServerId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const browseScope = useBrowseLibraryScope();
const browseServerId = browseScope.anchorServerId || activeServerId || '';
const homeScope = useMemo<HomeFeedScope>(() => ({
activeServerId: activeServerId ?? '',
browseServerId,
pairs: browseScope.pairs,
fingerprint: browseScope.fingerprint,
multiServer: browseScope.multiServer,
filterVersion: musicLibraryFilterVersion,
}), [
activeServerId,
browseServerId,
browseScope.fingerprint,
browseScope.multiServer,
browseScope.pairs,
musicLibraryFilterVersion,
]);
const homeScopeRef = useRef(homeScope);
// React Compiler refs rule: latest scope is read only after async pagination resolves.
// eslint-disable-next-line react-hooks/refs
homeScopeRef.current = homeScope;
const connStatus = useConnectionStatus().status;
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
const offlineBrowseActive = useOfflineBrowseContext().active;
@@ -82,22 +101,25 @@ export default function Home() {
// values are always used without re-triggering the effect on rehydration.
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
const [starred, setStarred] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.starred ?? []);
const [recent, setRecent] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.recent ?? []);
const [random, setRandom] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.random ?? []);
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.heroAlbums ?? []);
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.mostPlayed ?? []);
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.recentlyPlayed ?? []);
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>(() => getInitialHomeFeed()?.randomArtists ?? []);
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>(() => getInitialHomeFeed()?.discoverSongs ?? []);
const initialFeed = () => getInitialHomeFeed(homeScope.fingerprint, musicLibraryFilterVersion);
const [starred, setStarred] = useState<SubsonicAlbum[]>(() => initialFeed()?.starred ?? []);
const [recent, setRecent] = useState<SubsonicAlbum[]>(() => initialFeed()?.recent ?? []);
const [random, setRandom] = useState<SubsonicAlbum[]>(() => initialFeed()?.random ?? []);
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>(() => initialFeed()?.heroAlbums ?? []);
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>(() => initialFeed()?.mostPlayed ?? []);
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>(() => initialFeed()?.recentlyPlayed ?? []);
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>(() => initialFeed()?.randomArtists ?? []);
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>(() => initialFeed()?.discoverSongs ?? []);
// Pre-populated from cache → no loading spinner on return visits.
const [loading, setLoading] = useState(() => getInitialHomeFeed() == null);
const [loading, setLoading] = useState(() => initialFeed() == null);
// Track whether state was pre-populated from cache at mount so useEffect can
// skip re-applying the same snapshot (avoids creating new array references
// that would cause child components to re-render with unchanged data).
const [wasPrePopulated] = useState(() => getInitialHomeFeed() != null);
const [wasPrePopulated] = useState(() => initialFeed() != null);
const appliedScopeFingerprintRef = useRef(wasPrePopulated ? homeScope.fingerprint : '');
const applyFeedSnapshot = (snap: HomeFeedSnapshot) => {
appliedScopeFingerprintRef.current = snap.scopeFingerprint;
setStarred(snap.starred);
setRecent(snap.recent);
setRandom(snap.random);
@@ -130,52 +152,24 @@ export default function Home() {
useEffect(() => {
if (!activeServerId) return;
let cancelled = false;
const fetchFreshHomeFeed = async (): Promise<HomeFeedSnapshot | null> => {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMix =
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
const [s, n, rRaw, f, rp, artists, songs] = await Promise.all([
getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', randomSize).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
getAlbumList('recent', 12).catch(() => []),
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
isVisible('discoverSongs')
? (runLocalRandomSongs(activeServerId, HOME_DISCOVER_SONGS_SIZE)
.then(local => local ?? getRandomSongs(HOME_DISCOVER_SONGS_SIZE).catch(() => [] as SubsonicSong[]))
.catch(() => [] as SubsonicSong[]))
: Promise.resolve<SubsonicSong[]>([]),
]);
const r = dedupeById(await filterAlbumsByMixRatings(rRaw, mixCfg));
return {
serverId: activeServerId,
filterVersion: musicLibraryFilterVersion,
savedAt: Date.now(),
starred: dedupeById(s),
recent: dedupeById(n),
heroAlbums: r.slice(0, HOME_HERO_COUNT),
random: r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE),
mostPlayed: dedupeById(f),
recentlyPlayed: dedupeById(rp),
discoverSongs: dedupeById(songs),
randomArtists: dedupeById(shuffleArray(artists)).slice(0, 16),
};
};
const fetchFreshHomeFeed = () => loadHomeFeed(homeScope, {
discoverArtists: isVisible('discoverArtists'),
discoverSongs: isVisible('discoverSongs'),
});
const cached = readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
?? (offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null);
const cached = readHomeFeedCache(homeScope.fingerprint, musicLibraryFilterVersion)
?? (offlineBrowseActive ? readHomeFeedCacheStale(homeScope.fingerprint) : null);
if (cached) {
// When lazy initializers already pre-populated state from this same
// snapshot, re-applying it would only create new array references and
// trigger unnecessary child re-renders with identical data.
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
if (!wasPrePopulated || appliedScopeFingerprintRef.current !== homeScope.fingerprint) {
applyFeedSnapshot(cached);
}
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!wasPrePopulated) applyFeedSnapshot(cached);
setLoading(false);
void warmHomeMainstageCovers(cached);
const becauseSnap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion);
const becauseSnap = readBecauseYouLikeCache(homeScope.fingerprint, musicLibraryFilterVersion);
void primeAlbumCoversForDisplay(becauseSnap?.recs ?? [], HOME_BECAUSE_CARD_COVER_CSS_PX, {
limit: 6,
});
@@ -198,7 +192,7 @@ export default function Home() {
};
}
const stale = offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null;
const stale = offlineBrowseActive ? readHomeFeedCacheStale(homeScope.fingerprint) : null;
if (stale) {
applyFeedSnapshot(stale);
setLoading(false);
@@ -216,7 +210,7 @@ export default function Home() {
applyFeedSnapshot(snap);
if (!cancelled) setLoading(false);
void warmHomeMainstageCovers(snap);
const becauseSnap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion);
const becauseSnap = readBecauseYouLikeCache(homeScope.fingerprint, musicLibraryFilterVersion);
void primeAlbumCoversForDisplay(becauseSnap?.recs ?? [], HOME_BECAUSE_CARD_COVER_CSS_PX, {
limit: 6,
});
@@ -234,6 +228,7 @@ export default function Home() {
}, [
activeServerId,
musicLibraryFilterVersion,
homeScope,
homeSections,
offlineBrowseActive,
offlineBrowseReloadTs,
@@ -242,28 +237,29 @@ export default function Home() {
/** When offline toggles without a library-filter bump, re-apply stale cache if the feed was cleared. */
useEffect(() => {
if (!activeServerId || !offlineBrowseActive) return;
const stale = readHomeFeedCacheStale(activeServerId);
const stale = readHomeFeedCacheStale(homeScope.fingerprint);
if (!stale || isHomeFeedSnapshotEmpty(stale)) return;
if (recent.length > 0 || random.length > 0 || heroAlbums.length > 0) return;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
applyFeedSnapshot(stale);
setLoading(false);
}, [activeServerId, connStatus, devForceOffline, offlineBrowseActive]); // eslint-disable-line react-hooks/exhaustive-deps
}, [activeServerId, connStatus, devForceOffline, homeScope.fingerprint, offlineBrowseActive]); // eslint-disable-line react-hooks/exhaustive-deps
const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
type: HomeAlbumListType,
currentList: SubsonicAlbum[],
setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>>
) => {
try {
const more = await getAlbumList(type, 12, currentList.length);
const mixCfg = getMixMinRatingsConfigFromAuth();
const batchRaw =
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
const batch = dedupeById(batchRaw);
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
const requestedFingerprint = homeScope.fingerprint;
const batch = await loadHomeAlbumPage(homeScope, type, currentList.length);
setter(prev => appendHomeAlbumPage(
prev,
batch,
requestedFingerprint,
homeScopeRef.current.fingerprint,
));
} catch (e) {
console.error('Failed to load more', e);
}
@@ -315,6 +311,13 @@ export default function Home() {
reserveArtworkRow();
const becauseYouLikeHasSeed =
mostPlayed.length > 0 || recentlyPlayed.length > 0 || starred.length > 0;
const becauseYouLikeSourceServerId =
mostPlayed.find(album => album.serverId)?.serverId
?? recentlyPlayed.find(album => album.serverId)?.serverId
?? starred.find(album => album.serverId)?.serverId
?? activeServerId;
const sourceBound = (albums: SubsonicAlbum[]) =>
albums.filter(album => !becauseYouLikeSourceServerId || album.serverId === becauseYouLikeSourceServerId);
const becauseYouLikeArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
@@ -351,7 +354,9 @@ export default function Home() {
homeFlatArtworkClip ? 'home-flat-artwork-clip' : '',
].filter(Boolean).join(' ') || undefined}
>
{!loading && !perfFlags.disableMainstageHero && isVisible('hero') && <Hero albums={heroAlbums} />}
{!loading && !perfFlags.disableMainstageHero && isVisible('hero') && (
<Hero albums={heroAlbums} fallbackToNetwork={!browseScope.multiServer} />
)}
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{loading ? (
@@ -394,9 +399,11 @@ export default function Home() {
)}
{!homeAlbumRowsDisabled && isVisible('becauseYouLike') && becauseYouLikeHasSeed && (
<BecauseYouLikeRail
mostPlayed={mostPlayed}
recentlyPlayed={recentlyPlayed}
starred={starred}
sourceServerId={becauseYouLikeSourceServerId}
sourceKey={homeScope.fingerprint}
mostPlayed={sourceBound(mostPlayed)}
recentlyPlayed={sourceBound(recentlyPlayed)}
starred={sourceBound(starred)}
disableArtwork={!becauseYouLikeArtworkEnabled}
/>
)}
@@ -431,8 +438,8 @@ export default function Home() {
</NavLink>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{randomArtists.map(a => (
<button key={a.id} className="artist-ext-link" onClick={() => navigate(`/artist/${a.id}`)}>
{randomArtists.map(a => (
<button key={libraryEntityKey(a)} className="artist-ext-link" onClick={() => navigate({ pathname: `/artist/${a.id}`, search: appendServerQuery(undefined, a.serverId) })}>
{a.name}
</button>
))}
@@ -9,12 +9,23 @@ describe('becauseYouLikeCache', () => {
it('invalidates when music library filter version changes', () => {
clearBecauseYouLikeCache();
writeBecauseYouLikeCache({
serverId: 'srv-1',
sourceKey: 'scope-1',
filterVersion: 1,
anchor: { id: 'a1', name: 'Artist' },
recs: [{ id: 'alb-1', name: 'Album', artist: 'Artist', artistId: 'a1', songCount: 1, duration: 1 }],
});
expect(readBecauseYouLikeCache('srv-1', 1)?.recs).toHaveLength(1);
expect(readBecauseYouLikeCache('srv-1', 2)).toBeNull();
expect(readBecauseYouLikeCache('scope-1', 1)?.recs).toHaveLength(1);
expect(readBecauseYouLikeCache('scope-1', 2)).toBeNull();
});
it('invalidates when the browse scope fingerprint changes', () => {
clearBecauseYouLikeCache();
writeBecauseYouLikeCache({
sourceKey: 'scope-a',
filterVersion: 1,
anchor: { id: 'a1', name: 'Artist' },
recs: [{ id: 'alb-1', name: 'Album', artist: 'Artist', artistId: 'a1', songCount: 1, duration: 1 }],
});
expect(readBecauseYouLikeCache('scope-b', 1)).toBeNull();
});
});
@@ -3,7 +3,7 @@ import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
export type BecauseYouLikeAnchor = { id: string; name: string };
export type BecauseYouLikeSnapshot = {
serverId: string;
sourceKey: string;
filterVersion: number;
savedAt: number;
anchor: BecauseYouLikeAnchor;
@@ -14,11 +14,11 @@ const TTL_MS = 15 * 60 * 1000;
let snapshot: BecauseYouLikeSnapshot | null = null;
export function readBecauseYouLikeCache(
serverId: string | null | undefined,
sourceKey: string | null | undefined,
filterVersion: number,
): BecauseYouLikeSnapshot | null {
if (!serverId || !snapshot) return null;
if (snapshot.serverId !== serverId || snapshot.filterVersion !== filterVersion) return null;
if (!sourceKey || !snapshot) return null;
if (snapshot.sourceKey !== sourceKey || snapshot.filterVersion !== filterVersion) return null;
if (Date.now() - snapshot.savedAt > TTL_MS) return null;
return snapshot;
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
clearHomeFeedCache,
readHomeFeedCache,
readHomeFeedCacheStale,
writeHomeFeedCache,
} from '@/features/home/store/homeFeedCache';
const emptyFeed = {
scopeFingerprint: 'scope-a',
filterVersion: 1,
starred: [],
recent: [],
random: [],
heroAlbums: [],
mostPlayed: [],
recentlyPlayed: [],
randomArtists: [],
discoverSongs: [],
};
describe('homeFeedCache', () => {
it('keys fresh and stale snapshots by ordered browse fingerprint', () => {
clearHomeFeedCache();
writeHomeFeedCache(emptyFeed);
expect(readHomeFeedCache('scope-a', 1)).not.toBeNull();
expect(readHomeFeedCache('scope-b', 1)).toBeNull();
expect(readHomeFeedCacheStale('scope-b')).toBeNull();
});
});
+7 -7
View File
@@ -2,7 +2,7 @@ import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subs
/** Session cache so leaving Mainstage and returning does not refetch + reshuffle everything. */
export type HomeFeedSnapshot = {
serverId: string;
scopeFingerprint: string;
filterVersion: number;
savedAt: number;
starred: SubsonicAlbum[];
@@ -19,21 +19,21 @@ const TTL_MS = 15 * 60 * 1000;
let snapshot: HomeFeedSnapshot | null = null;
export function readHomeFeedCache(
serverId: string | null | undefined,
scopeFingerprint: string | null | undefined,
filterVersion: number,
): HomeFeedSnapshot | null {
if (!serverId || !snapshot) return null;
if (snapshot.serverId !== serverId || snapshot.filterVersion !== filterVersion) return null;
if (!scopeFingerprint || !snapshot) return null;
if (snapshot.scopeFingerprint !== scopeFingerprint || snapshot.filterVersion !== filterVersion) return null;
if (Date.now() - snapshot.savedAt > TTL_MS) return null;
return snapshot;
}
/** Last good snapshot for this server when filter version changed (e.g. offline filter suspend). */
export function readHomeFeedCacheStale(
serverId: string | null | undefined,
scopeFingerprint: string | null | undefined,
): HomeFeedSnapshot | null {
if (!serverId || !snapshot) return null;
if (snapshot.serverId !== serverId) return null;
if (!scopeFingerprint || !snapshot) return null;
if (snapshot.scopeFingerprint !== scopeFingerprint) return null;
if (Date.now() - snapshot.savedAt > TTL_MS) return null;
return snapshot;
}
@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getArtists } from '@/lib/api/subsonicArtists';
import { getAlbumList, getRandomSongs } from '@/lib/api/subsonicLibrary';
import {
libraryAdvancedSearch,
libraryScopeListArtists,
libraryScopeMostPlayedAlbums,
} from '@/lib/api/library';
import { runLocalRandomAlbums } from '@/lib/library/browseTextSearch';
import { runLocalRandomSongs } from '@/lib/library/randomScopeReads';
import { appendHomeAlbumPage, loadHomeAlbumPage, loadHomeFeed, type HomeFeedScope } from './homeFeedLoader';
vi.mock('@/lib/api/subsonicArtists');
vi.mock('@/lib/api/subsonicLibrary');
vi.mock('@/lib/api/library');
vi.mock('@/lib/library/browseTextSearch');
vi.mock('@/lib/library/randomScopeReads');
vi.mock('@/features/playback/utils/mixRatingFilter', () => ({
getMixMinRatingsConfigFromAuth: () => ({ enabled: false, minSong: 0, minAlbum: 0, minArtist: 0 }),
filterAlbumsByMixRatings: async (albums: unknown[]) => albums,
}));
const multiScope: HomeFeedScope = {
activeServerId: 'offline-active',
browseServerId: 'online-selected',
pairs: [{ serverId: 'online-selected', libraryId: 'music' }],
fingerprint: '[["online-selected","music"]]',
multiServer: true,
filterVersion: 3,
};
const albumDto = (serverId: string, id: string, name: string) => ({
serverId,
id,
name,
artist: `${name} Artist`,
artistId: `${id}-artist`,
songCount: 1,
durationSec: 60,
syncedAt: 1,
rawJson: {},
});
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(libraryAdvancedSearch).mockResolvedValue({
artists: [], albums: [], tracks: [], totals: { artists: 0, albums: 0, tracks: 0 }, appliedFilters: [], source: 'local',
});
vi.mocked(libraryScopeListArtists).mockResolvedValue([]);
vi.mocked(libraryScopeMostPlayedAlbums).mockResolvedValue([]);
vi.mocked(runLocalRandomAlbums).mockResolvedValue([]);
vi.mocked(runLocalRandomSongs).mockResolvedValue([]);
});
describe('homeFeedLoader multi-server contract', () => {
it('uses the reachable browse anchor and local merged readers without active-server network fallback', async () => {
vi.mocked(libraryAdvancedSearch)
.mockResolvedValueOnce({
artists: [], albums: [albumDto('online-selected', 'starred', 'Starred')], tracks: [],
totals: { artists: 0, albums: 1, tracks: 0 }, appliedFilters: ['starred'], source: 'local',
})
.mockResolvedValueOnce({
artists: [], albums: [albumDto('online-selected', 'newest', 'Newest')], tracks: [],
totals: { artists: 0, albums: 1, tracks: 0 }, appliedFilters: [], source: 'local',
});
vi.mocked(runLocalRandomAlbums).mockResolvedValue([
{ id: 'random', name: 'Random', artist: 'Artist', artistId: 'artist', songCount: 1, duration: 60, serverId: 'online-selected' },
]);
vi.mocked(libraryScopeMostPlayedAlbums).mockResolvedValue([{
album: albumDto('online-selected', 'frequent', 'Frequent'), playCount: 8,
}]);
vi.mocked(libraryScopeListArtists).mockResolvedValue([{
serverId: 'online-selected', id: 'artist', name: 'Artist', syncedAt: 1, rawJson: {},
}]);
vi.mocked(runLocalRandomSongs).mockResolvedValue([{
id: 'song', title: 'Song', artist: 'Artist', album: 'Album', albumId: 'album', duration: 60, serverId: 'online-selected',
}]);
const feed = await loadHomeFeed(multiScope, { discoverArtists: true, discoverSongs: true });
expect(feed.scopeFingerprint).toBe(multiScope.fingerprint);
expect(feed.starred[0]?.serverId).toBe('online-selected');
expect(feed.recent[0]?.serverId).toBe('online-selected');
expect(feed.mostPlayed[0]).toMatchObject({ serverId: 'online-selected', playCount: 8 });
expect(feed.randomArtists[0]?.serverId).toBe('online-selected');
expect(feed.discoverSongs[0]?.serverId).toBe('online-selected');
expect(feed.recentlyPlayed).toEqual([]);
expect(getAlbumList).not.toHaveBeenCalled();
expect(getArtists).not.toHaveBeenCalled();
expect(getRandomSongs).not.toHaveBeenCalled();
expect(libraryAdvancedSearch).toHaveBeenCalledWith(expect.objectContaining({
serverId: 'online-selected',
libraryScopes: multiScope.pairs,
}));
});
it('never falls back to active-server random network data when the merged index is unavailable', async () => {
vi.mocked(runLocalRandomAlbums).mockResolvedValue(null);
expect(await loadHomeAlbumPage(multiScope, 'random', 12)).toEqual([]);
expect(getAlbumList).not.toHaveBeenCalled();
});
it('keeps same raw ids from different servers as distinct composite entities', async () => {
vi.mocked(libraryScopeMostPlayedAlbums).mockResolvedValue([
{ album: albumDto('server-a', 'same', 'Album A'), playCount: 9 },
{ album: albumDto('server-b', 'same', 'Album B'), playCount: 7 },
]);
const page = await loadHomeAlbumPage(multiScope, 'frequent', 0);
expect(page).toHaveLength(2);
expect(page.map(album => `${album.serverId}:${album.id}`)).toEqual(['server-a:same', 'server-b:same']);
});
it('preserves single-server network behavior and stamps provenance', async () => {
const singleScope = { ...multiScope, activeServerId: 'single', browseServerId: 'single', pairs: [], fingerprint: 'single', multiServer: false };
vi.mocked(getAlbumList).mockResolvedValue([
{ id: 'same-id', name: 'Album', artist: 'Artist', artistId: 'artist', songCount: 1, duration: 60 },
]);
const page = await loadHomeAlbumPage(singleScope, 'newest', 0);
expect(getAlbumList).toHaveBeenCalledWith('newest', 12, 0);
expect(page).toEqual([expect.objectContaining({ id: 'same-id', serverId: 'single' })]);
expect(libraryAdvancedSearch).not.toHaveBeenCalled();
});
it('drops stale load-more results after the scope fingerprint changes', () => {
const current = [{
id: 'one', name: 'One', artist: 'Artist', artistId: 'artist', songCount: 1, duration: 60, serverId: 'server-a',
}];
const staleBatch = [{
id: 'two', name: 'Two', artist: 'Artist', artistId: 'artist', songCount: 1, duration: 60, serverId: 'server-b',
}];
expect(appendHomeAlbumPage(current, staleBatch, 'scope-a', 'scope-b')).toBe(current);
expect(appendHomeAlbumPage(current, staleBatch, 'scope-a', 'scope-a')).toHaveLength(2);
});
});
+185
View File
@@ -0,0 +1,185 @@
import { getArtists } from '@/lib/api/subsonicArtists';
import { getAlbumList, getRandomSongs } from '@/lib/api/subsonicLibrary';
import {
libraryAdvancedSearch,
libraryScopeListArtists,
libraryScopeMostPlayedAlbums,
type LibraryScopePair,
} from '@/lib/api/library';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { albumToAlbum, artistToArtist } from '@/lib/library/advancedSearchLocal';
import { runLocalRandomAlbums } from '@/lib/library/browseTextSearch';
import { runLocalRandomSongs } from '@/lib/library/randomScopeReads';
import { dedupeById } from '@/lib/util/dedupeById';
import { shuffleArray } from '@/lib/util/shuffleArray';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback';
import type { HomeFeedSnapshot } from '@/features/home/store/homeFeedCache';
const HOME_RANDOM_FETCH = 100;
const HOME_HERO_COUNT = 8;
const HOME_DISCOVER_SLICE = 20;
const HOME_DISCOVER_SONGS_SIZE = 18;
export type HomeAlbumListType = 'starred' | 'newest' | 'random' | 'frequent' | 'recent';
export interface HomeFeedScope {
activeServerId: string;
browseServerId: string;
pairs: LibraryScopePair[];
fingerprint: string;
multiServer: boolean;
filterVersion: number;
}
interface HomeFeedVisibility {
discoverArtists: boolean;
discoverSongs: boolean;
}
function withServerId<T extends { serverId?: string | null }>(items: T[], serverId: string): T[] {
return items.map(item => item.serverId ? item : { ...item, serverId });
}
async function loadScopedAlbums(
scope: HomeFeedScope,
type: HomeAlbumListType,
limit: number,
offset = 0,
): Promise<SubsonicAlbum[]> {
if (!scope.multiServer) {
return withServerId(await getAlbumList(type, limit, offset).catch(() => []), scope.activeServerId);
}
if (!scope.browseServerId || scope.pairs.length === 0) return [];
if (type === 'frequent') {
return libraryScopeMostPlayedAlbums(scope.browseServerId, {
scopes: scope.pairs,
limit,
offset,
}).then(rows => rows.map(row => ({
...albumToAlbum(row.album),
playCount: row.playCount,
}))).catch(() => []);
}
if (type === 'random') {
return (await runLocalRandomAlbums(scope.browseServerId, limit, scope.pairs)) ?? [];
}
if (type === 'newest') {
return libraryAdvancedSearch({
serverId: scope.browseServerId,
libraryScopes: scope.pairs,
entityTypes: ['album'],
sort: [
{ field: 'synced', dir: 'desc' },
{ field: 'year', dir: 'desc' },
{ field: 'name', dir: 'asc' },
],
limit,
offset,
skipTotals: true,
}).then(response => response.albums.map(albumToAlbum)).catch(() => []);
}
if (type === 'starred') {
return libraryAdvancedSearch({
serverId: scope.browseServerId,
libraryScopes: scope.pairs,
entityTypes: ['album'],
filters: [{ field: 'starred', op: 'is_true' }],
sort: [
{ field: 'name', dir: 'asc' },
{ field: 'artist', dir: 'asc' },
],
limit,
offset,
skipTotals: true,
}).then(response => response.albums.map(albumToAlbum)).catch(() => []);
}
// There is no merged album-level recently-played reader in the local index yet.
return [];
}
async function loadScopedArtists(scope: HomeFeedScope): Promise<SubsonicArtist[]> {
if (!scope.multiServer) {
return withServerId(await getArtists().catch(() => []), scope.activeServerId);
}
if (!scope.browseServerId || scope.pairs.length === 0) return [];
return libraryScopeListArtists(scope.browseServerId, {
scopes: scope.pairs,
sort: 'name',
limit: 500,
}).then(rows => rows.map(artistToArtist)).catch(() => []);
}
async function loadScopedRandomSongs(scope: HomeFeedScope): Promise<SubsonicSong[]> {
const local = await runLocalRandomSongs(
scope.browseServerId || scope.activeServerId,
HOME_DISCOVER_SONGS_SIZE,
undefined,
scope.pairs,
).catch(() => null);
if (local) return local;
if (scope.multiServer) return [];
return withServerId(
await getRandomSongs(HOME_DISCOVER_SONGS_SIZE).catch(() => [] as SubsonicSong[]),
scope.activeServerId,
);
}
export async function loadHomeAlbumPage(
scope: HomeFeedScope,
type: HomeAlbumListType,
offset: number,
limit = 12,
): Promise<SubsonicAlbum[]> {
const raw = await loadScopedAlbums(scope, type, limit, offset);
if (type !== 'random' || scope.multiServer) return dedupeById(raw);
return dedupeById(await filterAlbumsByMixRatings(raw, getMixMinRatingsConfigFromAuth()));
}
export function appendHomeAlbumPage(
current: SubsonicAlbum[],
batch: SubsonicAlbum[],
requestedFingerprint: string,
currentFingerprint: string,
): SubsonicAlbum[] {
if (requestedFingerprint !== currentFingerprint) return current;
return dedupeById([...current, ...batch]);
}
export async function loadHomeFeed(
scope: HomeFeedScope,
visibility: HomeFeedVisibility,
): Promise<HomeFeedSnapshot> {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMix = mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
const [starred, newest, randomRaw, frequent, recent, artists, songs] = await Promise.all([
loadScopedAlbums(scope, 'starred', 12),
loadScopedAlbums(scope, 'newest', 12),
loadScopedAlbums(scope, 'random', randomSize),
loadScopedAlbums(scope, 'frequent', 12),
loadScopedAlbums(scope, 'recent', 12),
visibility.discoverArtists ? loadScopedArtists(scope) : Promise.resolve<SubsonicArtist[]>([]),
visibility.discoverSongs ? loadScopedRandomSongs(scope) : Promise.resolve<SubsonicSong[]>([]),
]);
const random = dedupeById(scope.multiServer
? randomRaw
: await filterAlbumsByMixRatings(randomRaw, mixCfg));
return {
scopeFingerprint: scope.fingerprint,
filterVersion: scope.filterVersion,
savedAt: Date.now(),
starred: dedupeById(starred),
recent: dedupeById(newest),
heroAlbums: random.slice(0, HOME_HERO_COUNT),
random: random.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE),
mostPlayed: dedupeById(frequent),
recentlyPlayed: dedupeById(recent),
discoverSongs: dedupeById(songs),
randomArtists: dedupeById(shuffleArray(artists)).slice(0, 16),
};
}
@@ -1,4 +1,4 @@
import { star, unstar } from '@/lib/api/subsonicStarRating';
import { queueSongStar } from '@/features/playback';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { emit } from '@tauri-apps/api/event';
@@ -62,12 +62,7 @@ export default function MiniContextMenu({ x, y, track, index, onClose }: Props)
const toggleStar = async () => {
const next = !starred;
setStarred(next);
try {
if (next) await star(track.id, 'song');
else await unstar(track.id, 'song');
} catch {
setStarred(!next);
}
queueSongStar(track.id, next);
};
return createPortal(
@@ -1,4 +1,5 @@
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
import { queueSongStar } from '@/features/playback';
import { entityOverrideKey } from '@/lib/media/entityOverrideKey';
import { usePlaybackCoverArt } from '@/cover/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
import type { Track } from '@/lib/media/trackTypes';
@@ -252,7 +253,9 @@ export default function MobilePlayerView() {
// Star / favorite
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
? (entityOverrideKey(currentTrack.serverId, currentTrack.id) in starredOverrides
? starredOverrides[entityOverrideKey(currentTrack.serverId, currentTrack.id)]
: !!currentTrack.starred)
: false;
const toggleStar = useCallback(() => {
+2 -1
View File
@@ -9,6 +9,7 @@ import { usePlaybackServerId } from '@/features/playback/hooks/usePlaybackServer
import { useTranslation } from 'react-i18next';
import { Music, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { entityOverrideKey } from '@/lib/media/entityOverrideKey';
import { useAuthStore } from '@/store/authStore';
import { useLyricsStore } from '@/store/lyricsStore';
import { songToTrack } from '@/lib/media/songToTrack';
@@ -276,7 +277,7 @@ export default function NowPlaying() {
artistRefs={trackArtistRefs.length > 0 ? trackArtistRefs : undefined}
genre={songMeta?.genre ?? undefined}
playCount={(songMeta as (SubsonicSong & { playCount?: number }) | null)?.playCount}
userRatingOverride={userRatingOverrides[currentTrack.id]}
userRatingOverride={userRatingOverrides[entityOverrideKey(currentTrack.serverId ?? playbackServerId, currentTrack.id)]}
networkTrack={networkTrack}
networkArtist={networkArtist}
starred={starred}
+10 -6
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { cancelOfflineDownloads } from '@/lib/api/syncfs';
export interface DownloadJob {
serverId?: string;
trackId: string;
albumId: string;
albumName: string;
@@ -14,6 +15,7 @@ export interface DownloadJob {
}
export interface OfflinePinQueueEntry {
serverId?: string;
albumId: string;
albumName: string;
pinKind: 'album' | 'playlist' | 'artist' | 'track';
@@ -26,8 +28,8 @@ interface OfflineJobState {
/** Album / playlist / artist pins waiting for or undergoing download. */
pinQueue: OfflinePinQueueEntry[];
bulkProgress: Record<string, { done: number; total: number }>;
setPinQueueStatus: (albumId: string, status: OfflinePinQueueEntry['status']) => void;
removePinFromQueue: (albumId: string) => void;
setPinQueueStatus: (albumId: string, status: OfflinePinQueueEntry['status'], serverId?: string) => void;
removePinFromQueue: (albumId: string, serverId?: string) => void;
bumpBulkProgressDone: (groupId: string) => void;
cancelDownload: (albumId: string) => void;
cancelAllDownloads: () => void;
@@ -49,15 +51,17 @@ export const useOfflineJobStore = create<OfflineJobState>()((set, get) => ({
pinQueue: [],
bulkProgress: {},
setPinQueueStatus: (albumId, status) => {
setPinQueueStatus: (albumId, status, serverId) => {
set(state => ({
pinQueue: state.pinQueue.map(p => (p.albumId === albumId ? { ...p, status } : p)),
pinQueue: state.pinQueue.map(p => (
p.albumId === albumId && (!serverId || p.serverId === serverId) ? { ...p, status } : p
)),
}));
},
removePinFromQueue: (albumId) => {
removePinFromQueue: (albumId, serverId) => {
set(state => ({
pinQueue: state.pinQueue.filter(p => p.albumId !== albumId),
pinQueue: state.pinQueue.filter(p => p.albumId !== albumId || (!!serverId && p.serverId !== serverId)),
}));
},
@@ -147,6 +147,7 @@ async function runOfflinePinDownload(task: OfflinePinTask): Promise<void> {
jobs: [
...state.jobs.filter(j => j.albumId !== albumId),
...pendingSongs.map((s, i) => ({
serverId,
trackId: s.id,
albumId,
albumName,
@@ -54,6 +54,20 @@ describe('offlinePinQueue', () => {
await vi.waitFor(() => expect(useOfflineJobStore.getState().pinQueue).toHaveLength(0));
});
it('dequeues only the matching server when ids collide', () => {
useOfflineJobStore.setState({
pinQueue: [
{ serverId: 'a', albumId: 'same', albumName: 'A', pinKind: 'playlist', status: 'queued', queuedAt: 1 },
{ serverId: 'b', albumId: 'same', albumName: 'B', pinKind: 'playlist', status: 'queued', queuedAt: 2 },
],
});
expect(dequeueOfflinePin('same', 'b')).toBe(true);
expect(useOfflineJobStore.getState().pinQueue).toEqual([
{ serverId: 'a', albumId: 'same', albumName: 'A', pinKind: 'playlist', status: 'queued', queuedAt: 1 },
]);
});
it('allows re-enqueue after cancelDownload (e.g. remove offline cache)', async () => {
const ran: string[] = [];
registerOfflinePinExecutor(async task => {
@@ -119,6 +133,25 @@ describe('offlinePinQueue', () => {
expect(useOfflineJobStore.getState().pinQueue).toHaveLength(1);
});
it('keeps the concrete server source when equal raw ids are pinned', async () => {
const gate = { unblock: undefined as (() => void) | undefined };
let calls = 0;
registerOfflinePinExecutor(async () => {
calls += 1;
if (calls === 1) await new Promise<void>(resolve => { gate.unblock = resolve; });
});
const base = {
albumId: 'same', albumName: 'Same', albumArtist: '', coverArt: undefined,
year: undefined, songs: [], type: 'album' as const,
};
expect(enqueueOfflinePin({ ...base, serverId: 'a' })).toBe(true);
expect(enqueueOfflinePin({ ...base, serverId: 'b' })).toBe(true);
await vi.waitFor(() => expect(useOfflineJobStore.getState().pinQueue).toHaveLength(2));
expect(useOfflineJobStore.getState().pinQueue.map(pin => pin.serverId)).toEqual(['a', 'b']);
gate.unblock?.();
await vi.waitFor(() => expect(useOfflineJobStore.getState().pinQueue).toHaveLength(0));
});
it('does not replace the in-flight task when a download is active', async () => {
let capturedTrackIds: string[] = [];
const gate = { unblock: undefined as (() => void) | undefined };
+26 -20
View File
@@ -35,31 +35,36 @@ export function clearOfflinePinTasks(): void {
pinTasks.clear();
}
export function removeOfflinePinTask(albumId: string): void {
pinTasks.delete(albumId);
function pinKey(serverId: string | undefined, albumId: string): string {
return `${serverId}:${albumId}`;
}
export function removeOfflinePinTask(albumId: string, serverId?: string): void {
if (serverId) pinTasks.delete(pinKey(serverId, albumId));
else for (const key of pinTasks.keys()) if (key.endsWith(`:${albumId}`)) pinTasks.delete(key);
}
/** True when the album is waiting in the pin queue (not actively downloading). */
export function isAlbumPinQueued(albumId: string): boolean {
export function isAlbumPinQueued(albumId: string, serverId?: string): boolean {
return useOfflineJobStore.getState().pinQueue.some(
p => p.albumId === albumId && p.status === 'queued',
p => p.albumId === albumId && (!serverId || p.serverId === serverId) && p.status === 'queued',
);
}
/** Remove a queued pin before download starts. No-op if already downloading. */
export function dequeueOfflinePin(albumId: string): boolean {
export function dequeueOfflinePin(albumId: string, serverId?: string): boolean {
const store = useOfflineJobStore.getState();
const entry = store.pinQueue.find(p => p.albumId === albumId);
const entry = store.pinQueue.find(p => p.albumId === albumId && (!serverId || p.serverId === serverId));
if (!entry || entry.status !== 'queued') return false;
cancelledDownloads.add(albumId);
removeOfflinePinTask(albumId);
store.removePinFromQueue(albumId);
removeOfflinePinTask(albumId, serverId);
store.removePinFromQueue(albumId, serverId);
return true;
}
function isPinAlreadyScheduled(albumId: string): boolean {
function isPinAlreadyScheduled(serverId: string, albumId: string): boolean {
const { pinQueue } = useOfflineJobStore.getState();
return pinQueue.some(p => p.albumId === albumId);
return pinQueue.some(p => p.serverId === serverId && p.albumId === albumId);
}
/**
@@ -70,22 +75,23 @@ export function enqueueOfflinePin(task: OfflinePinTask): boolean {
cancelledDownloads.delete(task.albumId);
const store = useOfflineJobStore.getState();
const existing = store.pinQueue.find(p => p.albumId === task.albumId);
const existing = store.pinQueue.find(p => p.serverId === task.serverId && p.albumId === task.albumId);
if (existing?.status === 'downloading') {
return false;
}
pinTasks.set(task.albumId, task);
pinTasks.set(pinKey(task.serverId, task.albumId), task);
if (existing?.status === 'queued') {
scheduleOfflinePinQueue();
return true;
}
if (isPinAlreadyScheduled(task.albumId)) {
if (isPinAlreadyScheduled(task.serverId, task.albumId)) {
return false;
}
const entry: OfflinePinQueueEntry = {
serverId: task.serverId,
albumId: task.albumId,
albumName: task.albumName,
pinKind: task.type,
@@ -113,18 +119,18 @@ async function drainOfflinePinQueue(): Promise<void> {
if (!next) break;
if (cancelledDownloads.has(next.albumId)) {
store.removePinFromQueue(next.albumId);
pinTasks.delete(next.albumId);
store.removePinFromQueue(next.albumId, next.serverId);
pinTasks.delete(pinKey(next.serverId, next.albumId));
continue;
}
const task = pinTasks.get(next.albumId);
const task = pinTasks.get(pinKey(next.serverId, next.albumId));
if (!task) {
store.removePinFromQueue(next.albumId);
store.removePinFromQueue(next.albumId, next.serverId);
continue;
}
store.setPinQueueStatus(next.albumId, 'downloading');
store.setPinQueueStatus(next.albumId, 'downloading', next.serverId);
try {
await executor(task);
} catch {
@@ -133,8 +139,8 @@ async function drainOfflinePinQueue(): Promise<void> {
if (task.artistProgressGroupId) {
store.bumpBulkProgressDone(task.artistProgressGroupId);
}
store.removePinFromQueue(next.albumId);
pinTasks.delete(next.albumId);
store.removePinFromQueue(next.albumId, next.serverId);
pinTasks.delete(pinKey(next.serverId, next.albumId));
}
}
} finally {
@@ -14,6 +14,7 @@ import {
isActiveServerReachable,
onActiveServerBecameReachable,
} from '@/lib/network/activeServerReachability';
import { getLibraryServerConnection } from '@/lib/network/libraryServerReachability';
import { resolveIndexKey, serverIndexKeyForProfile } from '@/lib/server/serverIndexKey';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
import { findLocalPlaybackEntry } from '@/store/localPlaybackResolve';
@@ -43,6 +44,12 @@ function serverIndexKeyForOffline(serverId: string): string {
return resolveIndexKey(serverId) || serverId;
}
function isPinnedSourceServerReachable(serverId: string): boolean {
const activeServerId = useAuthStore.getState().activeServerId;
if (activeServerId === serverId) return isActiveServerReachable();
return getLibraryServerConnection(resolveIndexKey(serverId)) === 'online';
}
function belongsToProfile(metaServerKey: string, profileServerId: string): boolean {
const indexKey = serverIndexKeyForOffline(profileServerId);
return metaServerKey === profileServerId
@@ -464,7 +471,7 @@ export function schedulePinnedPlaylistSync(playlistId: string, serverId?: string
if (!playlistId || !sid) return;
if (!isSourcePinnedOffline(playlistId, sid, 'playlist')) return;
if (!isManualOfflinePlaylist(playlistId, sid)) return;
if (!isActiveServerReachable()) return;
if (!isPinnedSourceServerReachable(sid)) return;
pushUniquePlaylistJob(playlistId, sid);
scheduleDebouncedPlaylistSync();
}
@@ -2,14 +2,13 @@ import { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, LogIn, ClipboardPaste } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import {
parseOrbitShareLink,
findSessionPlaylistId,
readOrbitState,
joinOrbitSession,
} from '@/features/orbit/utils/orbit';
import { switchActiveServer } from '@/utils/server/switchActiveServer';
import { activateOrbitInviteServer } from '@/utils/server/switchActiveServer';
import { useOrbitAccountPickerStore } from '@/features/orbit/store/orbitAccountPickerStore';
import { showToast } from '@/lib/dom/toast';
@@ -43,31 +42,21 @@ export default function OrbitJoinModal({ onClose }: Props) {
const parsed = parseOrbitShareLink(text);
if (!parsed) { setError(t('orbit.joinErrInvalid')); return; }
const active = useAuthStore.getState().getActiveServer();
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
const wantUrl = parsed.serverBase.replace(/\/+$/, '');
setBusy(true);
try {
// Auto-switch to the link's server if the user has an account for it.
// Multiple candidates → picker modal. switch tears down any lingering
// orbit session.
if (activeUrl !== wantUrl) {
const candidates = useAuthStore.getState().servers
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
if (candidates.length === 0) {
const activation = await activateOrbitInviteServer(
parsed.serverBase,
accounts => useOrbitAccountPickerStore.getState().request(accounts),
);
if (!activation.ok) {
if (activation.reason === 'no-account') {
setError(t('orbit.toastNoAccountForServer', { url: wantUrl }));
return;
}
const target = candidates.length === 1
? candidates[0]
: await useOrbitAccountPickerStore.getState().request(candidates);
if (!target) { setBusy(false); return; }
const switched = await switchActiveServer(target);
if (!switched) {
} else if (activation.reason === 'switch-failed') {
setError(t('orbit.toastSwitchFailed', { url: wantUrl }));
return;
}
return;
}
const playlistId = await findSessionPlaylistId(parsed.sid);
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import OrbitStartTrigger from '@/features/orbit/components/OrbitStartTrigger';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { resetAllStores } from '@/test/helpers/storeReset';
import { useAuthStore } from '@/store/authStore';
import { makeServer } from '@/test/helpers/factories';
describe('OrbitStartTrigger host gate', () => {
beforeEach(resetAllStores);
it('keeps Join available but disables Create for a multi-server browse scope', () => {
const first = makeServer({ id: 'a' });
const second = makeServer({ id: 'b' });
useAuthStore.setState({
servers: [first, second],
activeServerId: first.id,
musicLibraryServerIds: [first.id, second.id],
showOrbitTrigger: true,
});
const { getByRole, queryByRole } = renderWithProviders(<OrbitStartTrigger />);
fireEvent.click(getByRole('button', { name: 'Orbit' }));
expect(getByRole('menuitem', { name: 'Create a session' })).toHaveAttribute('aria-disabled', 'true');
expect(getByRole('menuitem', { name: 'Join a session' })).toBeEnabled();
expect(queryByRole('dialog', { name: /Listen together/i })).not.toBeInTheDocument();
});
it('exposes a menu, focuses the first available action, explains disabled Create, and restores focus', async () => {
const user = userEvent.setup();
const first = makeServer({ id: 'a' });
const second = makeServer({ id: 'b' });
useAuthStore.setState({
servers: [first, second],
activeServerId: first.id,
musicLibraryServerIds: [first.id, second.id],
showOrbitTrigger: true,
});
renderWithProviders(<OrbitStartTrigger />);
const trigger = screen.getByRole('button', { name: 'Orbit' });
await user.click(trigger);
const menu = screen.getByRole('menu', { name: 'Orbit' });
const create = screen.getByRole('menuitem', { name: 'Create a session' });
const join = screen.getByRole('menuitem', { name: 'Join a session' });
expect(create).toHaveAttribute('aria-disabled', 'true');
expect(create).toHaveAccessibleDescription(/choose one browse server/i);
await waitFor(() => expect(create).toHaveFocus());
await user.keyboard('{ArrowDown}');
expect(join).toHaveFocus();
expect(menu).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
@@ -19,12 +19,14 @@ export default function OrbitStartTrigger() {
const { t } = useTranslation();
const role = useOrbitStore(s => s.role);
const visible = useAuthStore(s => s.showOrbitTrigger);
const hostBlocked = useAuthStore(s => s.musicLibraryServerIds.length > 1);
const [popoverOpen, setPopoverOpen] = useState(false);
const [startOpen, setStartOpen] = useState(false);
const [joinOpen, setJoinOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const firstEnabledItemRef = useRef<HTMLButtonElement>(null);
// Close popover on outside click / Escape.
useEffect(() => {
@@ -35,7 +37,13 @@ export default function OrbitStartTrigger() {
if (btnRef.current?.contains(target)) return;
setPopoverOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPopoverOpen(false); };
queueMicrotask(() => firstEnabledItemRef.current?.focus());
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.preventDefault();
setPopoverOpen(false);
btnRef.current?.focus();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
@@ -59,7 +67,11 @@ export default function OrbitStartTrigger() {
}
: { display: 'none' };
const pickCreate = () => { setPopoverOpen(false); setStartOpen(true); };
const pickCreate = () => {
if (hostBlocked) return;
setPopoverOpen(false);
setStartOpen(true);
};
const pickJoin = () => { setPopoverOpen(false); setJoinOpen(true); };
const pickHelp = () => { setPopoverOpen(false); useHelpModalStore.getState().open(); };
@@ -84,12 +96,49 @@ export default function OrbitStartTrigger() {
</button>
{popoverOpen && createPortal(
<div ref={popRef} className="nav-library-dropdown-panel orbit-launch-pop" style={popoverStyle} role="menu">
<button type="button" className="orbit-launch-pop__item" onClick={pickCreate}>
<div
ref={popRef}
className="nav-library-dropdown-panel orbit-launch-pop"
style={popoverStyle}
role="menu"
aria-label={t('orbit.triggerLabel')}
onKeyDown={event => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)'),
);
const current = items.indexOf(document.activeElement as HTMLButtonElement);
const delta = event.key === 'ArrowDown' ? 1 : -1;
items[(current + delta + items.length) % items.length]?.focus();
}}
>
<button
type="button"
className="orbit-launch-pop__item"
onClick={pickCreate}
aria-disabled={hostBlocked || undefined}
aria-describedby={hostBlocked ? 'orbit-create-scope-help' : undefined}
role="menuitem"
ref={firstEnabledItemRef}
>
<Plus size={14} />
<span>{t('orbit.launchCreate')}</span>
</button>
<button type="button" className="orbit-launch-pop__item" onClick={pickJoin}>
{hostBlocked && (
<p
id="orbit-create-scope-help"
style={{ margin: '0.25rem 0.75rem 0.5rem', maxWidth: '18rem', color: 'var(--text-muted)', fontSize: '0.75rem' }}
>
{t('orbit.launchCreateMultiServerBlocked')}
</p>
)}
<button
type="button"
className="orbit-launch-pop__item"
onClick={pickJoin}
role="menuitem"
>
<LogIn size={14} />
<span>{t('orbit.launchJoin')}</span>
</button>
@@ -97,6 +146,7 @@ export default function OrbitStartTrigger() {
type="button"
className="orbit-launch-pop__item"
onClick={pickHelp}
role="menuitem"
>
<HelpCircle size={14} />
<span>{t('orbit.launchHelp')}</span>
+20 -2
View File
@@ -16,19 +16,25 @@ const { orbitStore } = vi.hoisted(() => ({
setState: vi.fn(),
},
}));
const { authState } = vi.hoisted(() => ({
authState: {
musicLibraryServerIds: [] as string[],
getActiveServer: vi.fn(),
},
}));
vi.mock('@/features/orbit/utils/remote', () => ({
writeOrbitState,
writeOrbitHeartbeat: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/features/orbit/store/orbitStore', () => ({ useOrbitStore: { getState: () => orbitStore } }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => ({}) } }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => authState } }));
vi.mock('@/features/playback/store/playerStore', () => ({ usePlayerStore: { getState: () => ({ enqueue: vi.fn() }) } }));
vi.mock('@/lib/api/subsonicPlaylists', () => ({ createPlaylist: vi.fn(), deletePlaylist: vi.fn() }));
vi.mock('@/lib/api/subsonicLibrary', () => ({ getSong: vi.fn() }));
vi.mock('@/lib/media/songToTrack', () => ({ songToTrack: vi.fn() }));
import { updateOrbitSettings } from '@/features/orbit/utils/host';
import { OrbitHostScopeError, startOrbitSession, updateOrbitSettings } from '@/features/orbit/utils/host';
function hostStateWith(settings: OrbitSettings | undefined): OrbitState {
const base = makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh' });
@@ -47,6 +53,18 @@ beforeEach(() => {
orbitStore.setState.mockClear();
orbitStore.role = 'host';
orbitStore.sessionPlaylistId = 'session-pl';
authState.musicLibraryServerIds = [];
authState.getActiveServer.mockReset();
});
describe('startOrbitSession', () => {
it('blocks before host transport work when more than one browse server is selected', async () => {
authState.musicLibraryServerIds = ['a', 'b'];
await expect(startOrbitSession({ name: 'Mixed scope' })).rejects.toBeInstanceOf(OrbitHostScopeError);
expect(authState.getActiveServer).not.toHaveBeenCalled();
});
});
describe('updateOrbitSettings', () => {
+12
View File
@@ -31,6 +31,17 @@ export interface StartOrbitArgs {
sid?: string;
}
export class OrbitHostScopeError extends Error {
constructor() {
super('Orbit hosting requires exactly one selected browse server');
this.name = 'OrbitHostScopeError';
}
}
export function orbitHostBlockedByBrowseScope(): boolean {
return useAuthStore.getState().musicLibraryServerIds.length > 1;
}
/**
* Host: create a new session.
*
@@ -41,6 +52,7 @@ export interface StartOrbitArgs {
* On throw the store is left in the pre-call state nothing partially bound.
*/
export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitState> {
if (orbitHostBlockedByBrowseScope()) throw new OrbitHostScopeError();
const server = useAuthStore.getState().getActiveServer();
const username = server?.username;
if (!username) throw new Error('No active Navidrome server / user');
+2
View File
@@ -57,6 +57,8 @@ export {
export {
endOrbitSession,
hostEnqueueToOrbit,
orbitHostBlockedByBrowseScope,
OrbitHostScopeError,
startOrbitSession,
triggerOrbitShuffleNow,
updateOrbitSettings,
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PlaybackAlternativeModal from '@/features/playback/components/PlaybackAlternativeModal';
import {
_resetPlaybackAlternativeStoreForTest,
usePlaybackAlternativeStore,
} from '@/features/playback/store/playbackAlternativeStore';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
describe('PlaybackAlternativeModal accessibility', () => {
beforeEach(_resetPlaybackAlternativeStoreForTest);
it('focuses the close action, contains Tab, closes on Escape, and restores focus', async () => {
const user = userEvent.setup();
renderWithProviders(
<>
<button type="button">Playback retry trigger</button>
<PlaybackAlternativeModal />
</>,
);
const trigger = screen.getByRole('button', { name: 'Playback retry trigger' });
trigger.focus();
usePlaybackAlternativeStore.setState({
isOpen: true,
status: 'empty',
detail: 'Original source failed',
});
const close = await screen.findByRole('button', { name: 'Close playback source choices' });
await waitFor(() => expect(close).toHaveFocus());
await user.tab();
expect(close).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('uses viewport-safe structural classes on the dialog card', () => {
usePlaybackAlternativeStore.setState({ isOpen: true, status: 'empty' });
renderWithProviders(<PlaybackAlternativeModal />);
expect(screen.getByRole('dialog')).toHaveClass('playback-alternative-modal');
expect(screen.getByRole('dialog').parentElement).toHaveClass('playback-alternative-overlay');
});
});

Some files were not shown because too many files have changed in this diff Show More