feat(albums): combined browse filters, favorites reconcile, and session restore (#876)

* feat(albums): persist browse sort and genre filter for the session

Keep Albums sort and genre selection in an in-memory Zustand store so
navigating into album detail and back no longer resets browse context.
Fixes #875 (partial).

* feat(albums): restore browse filters only when returning from album detail

Keep sort in the session store for the app lifetime. Stash genre, year,
compilation, starred, and lossless filters when leaving Albums for an
album page and restore them on POP (back). Clear the stash when opening
Albums from elsewhere via sidebar navigation.

* feat(albums): filter quick-clear chips; fix lossless A–Z sort

Add inline × on active toolbar filters (genre, year, favorites, lossless,
compilations) without opening the popover. Route lossless album browse through
advanced search with album sort clauses on Albums and Lossless Albums; client-sort
on the network fallback path.

* fix(albums): apply year filter when only from or to is set

Resolve open-ended year bounds with gte/lte on the local index and partial
fromYear/toYear on Subsonic. Update the year filter chip label for single-bound
ranges.

* refactor(albums): combine browse filters in one query (genre + year + lossless)

Replace mutually exclusive load/loadFiltered branches with fetchAlbumBrowsePage
that ANDs server-side filters on the local index (genre OR union). Network
fallback applies year bounds after genre fetch. Always show sort while a year
filter is active.

* fix(albums): load favorites filter server-side instead of scanning all albums

Starred on Albums was client-only: each page was filtered locally and
pendingClientFilterMatch kept paginating the full catalog. Query starred
albums via the local index or getAlbumList(starred); apply overrides only
for in-session star/unstar.

* feat(library): local album/artist favorites via patch-on-use

Mirror album- and artist-level stars into the library index (library_patch_album,
library_patch_artist, migration 010). Albums and Artists favorites browse use
entity starred_at only; normal album catalog stays track-derived so patch stubs
do not hide the library. Keep album year on favorite cards via track COALESCE,
patch metadata, and safer raw_json merge.

* fix(library): reconcile album/artist stars from server, drop stubs

Favorites browse uses getAlbumList/getStarred2 as source of truth.
library_reconcile_*_stars clears local stars removed elsewhere; patch-on-use
updates existing rows only (no stub INSERT). Reconcile on favorites load and
after star/unstar in-app.

* feat(albums): favorites reconcile, filter combos, and back-navigation fix

Album browse keeps filter state when returning from album detail (POP stash
read on mount, request-generation guard against stale loads). Favorites use
getStarred2 as source of truth: reconcile album.starred_at in the local index
(UPDATE only, no stub rows), with a small session cache for instant paint.

Combine favorites with lossless or genre via restrictAlbumIds in advanced
search. Remove album/artist patch-on-use and migration 010; artist favorites
stay network-only. Track patch-on-use unchanged.

* feat(albums): catalog year bounds and genre list narrowed by filters

Year filter spinners use min/max years from the local track index (not
1900); "from" starts at oldest, "to" at newest, values clamp to catalog.

When year, lossless, favorites, or compilation filters are active, the genre
picker lists only genres present on matching albums (other filters applied,
genre excluded). Adds library_get_catalog_year_bounds for the year UI.

* feat(albums): debounce year filter and show genre album counts

Debounce year range changes by 350ms before reloading browse. Genre picker
lists album counts per genre (from getGenres or from albums matching other
active filters) and sorts genres by count descending.

* fix(albums): compilation filter detection and scan cap

Recognize OpenSubsonic compilation flags (compilation, releaseTypes) so
client-side comp filters work on local index rows. Cap background pagination
at 500 albums when no matches are visible and show empty state instead of
spinning through the whole catalog.

* feat(albums): filter compilations via local library index

Add `compilation` to advanced search (album entity): reads OpenSubsonic
flags from album raw_json. Album browse passes compFilter into
library_advanced_search when the index is ready; network-only path keeps
client-side filtering with the existing scan cap.

* fix(albums): apply compilation filter on track-grouped index browse

Album browse uses track aggregation, so compilation clauses were skipped.
Filter track raw_json (same SQL as album), merge album flags at sync, and
always run the client-side compilation pass as a fallback.

* refactor(albums): split browse modules and extract browse_support commands

Move album browse fetch/filter logic into focused modules and useAlbumBrowseData;
register reconcile/year-bounds Tauri commands from browse_support. Trim dead helpers
and barrel exports; fix typecheck in compilation tests.

* chore: note PR #876 in CHANGELOG and settings credits

* fix(albums): show catalog min/max in partial year filter chip label

When only from or to year is set, the active chip now reads e.g. 1990–2020
instead of 1990– or –2025, using indexed catalog bounds when available.
This commit is contained in:
cucadmuh
2026-05-27 12:32:20 +03:00
committed by GitHub
parent fab6ff19bf
commit 06da15caf3
55 changed files with 2707 additions and 383 deletions
+10
View File
@@ -113,6 +113,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Albums — combined browse filters and session restore
**By [@cucadmuh](https://github.com/cucadmuh), PR [#876](https://github.com/Psychotoxical/psysonic/pull/876)**
* **Albums** toolbar: sort, genre (with counts), year range, favorites, lossless, and compilations combine in one browse query when the local index is ready; returning from album detail restores the same filter state.
* Favorites list reconciles from the server into the local index (no stub album rows); genre/year/lossless/compilation filters apply on the indexed catalog.
* Year spinners use catalog min/max from the local index; compilation filter uses indexed OpenSubsonic flags (resync refreshes track metadata).
## Changed ## Changed
### Linux — session GDK, WebKitGTK mitigations, and Wayland text ### Linux — session GDK, WebKitGTK mitigations, and Wayland text
@@ -11,6 +11,7 @@
use std::collections::{BTreeSet, HashSet}; use std::collections::{BTreeSet, HashSet};
use rusqlite::types::Value as SqlValue; use rusqlite::types::Value as SqlValue;
use rusqlite::{params, OptionalExtension};
use serde_json::Value; use serde_json::Value;
use crate::dto::{ use crate::dto::{
@@ -33,8 +34,10 @@ fn bpm_resolved_sql() -> String {
} }
const ALBUM_COLUMNS: &str = "a.server_id, a.id, a.name, a.artist, a.artist_id, \ const ALBUM_COLUMNS: &str = "a.server_id, a.id, a.name, a.artist, a.artist_id, \
a.song_count, a.duration_sec, a.year, a.genre, a.cover_art_id, a.starred_at, \ a.song_count, a.duration_sec, \
a.synced_at, a.raw_json"; COALESCE(a.year, (SELECT MAX(t.year) FROM track t \
WHERE t.server_id = a.server_id AND t.album_id = a.id AND t.deleted = 0)), \
a.genre, a.cover_art_id, a.starred_at, a.synced_at, a.raw_json";
const ARTIST_COLUMNS: &str = "ar.server_id, ar.id, ar.name, ar.album_count, \ const ARTIST_COLUMNS: &str = "ar.server_id, ar.id, ar.name, ar.album_count, \
ar.synced_at, ar.raw_json"; ar.synced_at, ar.raw_json";
@@ -285,6 +288,23 @@ fn map_track_row_resolved_bpm(row: &rusqlite::Row<'_>) -> rusqlite::Result<Libra
crate::search::row_to_track_dto_resolved_bpm(row) crate::search::row_to_track_dto_resolved_bpm(row)
} }
/// Sync is track-first; the `album` table is often empty or holds only
/// patch-on-use stubs. Normal browse must not treat a handful of album rows
/// as the full catalog.
fn server_has_indexed_tracks(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
store
.with_read_conn(|conn| {
conn.query_row(
"SELECT 1 FROM track WHERE server_id = ?1 AND deleted = 0 LIMIT 1",
params![server_id],
|_| Ok(()),
)
.optional()
.map(|r| r.is_some())
})
.map_err(|e| e.to_string())
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn build_album( fn build_album(
store: &LibraryStore, store: &LibraryStore,
@@ -301,6 +321,19 @@ fn build_album(
store, req, text, scalar, limit, offset, skip_totals, applied, true, store, req, text, scalar, limit, offset, skip_totals, applied, true,
); );
} }
// Album browse favorites: album-level stars only (`a.starred_at`), not
// track-derived groups with `t.starred_at`.
if req.starred_only == Some(true) {
return build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied);
}
if server_has_indexed_tracks(store, &req.server_id)? {
if let Some(q) = text.and_then(fts_album_prefix_match_query) {
return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
}
return build_album_from_tracks(
store, req, text, scalar, limit, offset, skip_totals, applied, false,
);
}
if !scalar_requires_track_derived_entities(scalar) { if !scalar_requires_track_derived_entities(scalar) {
let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?; let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 { if !table.0.is_empty() || table.1 > 0 {
@@ -344,6 +377,12 @@ fn build_album_from_table(
w.push_raw("a.starred_at IS NOT NULL"); w.push_raw("a.starred_at IS NOT NULL");
applied.insert("starred".to_string()); applied.insert("starred".to_string());
} }
push_album_id_allowlist(
&mut w,
"a.id",
req.restrict_album_ids.as_deref(),
applied,
);
let order = order_clause(&req.sort, EntityKind::Album) let order = order_clause(&req.sort, EntityKind::Album)
.unwrap_or_else(|| "ORDER BY a.name COLLATE NOCASE ASC, a.id ASC".to_string()); .unwrap_or_else(|| "ORDER BY a.name COLLATE NOCASE ASC, a.id ASC".to_string());
@@ -379,8 +418,12 @@ fn build_album_from_tracks(
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''"); w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
if !include_album_table_rows { if !include_album_table_rows {
// Skip track groups only when the album table has a full row (synced
// metadata). Patch-on-use stubs omit `song_count` and must not hide the
// track-derived catalog entry.
w.push_raw( w.push_raw(
"NOT EXISTS (SELECT 1 FROM album a WHERE a.server_id = t.server_id AND a.id = t.album_id)", "NOT EXISTS (SELECT 1 FROM album a WHERE a.server_id = t.server_id \
AND a.id = t.album_id AND a.song_count IS NOT NULL)",
); );
} }
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
@@ -401,6 +444,12 @@ fn build_album_from_tracks(
w.push_raw("t.starred_at IS NOT NULL"); w.push_raw("t.starred_at IS NOT NULL");
applied.insert("starred".to_string()); applied.insert("starred".to_string());
} }
push_album_id_allowlist(
&mut w,
"t.album_id",
req.restrict_album_ids.as_deref(),
applied,
);
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \ let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \ COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
@@ -462,15 +511,12 @@ fn build_artist_from_table(
w.push_param("ar.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); w.push_param("ar.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string()); applied.insert("text".to_string());
} }
// Only `text` routes to artist with a real column; other registered
// fields resolve to `None` (skip). `starredOnly` has no artist column.
for c in scalar { for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Artist)? { if let Some(frag) = resolve_clause(c, EntityKind::Artist)? {
applied.insert(c.field.clone()); applied.insert(c.field.clone());
w.push(frag); w.push(frag);
} }
} }
let order = order_clause(&req.sort, EntityKind::Artist) let order = order_clause(&req.sort, EntityKind::Artist)
.unwrap_or_else(|| "ORDER BY ar.name COLLATE NOCASE ASC, ar.id ASC".to_string()); .unwrap_or_else(|| "ORDER BY ar.name COLLATE NOCASE ASC, ar.id ASC".to_string());
query_rows( query_rows(
@@ -584,6 +630,12 @@ fn build_album_from_fts(
w.push_raw("t.starred_at IS NOT NULL"); w.push_raw("t.starred_at IS NOT NULL");
applied.insert("starred".to_string()); applied.insert("starred".to_string());
} }
push_album_id_allowlist(
&mut w,
"t.album_id",
req.restrict_album_ids.as_deref(),
applied,
);
let where_sql = w.where_sql(); let where_sql = w.where_sql();
store.with_read_conn(|conn| { store.with_read_conn(|conn| {
@@ -784,8 +836,7 @@ fn resolve_clause(
("year", EntityKind::Album) => "a.year", ("year", EntityKind::Album) => "a.year",
("starred", EntityKind::Track) => "t.starred_at", ("starred", EntityKind::Track) => "t.starred_at",
("starred", EntityKind::Album) => "a.starred_at", ("starred", EntityKind::Album) => "a.starred_at",
// `starred` routes to artist in the registry, but the `artist` // `artist` has no `starred_at` column — favorites use the network list.
// table has no `starred_at` column — skip rather than error.
("starred", EntityKind::Artist) => return Ok(None), ("starred", EntityKind::Artist) => return Ok(None),
("mood_group" | "mood_tag", EntityKind::Track) => { ("mood_group" | "mood_tag", EntityKind::Track) => {
return crate::advanced_search_mood::resolve_mood_clause(c); return crate::advanced_search_mood::resolve_mood_clause(c);
@@ -808,6 +859,13 @@ fn resolve_clause(
params: vec![], params: vec![],
})); }));
} }
("compilation", EntityKind::Album) => {
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "a");
}
("compilation", EntityKind::Track) => {
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "t");
}
("compilation", _) => return Ok(None),
// `text` is handled by the entity builder (FTS / LIKE), never here. // `text` is handled by the entity builder (FTS / LIKE), never here.
("text", _) => return Ok(None), ("text", _) => return Ok(None),
// Registered but no v1 SQL builder (user_rating / suffix / bit_rate). // Registered but no v1 SQL builder (user_rating / suffix / bit_rate).
@@ -872,6 +930,30 @@ fn count_matching_rows(
Ok(n.max(0) as u32) Ok(n.max(0) as u32)
} }
/// Restrict album browse to an explicit id set (server favorites ∩ local filters).
fn push_album_id_allowlist(
w: &mut WhereBuilder,
column: &str,
ids: Option<&[String]>,
applied: &mut BTreeSet<String>,
) {
let Some(ids) = ids else {
return;
};
applied.insert("albumIds".to_string());
if ids.is_empty() {
w.push_raw("1 = 0");
return;
}
let placeholders = std::iter::repeat_n("?", ids.len()).collect::<Vec<_>>().join(", ");
let sql = format!("{column} IN ({placeholders})");
let params = ids
.iter()
.map(|id| SqlValue::Text(id.clone()))
.collect();
w.push_params(&sql, params);
}
/// Accumulates `AND`-joined WHERE clauses and their positional params in /// Accumulates `AND`-joined WHERE clauses and their positional params in
/// lockstep so anonymous `?` placeholders bind left-to-right. /// lockstep so anonymous `?` placeholders bind left-to-right.
struct WhereBuilder { struct WhereBuilder {
@@ -1160,6 +1242,48 @@ fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> {
} }
} }
fn compilation_filter_fragment(
field: &str,
op: FilterOp,
value: Option<&Value>,
table_alias: &str,
) -> Result<Option<SqlFragment>, String> {
let comp_sql = crate::album_compilation_filter::compilation_raw_json_sql(table_alias);
match op {
FilterOp::IsTrue => Ok(Some(SqlFragment {
sql: comp_sql,
params: vec![],
})),
FilterOp::Eq => {
let want_comp = json_to_bool(field, value)?;
let sql = if want_comp {
comp_sql
} else {
format!("NOT ({comp_sql})")
};
Ok(Some(SqlFragment { sql, params: vec![] }))
}
_ => Err(filter::FilterError::UnsupportedOp {
field: field.to_string(),
op: op.as_str(),
}
.to_string()),
}
}
fn json_to_bool(field: &str, v: Option<&Value>) -> Result<bool, String> {
match v {
Some(Value::Bool(b)) => Ok(*b),
Some(Value::Number(n)) => Ok(n.as_i64() == Some(1)),
Some(Value::String(s)) => Ok(matches!(s.as_str(), "1" | "true" | "TRUE")),
_ => Err(filter::FilterError::BadValue {
field: field.to_string(),
detail: "expected boolean".to_string(),
}
.to_string()),
}
}
fn json_to_text(field: &str, v: Option<&Value>) -> Result<SqlValue, String> { fn json_to_text(field: &str, v: Option<&Value>) -> Result<SqlValue, String> {
match v { match v {
Some(Value::String(s)) => Ok(SqlValue::Text(s.clone())), Some(Value::String(s)) => Ok(SqlValue::Text(s.clone())),
@@ -1273,6 +1397,7 @@ mod tests {
entity_types: entities.to_vec(), entity_types: entities.to_vec(),
filters: Vec::new(), filters: Vec::new(),
starred_only: None, starred_only: None,
restrict_album_ids: None,
sort: Vec::new(), sort: Vec::new(),
limit: 50, limit: 50,
offset: 0, offset: 0,
@@ -1455,6 +1580,58 @@ mod tests {
assert_eq!(resp.tracks[0].id, "t1"); assert_eq!(resp.tracks[0].id, "t1");
} }
#[test]
fn normal_album_browse_uses_track_catalog_when_album_table_is_sparse() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_stub", "Starred Stub", None, None);
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET starred_at = 100 WHERE server_id = 's1' AND id = 'al_stub'",
[],
)
})
.unwrap();
let mut a = track("s1", "t1", "A", "X", "Album A");
a.album_id = Some("al_a".into());
let mut b = track("s1", "t2", "B", "Y", "Album B");
b.album_id = Some("al_b".into());
TrackRepository::new(&store)
.upsert_batch(&[a, b])
.unwrap();
let r = req("s1", &[EntityKind::Album]);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"al_a"));
assert!(ids.contains(&"al_b"));
assert!(!ids.contains(&"al_stub"));
}
#[test]
fn starred_only_album_entity_uses_album_star_not_track_star() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_star", "Starred Album", None, None);
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET starred_at = 100 WHERE server_id = 's1' AND id = 'al_star'",
[],
)
})
.unwrap();
let mut track_star = track("s1", "t1", "T", "X", "TrackStar Alb");
track_star.album_id = Some("al_track_only".into());
track_star.starred_at = Some(200);
TrackRepository::new(&store)
.upsert_batch(&[track_star])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.starred_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_star");
}
// ── bpm dual storage ─────────────────────────────────────────────── // ── bpm dual storage ───────────────────────────────────────────────
#[test] #[test]
@@ -1683,6 +1860,36 @@ mod tests {
assert_eq!(resp.albums[0].id, "al1"); assert_eq!(resp.albums[0].id, "al1");
} }
#[test]
fn restrict_album_ids_intersects_with_lossless_filter() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_fav_lossless", "Fav Lossless", None, None);
insert_album(&store, "s1", "al_fav_lossy", "Fav Lossy", None, None);
insert_album(&store, "s1", "al_other_lossless", "Other Lossless", None, None);
let mut flac_fav = track("s1", "t1", "A", "X", "Alb");
flac_fav.album_id = Some("al_fav_lossless".into());
flac_fav.suffix = Some("flac".into());
let mut mp3_fav = track("s1", "t2", "B", "Y", "Alb2");
mp3_fav.album_id = Some("al_fav_lossy".into());
mp3_fav.suffix = Some("mp3".into());
let mut flac_other = track("s1", "t3", "C", "Z", "Alb3");
flac_other.album_id = Some("al_other_lossless".into());
flac_other.suffix = Some("flac".into());
TrackRepository::new(&store)
.upsert_batch(&[flac_fav, mp3_fav, flac_other])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("lossless", FilterOp::IsTrue, None, None)];
r.restrict_album_ids = Some(vec![
"al_fav_lossless".into(),
"al_fav_lossy".into(),
]);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_fav_lossless");
assert!(resp.applied_filters.contains(&"albumIds".to_string()));
}
#[test] #[test]
fn lossless_and_year_filters_use_track_year_when_album_table_differs() { fn lossless_and_year_filters_use_track_year_when_album_table_differs() {
let store = LibraryStore::open_in_memory(); let store = LibraryStore::open_in_memory();
@@ -1743,6 +1950,80 @@ mod tests {
assert_eq!(resp.artists[0].id, "ar1"); assert_eq!(resp.artists[0].id, "ar1");
} }
fn insert_album_raw(
store: &LibraryStore,
server: &str,
id: &str,
name: &str,
raw_json: &str,
) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 1, ?4)",
rusqlite::params![server, id, name, raw_json],
)
})
.unwrap();
}
#[test]
fn compilation_filter_only_returns_compilation_albums() {
let store = LibraryStore::open_in_memory();
insert_album_raw(
&store,
"s1",
"al_comp",
"Greatest Hits",
r#"{"compilation":true}"#,
);
insert_album_raw(&store, "s1", "al_regular", "Studio", "{}");
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("compilation", FilterOp::IsTrue, None, None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_comp");
}
#[test]
fn compilation_filter_on_track_grouped_album_browse() {
let store = LibraryStore::open_in_memory();
let mut comp = track("s1", "t_comp", "Hit", "VA", "Comp Album");
comp.album_id = Some("al_comp".into());
comp.raw_json = r#"{"compilation":true}"#.into();
let mut reg = track("s1", "t_reg", "Song", "Band", "Studio");
reg.album_id = Some("al_reg".into());
reg.raw_json = "{}".into();
TrackRepository::new(&store)
.upsert_batch(&[comp, reg])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("compilation", FilterOp::IsTrue, None, None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_comp");
assert!(resp.applied_filters.contains(&"compilation".to_string()));
}
#[test]
fn compilation_eq_false_hides_compilations() {
let store = LibraryStore::open_in_memory();
insert_album_raw(
&store,
"s1",
"al_comp",
"Greatest Hits",
r#"{"releaseTypes":["Compilation"]}"#,
);
insert_album_raw(&store, "s1", "al_regular", "Studio", "{}");
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("compilation", FilterOp::Eq, Some(json!(false)), None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_regular");
}
#[test] #[test]
fn planned_but_unbuilt_field_is_an_error() { fn planned_but_unbuilt_field_is_an_error() {
let store = LibraryStore::open_in_memory(); let store = LibraryStore::open_in_memory();
@@ -0,0 +1,30 @@
//! OpenSubsonic compilation flag in entity `raw_json` (Navidrome: `compilation`,
//! `isCompilation`, or `releaseTypes` containing `Compilation`).
/// SQL predicate on any row with a `raw_json` column (album or track).
pub fn compilation_raw_json_sql(table_alias: &str) -> String {
let a = table_alias;
// `NULL IN (...)` is unknown in SQL — wrap each probe in EXISTS so non-comp rows stay false.
format!(
"(EXISTS ( \
SELECT 1 WHERE json_extract({a}.raw_json, '$.compilation') IN (1, '1', 'true', 'TRUE') \
) OR EXISTS ( \
SELECT 1 WHERE json_extract({a}.raw_json, '$.isCompilation') IN (1, '1', 'true', 'TRUE') \
) OR EXISTS ( \
SELECT 1 FROM json_each(COALESCE(json_extract({a}.raw_json, '$.releaseTypes'), '[]')) AS rt \
WHERE lower(rt.value) = 'compilation' \
))"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sql_mentions_json_paths() {
let sql = compilation_raw_json_sql("t");
assert!(sql.contains("$.compilation"));
assert!(sql.contains("$.releaseTypes"));
}
}
@@ -0,0 +1,257 @@
//! Album browse helpers: favorites reconcile and catalog year bounds.
use rusqlite::params;
use tauri::State;
use crate::dto::CatalogYearBoundsDto;
use crate::runtime::LibraryRuntime;
use crate::store::LibraryStore;
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StarredAlbumReconcileItem {
pub id: String,
pub starred_at: i64,
}
/// Align `album.starred_at` with server favorites: UPDATE existing rows only
/// (no INSERT / stub rows). Clears local stars absent from `starred_albums`.
#[tauri::command]
pub fn library_reconcile_album_stars(
runtime: State<'_, LibraryRuntime>,
server_id: String,
starred_albums: Vec<StarredAlbumReconcileItem>,
) -> Result<(), String> {
reconcile_album_stars(&runtime, &server_id, &starred_albums)
}
pub(crate) fn reconcile_album_stars(
runtime: &LibraryRuntime,
server_id: &str,
starred: &[StarredAlbumReconcileItem],
) -> Result<(), String> {
runtime
.store
.with_conn("misc", |conn| {
if starred.is_empty() {
conn.execute(
"UPDATE album SET starred_at = NULL \
WHERE server_id = ?1 AND starred_at IS NOT NULL",
params![server_id],
)?;
return Ok(());
}
let placeholders = std::iter::repeat_n("?", starred.len())
.collect::<Vec<_>>()
.join(", ");
let clear_sql = format!(
"UPDATE album SET starred_at = NULL \
WHERE server_id = ?1 AND starred_at IS NOT NULL \
AND id NOT IN ({placeholders})"
);
let mut clear_params: Vec<rusqlite::types::Value> =
vec![rusqlite::types::Value::Text(server_id.to_string())];
for item in starred {
clear_params.push(rusqlite::types::Value::Text(item.id.clone()));
}
conn.execute(
&clear_sql,
rusqlite::params_from_iter(clear_params.iter()),
)?;
for item in starred {
conn.execute(
"UPDATE album SET starred_at = ?3 \
WHERE server_id = ?1 AND id = ?2",
params![server_id, item.id, item.starred_at],
)?;
}
Ok(())
})
.map_err(|e| e.to_string())
}
pub(crate) fn catalog_year_bounds_for_server(
store: &LibraryStore,
server_id: &str,
) -> Result<CatalogYearBoundsDto, String> {
store
.with_read_conn(|conn| {
let min_year: Option<i64> = conn.query_row(
"SELECT MIN(year) FROM track \
WHERE server_id = ?1 AND deleted = 0 AND year IS NOT NULL AND year > 0",
params![server_id],
|r| r.get(0),
)?;
let max_year: Option<i64> = conn.query_row(
"SELECT MAX(year) FROM track \
WHERE server_id = ?1 AND deleted = 0 AND year IS NOT NULL AND year > 0",
params![server_id],
|r| r.get(0),
)?;
let min_year = min_year.map(|y| y as i32);
let max_year = max_year.map(|y| y as i32);
Ok(CatalogYearBoundsDto { min_year, max_year })
})
.map_err(|e| e.to_string())
}
/// Min/max album years from the local track catalog (for Albums browse filter spinners).
#[tauri::command]
pub fn library_get_catalog_year_bounds(
runtime: State<'_, LibraryRuntime>,
server_id: String,
) -> Result<CatalogYearBoundsDto, String> {
catalog_year_bounds_for_server(&runtime.store, &server_id)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::repos::TrackRepository;
use crate::runtime::LibraryRuntime;
use crate::store::LibraryStore;
use super::{catalog_year_bounds_for_server, reconcile_album_stars, StarredAlbumReconcileItem};
fn make_row(server: &str, id: &str, album_id: &str, track: i64) -> crate::repos::TrackRow {
crate::repos::TrackRow {
server_id: server.into(),
id: id.into(),
title: format!("T{id}"),
title_sort: None,
artist: Some("A".into()),
artist_id: Some("ar".into()),
album: album_id.into(),
album_id: Some(album_id.into()),
album_artist: None,
duration_sec: 200,
track_number: Some(track),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
fn runtime(store: Arc<LibraryStore>) -> LibraryRuntime {
LibraryRuntime::new(store)
}
#[test]
fn reconcile_album_stars_clears_stale_and_sets_existing_rows() {
let store = Arc::new(LibraryStore::open_in_memory());
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, starred_at, synced_at, raw_json) \
VALUES ('s1', 'al_old', 'Old', 1, 1, '{}'), \
('s1', 'al_keep', 'Keep', 1, 1, '{}'), \
('s1', 'al_new', 'New', NULL, 1, '{}')",
[],
)
})
.unwrap();
let rt = runtime(store.clone());
reconcile_album_stars(
&rt,
"s1",
&[StarredAlbumReconcileItem {
id: "al_keep".into(),
starred_at: 99,
}],
)
.unwrap();
let old: Option<i64> = store
.with_conn("misc", |c| {
c.query_row(
"SELECT starred_at FROM album WHERE server_id = 's1' AND id = 'al_old'",
[],
|r| r.get(0),
)
})
.unwrap();
let keep: Option<i64> = store
.with_conn("misc", |c| {
c.query_row(
"SELECT starred_at FROM album WHERE server_id = 's1' AND id = 'al_keep'",
[],
|r| r.get(0),
)
})
.unwrap();
let new: Option<i64> = store
.with_conn("misc", |c| {
c.query_row(
"SELECT starred_at FROM album WHERE server_id = 's1' AND id = 'al_new'",
[],
|r| r.get(0),
)
})
.unwrap();
assert!(old.is_none());
assert_eq!(keep, Some(99));
assert!(new.is_none());
}
#[test]
fn catalog_year_bounds_from_indexed_tracks() {
let store = Arc::new(LibraryStore::open_in_memory());
let mut old = make_row("s1", "t1", "al1", 1);
old.year = Some(1985);
let mut recent = make_row("s1", "t2", "al2", 1);
recent.year = Some(2018);
TrackRepository::new(&store)
.upsert_batch(&[old, recent])
.unwrap();
let bounds = catalog_year_bounds_for_server(&store, "s1").unwrap();
assert_eq!(bounds.min_year, Some(1985));
assert_eq!(bounds.max_year, Some(2018));
}
#[test]
fn reconcile_album_stars_clears_all_when_server_list_empty() {
let store = Arc::new(LibraryStore::open_in_memory());
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, starred_at, synced_at, raw_json) \
VALUES ('s1', 'al1', 'A', 5, 1, '{}')",
[],
)
})
.unwrap();
let rt = runtime(store.clone());
reconcile_album_stars(&rt, "s1", &[]).unwrap();
let starred_at: Option<i64> = store
.with_conn("misc", |c| {
c.query_row(
"SELECT starred_at FROM album WHERE server_id = 's1' AND id = 'al1'",
[],
|r| r.get(0),
)
})
.unwrap();
assert!(starred_at.is_none());
}
}
@@ -373,6 +373,14 @@ pub struct PlaySessionYearBoundsDto {
pub max_year: Option<i32>, pub max_year: Option<i32>,
} }
/// Min/max `year` from indexed tracks for a server (Albums year filter UI).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CatalogYearBoundsDto {
pub min_year: Option<i32>,
pub max_year: Option<i32>,
}
/// `library_purge_server` outcome. /// `library_purge_server` outcome.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -478,6 +486,11 @@ pub struct LibraryAdvancedSearchRequest {
pub filters: Vec<LibraryFilterClause>, pub filters: Vec<LibraryFilterClause>,
#[serde(default)] #[serde(default)]
pub starred_only: Option<bool>, pub starred_only: Option<bool>,
/// When set, album browse is limited to these ids (e.g. server `getStarred2`
/// intersected with local lossless / genre filters). Not combined with
/// `starred_only` — use one or the other.
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub sort: Vec<LibrarySortClause>, pub sort: Vec<LibrarySortClause>,
pub limit: u32, pub limit: u32,
@@ -128,6 +128,12 @@ pub const FILTER_FIELD_REGISTRY: &[FilterField] = &[
ops: &[FilterOp::IsTrue], ops: &[FilterOp::IsTrue],
status: FilterStatus::V1, status: FilterStatus::V1,
}, },
FilterField {
id: "compilation",
entities: &[EntityKind::Track, EntityKind::Album],
ops: &[FilterOp::IsTrue, FilterOp::Eq],
status: FilterStatus::V1,
},
FilterField { FilterField {
id: "bit_rate", id: "bit_rate",
entities: &[EntityKind::Track], entities: &[EntityKind::Track],
@@ -9,6 +9,8 @@
pub(crate) mod bulk_ingest; pub(crate) mod bulk_ingest;
pub mod advanced_search; pub mod advanced_search;
pub mod album_compilation_filter;
pub mod browse_support;
mod advanced_search_mood; mod advanced_search_mood;
pub mod analysis_backfill; pub mod analysis_backfill;
pub mod artist_lossless_browse; pub mod artist_lossless_browse;
@@ -27,7 +27,9 @@ use serde_json::Value;
use super::backoff::{jitter_salt, with_jitter, Backoff}; use super::backoff::{jitter_salt, with_jitter, Backoff};
use super::capability::{CapabilityFlags, NavidromeProbeCredentials}; use super::capability::{CapabilityFlags, NavidromeProbeCredentials};
use super::error::SyncError; use super::error::SyncError;
use super::mapping::{navidrome_song_to_track_row, subsonic_song_to_track_row}; use super::mapping::{
merge_album_open_subsonic_track_raw, navidrome_song_to_track_row, subsonic_song_to_track_row,
};
use super::progress::{NoopProgress, Progress, ProgressEvent}; use super::progress::{NoopProgress, Progress, ProgressEvent};
use super::strategy::IngestStrategy; use super::strategy::IngestStrategy;
use super::tombstone::TombstoneReconciler; use super::tombstone::TombstoneReconciler;
@@ -488,10 +490,11 @@ impl<'a> DeltaSyncRunner<'a> {
.unwrap_or_default(); .unwrap_or_default();
let mut rows: Vec<TrackRow> = Vec::with_capacity(album.song.len()); let mut rows: Vec<TrackRow> = Vec::with_capacity(album.song.len());
for (i, song) in album.song.iter().enumerate() { for (i, song) in album.song.iter().enumerate() {
let raw = raw_songs let mut raw = raw_songs
.get(i) .get(i)
.cloned() .cloned()
.unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null)); .unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null));
merge_album_open_subsonic_track_raw(&raw_album, &mut raw);
rows.push(subsonic_song_to_track_row( rows.push(subsonic_song_to_track_row(
&self.server_id, &self.server_id,
song, song,
@@ -25,7 +25,9 @@ use super::ingest_parallel::{
check_cancel_flag, fetch_albums_parallel, linear_prefetch_depth, retry_fetch, check_cancel_flag, fetch_albums_parallel, linear_prefetch_depth, retry_fetch,
sleep_request_gap, wait_while_bulk_paused, LinearPrefetchQueue, ParallelAlbumFetchOpts, sleep_request_gap, wait_while_bulk_paused, LinearPrefetchQueue, ParallelAlbumFetchOpts,
}; };
use super::mapping::{navidrome_song_to_track_row, subsonic_song_to_track_row}; use super::mapping::{
merge_album_open_subsonic_track_raw, navidrome_song_to_track_row, subsonic_song_to_track_row,
};
use super::progress::{IngestBatchMetrics, NoopProgress, Progress, ProgressEvent}; use super::progress::{IngestBatchMetrics, NoopProgress, Progress, ProgressEvent};
use super::strategy::IngestStrategy; use super::strategy::IngestStrategy;
use crate::bulk_ingest::{restore_track_secondary_indexes, suspend_track_secondary_indexes}; use crate::bulk_ingest::{restore_track_secondary_indexes, suspend_track_secondary_indexes};
@@ -1124,10 +1126,11 @@ impl<'a> InitialSyncRunner<'a> {
.unwrap_or_default(); .unwrap_or_default();
let mut rows: Vec<TrackRow> = Vec::with_capacity(album.song.len()); let mut rows: Vec<TrackRow> = Vec::with_capacity(album.song.len());
for (i, song) in album.song.iter().enumerate() { for (i, song) in album.song.iter().enumerate() {
let raw = raw_songs let mut raw = raw_songs
.get(i) .get(i)
.cloned() .cloned()
.unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null)); .unwrap_or_else(|| serde_json::to_value(song).unwrap_or(Value::Null));
merge_album_open_subsonic_track_raw(&raw_album, &mut raw);
rows.push(subsonic_song_to_track_row( rows.push(subsonic_song_to_track_row(
&self.server_id, &self.server_id,
song, song,
@@ -9,6 +9,24 @@ use psysonic_integration::subsonic::Song;
/// Project a Subsonic `Song` plus its raw JSON sub-tree into a /// Project a Subsonic `Song` plus its raw JSON sub-tree into a
/// `TrackRow`. `raw_value` is what `track.raw_json` stores verbatim so /// `TrackRow`. `raw_value` is what `track.raw_json` stores verbatim so
/// OpenSubsonic extensions survive (spec §5.1 / ADR-7). /// OpenSubsonic extensions survive (spec §5.1 / ADR-7).
/// Copy album-level OpenSubsonic fields onto each track `raw_json` during S2/getAlbum
/// ingest so track-grouped album browse can filter compilations.
pub fn merge_album_open_subsonic_track_raw(raw_album: &Value, raw_song: &mut Value) {
let Some(obj) = raw_song.as_object_mut() else {
return;
};
for key in ["compilation", "isCompilation", "releaseTypes"] {
if obj.contains_key(key) {
continue;
}
if let Some(v) = raw_album.get(key) {
if !v.is_null() {
obj.insert(key.to_string(), v.clone());
}
}
}
}
pub fn subsonic_song_to_track_row( pub fn subsonic_song_to_track_row(
server_id: &str, server_id: &str,
song: &Song, song: &Song,
@@ -220,6 +238,15 @@ mod tests {
assert!(parse_iso_ms_str("9999-99-99").is_none()); assert!(parse_iso_ms_str("9999-99-99").is_none());
} }
#[test]
fn merge_album_open_subsonic_track_raw_copies_album_flags() {
let album = json!({ "compilation": true, "releaseTypes": ["Compilation"] });
let mut song = json!({ "id": "tr_1", "title": "A" });
merge_album_open_subsonic_track_raw(&album, &mut song);
assert_eq!(song.get("compilation"), Some(&json!(true)));
assert_eq!(song.get("releaseTypes"), Some(&json!(["Compilation"])));
}
#[test] #[test]
fn subsonic_song_maps_hot_columns_and_keeps_raw_json() { fn subsonic_song_maps_hot_columns_and_keeps_raw_json() {
let raw = json!({ let raw = json!({
+2
View File
@@ -720,6 +720,8 @@ pub fn run() {
psysonic_library::commands::library_sync_verify_integrity, psysonic_library::commands::library_sync_verify_integrity,
psysonic_library::commands::library_sync_cancel, psysonic_library::commands::library_sync_cancel,
psysonic_library::commands::library_patch_track, psysonic_library::commands::library_patch_track,
psysonic_library::browse_support::library_reconcile_album_stars,
psysonic_library::browse_support::library_get_catalog_year_bounds,
psysonic_library::commands::library_put_artifact, psysonic_library::commands::library_put_artifact,
psysonic_library::commands::library_put_fact, psysonic_library::commands::library_put_fact,
psysonic_library::commands::library_record_play_session, psysonic_library::commands::library_record_play_session,
+26
View File
@@ -206,6 +206,8 @@ export interface LibraryAdvancedSearchRequest {
entityTypes: LibraryEntityType[]; entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[]; filters?: LibraryFilterClause[];
starredOnly?: boolean | null; starredOnly?: boolean | null;
/** Server favorites ids ∩ local filters (lossless, genre, year). */
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[]; sort?: LibrarySortClause[];
limit: number; limit: number;
offset?: number; offset?: number;
@@ -599,6 +601,18 @@ export function libraryPatchTrack(args: {
return invoke<void>('library_patch_track', { ...args, serverId: indexKey }); return invoke<void>('library_patch_track', { ...args, serverId: indexKey });
} }
/** Server favorites → `album.starred_at` (UPDATE only, no stub rows). */
export function libraryReconcileAlbumStars(args: {
serverId: string;
starredAlbums: Array<{ id: string; starredAt: number }>;
}): Promise<void> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<void>('library_reconcile_album_stars', {
serverId: indexKey,
starredAlbums: args.starredAlbums.map(a => ({ id: a.id, starredAt: a.starredAt })),
});
}
export function libraryPutArtifact(args: { export function libraryPutArtifact(args: {
serverId: string; serverId: string;
trackId: string; trackId: string;
@@ -687,6 +701,18 @@ export type PlaySessionYearBounds = {
maxYear: number | null; maxYear: number | null;
}; };
export type CatalogYearBounds = {
minYear: number | null;
maxYear: number | null;
};
export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise<CatalogYearBounds> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<CatalogYearBounds>('library_get_catalog_year_bounds', {
serverId: indexKey,
});
}
export type PlaySessionRecentDay = { export type PlaySessionRecentDay = {
date: string; date: string;
totalListenedSec: number; totalListenedSec: number;
+28 -5
View File
@@ -1,7 +1,12 @@
import { api, libraryFilterParams } from './subsonicClient'; import { api, libraryFilterParams } from './subsonicClient';
import { invalidateEntityUserRatingCaches } from './subsonicRatings'; import { invalidateEntityUserRatingCaches } from './subsonicRatings';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse'; import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import {
invalidateStarredAlbumBrowse,
refreshStarredAlbumIndexFromServer,
} from '../utils/library/starredAlbumIndexSync';
import type { import type {
EntityRatingSupportLevel, EntityRatingSupportLevel,
StarredResults, StarredResults,
@@ -22,25 +27,43 @@ export async function getStarred(): Promise<StarredResults> {
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
} }
export async function star(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise<void> { export async function star(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
): Promise<void> {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (type === 'song') params.id = id; if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id; if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id; if (type === 'artist') params.artistId = id;
await api('star.view', params); await api('star.view', params);
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song') { if (type === 'song') {
patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { starredAt: Date.now() }); patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
} else if (type === 'album' && serverId) {
invalidateStarredAlbumBrowse(serverId);
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
} }
} }
export async function unstar(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise<void> { export async function unstar(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
): Promise<void> {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (type === 'song') params.id = id; if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id; if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id; if (type === 'artist') params.artistId = id;
await api('unstar.view', params); await api('unstar.view', params);
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song') { if (type === 'song') {
patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { starredAt: null }); patchLibraryTrackOnUse(serverId, id, { starredAt: null });
} else if (type === 'album' && serverId) {
invalidateStarredAlbumBrowse(serverId);
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
} }
} }
+34
View File
@@ -0,0 +1,34 @@
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface Props {
onClear: () => void;
/** Parent filter chip uses accent background (`btn-sort-active`). */
onActiveChip?: boolean;
}
/** Inline dismiss control on toolbar filter buttons (does not open the filter popover). */
export default function FilterQuickClear({ onClear, onActiveChip = false }: Props) {
const { t } = useTranslation();
const activate = (e: React.SyntheticEvent) => {
e.stopPropagation();
e.preventDefault();
onClear();
};
return (
<span
role="button"
tabIndex={0}
className={`filter-quick-clear${onActiveChip ? ' filter-quick-clear--on-active-chip' : ''}`}
aria-label={t('common.filterClear')}
onClick={activate}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') activate(e);
}}
>
<X size={12} strokeWidth={2.5} aria-hidden />
</span>
);
}
+59 -28
View File
@@ -1,18 +1,45 @@
import { getGenres } from '../api/subsonicGenres'; import { getGenres } from '../api/subsonicGenres';
import type { GenreFilterOption } from '../utils/library/albumBrowseLoad';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Check, Filter, X } from 'lucide-react'; import { Check, Filter, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FilterQuickClear from './FilterQuickClear';
type GenreRow = GenreFilterOption;
function mergeGenreRows(
catalogGenres: GenreFilterOption[],
selected: string[],
): GenreRow[] {
const byGenre = new Map<string, number>();
for (const { genre, count } of catalogGenres) byGenre.set(genre, count);
for (const genre of selected) {
if (!byGenre.has(genre)) byGenre.set(genre, 0);
}
return [...byGenre.entries()]
.map(([genre, count]) => ({ genre, count }))
.sort((a, b) => b.count - a.count || a.genre.localeCompare(b.genre));
}
interface GenreFilterBarProps { interface GenreFilterBarProps {
selected: string[]; selected: string[];
onSelectionChange: (selected: string[]) => void; onSelectionChange: (selected: string[]) => void;
/**
* When set, only these genres are listed (e.g. from the current non-genre filters).
* `undefined` = full server genre list from `getGenres`.
*/
catalogGenres?: GenreFilterOption[] | null;
} }
export default function GenreFilterBar({ selected, onSelectionChange }: GenreFilterBarProps) { export default function GenreFilterBar({
selected,
onSelectionChange,
catalogGenres,
}: GenreFilterBarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [genres, setGenres] = useState<string[]>([]); const [genreRows, setGenreRows] = useState<GenreRow[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [popStyle, setPopStyle] = useState<React.CSSProperties>({}); const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
@@ -21,30 +48,30 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
getGenres().then(data => if (catalogGenres != null) {
setGenres(data.map(g => g.value).sort((a, b) => a.localeCompare(b))) setGenreRows(mergeGenreRows(catalogGenres, selected));
); return;
}, []); }
let cancelled = false;
getGenres().then(data => {
if (cancelled) return;
const rows: GenreRow[] = data
.map(g => ({ genre: g.value, count: g.albumCount ?? 0 }))
.sort((a, b) => b.count - a.count || a.genre.localeCompare(b.genre));
setGenreRows(mergeGenreRows(rows, selected));
});
return () => {
cancelled = true;
};
}, [catalogGenres, selected]);
const selectedSet = useMemo(() => new Set(selected), [selected]); const selectedSet = useMemo(() => new Set(selected), [selected]);
// Selected on top, then alphabetical (stable for comfortable scanning).
const sortedGenres = useMemo(() => {
const arr = [...genres];
arr.sort((a, b) => {
const sa = selectedSet.has(a) ? 0 : 1;
const sb = selectedSet.has(b) ? 0 : 1;
if (sa !== sb) return sa - sb;
return a.localeCompare(b);
});
return arr;
}, [genres, selectedSet]);
const filteredGenres = useMemo(() => { const filteredGenres = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
if (!q) return sortedGenres; if (!q) return genreRows;
return sortedGenres.filter(g => g.toLowerCase().includes(q)); return genreRows.filter(({ genre }) => genre.toLowerCase().includes(q));
}, [sortedGenres, search]); }, [genreRows, search]);
const updatePopStyle = () => { const updatePopStyle = () => {
if (!triggerRef.current) return; if (!triggerRef.current) return;
@@ -131,6 +158,7 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
<Filter size={14} /> <Filter size={14} />
{t('common.filterGenre')} {t('common.filterGenre')}
{count > 0 && <span className="genre-filter-count">{count}</span>} {count > 0 && <span className="genre-filter-count">{count}</span>}
{count > 0 && <FilterQuickClear onActiveChip onClear={clear} />}
</button> </button>
{open && createPortal( {open && createPortal(
@@ -149,7 +177,7 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Enter' && filteredGenres.length > 0) { if (e.key === 'Enter' && filteredGenres.length > 0) {
toggle(filteredGenres[0]); toggle(filteredGenres[0].genre);
} }
}} }}
/> />
@@ -161,21 +189,24 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
{t('common.filterNoGenres')} {t('common.filterNoGenres')}
</div> </div>
) : ( ) : (
filteredGenres.map(g => { filteredGenres.map(({ genre, count: albumCount }) => {
const isSel = selectedSet.has(g); const isSel = selectedSet.has(genre);
return ( return (
<div <div
key={g} key={genre}
className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`} className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`}
onClick={() => toggle(g)} onClick={() => toggle(genre)}
role="option" role="option"
aria-selected={isSel} aria-selected={isSel}
> >
<span className="genre-filter-popover__check"> <span className="genre-filter-popover__check">
{isSel && <Check size={12} strokeWidth={3} />} {isSel && <Check size={12} strokeWidth={3} />}
</span> </span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span className="genre-filter-popover__label">
{g} {genre}
</span>
<span className="genre-filter-popover__album-count" aria-hidden>
{albumCount}
</span> </span>
</div> </div>
); );
+2
View File
@@ -1,5 +1,6 @@
import { Gem } from 'lucide-react'; import { Gem } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FilterQuickClear from './FilterQuickClear';
interface Props { interface Props {
active: boolean; active: boolean;
@@ -25,6 +26,7 @@ export default function LosslessFilterButton({ active, onChange }: Props) {
> >
<Gem size={14} /> <Gem size={14} />
{t('albums.losslessLabel')} {t('albums.losslessLabel')}
{active && <FilterQuickClear onActiveChip onClear={() => onChange(false)} />}
</button> </button>
); );
} }
+2
View File
@@ -1,5 +1,6 @@
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FilterQuickClear from './FilterQuickClear';
interface Props { interface Props {
active: boolean; active: boolean;
@@ -63,6 +64,7 @@ export default function StarFilterButton({ active, onChange, size = 'default' }:
> >
<Star size={14} fill={active ? 'currentColor' : 'none'} /> <Star size={14} fill={active ? 'currentColor' : 'none'} />
{t('common.favorites')} {t('common.favorites')}
{active && <FilterQuickClear onActiveChip onClear={() => onChange(false)} />}
</button> </button>
); );
} }
+58 -15
View File
@@ -2,16 +2,33 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { CalendarRange, X } from 'lucide-react'; import { CalendarRange, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FilterQuickClear from './FilterQuickClear';
import {
ALBUM_YEAR_MAX,
ALBUM_YEAR_MIN,
clampAlbumYearFieldInput,
formatAlbumYearFilterLabel,
normalizeAlbumYearToFieldChange,
resolveAlbumYearBounds,
stepAlbumYearField,
} from '../utils/library/albumYearFilter';
interface Props { interface Props {
from: string; from: string;
to: string; to: string;
onChange: (from: string, to: string) => void; onChange: (from: string, to: string) => void;
/** When set, spinners are limited to the indexed catalog (from `library_get_catalog_year_bounds`). */
catalogMinYear?: number;
catalogMaxYear?: number;
} }
const CURRENT_YEAR = new Date().getFullYear(); export default function YearFilterButton({
from,
export default function YearFilterButton({ from, to, onChange }: Props) { to,
onChange,
catalogMinYear,
catalogMaxYear,
}: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [popStyle, setPopStyle] = useState<React.CSSProperties>({}); const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
@@ -20,9 +37,11 @@ export default function YearFilterButton({ from, to, onChange }: Props) {
const popRef = useRef<HTMLDivElement>(null); const popRef = useRef<HTMLDivElement>(null);
const fromRef = useRef<HTMLInputElement>(null); const fromRef = useRef<HTMLInputElement>(null);
const fromNum = parseInt(from, 10); const yMin = catalogMinYear ?? ALBUM_YEAR_MIN;
const toNum = parseInt(to, 10); const yMax = catalogMaxYear ?? ALBUM_YEAR_MAX;
const active = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
const { active, bounds } = resolveAlbumYearBounds(from, to);
const activeLabel = formatAlbumYearFilterLabel(bounds, { min: yMin, max: yMax });
const updatePopStyle = () => { const updatePopStyle = () => {
if (!triggerRef.current) return; if (!triggerRef.current) return;
@@ -87,6 +106,27 @@ export default function YearFilterButton({ from, to, onChange }: Props) {
onChange('', ''); onChange('', '');
}; };
const handleFromChange = (raw: string) => {
onChange(clampAlbumYearFieldInput(raw, yMin, yMax), to);
};
const handleToChange = (raw: string) => {
onChange(from, normalizeAlbumYearToFieldChange(to, raw, yMin, yMax));
};
const onYearWheel = (
e: React.WheelEvent<HTMLInputElement>,
field: 'from' | 'to',
) => {
e.preventDefault();
const delta = e.deltaY < 0 ? 1 : -1;
if (field === 'from') {
onChange(stepAlbumYearField(from, delta, yMin, yMax, 'min'), to);
} else {
onChange(from, stepAlbumYearField(to, delta, yMin, yMax, 'max'));
}
};
return ( return (
<> <>
<button <button
@@ -102,7 +142,8 @@ export default function YearFilterButton({ from, to, onChange }: Props) {
}} }}
> >
<CalendarRange size={14} /> <CalendarRange size={14} />
{active ? `${fromNum}${toNum}` : t('albums.yearFilterLabel')} {active && activeLabel ? activeLabel : t('albums.yearFilterLabel')}
{active && <FilterQuickClear onActiveChip onClear={clear} />}
</button> </button>
{open && createPortal( {open && createPortal(
@@ -122,11 +163,12 @@ export default function YearFilterButton({ from, to, onChange }: Props) {
ref={fromRef} ref={fromRef}
className="input" className="input"
type="number" type="number"
min={1900} min={yMin}
max={CURRENT_YEAR} max={yMax}
placeholder="1970" placeholder={String(yMin)}
value={from} value={from}
onChange={e => onChange(e.target.value, to)} onChange={e => handleFromChange(e.target.value)}
onWheel={e => onYearWheel(e, 'from')}
/> />
</div> </div>
<span style={{ alignSelf: 'flex-end', paddingBottom: '0.4rem', color: 'var(--text-muted)' }}></span> <span style={{ alignSelf: 'flex-end', paddingBottom: '0.4rem', color: 'var(--text-muted)' }}></span>
@@ -137,11 +179,12 @@ export default function YearFilterButton({ from, to, onChange }: Props) {
<input <input
className="input" className="input"
type="number" type="number"
min={1900} min={yMin}
max={CURRENT_YEAR} max={yMax}
placeholder={String(CURRENT_YEAR)} placeholder={String(yMax)}
value={to} value={to}
onChange={e => onChange(from, e.target.value)} onChange={e => handleToChange(e.target.value)}
onWheel={e => onYearWheel(e, 'to')}
/> />
</div> </div>
</div> </div>
@@ -60,7 +60,14 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => { <div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(album.id, album.starred); const starred = isStarred(album.id, album.starred);
setStarredOverride(album.id, !starred); setStarredOverride(album.id, !starred);
return starred ? unstar(album.id, 'album') : star(album.id, 'album'); const meta = {
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);
})}> })}>
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} /> <Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')} {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
@@ -54,7 +54,10 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => { <div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(artist.id, artist.starred); const starred = isStarred(artist.id, artist.starred);
setStarredOverride(artist.id, !starred); setStarredOverride(artist.id, !starred);
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist'); const meta = { name: artist.name, albumCount: artist.albumCount };
return starred
? unstar(artist.id, 'artist', meta)
: star(artist.id, 'artist', meta);
})}> })}>
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} /> <Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')} {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
+1
View File
@@ -132,6 +132,7 @@ const CONTRIBUTOR_ENTRIES = [
'Live Search: server-scoped local FTS, multi-server hit fix, and local vs search3 race merge (PR #868)', 'Live Search: server-scoped local FTS, multi-server hit fix, and local vs search3 race merge (PR #868)',
'Cover art pipeline: tier ladder, WebP disk cache, dense-grid prefetch, Settings cover cache budget (PR #869)', 'Cover art pipeline: tier ladder, WebP disk cache, dense-grid prefetch, Settings cover cache budget (PR #869)',
'Lossless: local index browse, Advanced Search and All Albums filters, artist/album drill-down mode, conserved sidebar page (PR #871)', 'Lossless: local index browse, Advanced Search and All Albums filters, artist/album drill-down mode, conserved sidebar page (PR #871)',
'Albums: combined browse filters (genre/year/favorites/lossless/compilations), session restore from album detail, favorites reconcile via local index (PR #876)',
], ],
}, },
{ {
+215
View File
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import { albumBrowseCompScanComplete } from '../utils/library/albumCompilation';
import type { AlbumCompFilter } from '../utils/library/albumCompilation';
import {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
fetchAlbumBrowseGenreOptions,
fetchAlbumBrowsePage,
filterAlbumsByCompilation,
filterAlbumsByStarred,
type AlbumBrowseQuery,
type GenreFilterOption,
} from '../utils/library/albumBrowseLoad';
import {
ALBUM_YEAR_FILTER_DEBOUNCE_MS,
resolveAlbumYearBounds,
} from '../utils/library/albumYearFilter';
import { useDebouncedValue } from './useDebouncedValue';
const PAGE_SIZE = 30;
export type UseAlbumBrowseDataArgs = {
serverId: string;
indexEnabled: boolean;
musicLibraryFilterVersion: number;
sort: AlbumBrowseQuery['sort'];
selectedGenres: string[];
yearFrom: string;
yearTo: string;
losslessOnly: boolean;
starredOnly: boolean;
compFilter: AlbumCompFilter;
starredOverrides: Record<string, boolean>;
};
export function useAlbumBrowseData({
serverId,
indexEnabled,
musicLibraryFilterVersion,
sort,
selectedGenres,
yearFrom,
yearTo,
losslessOnly,
starredOnly,
compFilter,
starredOverrides,
}: UseAlbumBrowseDataArgs) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [genreCatalogOptions, setGenreCatalogOptions] = useState<GenreFilterOption[] | null>(null);
const yearFields = useMemo(() => ({ from: yearFrom, to: yearTo }), [yearFrom, yearTo]);
const debouncedYearFields = useDebouncedValue(yearFields, ALBUM_YEAR_FILTER_DEBOUNCE_MS);
const { active: yearFilterActive, bounds: yearFilterBounds } = useMemo(
() => resolveAlbumYearBounds(debouncedYearFields.from, debouncedYearFields.to),
[debouncedYearFields.from, debouncedYearFields.to],
);
const browseQuery = useMemo<AlbumBrowseQuery>(() => ({
sort,
genres: selectedGenres,
year: yearFilterActive ? yearFilterBounds : undefined,
losslessOnly,
starredOnly,
compFilter,
}), [sort, selectedGenres, yearFilterActive, yearFilterBounds, losslessOnly, starredOnly, compFilter]);
const browseQueryWithoutGenre = useMemo<AlbumBrowseQuery>(() => ({
sort,
genres: [],
year: yearFilterActive ? yearFilterBounds : undefined,
losslessOnly,
starredOnly,
compFilter,
}), [sort, yearFilterActive, yearFilterBounds, losslessOnly, starredOnly, compFilter]);
const compFilterActive = compFilter !== 'all';
const compFilterClientOnly = compFilterActive && !indexEnabled;
const visibleAlbums = useMemo(() => {
let out = compFilterActive
? filterAlbumsByCompilation(albums, compFilter)
: albums;
if (starredOnly) out = filterAlbumsByStarred(out, starredOverrides);
return out;
}, [albums, compFilter, compFilterActive, starredOnly, starredOverrides]);
const genreFiltered = albumBrowseHasGenreFilter(browseQuery);
const serverFilterActive = albumBrowseHasServerFilters(browseQuery);
const narrowGenreList = yearFilterActive || losslessOnly || starredOnly || compFilterActive;
const compScanExhausted = useMemo(
() => compFilterClientOnly && !genreFiltered
&& albumBrowseCompScanComplete(albums, compFilter, hasMore),
[compFilterClientOnly, genreFiltered, albums, compFilter, hasMore],
);
const pendingClientFilterMatch =
compFilterClientOnly && visibleAlbums.length === 0 && hasMore && !genreFiltered && !compScanExhausted;
const loadGenerationRef = useRef(0);
const loadBrowse = useCallback(async (
query: AlbumBrowseQuery,
offset: number,
append = false,
) => {
const generation = ++loadGenerationRef.current;
setLoading(true);
const applyPage = (page: { albums: SubsonicAlbum[]; hasMore: boolean }) => {
if (generation !== loadGenerationRef.current) return;
if (append) setAlbums(prev => dedupeById([...prev, ...page.albums]));
else setAlbums(page.albums);
setHasMore(page.hasMore);
};
try {
const page = await fetchAlbumBrowsePage(
serverId,
indexEnabled,
query,
offset,
PAGE_SIZE,
{
onPartial: partial => {
if (generation !== loadGenerationRef.current) return;
applyPage(partial);
setLoading(false);
},
},
);
applyPage(page);
} finally {
if (generation === loadGenerationRef.current) setLoading(false);
}
}, [indexEnabled, serverId]);
useEffect(() => {
setPage(0);
loadBrowse(browseQuery, 0, false);
}, [browseQuery, loadBrowse, musicLibraryFilterVersion]);
useEffect(() => {
if (!narrowGenreList) {
setGenreCatalogOptions(null);
return;
}
let cancelled = false;
void fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre).then(options => {
if (!cancelled) setGenreCatalogOptions(options);
});
return () => {
cancelled = true;
};
}, [
narrowGenreList,
serverId,
indexEnabled,
browseQueryWithoutGenre,
musicLibraryFilterVersion,
]);
const loadMore = useCallback(() => {
if (loading || !hasMore || genreFiltered) return;
if (compFilterClientOnly && visibleAlbums.length === 0
&& albumBrowseCompScanComplete(albums, compFilter, hasMore)) {
return;
}
const next = page + 1;
setPage(next);
loadBrowse(browseQuery, next * PAGE_SIZE, true);
}, [
loading,
hasMore,
page,
browseQuery,
loadBrowse,
genreFiltered,
compFilterClientOnly,
visibleAlbums.length,
albums,
compFilter,
]);
useEffect(() => {
if (!pendingClientFilterMatch || loading) return;
loadMore();
}, [pendingClientFilterMatch, loading, loadMore]);
return {
albums,
loading,
hasMore,
PAGE_SIZE,
browseQuery,
browseQueryWithoutGenre,
visibleAlbums,
genreFiltered,
serverFilterActive,
narrowGenreList,
genreCatalogOptions,
yearFilterActive,
debouncedYearFields,
compFilterActive,
compFilterClientOnly,
compScanExhausted,
pendingClientFilterMatch,
loadMore,
};
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigationType, type NavigationType } from 'react-router-dom';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
type AlbumBrowseCompFilter,
type AlbumBrowseReturnFilters,
albumBrowseSortForServer,
isAlbumDetailPath,
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
import type { AlbumBrowseSort } from '../utils/library/browseTextSearch';
function returnFiltersForNavigation(
serverId: string,
navigationType: NavigationType,
): AlbumBrowseReturnFilters {
if (navigationType !== 'POP' || !serverId) return DEFAULT_ALBUM_BROWSE_RETURN_FILTERS;
return (
useAlbumBrowseSessionStore.getState().peekReturnStash(serverId)
?? DEFAULT_ALBUM_BROWSE_RETURN_FILTERS
);
}
export function useAlbumBrowseFilters(serverId: string) {
const navigationType = useNavigationType();
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
const setBrowseSort = useAlbumBrowseSessionStore(s => s.setSort);
const [selectedGenres, setSelectedGenres] = useState<string[]>(() =>
returnFiltersForNavigation(serverId, navigationType).selectedGenres,
);
const [yearFrom, setYearFrom] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).yearFrom,
);
const [yearTo, setYearTo] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).yearTo,
);
const [compFilter, setCompFilter] = useState<AlbumBrowseCompFilter>(() =>
returnFiltersForNavigation(serverId, navigationType).compFilter,
);
const [starredOnly, setStarredOnly] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).starredOnly,
);
const [losslessOnly, setLosslessOnly] = useState(() =>
returnFiltersForNavigation(serverId, navigationType).losslessOnly,
);
const filtersRef = useRef<AlbumBrowseReturnFilters>(DEFAULT_ALBUM_BROWSE_RETURN_FILTERS);
filtersRef.current = {
selectedGenres,
yearFrom,
yearTo,
compFilter,
starredOnly,
losslessOnly,
};
useEffect(() => {
if (!serverId) return;
if (navigationType === 'POP') {
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId);
if (restored) {
setSelectedGenres(restored.selectedGenres);
setYearFrom(restored.yearFrom);
setYearTo(restored.yearTo);
setCompFilter(restored.compFilter);
setStarredOnly(restored.starredOnly);
setLosslessOnly(restored.losslessOnly);
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId);
}
return;
}
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId);
setSelectedGenres([]);
setYearFrom('');
setYearTo('');
setCompFilter('all');
setStarredOnly(false);
setLosslessOnly(false);
}, [serverId, navigationType]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isAlbumDetailPath(path)) {
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, filtersRef.current);
} else if (path !== '/albums') {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId);
}
};
}, [serverId]);
const onSortChange = (value: AlbumBrowseSort) => setBrowseSort(serverId, value);
return {
sort,
onSortChange,
selectedGenres,
setSelectedGenres,
yearFrom,
setYearFrom,
yearTo,
setYearTo,
compFilter,
setCompFilter,
starredOnly,
setStarredOnly,
losslessOnly,
setLosslessOnly,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import {
ALBUM_YEAR_MAX,
ALBUM_YEAR_MIN,
type AlbumCatalogYearRange,
} from '../utils/library/albumYearFilter';
import { fetchAlbumCatalogYearBounds } from '../utils/library/albumCatalogYearBounds';
const DEFAULT: AlbumCatalogYearRange = { min: ALBUM_YEAR_MIN, max: ALBUM_YEAR_MAX };
export function useAlbumCatalogYearBounds(
serverId: string,
indexEnabled: boolean,
libraryFilterVersion: number,
): AlbumCatalogYearRange {
const [bounds, setBounds] = useState(DEFAULT);
useEffect(() => {
let cancelled = false;
void fetchAlbumCatalogYearBounds(serverId, indexEnabled).then(next => {
if (!cancelled) setBounds(next);
});
return () => {
cancelled = true;
};
}, [serverId, indexEnabled, libraryFilterVersion]);
return bounds;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebouncedValue } from './useDebouncedValue';
describe('useDebouncedValue', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('updates after delay when value changes', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 1000),
{ initialProps: { value: 'a' } },
);
expect(result.current).toBe('a');
rerender({ value: 'b' });
expect(result.current).toBe('a');
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current).toBe('b');
});
});
+13
View File
@@ -0,0 +1,13 @@
import { useEffect, useState } from 'react';
/** Returns `value` after it stays unchanged for `delayMs`. */
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
+9 -2
View File
@@ -210,8 +210,15 @@ const handleShuffleAll = () => {
const wasStarred = isStarred; const wasStarred = isStarred;
setIsStarred(!wasStarred); setIsStarred(!wasStarred);
try { try {
if (wasStarred) await unstar(album.album.id); const meta = {
else await star(album.album.id); 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) { } catch (e) {
console.error('Failed to toggle star', e); console.error('Failed to toggle star', e);
setIsStarred(wasStarred); setIsStarred(wasStarred);
+78 -207
View File
@@ -1,9 +1,6 @@
import { buildDownloadUrl } from '../api/subsonicStreamUrl'; import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbumsByGenre } from '../api/subsonicGenres'; import { getAlbum } from '../api/subsonicLibrary';
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack'; import { songToTrack } from '../utils/playback/songToTrack';
import { dedupeById } from '../utils/dedupeById';
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'; import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import { albumGridWarmCovers, coverDisplayCssPxForAlbumGrid } from '../cover/layoutSizes'; import { albumGridWarmCovers, coverDisplayCssPxForAlbumGrid } from '../cover/layoutSizes';
@@ -26,6 +23,7 @@ import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/ui/toast'; import { showToast } from '../utils/ui/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useZipDownloadStore } from '../store/zipDownloadStore';
import { CheckSquare2, Download, HardDriveDownload, Disc3, ListPlus } from 'lucide-react'; import { CheckSquare2, Download, HardDriveDownload, Disc3, ListPlus } from 'lucide-react';
import FilterQuickClear from '../components/FilterQuickClear';
import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { useRangeSelection } from '../hooks/useRangeSelection'; import { useRangeSelection } from '../hooks/useRangeSelection';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
@@ -33,28 +31,18 @@ import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea'; import OverlayScrollArea from '../components/OverlayScrollArea';
import { ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { import { useAlbumBrowseFilters } from '../hooks/useAlbumBrowseFilters';
runLocalAlbumBrowsePage, import { useAlbumBrowseData } from '../hooks/useAlbumBrowseData';
runLocalAlbumsByGenres, import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds';
runLocalLosslessAlbums, import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort';
type AlbumBrowseSort,
} from '../utils/library/browseTextSearch';
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
type SortType = AlbumBrowseSort; type SortType = AlbumBrowseSort;
type CompFilter = 'all' | 'only' | 'hide';
const PAGE_SIZE = 30;
function sanitizeFilename(name: string): string { function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download'; return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
} }
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
return dedupeById(results.flat());
}
export default function Albums() { export default function Albums() {
const perfFlags = usePerfProbeFlags(); const perfFlags = usePerfProbeFlags();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -62,20 +50,56 @@ export default function Albums() {
const auth = useAuthStore(); const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? ''); const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const catalogYears = useAlbumCatalogYearBounds(serverId, indexEnabled, musicLibraryFilterVersion);
const downloadAlbum = useOfflineStore(s => s.downloadAlbum); const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const {
const [sort, setSort] = useState<SortType>('alphabeticalByName'); sort,
const [loading, setLoading] = useState(true); onSortChange,
const [page, setPage] = useState(0); selectedGenres,
const [hasMore, setHasMore] = useState(true); setSelectedGenres,
const [selectedGenres, setSelectedGenres] = useState<string[]>([]); yearFrom,
const [yearFrom, setYearFrom] = useState(''); setYearFrom,
const [yearTo, setYearTo] = useState(''); yearTo,
const [compFilter, setCompFilter] = useState<CompFilter>('all'); setYearTo,
const [starredOnly, setStarredOnly] = useState(false); compFilter,
const [losslessOnly, setLosslessOnly] = useState(false); setCompFilter,
starredOnly,
setStarredOnly,
losslessOnly,
setLosslessOnly,
} = useAlbumBrowseFilters(serverId);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const {
albums,
loading,
hasMore,
visibleAlbums,
genreFiltered,
serverFilterActive,
narrowGenreList,
genreCatalogOptions,
yearFilterActive,
debouncedYearFields,
compFilterActive,
pendingClientFilterMatch,
loadMore,
} = useAlbumBrowseData({
serverId,
indexEnabled,
musicLibraryFilterVersion,
sort,
selectedGenres,
yearFrom,
yearTo,
losslessOnly,
starredOnly,
compFilter,
starredOverrides,
});
const observerTarget = useRef<HTMLDivElement>(null); const observerTarget = useRef<HTMLDivElement>(null);
const gridMeasureRef = useRef<HTMLDivElement>(null); const gridMeasureRef = useRef<HTMLDivElement>(null);
const maxGridCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns)); const maxGridCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns));
@@ -93,18 +117,6 @@ export default function Albums() {
// `visibleAlbums` so Shift-click range expansion follows the visible order). // `visibleAlbums` so Shift-click range expansion follows the visible order).
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const clientFilterActive = starredOnly || compFilter !== 'all';
const visibleAlbums = useMemo(() => {
let out = albums;
if (compFilter === 'only') out = out.filter(a => a.isCompilation);
else if (compFilter === 'hide') out = out.filter(a => !a.isCompilation);
if (starredOnly) {
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
}
return out;
}, [albums, compFilter, starredOnly, starredOverrides]);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(visibleAlbums); const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(visibleAlbums);
const toggleSelectionMode = () => { const toggleSelectionMode = () => {
@@ -178,15 +190,6 @@ export default function Albums() {
clearSelection(); clearSelection();
}; };
// ── Data loading ─────────────────────────────────────────────────────────
const genreFiltered = selectedGenres.length > 0;
const fromNum = parseInt(yearFrom, 10);
const toNum = parseInt(yearTo, 10);
const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
const pendingClientFilterMatch =
clientFilterActive && visibleAlbums.length === 0 && hasMore && !genreFiltered;
const visibleEmptyMessage = useMemo(() => { const visibleEmptyMessage = useMemo(() => {
if (starredOnly) return t('albums.noFavorites'); if (starredOnly) return t('albums.noFavorites');
if (compFilter === 'only') return t('albums.noCompilations'); if (compFilter === 'only') return t('albums.noCompilations');
@@ -219,9 +222,9 @@ export default function Albums() {
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
sort, sort,
genreFiltered, genreFiltered,
yearActive, yearFilterActive,
yearFrom, debouncedYearFields.from,
yearTo, debouncedYearFields.to,
compFilter, compFilter,
starredOnly, starredOnly,
losslessOnly, losslessOnly,
@@ -229,144 +232,6 @@ export default function Albums() {
selectedGenres, selectedGenres,
]); ]);
const load = useCallback(async (
sortType: SortType,
offset: number,
append = false,
yearFilter?: { from: number; to: number },
lossless = false,
) => {
setLoading(true);
try {
if (lossless) {
if (!indexEnabled || !serverId) {
setAlbums([]);
setHasMore(false);
return;
}
if (!yearFilter) {
const page = await runLocalLosslessAlbums(serverId, PAGE_SIZE, offset);
if (!page) {
setAlbums([]);
setHasMore(false);
return;
}
if (append) setAlbums(prev => dedupeById([...prev, ...page.albums]));
else setAlbums(page.albums);
setHasMore(page.hasMore);
return;
}
const data = await runLocalAlbumBrowsePage(
serverId,
sortType,
offset,
PAGE_SIZE,
yearFilter,
true,
);
if (data == null) {
setAlbums([]);
setHasMore(false);
return;
}
if (append) setAlbums(prev => [...prev, ...data]);
else setAlbums(data);
setHasMore(data.length === PAGE_SIZE);
return;
}
let data: SubsonicAlbum[] | null = null;
if (indexEnabled && serverId) {
data = await runLocalAlbumBrowsePage(
serverId,
sortType,
offset,
PAGE_SIZE,
yearFilter,
false,
);
}
if (data == null) {
const extra = yearFilter ? { fromYear: yearFilter.from, toYear: yearFilter.to } : {};
const type = yearFilter ? 'byYear' : sortType;
data = await getAlbumList(type, PAGE_SIZE, offset, extra);
}
if (append) setAlbums(prev => [...prev, ...data]);
else setAlbums(data);
setHasMore(data.length === PAGE_SIZE);
} finally {
setLoading(false);
}
}, [musicLibraryFilterVersion, indexEnabled, serverId]);
const loadFiltered = useCallback(async (
genres: string[],
sortType: SortType,
lossless: boolean,
) => {
setLoading(true);
try {
if (lossless) {
if (!indexEnabled || !serverId) {
setAlbums([]);
setHasMore(false);
return;
}
const data = await runLocalAlbumsByGenres(serverId, genres, sortType, undefined, true);
setAlbums(data ?? []);
setHasMore(false);
return;
}
let data: SubsonicAlbum[] | null = null;
if (indexEnabled && serverId) {
data = await runLocalAlbumsByGenres(serverId, genres, sortType);
}
if (data == null) {
data = await fetchByGenres(genres);
data = [...data].sort((a, b) =>
sortType === 'alphabeticalByArtist'
? a.artist.localeCompare(b.artist)
: a.name.localeCompare(b.name),
);
}
setAlbums(data);
setHasMore(false);
} finally {
setLoading(false);
}
}, [musicLibraryFilterVersion, indexEnabled, serverId]);
useEffect(() => {
setPage(0);
if (genreFiltered) {
loadFiltered(selectedGenres, sort, losslessOnly);
} else if (yearActive) {
load(sort, 0, false, { from: fromNum, to: toNum }, losslessOnly);
} else {
load(sort, 0, false, undefined, losslessOnly);
}
}, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, losslessOnly, load, loadFiltered]);
const loadMore = useCallback(() => {
if (loading || !hasMore || genreFiltered) return;
const next = page + 1;
setPage(next);
const yf = yearActive ? { from: fromNum, to: toNum } : undefined;
load(sort, next * PAGE_SIZE, true, yf, losslessOnly);
}, [
loading,
hasMore,
page,
sort,
load,
genreFiltered,
yearActive,
fromNum,
toNum,
losslessOnly,
]);
useEffect(() => { useEffect(() => {
if (!indexEnabled && losslessOnly) setLosslessOnly(false); if (!indexEnabled && losslessOnly) setLosslessOnly(false);
}, [indexEnabled, losslessOnly]); }, [indexEnabled, losslessOnly]);
@@ -386,11 +251,6 @@ export default function Albums() {
return () => observer.disconnect(); return () => observer.disconnect();
}, [loadMore, scrollBodyEl]); }, [loadMore, scrollBodyEl]);
useEffect(() => {
if (!pendingClientFilterMatch || loading) return;
loadMore();
}, [pendingClientFilterMatch, loading, loadMore]);
const sortOptions: { value: SortType; label: string }[] = [ const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') }, { value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') }, { value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
@@ -424,21 +284,25 @@ export default function Albums() {
</> </>
) : ( ) : (
<> <>
{!yearActive && ( <SortDropdown
<SortDropdown value={sort}
value={sort} options={sortOptions}
options={sortOptions} onChange={onSortChange}
onChange={setSort} />
/>
)}
<YearFilterButton <YearFilterButton
from={yearFrom} from={yearFrom}
to={yearTo} to={yearTo}
catalogMinYear={catalogYears.min}
catalogMaxYear={catalogYears.max}
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }} onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
/> />
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} /> <GenreFilterBar
selected={selectedGenres}
catalogGenres={narrowGenreList ? genreCatalogOptions : null}
onSelectionChange={setSelectedGenres}
/>
<StarFilterButton active={starredOnly} onChange={setStarredOnly} /> <StarFilterButton active={starredOnly} onChange={setStarredOnly} />
@@ -464,6 +328,9 @@ export default function Albums() {
{compFilter === 'all' ? t('albums.compilationLabel') {compFilter === 'all' ? t('albums.compilationLabel')
: compFilter === 'only' ? t('albums.compilationOnly') : compFilter === 'only' ? t('albums.compilationOnly')
: t('albums.compilationHide')} : t('albums.compilationHide')}
{compFilter !== 'all' && (
<FilterQuickClear onActiveChip onClear={() => setCompFilter('all')} />
)}
</button> </button>
</> </>
)} )}
@@ -500,11 +367,11 @@ export default function Albums() {
perfFlags.disableMainstageVirtualLists, perfFlags.disableMainstageVirtualLists,
]} ]}
> >
{(loading && albums.length === 0) || pendingClientFilterMatch ? ( {loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" /> <div className="spinner" />
</div> </div>
) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !clientFilterActive && !losslessOnly ? ( ) : !loading && albums.length === 0 && !serverFilterActive && !compFilterActive ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}> <div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('common.libraryEmpty')} {t('common.libraryEmpty')}
</div> </div>
@@ -512,7 +379,11 @@ export default function Albums() {
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}> <div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('losslessAlbums.empty')} {t('losslessAlbums.empty')}
</div> </div>
) : !loading && visibleAlbums.length === 0 && clientFilterActive ? ( ) : !loading && visibleAlbums.length === 0 && pendingClientFilterMatch ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && visibleAlbums.length === 0 && (starredOnly || compFilterActive) ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}> <div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{visibleEmptyMessage} {visibleEmptyMessage}
</div> </div>
+13 -11
View File
@@ -27,7 +27,7 @@ import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { useArtistsInfiniteScroll } from '../hooks/useArtistsInfiniteScroll'; import { useArtistsInfiniteScroll } from '../hooks/useArtistsInfiniteScroll';
import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { runLocalBrowseAllArtists } from '../utils/library/browseTextSearch'; import { fetchNetworkStarredArtists, runLocalBrowseAllArtists } from '../utils/library/browseTextSearch';
import { ArtistsGridView } from '../components/artists/ArtistsGridView'; import { ArtistsGridView } from '../components/artists/ArtistsGridView';
import { ArtistsListView } from '../components/artists/ArtistsListView'; import { ArtistsListView } from '../components/artists/ArtistsListView';
@@ -96,17 +96,19 @@ export default function Artists() {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
void (async () => { void (async () => {
if (indexEnabled && serverId) { try {
const local = await runLocalBrowseAllArtists(serverId); if (starredOnly) {
if (!cancelled && local != null) { if (!cancelled) setCatalogArtists(await fetchNetworkStarredArtists());
setCatalogArtists(local);
setLoading(false);
return; return;
} }
} if (indexEnabled && serverId) {
try { const local = await runLocalBrowseAllArtists(serverId);
const data = await getArtists(); if (!cancelled && local != null) {
if (!cancelled) setCatalogArtists(data); setCatalogArtists(local);
return;
}
}
if (!cancelled) setCatalogArtists(await getArtists());
} catch { } catch {
/* ignore */ /* ignore */
} finally { } finally {
@@ -116,7 +118,7 @@ export default function Artists() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [musicLibraryFilterVersion, indexEnabled, serverId]); }, [musicLibraryFilterVersion, indexEnabled, serverId, starredOnly]);
const { const {
filtered, visible, hasMore, groups, letters, artistListFlatRows, filtered, visible, hasMore, groups, letters, artistListFlatRows,
+3 -2
View File
@@ -99,8 +99,9 @@ export default function ComposerDetail() {
setIsStarred(next); setIsStarred(next);
setStarredOverride(artist.id, next); setStarredOverride(artist.id, next);
try { try {
if (next) await star(artist.id, 'artist'); const meta = { name: artist.name, albumCount: artist.albumCount };
else await unstar(artist.id, 'artist'); if (next) await star(artist.id, 'artist', meta);
else await unstar(artist.id, 'artist', meta);
} catch (err) { } catch (err) {
console.warn('[psysonic] composer star failed:', err); console.warn('[psysonic] composer star failed:', err);
setIsStarred(!next); setIsStarred(!next);
+62 -19
View File
@@ -2,7 +2,7 @@ import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbum } from '../api/subsonicLibrary'; import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes'; import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack'; import { songToTrack } from '../utils/playback/songToTrack';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse'; import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
@@ -24,7 +24,16 @@ import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea'; import OverlayScrollArea from '../components/OverlayScrollArea';
import { LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { runLocalLosslessAlbums } from '../utils/library/browseTextSearch'; import SortDropdown from '../components/SortDropdown';
import {
albumBrowseSortForServer,
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
import {
runLocalAlbumBrowsePage,
sortSubsonicAlbums,
type AlbumBrowseSort,
} from '../utils/library/browseTextSearch';
/** Local index page size — SQLite is cheap; larger pages than the network walk. */ /** Local index page size — SQLite is cheap; larger pages than the network walk. */
const LOCAL_PAGE_SIZE = 30; const LOCAL_PAGE_SIZE = 30;
@@ -45,6 +54,8 @@ export default function LosslessAlbums() {
const activeServerId = useAuthStore(s => s.activeServerId); const activeServerId = useAuthStore(s => s.activeServerId);
const serverId = useAuthStore(s => s.activeServerId ?? ''); const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
const setBrowseSort = useAlbumBrowseSessionStore(s => s.setSort);
const downloadAlbum = useOfflineStore(s => s.downloadAlbum); const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const enqueue = usePlayerStore(s => s.enqueue); const enqueue = usePlayerStore(s => s.enqueue);
@@ -57,8 +68,13 @@ export default function LosslessAlbums() {
/** `true` = local SQLite; `false` = Navidrome song-stream walk; `null` until first fetch picks. */ /** `true` = local SQLite; `false` = Navidrome song-stream walk; `null` until first fetch picks. */
const [useLocalIndex, setUseLocalIndex] = useState<boolean | null>(null); const [useLocalIndex, setUseLocalIndex] = useState<boolean | null>(null);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums); const displayAlbums = useMemo(() => {
const selectedAlbums = albums.filter(a => selectedIds.has(a.id)); if (useLocalIndex === false) return sortSubsonicAlbums(albums, sort);
return albums;
}, [albums, sort, useLocalIndex]);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); }; const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); resetSelection(); }; const clearSelection = () => { setSelectionMode(false); resetSelection(); };
@@ -76,10 +92,16 @@ export default function LosslessAlbums() {
setScrollBodyEl(el); setScrollBodyEl(el);
}, []); }, []);
const sortOptions: { value: AlbumBrowseSort; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
];
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
unsupported, unsupported,
selectionMode, selectionMode,
activeServerId, activeServerId,
sort,
]); ]);
const loadMoreNetwork = useCallback(async (onProgress?: (albums: SubsonicAlbum[]) => void) => { const loadMoreNetwork = useCallback(async (onProgress?: (albums: SubsonicAlbum[]) => void) => {
@@ -98,11 +120,18 @@ export default function LosslessAlbums() {
}, []); }, []);
const loadMoreLocal = useCallback(async () => { const loadMoreLocal = useCallback(async () => {
const page = await runLocalLosslessAlbums(serverId, LOCAL_PAGE_SIZE, localOffset.current); const data = await runLocalAlbumBrowsePage(
if (!page) return null; serverId,
localOffset.current += page.albums.length; sort,
return page; localOffset.current,
}, [serverId]); LOCAL_PAGE_SIZE,
undefined,
true,
);
if (data == null) return null;
localOffset.current += data.length;
return { albums: data, hasMore: data.length === LOCAL_PAGE_SIZE };
}, [serverId, sort]);
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (inFlight.current || useLocalIndex === null) return; if (inFlight.current || useLocalIndex === null) return;
@@ -151,13 +180,20 @@ export default function LosslessAlbums() {
inFlight.current = true; inFlight.current = true;
try { try {
if (indexEnabled && serverId) { if (indexEnabled && serverId) {
const local = await runLocalLosslessAlbums(serverId, LOCAL_PAGE_SIZE, 0); const data = await runLocalAlbumBrowsePage(
serverId,
sort,
0,
LOCAL_PAGE_SIZE,
undefined,
true,
);
if (cancelled) return; if (cancelled) return;
if (local) { if (data != null) {
setUseLocalIndex(true); setUseLocalIndex(true);
localOffset.current = local.albums.length; localOffset.current = data.length;
setAlbums(local.albums); setAlbums(data);
setHasMore(local.hasMore); setHasMore(data.length === LOCAL_PAGE_SIZE);
return; return;
} }
} }
@@ -182,7 +218,7 @@ export default function LosslessAlbums() {
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [activeServerId, indexEnabled, loadMoreNetwork, serverId]); }, [activeServerId, indexEnabled, loadMoreNetwork, serverId, sort]);
useEffect(() => { useEffect(() => {
if (!hasMore || useLocalIndex === null) return; if (!hasMore || useLocalIndex === null) return;
@@ -271,6 +307,13 @@ export default function LosslessAlbums() {
)} )}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{!(selectionMode && selectedIds.size > 0) && (
<SortDropdown
value={sort}
options={sortOptions}
onChange={value => setBrowseSort(serverId, value)}
/>
)}
{selectionMode && selectedIds.size > 0 && ( {selectionMode && selectedIds.size > 0 && (
<> <>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleEnqueueSelected}> <button className="btn btn-surface albums-selection-action-btn" onClick={handleEnqueueSelected}>
@@ -323,22 +366,22 @@ export default function LosslessAlbums() {
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}> <div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
{t('losslessAlbums.unsupported')} {t('losslessAlbums.unsupported')}
</div> </div>
) : loading && albums.length === 0 ? ( ) : loading && displayAlbums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" /> <div className="spinner" />
</div> </div>
) : albums.length === 0 ? ( ) : displayAlbums.length === 0 ? (
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}> <div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
{t('losslessAlbums.empty')} {t('losslessAlbums.empty')}
</div> </div>
) : ( ) : (
<> <>
<VirtualCardGrid <VirtualCardGrid
items={albums} items={displayAlbums}
itemKey={(a, _i) => a.id} itemKey={(a, _i) => a.id}
rowVariant="album" rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists} disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length} layoutSignal={displayAlbums.length}
scrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} scrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
warmGridCovers={albumGridWarmCovers()} warmGridCovers={albumGridWarmCovers()}
renderItem={a => ( renderItem={a => (
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it, beforeEach } from 'vitest';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
DEFAULT_ALBUM_BROWSE_SORT,
albumBrowseSortForServer,
isAlbumDetailPath,
useAlbumBrowseSessionStore,
} from './albumBrowseSessionStore';
describe('albumBrowseSessionStore', () => {
beforeEach(() => {
useAlbumBrowseSessionStore.setState({ sortByServer: {}, returnStashByServer: {} });
});
it('keeps sort per server for the session', () => {
const { setSort } = useAlbumBrowseSessionStore.getState();
setSort('srv-a', 'alphabeticalByArtist');
setSort('srv-b', 'alphabeticalByName');
const { sortByServer } = useAlbumBrowseSessionStore.getState();
expect(albumBrowseSortForServer(sortByServer, 'srv-a')).toBe('alphabeticalByArtist');
expect(albumBrowseSortForServer(sortByServer, 'srv-b')).toBe('alphabeticalByName');
});
it('stashes and peeks return filters', () => {
const { stashReturnFilters, peekReturnStash } = useAlbumBrowseSessionStore.getState();
stashReturnFilters('srv-a', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Rock'],
yearFrom: '1990',
yearTo: '2000',
starredOnly: true,
});
expect(peekReturnStash('srv-a')).toEqual({
selectedGenres: ['Rock'],
yearFrom: '1990',
yearTo: '2000',
compFilter: 'all',
starredOnly: true,
losslessOnly: false,
});
expect(peekReturnStash('srv-a')).not.toBeNull();
});
it('clears return stash', () => {
const { stashReturnFilters, clearReturnStash, peekReturnStash } = useAlbumBrowseSessionStore.getState();
stashReturnFilters('srv-a', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Jazz'],
});
clearReturnStash('srv-a');
expect(peekReturnStash('srv-a')).toBeNull();
});
it('defaults sort when server has no entry', () => {
const { sortByServer } = useAlbumBrowseSessionStore.getState();
expect(albumBrowseSortForServer(sortByServer, 'unknown')).toBe(DEFAULT_ALBUM_BROWSE_SORT);
});
});
describe('isAlbumDetailPath', () => {
it('matches album detail routes only', () => {
expect(isAlbumDetailPath('/album/abc')).toBe(true);
expect(isAlbumDetailPath('/album/abc/')).toBe(true);
expect(isAlbumDetailPath('/albums')).toBe(false);
expect(isAlbumDetailPath('/artist/abc')).toBe(false);
expect(isAlbumDetailPath('/album/abc/tracks')).toBe(false);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { create } from 'zustand';
import type { AlbumBrowseSort } from '../utils/library/browseTextSearch';
export const DEFAULT_ALBUM_BROWSE_SORT: AlbumBrowseSort = 'alphabeticalByName';
export type AlbumBrowseCompFilter = 'all' | 'only' | 'hide';
/** Filters restored only when returning to Albums via browser/app back from album detail. */
export interface AlbumBrowseReturnFilters {
selectedGenres: string[];
yearFrom: string;
yearTo: string;
compFilter: AlbumBrowseCompFilter;
starredOnly: boolean;
losslessOnly: boolean;
}
export const DEFAULT_ALBUM_BROWSE_RETURN_FILTERS: AlbumBrowseReturnFilters = {
selectedGenres: [],
yearFrom: '',
yearTo: '',
compFilter: 'all',
starredOnly: false,
losslessOnly: false,
};
interface ServerAlbumBrowseSession {
sort: AlbumBrowseSort;
}
interface AlbumBrowseSessionStore {
/** Session-lifetime sort per server (sidebar ↔ album detail). */
sortByServer: Record<string, AlbumBrowseSort>;
/** Stashed when leaving Albums → album detail; consumed on POP back. */
returnStashByServer: Record<string, AlbumBrowseReturnFilters>;
setSort: (serverId: string, sort: AlbumBrowseSort) => void;
stashReturnFilters: (serverId: string, filters: AlbumBrowseReturnFilters) => void;
clearReturnStash: (serverId: string) => void;
peekReturnStash: (serverId: string) => AlbumBrowseReturnFilters | null;
}
function sortEntryFor(
sortByServer: Record<string, AlbumBrowseSort>,
serverId: string,
): AlbumBrowseSort {
return sortByServer[serverId] ?? DEFAULT_ALBUM_BROWSE_SORT;
}
export const useAlbumBrowseSessionStore = create<AlbumBrowseSessionStore>((set, get) => ({
sortByServer: {},
returnStashByServer: {},
setSort: (serverId, sort) => {
if (!serverId) return;
set((s) => ({
sortByServer: { ...s.sortByServer, [serverId]: sort },
}));
},
stashReturnFilters: (serverId, filters) => {
if (!serverId) return;
set((s) => ({
returnStashByServer: {
...s.returnStashByServer,
[serverId]: {
selectedGenres: [...filters.selectedGenres],
yearFrom: filters.yearFrom,
yearTo: filters.yearTo,
compFilter: filters.compFilter,
starredOnly: filters.starredOnly,
losslessOnly: filters.losslessOnly,
},
},
}));
},
clearReturnStash: (serverId) => {
if (!serverId) return;
const next = { ...get().returnStashByServer };
delete next[serverId];
set({ returnStashByServer: next });
},
peekReturnStash: (serverId) => {
if (!serverId) return null;
const stash = get().returnStashByServer[serverId];
if (!stash) return null;
return {
selectedGenres: [...stash.selectedGenres],
yearFrom: stash.yearFrom,
yearTo: stash.yearTo,
compFilter: stash.compFilter,
starredOnly: stash.starredOnly,
losslessOnly: stash.losslessOnly,
};
},
}));
export function albumBrowseSortForServer(
sortByServer: Record<string, AlbumBrowseSort>,
serverId: string,
): AlbumBrowseSort {
if (!serverId) return DEFAULT_ALBUM_BROWSE_SORT;
return sortEntryFor(sortByServer, serverId);
}
/** True when pathname is a single album detail route (`/album/:id`). */
export function isAlbumDetailPath(pathname: string): boolean {
return /^\/album\/[^/]+\/?$/.test(pathname);
}
@@ -0,0 +1,26 @@
.filter-quick-clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: 0.1rem;
padding: 0.12rem;
border-radius: 4px;
cursor: pointer;
opacity: 0.88;
color: inherit;
}
.filter-quick-clear:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 14%, transparent);
}
.filter-quick-clear--on-active-chip:hover {
background: color-mix(in srgb, var(--ctp-crust) 22%, transparent);
}
.filter-quick-clear:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
@@ -64,6 +64,23 @@
user-select: none; user-select: none;
} }
.genre-filter-popover__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.genre-filter-popover__album-count {
flex-shrink: 0;
min-width: 1.5rem;
text-align: right;
font-size: 0.78rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.genre-filter-popover__option:hover { .genre-filter-popover__option:hover {
background: var(--bg-hover); background: var(--bg-hover);
} }
+1
View File
@@ -44,6 +44,7 @@
@import './custom-select.css'; @import './custom-select.css';
@import './changelog.css'; @import './changelog.css';
@import './genre-filter-bar.css'; @import './genre-filter-bar.css';
@import './filter-quick-clear.css';
@import './genre-tag-cloud.css'; @import './genre-tag-cloud.css';
@import './random-landing.css'; @import './random-landing.css';
@import './playlists-overview-header.css'; @import './playlists-overview-header.css';
@@ -55,8 +55,9 @@ export async function runArtistToggleStar(deps: RunArtistToggleStarDeps): Promis
const currentlyStarred = isStarred; const currentlyStarred = isStarred;
setIsStarred(!currentlyStarred); setIsStarred(!currentlyStarred);
try { try {
if (currentlyStarred) await unstar(artist.id, 'artist'); const meta = { name: artist.name, albumCount: artist.albumCount };
else await star(artist.id, 'artist'); if (currentlyStarred) await unstar(artist.id, 'artist', meta);
else await star(artist.id, 'artist', meta);
} catch (e) { } catch (e) {
console.error('Failed to toggle star', e); console.error('Failed to toggle star', e);
setIsStarred(currentlyStarred); setIsStarred(currentlyStarred);
+13 -1
View File
@@ -26,6 +26,7 @@ import { libraryScopeForServer } from '../../api/subsonicClient';
import { libraryIsReady } from './libraryReady'; import { libraryIsReady } from './libraryReady';
import { logLibrarySearch, timed } from './libraryDevLog'; import { logLibrarySearch, timed } from './libraryDevLog';
import { isLosslessSuffix } from './losslessFormats'; import { isLosslessSuffix } from './losslessFormats';
import { albumIsCompilation } from './albumCompilation';
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment'; import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
export type AdvancedResultType = 'all' | 'artists' | 'albums' | 'songs'; export type AdvancedResultType = 'all' | 'artists' | 'albums' | 'songs';
@@ -191,6 +192,15 @@ export function trackToSong(t: LibraryTrackDto): SubsonicSong {
return merged; return merged;
} }
/** Merge `raw_json` without nullish Subsonic fields wiping hot columns (e.g. year). */
function mergeAlbumRawJson(base: SubsonicAlbum, raw: Partial<SubsonicAlbum>): SubsonicAlbum {
const merged = { ...base } as SubsonicAlbum & Record<string, unknown>;
for (const [key, value] of Object.entries(raw)) {
if (value != null && value !== '') merged[key] = value;
}
return merged;
}
export function albumToAlbum(a: LibraryAlbumDto): SubsonicAlbum { export function albumToAlbum(a: LibraryAlbumDto): SubsonicAlbum {
const raw = isObject(a.rawJson) ? a.rawJson : {}; const raw = isObject(a.rawJson) ? a.rawJson : {};
const base: SubsonicAlbum = { const base: SubsonicAlbum = {
@@ -205,7 +215,9 @@ export function albumToAlbum(a: LibraryAlbumDto): SubsonicAlbum {
coverArt: a.coverArtId ?? a.id, coverArt: a.coverArtId ?? a.id,
starred: a.starredAt != null ? new Date(a.starredAt).toISOString() : undefined, starred: a.starredAt != null ? new Date(a.starredAt).toISOString() : undefined,
}; };
return { ...base, ...(raw as Partial<SubsonicAlbum>) }; const merged = mergeAlbumRawJson(base, raw as Partial<SubsonicAlbum>);
if (albumIsCompilation(merged)) merged.isCompilation = true;
return merged;
} }
export function artistToArtist(ar: LibraryArtistDto): SubsonicArtist { export function artistToArtist(ar: LibraryArtistDto): SubsonicArtist {
+95
View File
@@ -0,0 +1,95 @@
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import type { LibraryFilterClause } from '../../api/library';
import { albumIsCompilation, type AlbumCompFilter } from './albumCompilation';
import { albumYearFilterClauses, type AlbumYearBounds } from './albumYearFilter';
import type { AlbumBrowseQuery, GenreFilterOption } from './albumBrowseTypes';
export function albumBrowseHasGenreFilter(query: AlbumBrowseQuery): boolean {
return query.genres.length > 0;
}
export function albumBrowseHasServerFilters(query: AlbumBrowseQuery): boolean {
return (
albumBrowseHasGenreFilter(query)
|| query.year != null
|| query.losslessOnly
|| query.starredOnly
);
}
/** Favorites need the local index when combined with lossless or genre (AND). */
export function albumBrowseStarredNeedsLocalIntersect(
query: AlbumBrowseQuery,
indexEnabled: boolean,
serverId: string | null | undefined,
): boolean {
return !!(
query.starredOnly
&& indexEnabled
&& serverId
&& (query.losslessOnly || query.genres.length > 0)
);
}
export function compilationFilterClauses(compFilter: AlbumCompFilter): LibraryFilterClause[] {
if (compFilter === 'only') return [{ field: 'compilation', op: 'is_true' }];
if (compFilter === 'hide') return [{ field: 'compilation', op: 'eq', value: false }];
return [];
}
export function sharedServerFilters(
query: AlbumBrowseQuery,
useServerStarredIds: boolean,
): LibraryFilterClause[] {
const filters: LibraryFilterClause[] = [];
if (query.year) filters.push(...albumYearFilterClauses(query.year));
if (query.losslessOnly) filters.push({ field: 'lossless', op: 'is_true' });
filters.push(...compilationFilterClauses(query.compFilter));
if (query.starredOnly && !useServerStarredIds) {
filters.push({ field: 'starred', op: 'is_true' });
}
return filters;
}
export function filterAlbumsByStarred(
albums: SubsonicAlbum[],
starredOverrides: Record<string, boolean>,
): SubsonicAlbum[] {
return albums.filter(a => {
if (a.id in starredOverrides) return starredOverrides[a.id];
return !!a.starred;
});
}
export function filterAlbumsByYearBounds(
albums: SubsonicAlbum[],
bounds: AlbumYearBounds,
): SubsonicAlbum[] {
return albums.filter(a => {
if (a.year == null) return false;
if (bounds.from != null && a.year < bounds.from) return false;
if (bounds.to != null && a.year > bounds.to) return false;
return true;
});
}
export function filterAlbumsByCompilation(
albums: SubsonicAlbum[],
compFilter: AlbumCompFilter,
): SubsonicAlbum[] {
if (compFilter === 'only') return albums.filter(albumIsCompilation);
if (compFilter === 'hide') return albums.filter(a => !albumIsCompilation(a));
return albums;
}
export function countGenresFromAlbums(albums: SubsonicAlbum[]): GenreFilterOption[] {
const counts = new Map<string, number>();
for (const a of albums) {
const g = (a.genre ?? '').trim();
if (!g) continue;
counts.set(g, (counts.get(g) ?? 0) + 1);
}
return [...counts.entries()]
.map(([genre, count]) => ({ genre, count }))
.sort((a, b) => b.count - a.count || a.genre.localeCompare(b.genre));
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
albumBrowseStarredNeedsLocalIntersect,
compilationFilterClauses,
countGenresFromAlbums,
filterAlbumsByStarred,
filterAlbumsByYearBounds,
} from './albumBrowseFilters';
import type { AlbumBrowseQuery } from './albumBrowseTypes';
describe('albumBrowseLoad', () => {
const base: AlbumBrowseQuery = {
sort: 'alphabeticalByName',
genres: [],
losslessOnly: false,
starredOnly: false,
compFilter: 'all',
};
it('detects combined server filters', () => {
expect(albumBrowseHasServerFilters(base)).toBe(false);
expect(albumBrowseHasServerFilters({ ...base, genres: ['Rock'] })).toBe(true);
expect(albumBrowseHasServerFilters({ ...base, year: { from: 1990 } })).toBe(true);
expect(albumBrowseHasServerFilters({ ...base, losslessOnly: true })).toBe(true);
expect(albumBrowseHasServerFilters({ ...base, starredOnly: true })).toBe(true);
expect(
albumBrowseHasServerFilters({
...base,
genres: ['Jazz'],
year: { to: 2000 },
losslessOnly: true,
}),
).toBe(true);
});
it('genre filter disables pagination path', () => {
expect(albumBrowseHasGenreFilter({ ...base, genres: ['Rock'] })).toBe(true);
});
it('starred + lossless uses local intersect when index is on', () => {
expect(albumBrowseStarredNeedsLocalIntersect({ ...base, starredOnly: true }, true, 's1')).toBe(
false,
);
expect(
albumBrowseStarredNeedsLocalIntersect(
{ ...base, starredOnly: true, losslessOnly: true },
true,
's1',
),
).toBe(true);
expect(
albumBrowseStarredNeedsLocalIntersect(
{ ...base, starredOnly: true, genres: ['Rock'] },
true,
's1',
),
).toBe(true);
expect(
albumBrowseStarredNeedsLocalIntersect(
{ ...base, starredOnly: true, losslessOnly: true },
false,
's1',
),
).toBe(false);
});
});
describe('filterAlbumsByStarred', () => {
const album: SubsonicAlbum = {
id: 'a1',
name: 'A',
artist: 'X',
artistId: 'x',
songCount: 1,
duration: 1,
};
it('requires starred flag or a positive override', () => {
expect(filterAlbumsByStarred([album], {})).toHaveLength(0);
expect(filterAlbumsByStarred([{ ...album, starred: '2020-01-01' }], {})).toHaveLength(1);
expect(filterAlbumsByStarred([album], { a1: true })).toHaveLength(1);
expect(filterAlbumsByStarred([{ ...album, starred: '2020-01-01' }], { a1: false })).toHaveLength(0);
});
});
describe('compilationFilterClauses', () => {
it('maps only/hide to local index filters', () => {
expect(compilationFilterClauses('only')).toEqual([{ field: 'compilation', op: 'is_true' }]);
expect(compilationFilterClauses('hide')).toEqual([{ field: 'compilation', op: 'eq', value: false }]);
expect(compilationFilterClauses('all')).toEqual([]);
});
});
describe('countGenresFromAlbums', () => {
const album = (id: string, genre?: string): SubsonicAlbum => ({
id,
name: 'A',
artist: 'X',
artistId: 'a',
songCount: 1,
duration: 1,
genre,
});
it('returns genres sorted by album count descending', () => {
expect(countGenresFromAlbums([
album('1', 'Rock'),
album('2', 'Jazz'),
album('3', 'Rock'),
album('4'),
])).toEqual([
{ genre: 'Rock', count: 2 },
{ genre: 'Jazz', count: 1 },
]);
});
});
describe('filterAlbumsByYearBounds', () => {
const albums: SubsonicAlbum[] = [
{ id: '1', name: 'A', artist: 'X', artistId: 'a', songCount: 1, duration: 1, year: 1985 },
{ id: '2', name: 'B', artist: 'Y', artistId: 'b', songCount: 1, duration: 1, year: 1995 },
{ id: '3', name: 'C', artist: 'Z', artistId: 'c', songCount: 1, duration: 1, year: 2005 },
];
it('filters with only from bound', () => {
expect(filterAlbumsByYearBounds(albums, { from: 1990 }).map(a => a.id)).toEqual(['2', '3']);
});
it('filters with only to bound', () => {
expect(filterAlbumsByYearBounds(albums, { to: 1995 }).map(a => a.id)).toEqual(['1', '2']);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Albums browse: local index + Subsonic network paths.
* Filters and types live in sibling modules; this file is the fetch entry point.
*/
export type { AlbumCompFilter } from './albumCompilation';
export type {
AlbumBrowseFetchCallbacks,
AlbumBrowsePageResult,
AlbumBrowseQuery,
GenreFilterOption,
} from './albumBrowseTypes';
export {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
filterAlbumsByCompilation,
filterAlbumsByStarred,
} from './albumBrowseFilters';
export { runLocalAlbumBrowse } from './albumBrowseLocal';
import { countGenresFromAlbums, filterAlbumsByCompilation } from './albumBrowseFilters';
import { runLocalAlbumBrowse } from './albumBrowseLocal';
import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
import { fetchStarredAlbumBrowse } from './albumBrowseStarredFetch';
import type {
AlbumBrowseFetchCallbacks,
AlbumBrowsePageResult,
AlbumBrowseQuery,
GenreFilterOption,
} from './albumBrowseTypes';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
/** Genres in albums matching all filters except genre (for combined-filter UI). */
export async function fetchAlbumBrowseGenreOptions(
serverId: string,
indexEnabled: boolean,
query: AlbumBrowseQuery,
): Promise<GenreFilterOption[]> {
const withoutGenre: AlbumBrowseQuery = { ...query, genres: [] };
const page = await fetchAlbumBrowsePage(
serverId,
indexEnabled,
withoutGenre,
0,
GENRE_ALBUM_FETCH_LIMIT,
);
return countGenresFromAlbums(filterAlbumsByCompilation(page.albums, query.compFilter));
}
export async function fetchAlbumBrowsePage(
serverId: string,
indexEnabled: boolean,
query: AlbumBrowseQuery,
offset: number,
pageSize: number,
callbacks?: AlbumBrowseFetchCallbacks,
): Promise<AlbumBrowsePageResult> {
if (query.losslessOnly && (!indexEnabled || !serverId)) {
return { albums: [], hasMore: false };
}
if (query.starredOnly) {
return fetchStarredAlbumBrowse(serverId, indexEnabled, query, offset, pageSize, callbacks);
}
if (indexEnabled && serverId) {
const local = await runLocalAlbumBrowse(serverId, query, offset, pageSize);
if (local != null) return local;
}
return fetchAlbumBrowseNetwork(query, offset, pageSize);
}
+82
View File
@@ -0,0 +1,82 @@
import { libraryAdvancedSearch } from '../../api/library';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { dedupeById } from '../dedupeById';
import { albumToAlbum } from './advancedSearchLocal';
import { sharedServerFilters } from './albumBrowseFilters';
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
import { libraryIsReady } from './libraryReady';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
function markServerStarredAlbums(albums: SubsonicAlbum[]) {
return albums.map(a => ({ ...a, starred: a.starred ?? 'true' }));
}
/** Local index: combined genre + year + lossless filters (AND), genres OR union. */
export async function runLocalAlbumBrowse(
serverId: string,
query: AlbumBrowseQuery,
offset: number,
pageSize: number,
restrictAlbumIds?: string[],
): Promise<AlbumBrowsePageResult | null> {
if (!serverId || !(await libraryIsReady(serverId))) return null;
const scope = libraryScopeForServer(serverId) ?? undefined;
const useServerStarredIds = restrictAlbumIds != null;
const shared = sharedServerFilters(query, useServerStarredIds);
const starredOnly = useServerStarredIds ? undefined : (query.starredOnly || undefined);
if (query.genres.length > 0) {
if (offset > 0) return { albums: [], hasMore: false };
try {
const pages = await Promise.all(
query.genres.map(genre =>
libraryAdvancedSearch({
serverId,
libraryScope: scope,
entityTypes: ['album'],
filters: [{ field: 'genre', op: 'eq', value: genre }, ...shared],
starredOnly,
restrictAlbumIds: useServerStarredIds ? restrictAlbumIds : undefined,
sort: albumSortClauses(query.sort),
limit: GENRE_ALBUM_FETCH_LIMIT,
offset: 0,
skipTotals: true,
}),
),
);
if (pages.some(p => p.source !== 'local')) return null;
let merged = dedupeById(pages.flatMap(p => p.albums.map(albumToAlbum)));
if (useServerStarredIds) merged = markServerStarredAlbums(merged);
return {
albums: sortSubsonicAlbums(merged, query.sort),
hasMore: false,
};
} catch {
return null;
}
}
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: scope,
entityTypes: ['album'],
filters: shared,
starredOnly,
restrictAlbumIds: useServerStarredIds ? restrictAlbumIds : undefined,
sort: albumSortClauses(query.sort),
limit: pageSize,
offset,
skipTotals: true,
});
if (resp.source !== 'local') return null;
let albums = resp.albums.map(albumToAlbum);
if (useServerStarredIds) albums = markServerStarredAlbums(albums);
return { albums, hasMore: albums.length === pageSize };
} catch {
return null;
}
}
+65
View File
@@ -0,0 +1,65 @@
import { getAlbumList } from '../../api/subsonicLibrary';
import { getAlbumsByGenre } from '../../api/subsonicGenres';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { dedupeById } from '../dedupeById';
import {
filterAlbumsByCompilation,
filterAlbumsByYearBounds,
} from './albumBrowseFilters';
import { albumYearSubsonicParams } from './albumYearFilter';
import { sortSubsonicAlbums } from './albumBrowseSort';
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
async function fetchByGenres(genres: string[]) {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, GENRE_ALBUM_FETCH_LIMIT, 0)));
return dedupeById(results.flat());
}
function applyNetworkPostFilters(albums: SubsonicAlbum[], query: AlbumBrowseQuery) {
let out = albums;
if (query.year) out = filterAlbumsByYearBounds(out, query.year);
out = filterAlbumsByCompilation(out, query.compFilter);
if (query.starredOnly) out = out.filter(a => !!a.starred);
return sortSubsonicAlbums(out, query.sort);
}
export async function fetchAlbumBrowseNetwork(
query: AlbumBrowseQuery,
offset: number,
pageSize: number,
): Promise<AlbumBrowsePageResult> {
if (query.genres.length > 0) {
if (offset > 0) return { albums: [], hasMore: false };
const data = applyNetworkPostFilters(await fetchByGenres(query.genres), query);
return { albums: data, hasMore: false };
}
if (query.starredOnly) {
const extra = query.year ? albumYearSubsonicParams(query.year) : {};
const data = applyNetworkPostFilters(
await getAlbumList('starred', pageSize, offset, extra),
query,
);
return { albums: data, hasMore: data.length === pageSize };
}
if (query.year) {
const data = applyNetworkPostFilters(
await getAlbumList(
'byYear',
pageSize,
offset,
albumYearSubsonicParams(query.year),
),
query,
);
return { albums: data, hasMore: data.length === pageSize };
}
const data = applyNetworkPostFilters(
await getAlbumList(query.sort, pageSize, offset, {}),
query,
);
return { albums: data, hasMore: data.length === pageSize };
}
+21
View File
@@ -0,0 +1,21 @@
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import type { LibrarySortClause } from '../../api/library';
export type AlbumBrowseSort = 'alphabeticalByName' | 'alphabeticalByArtist';
export function albumSortClauses(sort: AlbumBrowseSort): LibrarySortClause[] {
if (sort === 'alphabeticalByArtist') {
return [{ field: 'artist', dir: 'asc' }];
}
return [{ field: 'name', dir: 'asc' }];
}
export function sortSubsonicAlbums(albums: SubsonicAlbum[], sort: AlbumBrowseSort): SubsonicAlbum[] {
const out = [...albums];
out.sort((a, b) =>
sort === 'alphabeticalByArtist'
? a.artist.localeCompare(b.artist) || a.name.localeCompare(b.name)
: a.name.localeCompare(b.name) || a.artist.localeCompare(b.artist),
);
return out;
}
@@ -0,0 +1,23 @@
import type { SubsonicAlbum } from '../../api/subsonicTypes';
type StarredCacheEntry = {
albums: SubsonicAlbum[];
fetchedAt: number;
};
const starredAlbumsByServer = new Map<string, StarredCacheEntry>();
/** Drop cached favorites for a server (after star/unstar or server switch). */
export function invalidateStarredAlbumBrowseCache(serverId: string | null | undefined): void {
if (!serverId) return;
starredAlbumsByServer.delete(serverId);
}
export function peekStarredAlbumBrowseCache(serverId: string): SubsonicAlbum[] | null {
const entry = starredAlbumsByServer.get(serverId);
return entry?.albums ?? null;
}
export function setStarredAlbumBrowseCache(serverId: string, albums: SubsonicAlbum[]): void {
starredAlbumsByServer.set(serverId, { albums, fetchedAt: Date.now() });
}
@@ -0,0 +1,85 @@
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { peekStarredAlbumBrowseCache } from './albumBrowseStarredCache';
import { refreshStarredAlbumIndexFromServer } from './starredAlbumIndexSync';
import {
albumBrowseStarredNeedsLocalIntersect,
filterAlbumsByCompilation,
filterAlbumsByYearBounds,
} from './albumBrowseFilters';
import { runLocalAlbumBrowse } from './albumBrowseLocal';
import { sortSubsonicAlbums } from './albumBrowseSort';
import type {
AlbumBrowseFetchCallbacks,
AlbumBrowsePageResult,
AlbumBrowseQuery,
} from './albumBrowseTypes';
function markServerStarredAlbums(albums: SubsonicAlbum[]): SubsonicAlbum[] {
return albums.map(a => ({ ...a, starred: a.starred ?? 'true' }));
}
function applyStarredNetworkPostFilters(
albums: SubsonicAlbum[],
query: AlbumBrowseQuery,
): SubsonicAlbum[] {
let out = albums;
if (query.year) out = filterAlbumsByYearBounds(out, query.year);
out = filterAlbumsByCompilation(out, query.compFilter);
if (query.starredOnly) out = out.filter(a => !!a.starred);
return sortSubsonicAlbums(out, query.sort);
}
function paginateStarredAlbums(
all: SubsonicAlbum[],
query: AlbumBrowseQuery,
offset: number,
pageSize: number,
): AlbumBrowsePageResult {
const filtered = applyStarredNetworkPostFilters(all, query);
const page = filtered.slice(offset, offset + pageSize);
return { albums: page, hasMore: offset + pageSize < filtered.length };
}
export async function fetchStarredAlbumBrowse(
serverId: string,
indexEnabled: boolean,
query: AlbumBrowseQuery,
offset: number,
pageSize: number,
callbacks?: AlbumBrowseFetchCallbacks,
): Promise<AlbumBrowsePageResult> {
const emitPartial = (page: AlbumBrowsePageResult | null) => {
if (page && offset === 0 && page.albums.length > 0) {
callbacks?.onPartial?.(page);
}
};
if (offset === 0) {
const cached = peekStarredAlbumBrowseCache(serverId);
if (cached?.length) {
if (albumBrowseStarredNeedsLocalIntersect(query, indexEnabled, serverId)) {
const fromCache = await runLocalAlbumBrowse(
serverId,
query,
0,
pageSize,
cached.map(a => a.id),
);
emitPartial(fromCache);
} else {
emitPartial(paginateStarredAlbums(cached, query, 0, pageSize));
}
}
}
const serverAlbums = await refreshStarredAlbumIndexFromServer(serverId, indexEnabled);
if (albumBrowseStarredNeedsLocalIntersect(query, indexEnabled, serverId)) {
const serverIds = serverAlbums.map(a => a.id);
const authoritative = await runLocalAlbumBrowse(serverId, query, offset, pageSize, serverIds);
if (authoritative != null) return authoritative;
if (query.losslessOnly) return { albums: [], hasMore: false };
}
return paginateStarredAlbums(serverAlbums, query, offset, pageSize);
}
+30
View File
@@ -0,0 +1,30 @@
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import type { AlbumYearBounds } from './albumYearFilter';
import type { AlbumCompFilter } from './albumCompilation';
import type { AlbumBrowseSort } from './albumBrowseSort';
export const GENRE_ALBUM_FETCH_LIMIT = 500;
export type AlbumBrowseQuery = {
sort: AlbumBrowseSort;
genres: string[];
year?: AlbumYearBounds;
losslessOnly: boolean;
starredOnly: boolean;
compFilter: AlbumCompFilter;
};
export type AlbumBrowsePageResult = {
albums: SubsonicAlbum[];
hasMore: boolean;
};
export type AlbumBrowseFetchCallbacks = {
/** Earlier page (cache / local index) before server favorites refresh finishes. */
onPartial?: (page: AlbumBrowsePageResult) => void;
};
export type GenreFilterOption = {
genre: string;
count: number;
};
@@ -0,0 +1,24 @@
import { libraryGetCatalogYearBounds } from '../../api/library';
import { ALBUM_YEAR_MAX, ALBUM_YEAR_MIN, type AlbumCatalogYearRange } from './albumYearFilter';
import { libraryIsReady } from './libraryReady';
const FALLBACK: AlbumCatalogYearRange = { min: ALBUM_YEAR_MIN, max: ALBUM_YEAR_MAX };
/** Indexed track years for Albums filter spinners (falls back when index is off). */
export async function fetchAlbumCatalogYearBounds(
serverId: string,
indexEnabled: boolean,
): Promise<AlbumCatalogYearRange> {
if (!serverId || !indexEnabled || !(await libraryIsReady(serverId))) {
return FALLBACK;
}
try {
const b = await libraryGetCatalogYearBounds({ serverId });
if (b.minYear != null && b.maxYear != null && b.minYear <= b.maxYear) {
return { min: b.minYear, max: b.maxYear };
}
} catch {
/* ignore */
}
return FALLBACK;
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import {
albumBrowseCompScanComplete,
albumIsCompilation,
ALBUM_COMP_FILTER_MAX_SCAN_ALBUMS,
} from './albumCompilation';
import { filterAlbumsByCompilation } from './albumBrowseFilters';
const album = (
overrides: Partial<SubsonicAlbum> & { compilation?: boolean } = {},
): SubsonicAlbum => ({
id: '1',
name: 'A',
artist: 'X',
artistId: 'a',
songCount: 1,
duration: 1,
...overrides,
});
describe('albumIsCompilation', () => {
it('reads isCompilation, compilation, releaseTypes, and VA artist', () => {
expect(albumIsCompilation(album({ isCompilation: true }))).toBe(true);
expect(albumIsCompilation(album({ compilation: true }))).toBe(true);
expect(albumIsCompilation(album({ releaseTypes: ['Live', 'Compilation'] }))).toBe(true);
expect(albumIsCompilation(album({ artist: 'Various Artists' }))).toBe(true);
expect(albumIsCompilation(album())).toBe(false);
});
});
describe('filterAlbumsByCompilation', () => {
const albums = [
album({ id: 'c', isCompilation: true }),
album({ id: 'n' }),
];
it('keeps only compilations', () => {
expect(filterAlbumsByCompilation(albums, 'only').map(a => a.id)).toEqual(['c']);
});
it('hides compilations', () => {
expect(filterAlbumsByCompilation(albums, 'hide').map(a => a.id)).toEqual(['n']);
});
});
describe('albumBrowseCompScanComplete', () => {
it('stops after scan budget when more pages exist', () => {
const loaded = Array.from({ length: ALBUM_COMP_FILTER_MAX_SCAN_ALBUMS }, (_, i) =>
album({ id: String(i) }),
);
expect(albumBrowseCompScanComplete(loaded, 'only', true)).toBe(true);
});
it('continues while under budget', () => {
expect(albumBrowseCompScanComplete([album()], 'only', true)).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
import type { SubsonicAlbum } from '../../api/subsonicTypes';
export type AlbumCompFilter = 'all' | 'only' | 'hide';
/** Max albums to scan client-side for compilation filter before showing empty. */
export const ALBUM_COMP_FILTER_MAX_SCAN_ALBUMS = 500;
const VARIOUS_ARTISTS = /\bvarious artists\b/i;
/** OpenSubsonic / Navidrome: `compilation`, `isCompilation`, `releaseTypes`, or VA artist. */
export function albumIsCompilation(a: SubsonicAlbum): boolean {
if (a.isCompilation === true) return true;
const loose = a as SubsonicAlbum & { compilation?: boolean };
if (loose.compilation === true) return true;
if (a.releaseTypes?.some(t => /^compilation$/i.test(t.trim()))) return true;
const artist = (a.artist ?? '').trim();
const displayArtist = (a.displayArtist ?? '').trim();
return VARIOUS_ARTISTS.test(artist) || VARIOUS_ARTISTS.test(displayArtist);
}
/** Stop paginating when the catalog tail is reached or the scan budget is spent. */
export function albumBrowseCompScanComplete(
loadedAlbums: SubsonicAlbum[],
compFilter: AlbumCompFilter,
hasMore: boolean,
): boolean {
if (compFilter === 'all') return true;
if (!hasMore) return true;
if (loadedAlbums.length >= ALBUM_COMP_FILTER_MAX_SCAN_ALBUMS) return true;
return false;
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import {
albumYearFilterClauses,
albumYearSubsonicParams,
clampAlbumYearFieldInput,
formatAlbumYearFilterLabel,
normalizeAlbumYearToFieldChange,
resolveAlbumYearBounds,
stepAlbumYearField,
} from './albumYearFilter';
describe('resolveAlbumYearBounds', () => {
it('is inactive when both fields are empty', () => {
expect(resolveAlbumYearBounds('', '')).toEqual({ active: false, bounds: {} });
});
it('is active with only from', () => {
expect(resolveAlbumYearBounds('1990', '')).toEqual({
active: true,
bounds: { from: 1990 },
});
});
it('is active with only to', () => {
expect(resolveAlbumYearBounds('', '2005')).toEqual({
active: true,
bounds: { to: 2005 },
});
});
it('is active with both bounds', () => {
expect(resolveAlbumYearBounds('1980', '1999')).toEqual({
active: true,
bounds: { from: 1980, to: 1999 },
});
});
});
describe('albumYearFilterClauses', () => {
it('uses gte for open-ended from', () => {
expect(albumYearFilterClauses({ from: 2000 })).toEqual([
{ field: 'year', op: 'gte', value: 2000 },
]);
});
it('uses lte for open-ended to', () => {
expect(albumYearFilterClauses({ to: 2010 })).toEqual([
{ field: 'year', op: 'lte', value: 2010 },
]);
});
});
describe('formatAlbumYearFilterLabel', () => {
const catalog = { min: 1975, max: 2020 };
it('formats partial ranges using catalog edges', () => {
expect(formatAlbumYearFilterLabel({ from: 1990 }, catalog)).toBe('19902020');
expect(formatAlbumYearFilterLabel({ to: 2000 }, catalog)).toBe('19752000');
expect(formatAlbumYearFilterLabel({ from: 2000, to: 2010 }, catalog)).toBe('20002010');
});
it('collapses when the only bound equals the implied catalog edge', () => {
expect(formatAlbumYearFilterLabel({ from: 2020 }, catalog)).toBe('2020');
expect(formatAlbumYearFilterLabel({ to: 1975 }, catalog)).toBe('1975');
});
});
describe('albumYearSubsonicParams', () => {
it('omits unset bounds', () => {
expect(albumYearSubsonicParams({ from: 1995 })).toEqual({ fromYear: 1995 });
expect(albumYearSubsonicParams({ to: 2010 })).toEqual({ toYear: 2010 });
});
});
describe('album year spinner helpers', () => {
const min = 1975;
const max = 2020;
it('steps from field from catalog min when empty', () => {
expect(stepAlbumYearField('', 1, min, max, 'min')).toBe('1976');
expect(stepAlbumYearField('', 0, min, max, 'min')).toBe('1975');
});
it('steps to field from catalog max when empty', () => {
expect(stepAlbumYearField('', -1, min, max, 'max')).toBe('2019');
expect(stepAlbumYearField('', 0, min, max, 'max')).toBe('2020');
});
it('clamps typed values to catalog bounds', () => {
expect(clampAlbumYearFieldInput('1960', min, max)).toBe('1975');
expect(clampAlbumYearFieldInput('2030', min, max)).toBe('2020');
});
it('maps first native spinner tick on empty to field to max', () => {
expect(normalizeAlbumYearToFieldChange('', '1975', min, max)).toBe('2020');
expect(normalizeAlbumYearToFieldChange('2010', '1975', min, max)).toBe('1975');
});
});
+121
View File
@@ -0,0 +1,121 @@
import type { LibraryFilterClause } from '../../api/library';
export const ALBUM_YEAR_MIN = 1900;
export const ALBUM_YEAR_MAX = new Date().getFullYear();
/** Delay before year filter triggers album browse reload. */
export const ALBUM_YEAR_FILTER_DEBOUNCE_MS = 350;
export type AlbumCatalogYearRange = { min: number; max: number };
export function clampAlbumYear(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/** Spinner / wheel step; empty field starts at `startEdge` of the catalog range. */
export function stepAlbumYearField(
raw: string,
delta: number,
min: number,
max: number,
startEdge: 'min' | 'max',
): string {
const start = startEdge === 'min' ? min : max;
const current = raw.trim() ? (parseAlbumYearField(raw) ?? start) : start;
return String(clampAlbumYear(current + delta, min, max));
}
export function clampAlbumYearFieldInput(
raw: string,
min: number,
max: number,
): string {
if (!raw.trim()) return '';
const n = parseAlbumYearField(raw);
if (n == null) return '';
return String(clampAlbumYear(n, min, max));
}
/** Native number spinners jump to `min` from empty — map the "to" field to catalog max. */
export function normalizeAlbumYearToFieldChange(
prevTo: string,
nextRaw: string,
catalogMin: number,
catalogMax: number,
): string {
if (!prevTo.trim() && nextRaw === String(catalogMin) && catalogMin !== catalogMax) {
return String(catalogMax);
}
return clampAlbumYearFieldInput(nextRaw, catalogMin, catalogMax);
}
export type AlbumYearBounds = { from?: number; to?: number };
export function parseAlbumYearField(raw: string): number | null {
const n = parseInt(raw.trim(), 10);
if (Number.isNaN(n) || n < 1) return null;
return n;
}
export function resolveAlbumYearBounds(from: string, to: string): {
active: boolean;
bounds: AlbumYearBounds;
} {
const fromN = parseAlbumYearField(from);
const toN = parseAlbumYearField(to);
if (fromN == null && toN == null) {
return { active: false, bounds: {} };
}
return {
active: true,
bounds: {
...(fromN != null ? { from: fromN } : {}),
...(toN != null ? { to: toN } : {}),
},
};
}
/** Chip label; open-ended bounds show catalog (or default) min/max on the missing side. */
export function formatAlbumYearFilterLabel(
bounds: AlbumYearBounds,
catalog?: AlbumCatalogYearRange,
): string | null {
const catalogMin = catalog?.min ?? ALBUM_YEAR_MIN;
const catalogMax = catalog?.max ?? ALBUM_YEAR_MAX;
if (bounds.from != null && bounds.to != null) {
const lo = Math.min(bounds.from, bounds.to);
const hi = Math.max(bounds.from, bounds.to);
return lo === hi ? String(lo) : `${lo}${hi}`;
}
if (bounds.from != null) {
const hi = catalogMax;
return bounds.from === hi ? String(bounds.from) : `${bounds.from}${hi}`;
}
if (bounds.to != null) {
const lo = catalogMin;
return bounds.to === lo ? String(bounds.to) : `${lo}${bounds.to}`;
}
return null;
}
export function albumYearFilterClauses(bounds: AlbumYearBounds): LibraryFilterClause[] {
const clauses: LibraryFilterClause[] = [];
if (bounds.from != null && bounds.to != null) {
const lo = Math.min(bounds.from, bounds.to);
const hi = Math.max(bounds.from, bounds.to);
clauses.push({ field: 'year', op: 'between', value: lo, valueTo: hi });
} else if (bounds.from != null) {
clauses.push({ field: 'year', op: 'gte', value: bounds.from });
} else if (bounds.to != null) {
clauses.push({ field: 'year', op: 'lte', value: bounds.to });
}
return clauses;
}
/** Params for Subsonic `getAlbumList2` `byYear` when the local index is unavailable. */
export function albumYearSubsonicParams(bounds: AlbumYearBounds): Record<string, number> {
const out: Record<string, number> = {};
if (bounds.from != null) out.fromYear = bounds.from;
if (bounds.to != null) out.toYear = bounds.to;
return out;
}
+36 -71
View File
@@ -1,6 +1,7 @@
/** /**
* Browse-page text search local index vs network race (LiveSearch / AdvancedSearch pattern). * Browse-page text search local index vs network race (LiveSearch / AdvancedSearch pattern).
*/ */
import { getStarred } from '../../api/subsonicStarRating';
import { search, searchSongsPaged } from '../../api/subsonicSearch'; import { search, searchSongsPaged } from '../../api/subsonicSearch';
import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes'; import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import { libraryAdvancedSearch, libraryGetArtistLosslessBrowse, libraryListLosslessAlbums } from '../../api/library'; import { libraryAdvancedSearch, libraryGetArtistLosslessBrowse, libraryListLosslessAlbums } from '../../api/library';
@@ -9,7 +10,7 @@ import {
LIVE_SEARCH_DEBOUNCE_NETWORK_MS, LIVE_SEARCH_DEBOUNCE_NETWORK_MS,
LIVE_SEARCH_DEBOUNCE_RACE_MS, LIVE_SEARCH_DEBOUNCE_RACE_MS,
} from './liveSearchLocal'; } from './liveSearchLocal';
import type { LibraryFilterClause, LibrarySortClause } from '../../api/library'; import type { LibrarySortClause } from '../../api/library';
import { dedupeById } from '../dedupeById'; import { dedupeById } from '../dedupeById';
import { import {
albumToAlbum, albumToAlbum,
@@ -20,6 +21,7 @@ import {
trackToSong, trackToSong,
type LocalSearchOpts, type LocalSearchOpts,
} from './advancedSearchLocal'; } from './advancedSearchLocal';
import type { AlbumYearBounds } from './albumYearFilter';
import { import {
logLibrarySearch, logLibrarySearch,
timed, timed,
@@ -329,14 +331,11 @@ export async function loadMoreLocalBrowseSongs(
return loadMoreLocalSongs(serverId, songBrowseOpts(query), offset, pageSize); return loadMoreLocalSongs(serverId, songBrowseOpts(query), offset, pageSize);
} }
export type AlbumBrowseSort = 'alphabeticalByName' | 'alphabeticalByArtist'; export type { AlbumBrowseSort } from './albumBrowseSort';
export { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
function albumSortClauses(sort: AlbumBrowseSort): LibrarySortClause[] { import { albumSortClauses, type AlbumBrowseSort } from './albumBrowseSort';
if (sort === 'alphabeticalByArtist') { import { runLocalAlbumBrowse, type AlbumBrowseQuery } from './albumBrowseLoad';
return [{ field: 'artist', dir: 'asc' }]; import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
}
return [{ field: 'name', dir: 'asc' }];
}
/** /**
* Random track sample from the local `track` table SQLite `ORDER BY RANDOM() LIMIT N`. * Random track sample from the local `track` table SQLite `ORDER BY RANDOM() LIMIT N`.
@@ -442,44 +441,22 @@ export async function runLocalAlbumBrowsePage(
sort: AlbumBrowseSort, sort: AlbumBrowseSort,
offset: number, offset: number,
pageSize: number, pageSize: number,
yearFilter?: { from: number; to: number }, yearFilter?: AlbumYearBounds,
losslessOnly?: boolean, losslessOnly?: boolean,
): Promise<SubsonicAlbum[] | null> { ): Promise<SubsonicAlbum[] | null> {
if (!serverId || !(await libraryIsReady(serverId))) return null; if (!serverId) return null;
const filters: LibraryFilterClause[] = []; const query: AlbumBrowseQuery = {
if (yearFilter) { sort,
filters.push({ genres: [],
field: 'year', year: yearFilter,
op: 'between', losslessOnly: !!losslessOnly,
value: yearFilter.from, starredOnly: false,
valueTo: yearFilter.to, compFilter: 'all',
}); };
} const page = await runLocalAlbumBrowse(serverId, query, offset, pageSize);
if (losslessOnly) { return page?.albums ?? null;
filters.push({ field: 'lossless', op: 'is_true' });
}
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
entityTypes: ['album'],
filters,
sort: yearFilter
? [{ field: 'year', dir: 'desc' }, { field: 'name', dir: 'asc' }]
: albumSortClauses(sort),
limit: pageSize,
offset,
skipTotals: true,
});
if (resp.source !== 'local') return null;
return resp.albums.map(albumToAlbum);
} catch {
return null;
}
} }
const GENRE_ALBUM_FETCH_LIMIT = 500;
/** Genre-filtered album union for All Albums / Random Albums genre bar. */ /** Genre-filtered album union for All Albums / Random Albums genre bar. */
export async function runLocalAlbumsByGenres( export async function runLocalAlbumsByGenres(
serverId: string | null | undefined, serverId: string | null | undefined,
@@ -488,34 +465,16 @@ export async function runLocalAlbumsByGenres(
limitPerGenre = GENRE_ALBUM_FETCH_LIMIT, limitPerGenre = GENRE_ALBUM_FETCH_LIMIT,
losslessOnly?: boolean, losslessOnly?: boolean,
): Promise<SubsonicAlbum[] | null> { ): Promise<SubsonicAlbum[] | null> {
if (!serverId || !(await libraryIsReady(serverId)) || genres.length === 0) return null; if (!serverId || genres.length === 0) return null;
try { const query: AlbumBrowseQuery = {
const pages = await Promise.all( sort,
genres.map(genre => { genres,
const filters: LibraryFilterClause[] = [{ field: 'genre', op: 'eq', value: genre }]; losslessOnly: !!losslessOnly,
if (losslessOnly) filters.push({ field: 'lossless', op: 'is_true' }); starredOnly: false,
return libraryAdvancedSearch({ compFilter: 'all',
serverId, };
libraryScope: libraryScopeForServer(serverId) ?? undefined, const page = await runLocalAlbumBrowse(serverId, query, 0, limitPerGenre);
entityTypes: ['album'], return page?.albums ?? null;
filters,
sort: albumSortClauses(sort),
limit: limitPerGenre,
offset: 0,
skipTotals: true,
});
}),
);
if (pages.some(p => p.source !== 'local')) return null;
const merged = dedupeById(pages.flatMap(p => p.albums.map(albumToAlbum)));
return merged.sort((a, b) =>
sort === 'alphabeticalByArtist'
? a.artist.localeCompare(b.artist) || a.name.localeCompare(b.name)
: a.name.localeCompare(b.name) || a.artist.localeCompare(b.artist),
);
} catch {
return null;
}
} }
/** Local artist table browse-all when the index is ready (optional fast path). */ /** Local artist table browse-all when the index is ready (optional fast path). */
@@ -539,3 +498,9 @@ export async function runLocalBrowseAllArtists(
return null; return null;
} }
} }
/** Starred artists from `getStarred2` (artist-level only; server is source of truth). */
export async function fetchNetworkStarredArtists(): Promise<SubsonicArtist[]> {
const { artists } = await getStarred();
return artists.map(a => ({ ...a, starred: a.starred ?? 'true' }));
}
+15 -6
View File
@@ -10,13 +10,22 @@ type TrackPatch = {
playedAt?: number | null; playedAt?: number | null;
}; };
/** Optional metadata on star/unstar (album/artist); not mirrored into the local index. */
export type StarPatchMeta = {
name?: string;
artist?: string;
artistId?: string;
coverArtId?: string;
year?: number;
albumCount?: number;
};
/** /**
* Patch-on-use (spec §6.5 / F3): after a successful star / rating / scrobble, * Patch-on-use (spec §6.5 / F3): after a successful star / rating / scrobble on a
* mirror the change into the local library index so its reads (browse F1, * **track**, mirror the change into the local library index. Skipped when the index
* advanced search F2) reflect the action immediately no stale list after a * is off; Rust no-ops when no row exists. Fire-and-forget.
* rate, no full resync. Skipped when the index is off for the server; the Rust *
* command additionally no-ops when no row exists / the id is not a track. * Album/artist stars are server-only on browse (no stub rows, no `artist.starred_at`).
* Fire-and-forget: never throws, never blocks the originating network action.
*/ */
export function patchLibraryTrackOnUse( export function patchLibraryTrackOnUse(
serverId: string | null | undefined, serverId: string | null | undefined,
@@ -0,0 +1,44 @@
import { libraryReconcileAlbumStars } from '../../api/library';
import { getStarred } from '../../api/subsonicStarRating';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import {
invalidateStarredAlbumBrowseCache,
setStarredAlbumBrowseCache,
} from './albumBrowseStarredCache';
function parseAlbumStarredAtMs(album: SubsonicAlbum): number {
if (!album.starred) return Date.now();
const parsed = Date.parse(album.starred);
return Number.isFinite(parsed) ? parsed : Date.now();
}
function markServerStarredAlbums(albums: SubsonicAlbum[]): SubsonicAlbum[] {
return albums.map(a => ({ ...a, starred: a.starred ?? 'true' }));
}
/**
* `getStarred2` `album.starred_at` in the local index (UPDATE only).
* Updates the in-memory favorites cache used for instant Albums browse.
*/
export async function refreshStarredAlbumIndexFromServer(
serverId: string,
indexEnabled: boolean,
): Promise<SubsonicAlbum[]> {
const { albums } = await getStarred();
const mapped = markServerStarredAlbums(albums);
if (indexEnabled) {
await libraryReconcileAlbumStars({
serverId,
starredAlbums: mapped.map(a => ({
id: a.id,
starredAt: parseAlbumStarredAtMs(a),
})),
});
}
setStarredAlbumBrowseCache(serverId, mapped);
return mapped;
}
export function invalidateStarredAlbumBrowse(serverId: string | null | undefined): void {
invalidateStarredAlbumBrowseCache(serverId);
}