From 06da15caf3ebbde25a3c8044241a2afb531eb793 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 27 May 2026 12:32:20 +0300 Subject: [PATCH] feat(albums): combined browse filters, favorites reconcile, and session restore (#876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- CHANGELOG.md | 10 + .../psysonic-library/src/advanced_search.rs | 297 +++++++++++++++++- .../src/album_compilation_filter.rs | 30 ++ .../psysonic-library/src/browse_support.rs | 257 +++++++++++++++ src-tauri/crates/psysonic-library/src/dto.rs | 13 + .../crates/psysonic-library/src/filter.rs | 6 + src-tauri/crates/psysonic-library/src/lib.rs | 2 + .../crates/psysonic-library/src/sync/delta.rs | 7 +- .../psysonic-library/src/sync/initial.rs | 7 +- .../psysonic-library/src/sync/mapping.rs | 27 ++ src-tauri/src/lib.rs | 2 + src/api/library.ts | 26 ++ src/api/subsonicStarRating.ts | 33 +- src/components/FilterQuickClear.tsx | 34 ++ src/components/GenreFilterBar.tsx | 87 +++-- src/components/LosslessFilterButton.tsx | 2 + src/components/StarFilterButton.tsx | 2 + src/components/YearFilterButton.tsx | 73 ++++- .../contextMenu/AlbumContextItems.tsx | 9 +- .../contextMenu/ArtistContextItems.tsx | 5 +- src/config/settingsCredits.ts | 1 + src/hooks/useAlbumBrowseData.ts | 215 +++++++++++++ src/hooks/useAlbumBrowseFilters.ts | 114 +++++++ src/hooks/useAlbumCatalogYearBounds.ts | 29 ++ src/hooks/useDebouncedValue.test.ts | 29 ++ src/hooks/useDebouncedValue.ts | 13 + src/pages/AlbumDetail.tsx | 11 +- src/pages/Albums.tsx | 285 +++++------------ src/pages/Artists.tsx | 24 +- src/pages/ComposerDetail.tsx | 5 +- src/pages/LosslessAlbums.tsx | 81 +++-- src/store/albumBrowseSessionStore.test.ts | 70 +++++ src/store/albumBrowseSessionStore.ts | 110 +++++++ src/styles/components/filter-quick-clear.css | 26 ++ src/styles/components/genre-filter-bar.css | 17 + src/styles/components/index.css | 1 + .../runArtistDetailActions.ts | 5 +- src/utils/library/advancedSearchLocal.ts | 14 +- src/utils/library/albumBrowseFilters.ts | 95 ++++++ src/utils/library/albumBrowseLoad.test.ts | 135 ++++++++ src/utils/library/albumBrowseLoad.ts | 71 +++++ src/utils/library/albumBrowseLocal.ts | 82 +++++ src/utils/library/albumBrowseNetwork.ts | 65 ++++ src/utils/library/albumBrowseSort.ts | 21 ++ src/utils/library/albumBrowseStarredCache.ts | 23 ++ src/utils/library/albumBrowseStarredFetch.ts | 85 +++++ src/utils/library/albumBrowseTypes.ts | 30 ++ src/utils/library/albumCatalogYearBounds.ts | 24 ++ src/utils/library/albumCompilation.test.ts | 58 ++++ src/utils/library/albumCompilation.ts | 31 ++ src/utils/library/albumYearFilter.test.ts | 98 ++++++ src/utils/library/albumYearFilter.ts | 121 +++++++ src/utils/library/browseTextSearch.ts | 107 +++---- src/utils/library/patchOnUse.ts | 21 +- src/utils/library/starredAlbumIndexSync.ts | 44 +++ 55 files changed, 2707 insertions(+), 383 deletions(-) create mode 100644 src-tauri/crates/psysonic-library/src/album_compilation_filter.rs create mode 100644 src-tauri/crates/psysonic-library/src/browse_support.rs create mode 100644 src/components/FilterQuickClear.tsx create mode 100644 src/hooks/useAlbumBrowseData.ts create mode 100644 src/hooks/useAlbumBrowseFilters.ts create mode 100644 src/hooks/useAlbumCatalogYearBounds.ts create mode 100644 src/hooks/useDebouncedValue.test.ts create mode 100644 src/hooks/useDebouncedValue.ts create mode 100644 src/store/albumBrowseSessionStore.test.ts create mode 100644 src/store/albumBrowseSessionStore.ts create mode 100644 src/styles/components/filter-quick-clear.css create mode 100644 src/utils/library/albumBrowseFilters.ts create mode 100644 src/utils/library/albumBrowseLoad.test.ts create mode 100644 src/utils/library/albumBrowseLoad.ts create mode 100644 src/utils/library/albumBrowseLocal.ts create mode 100644 src/utils/library/albumBrowseNetwork.ts create mode 100644 src/utils/library/albumBrowseSort.ts create mode 100644 src/utils/library/albumBrowseStarredCache.ts create mode 100644 src/utils/library/albumBrowseStarredFetch.ts create mode 100644 src/utils/library/albumBrowseTypes.ts create mode 100644 src/utils/library/albumCatalogYearBounds.ts create mode 100644 src/utils/library/albumCompilation.test.ts create mode 100644 src/utils/library/albumCompilation.ts create mode 100644 src/utils/library/albumYearFilter.test.ts create mode 100644 src/utils/library/albumYearFilter.ts create mode 100644 src/utils/library/starredAlbumIndexSync.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f77a96..0d0c0252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Linux — session GDK, WebKitGTK mitigations, and Wayland text diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index dacf1c2c..1f772752 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -11,6 +11,7 @@ use std::collections::{BTreeSet, HashSet}; use rusqlite::types::Value as SqlValue; +use rusqlite::{params, OptionalExtension}; use serde_json::Value; 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, \ - a.song_count, a.duration_sec, a.year, a.genre, a.cover_art_id, a.starred_at, \ - a.synced_at, a.raw_json"; + a.song_count, a.duration_sec, \ + 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, \ ar.synced_at, ar.raw_json"; @@ -285,6 +288,23 @@ fn map_track_row_resolved_bpm(row: &rusqlite::Row<'_>) -> rusqlite::Result Result { + 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)] fn build_album( store: &LibraryStore, @@ -301,6 +321,19 @@ fn build_album( 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) { let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?; 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"); 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) .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_raw("t.album_id IS NOT NULL AND t.album_id != ''"); 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( - "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()) { @@ -401,6 +444,12 @@ fn build_album_from_tracks( w.push_raw("t.starred_at IS NOT NULL"); 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), \ 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))); 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 { if let Some(frag) = resolve_clause(c, EntityKind::Artist)? { applied.insert(c.field.clone()); w.push(frag); } } - let order = order_clause(&req.sort, EntityKind::Artist) .unwrap_or_else(|| "ORDER BY ar.name COLLATE NOCASE ASC, ar.id ASC".to_string()); query_rows( @@ -584,6 +630,12 @@ fn build_album_from_fts( w.push_raw("t.starred_at IS NOT NULL"); 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(); store.with_read_conn(|conn| { @@ -784,8 +836,7 @@ fn resolve_clause( ("year", EntityKind::Album) => "a.year", ("starred", EntityKind::Track) => "t.starred_at", ("starred", EntityKind::Album) => "a.starred_at", - // `starred` routes to artist in the registry, but the `artist` - // table has no `starred_at` column — skip rather than error. + // `artist` has no `starred_at` column — favorites use the network list. ("starred", EntityKind::Artist) => return Ok(None), ("mood_group" | "mood_tag", EntityKind::Track) => { return crate::advanced_search_mood::resolve_mood_clause(c); @@ -808,6 +859,13 @@ fn resolve_clause( 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", _) => return Ok(None), // 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) } +/// 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, +) { + 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::>().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 /// lockstep so anonymous `?` placeholders bind left-to-right. 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, 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 { + 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 { match v { Some(Value::String(s)) => Ok(SqlValue::Text(s.clone())), @@ -1273,6 +1397,7 @@ mod tests { entity_types: entities.to_vec(), filters: Vec::new(), starred_only: None, + restrict_album_ids: None, sort: Vec::new(), limit: 50, offset: 0, @@ -1455,6 +1580,58 @@ mod tests { 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 ─────────────────────────────────────────────── #[test] @@ -1683,6 +1860,36 @@ mod tests { 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] fn lossless_and_year_filters_use_track_year_when_album_table_differs() { let store = LibraryStore::open_in_memory(); @@ -1743,6 +1950,80 @@ mod tests { 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] fn planned_but_unbuilt_field_is_an_error() { let store = LibraryStore::open_in_memory(); diff --git a/src-tauri/crates/psysonic-library/src/album_compilation_filter.rs b/src-tauri/crates/psysonic-library/src/album_compilation_filter.rs new file mode 100644 index 00000000..38e23f7d --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/album_compilation_filter.rs @@ -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")); + } +} diff --git a/src-tauri/crates/psysonic-library/src/browse_support.rs b/src-tauri/crates/psysonic-library/src/browse_support.rs new file mode 100644 index 00000000..c66e0b5d --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/browse_support.rs @@ -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, +) -> 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::>() + .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 = + 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 { + store + .with_read_conn(|conn| { + let min_year: Option = 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 = 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 { + 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) -> 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 = 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 = 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 = 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 = 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()); + } +} diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index 42a95f90..d925b90c 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -373,6 +373,14 @@ pub struct PlaySessionYearBoundsDto { pub max_year: Option, } +/// 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, + pub max_year: Option, +} + /// `library_purge_server` outcome. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -478,6 +486,11 @@ pub struct LibraryAdvancedSearchRequest { pub filters: Vec, #[serde(default)] pub starred_only: Option, + /// 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>, #[serde(default)] pub sort: Vec, pub limit: u32, diff --git a/src-tauri/crates/psysonic-library/src/filter.rs b/src-tauri/crates/psysonic-library/src/filter.rs index c4ff4d93..02dac3c0 100644 --- a/src-tauri/crates/psysonic-library/src/filter.rs +++ b/src-tauri/crates/psysonic-library/src/filter.rs @@ -128,6 +128,12 @@ pub const FILTER_FIELD_REGISTRY: &[FilterField] = &[ ops: &[FilterOp::IsTrue], status: FilterStatus::V1, }, + FilterField { + id: "compilation", + entities: &[EntityKind::Track, EntityKind::Album], + ops: &[FilterOp::IsTrue, FilterOp::Eq], + status: FilterStatus::V1, + }, FilterField { id: "bit_rate", entities: &[EntityKind::Track], diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index 3561aad8..7d05778a 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -9,6 +9,8 @@ pub(crate) mod bulk_ingest; pub mod advanced_search; +pub mod album_compilation_filter; +pub mod browse_support; mod advanced_search_mood; pub mod analysis_backfill; pub mod artist_lossless_browse; diff --git a/src-tauri/crates/psysonic-library/src/sync/delta.rs b/src-tauri/crates/psysonic-library/src/sync/delta.rs index 4e1c5fb1..2a0b2be2 100644 --- a/src-tauri/crates/psysonic-library/src/sync/delta.rs +++ b/src-tauri/crates/psysonic-library/src/sync/delta.rs @@ -27,7 +27,9 @@ use serde_json::Value; use super::backoff::{jitter_salt, with_jitter, Backoff}; use super::capability::{CapabilityFlags, NavidromeProbeCredentials}; 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::strategy::IngestStrategy; use super::tombstone::TombstoneReconciler; @@ -488,10 +490,11 @@ impl<'a> DeltaSyncRunner<'a> { .unwrap_or_default(); let mut rows: Vec = Vec::with_capacity(album.song.len()); for (i, song) in album.song.iter().enumerate() { - let raw = raw_songs + let mut raw = raw_songs .get(i) .cloned() .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( &self.server_id, song, diff --git a/src-tauri/crates/psysonic-library/src/sync/initial.rs b/src-tauri/crates/psysonic-library/src/sync/initial.rs index cff5f592..1852e893 100644 --- a/src-tauri/crates/psysonic-library/src/sync/initial.rs +++ b/src-tauri/crates/psysonic-library/src/sync/initial.rs @@ -25,7 +25,9 @@ use super::ingest_parallel::{ check_cancel_flag, fetch_albums_parallel, linear_prefetch_depth, retry_fetch, 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::strategy::IngestStrategy; use crate::bulk_ingest::{restore_track_secondary_indexes, suspend_track_secondary_indexes}; @@ -1124,10 +1126,11 @@ impl<'a> InitialSyncRunner<'a> { .unwrap_or_default(); let mut rows: Vec = Vec::with_capacity(album.song.len()); for (i, song) in album.song.iter().enumerate() { - let raw = raw_songs + let mut raw = raw_songs .get(i) .cloned() .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( &self.server_id, song, diff --git a/src-tauri/crates/psysonic-library/src/sync/mapping.rs b/src-tauri/crates/psysonic-library/src/sync/mapping.rs index 6c097e0c..4ce02783 100644 --- a/src-tauri/crates/psysonic-library/src/sync/mapping.rs +++ b/src-tauri/crates/psysonic-library/src/sync/mapping.rs @@ -9,6 +9,24 @@ use psysonic_integration::subsonic::Song; /// Project a Subsonic `Song` plus its raw JSON sub-tree into a /// `TrackRow`. `raw_value` is what `track.raw_json` stores verbatim so /// 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( server_id: &str, song: &Song, @@ -220,6 +238,15 @@ mod tests { 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] fn subsonic_song_maps_hot_columns_and_keeps_raw_json() { let raw = json!({ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad307728..0eb46021 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -720,6 +720,8 @@ pub fn run() { psysonic_library::commands::library_sync_verify_integrity, psysonic_library::commands::library_sync_cancel, 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_fact, psysonic_library::commands::library_record_play_session, diff --git a/src/api/library.ts b/src/api/library.ts index 1e637b51..e1556c40 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -206,6 +206,8 @@ export interface LibraryAdvancedSearchRequest { entityTypes: LibraryEntityType[]; filters?: LibraryFilterClause[]; starredOnly?: boolean | null; + /** Server favorites ids ∩ local filters (lossless, genre, year). */ + restrictAlbumIds?: string[] | null; sort?: LibrarySortClause[]; limit: number; offset?: number; @@ -599,6 +601,18 @@ export function libraryPatchTrack(args: { return invoke('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 { + const indexKey = serverIndexKeyForId(args.serverId); + return invoke('library_reconcile_album_stars', { + serverId: indexKey, + starredAlbums: args.starredAlbums.map(a => ({ id: a.id, starredAt: a.starredAt })), + }); +} + export function libraryPutArtifact(args: { serverId: string; trackId: string; @@ -687,6 +701,18 @@ export type PlaySessionYearBounds = { maxYear: number | null; }; +export type CatalogYearBounds = { + minYear: number | null; + maxYear: number | null; +}; + +export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise { + const indexKey = serverIndexKeyForId(args.serverId); + return invoke('library_get_catalog_year_bounds', { + serverId: indexKey, + }); +} + export type PlaySessionRecentDay = { date: string; totalListenedSec: number; diff --git a/src/api/subsonicStarRating.ts b/src/api/subsonicStarRating.ts index eb5aa39b..2f41627a 100644 --- a/src/api/subsonicStarRating.ts +++ b/src/api/subsonicStarRating.ts @@ -1,7 +1,12 @@ import { api, libraryFilterParams } from './subsonicClient'; import { invalidateEntityUserRatingCaches } from './subsonicRatings'; 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 { EntityRatingSupportLevel, StarredResults, @@ -22,25 +27,43 @@ export async function getStarred(): Promise { return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; } -export async function star(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise { +export async function star( + id: string, + type: 'song' | 'album' | 'artist' = 'album', + _meta?: StarPatchMeta, +): Promise { const params: Record = {}; if (type === 'song') params.id = id; if (type === 'album') params.albumId = id; if (type === 'artist') params.artistId = id; await api('star.view', params); + const serverId = useAuthStore.getState().activeServerId; 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 { +export async function unstar( + id: string, + type: 'song' | 'album' | 'artist' = 'album', + _meta?: StarPatchMeta, +): Promise { const params: Record = {}; if (type === 'song') params.id = id; if (type === 'album') params.albumId = id; if (type === 'artist') params.artistId = id; await api('unstar.view', params); + const serverId = useAuthStore.getState().activeServerId; 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(() => {}); } } diff --git a/src/components/FilterQuickClear.tsx b/src/components/FilterQuickClear.tsx new file mode 100644 index 00000000..c2316553 --- /dev/null +++ b/src/components/FilterQuickClear.tsx @@ -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 ( + { + if (e.key === 'Enter' || e.key === ' ') activate(e); + }} + > + + + ); +} diff --git a/src/components/GenreFilterBar.tsx b/src/components/GenreFilterBar.tsx index 41a9b5d6..ef45294a 100644 --- a/src/components/GenreFilterBar.tsx +++ b/src/components/GenreFilterBar.tsx @@ -1,18 +1,45 @@ import { getGenres } from '../api/subsonicGenres'; +import type { GenreFilterOption } from '../utils/library/albumBrowseLoad'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Check, Filter, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import FilterQuickClear from './FilterQuickClear'; + +type GenreRow = GenreFilterOption; + +function mergeGenreRows( + catalogGenres: GenreFilterOption[], + selected: string[], +): GenreRow[] { + const byGenre = new Map(); + 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 { selected: string[]; 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 [open, setOpen] = useState(false); - const [genres, setGenres] = useState([]); + const [genreRows, setGenreRows] = useState([]); const [search, setSearch] = useState(''); const [popStyle, setPopStyle] = useState({}); @@ -21,30 +48,30 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil const inputRef = useRef(null); useEffect(() => { - getGenres().then(data => - setGenres(data.map(g => g.value).sort((a, b) => a.localeCompare(b))) - ); - }, []); + if (catalogGenres != null) { + 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]); - // 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 q = search.trim().toLowerCase(); - if (!q) return sortedGenres; - return sortedGenres.filter(g => g.toLowerCase().includes(q)); - }, [sortedGenres, search]); + if (!q) return genreRows; + return genreRows.filter(({ genre }) => genre.toLowerCase().includes(q)); + }, [genreRows, search]); const updatePopStyle = () => { if (!triggerRef.current) return; @@ -131,6 +158,7 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil {t('common.filterGenre')} {count > 0 && {count}} + {count > 0 && } {open && createPortal( @@ -149,7 +177,7 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil onChange={e => setSearch(e.target.value)} onKeyDown={e => { 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')} ) : ( - filteredGenres.map(g => { - const isSel = selectedSet.has(g); + filteredGenres.map(({ genre, count: albumCount }) => { + const isSel = selectedSet.has(genre); return (
toggle(g)} + onClick={() => toggle(genre)} role="option" aria-selected={isSel} > {isSel && } - - {g} + + {genre} + + + {albumCount}
); diff --git a/src/components/LosslessFilterButton.tsx b/src/components/LosslessFilterButton.tsx index 68d66aab..e38d4f53 100644 --- a/src/components/LosslessFilterButton.tsx +++ b/src/components/LosslessFilterButton.tsx @@ -1,5 +1,6 @@ import { Gem } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import FilterQuickClear from './FilterQuickClear'; interface Props { active: boolean; @@ -25,6 +26,7 @@ export default function LosslessFilterButton({ active, onChange }: Props) { > {t('albums.losslessLabel')} + {active && onChange(false)} />} ); } diff --git a/src/components/StarFilterButton.tsx b/src/components/StarFilterButton.tsx index 5e9aed34..bf30a164 100644 --- a/src/components/StarFilterButton.tsx +++ b/src/components/StarFilterButton.tsx @@ -1,5 +1,6 @@ import { Star } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import FilterQuickClear from './FilterQuickClear'; interface Props { active: boolean; @@ -63,6 +64,7 @@ export default function StarFilterButton({ active, onChange, size = 'default' }: > {t('common.favorites')} + {active && onChange(false)} />} ); } diff --git a/src/components/YearFilterButton.tsx b/src/components/YearFilterButton.tsx index 7748b893..7fc7a3a8 100644 --- a/src/components/YearFilterButton.tsx +++ b/src/components/YearFilterButton.tsx @@ -2,16 +2,33 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { CalendarRange, X } from 'lucide-react'; 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 { from: string; to: string; 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, to, onChange }: Props) { +export default function YearFilterButton({ + from, + to, + onChange, + catalogMinYear, + catalogMaxYear, +}: Props) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [popStyle, setPopStyle] = useState({}); @@ -20,9 +37,11 @@ export default function YearFilterButton({ from, to, onChange }: Props) { const popRef = useRef(null); const fromRef = useRef(null); - const fromNum = parseInt(from, 10); - const toNum = parseInt(to, 10); - const active = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1; + const yMin = catalogMinYear ?? ALBUM_YEAR_MIN; + const yMax = catalogMaxYear ?? ALBUM_YEAR_MAX; + + const { active, bounds } = resolveAlbumYearBounds(from, to); + const activeLabel = formatAlbumYearFilterLabel(bounds, { min: yMin, max: yMax }); const updatePopStyle = () => { if (!triggerRef.current) return; @@ -87,6 +106,27 @@ export default function YearFilterButton({ from, to, onChange }: Props) { 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, + 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 ( <> {open && createPortal( @@ -122,11 +163,12 @@ export default function YearFilterButton({ from, to, onChange }: Props) { ref={fromRef} className="input" type="number" - min={1900} - max={CURRENT_YEAR} - placeholder="1970" + min={yMin} + max={yMax} + placeholder={String(yMin)} value={from} - onChange={e => onChange(e.target.value, to)} + onChange={e => handleFromChange(e.target.value)} + onWheel={e => onYearWheel(e, 'from')} /> @@ -137,11 +179,12 @@ export default function YearFilterButton({ from, to, onChange }: Props) { onChange(from, e.target.value)} + onChange={e => handleToChange(e.target.value)} + onWheel={e => onYearWheel(e, 'to')} /> diff --git a/src/components/contextMenu/AlbumContextItems.tsx b/src/components/contextMenu/AlbumContextItems.tsx index 605fc636..b96431b4 100644 --- a/src/components/contextMenu/AlbumContextItems.tsx +++ b/src/components/contextMenu/AlbumContextItems.tsx @@ -60,7 +60,14 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
handleAction(() => { const starred = isStarred(album.id, album.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); })}> {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')} diff --git a/src/components/contextMenu/ArtistContextItems.tsx b/src/components/contextMenu/ArtistContextItems.tsx index 7019f53c..9dfb6325 100644 --- a/src/components/contextMenu/ArtistContextItems.tsx +++ b/src/components/contextMenu/ArtistContextItems.tsx @@ -54,7 +54,10 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
handleAction(() => { const starred = isStarred(artist.id, artist.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); })}> {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 738d0d28..4010745f 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', '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)', + 'Albums: combined browse filters (genre/year/favorites/lossless/compilations), session restore from album detail, favorites reconcile via local index (PR #876)', ], }, { diff --git a/src/hooks/useAlbumBrowseData.ts b/src/hooks/useAlbumBrowseData.ts new file mode 100644 index 00000000..e5b29781 --- /dev/null +++ b/src/hooks/useAlbumBrowseData.ts @@ -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; +}; + +export function useAlbumBrowseData({ + serverId, + indexEnabled, + musicLibraryFilterVersion, + sort, + selectedGenres, + yearFrom, + yearTo, + losslessOnly, + starredOnly, + compFilter, + starredOverrides, +}: UseAlbumBrowseDataArgs) { + const [albums, setAlbums] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [genreCatalogOptions, setGenreCatalogOptions] = useState(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(() => ({ + sort, + genres: selectedGenres, + year: yearFilterActive ? yearFilterBounds : undefined, + losslessOnly, + starredOnly, + compFilter, + }), [sort, selectedGenres, yearFilterActive, yearFilterBounds, losslessOnly, starredOnly, compFilter]); + + const browseQueryWithoutGenre = useMemo(() => ({ + 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, + }; +} diff --git a/src/hooks/useAlbumBrowseFilters.ts b/src/hooks/useAlbumBrowseFilters.ts new file mode 100644 index 00000000..45105b32 --- /dev/null +++ b/src/hooks/useAlbumBrowseFilters.ts @@ -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(() => + returnFiltersForNavigation(serverId, navigationType).selectedGenres, + ); + const [yearFrom, setYearFrom] = useState(() => + returnFiltersForNavigation(serverId, navigationType).yearFrom, + ); + const [yearTo, setYearTo] = useState(() => + returnFiltersForNavigation(serverId, navigationType).yearTo, + ); + const [compFilter, setCompFilter] = useState(() => + returnFiltersForNavigation(serverId, navigationType).compFilter, + ); + const [starredOnly, setStarredOnly] = useState(() => + returnFiltersForNavigation(serverId, navigationType).starredOnly, + ); + const [losslessOnly, setLosslessOnly] = useState(() => + returnFiltersForNavigation(serverId, navigationType).losslessOnly, + ); + + const filtersRef = useRef(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, + }; +} diff --git a/src/hooks/useAlbumCatalogYearBounds.ts b/src/hooks/useAlbumCatalogYearBounds.ts new file mode 100644 index 00000000..29bf6b32 --- /dev/null +++ b/src/hooks/useAlbumCatalogYearBounds.ts @@ -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; +} diff --git a/src/hooks/useDebouncedValue.test.ts b/src/hooks/useDebouncedValue.test.ts new file mode 100644 index 00000000..53848c25 --- /dev/null +++ b/src/hooks/useDebouncedValue.test.ts @@ -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'); + }); +}); diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000..a70a7de3 --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react'; + +/** Returns `value` after it stays unchanged for `delayMs`. */ +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(id); + }, [value, delayMs]); + + return debounced; +} diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index f886a27a..03e74b86 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -210,8 +210,15 @@ const handleShuffleAll = () => { const wasStarred = isStarred; setIsStarred(!wasStarred); try { - if (wasStarred) await unstar(album.album.id); - else await star(album.album.id); + const meta = { + name: album.album.name, + artist: album.album.artist, + artistId: album.album.artistId, + coverArtId: album.album.coverArt, + year: album.album.year, + }; + if (wasStarred) await unstar(album.album.id, 'album', meta); + else await star(album.album.id, 'album', meta); } catch (e) { console.error('Failed to toggle star', e); setIsStarred(wasStarred); diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index 315b53d6..5851efce 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -1,9 +1,6 @@ import { buildDownloadUrl } from '../api/subsonicStreamUrl'; -import { getAlbumsByGenre } from '../api/subsonicGenres'; -import { getAlbumList, getAlbum } from '../api/subsonicLibrary'; -import type { SubsonicAlbum } from '../api/subsonicTypes'; +import { getAlbum } from '../api/subsonicLibrary'; import { songToTrack } from '../utils/playback/songToTrack'; -import { dedupeById } from '../utils/dedupeById'; import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'; import AlbumCard from '../components/AlbumCard'; import { albumGridWarmCovers, coverDisplayCssPxForAlbumGrid } from '../cover/layoutSizes'; @@ -26,6 +23,7 @@ import { join } from '@tauri-apps/api/path'; import { showToast } from '../utils/ui/toast'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { CheckSquare2, Download, HardDriveDownload, Disc3, ListPlus } from 'lucide-react'; +import FilterQuickClear from '../components/FilterQuickClear'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { useRangeSelection } from '../hooks/useRangeSelection'; import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; @@ -33,28 +31,18 @@ import { VirtualCardGrid } from '../components/VirtualCardGrid'; import OverlayScrollArea from '../components/OverlayScrollArea'; import { ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; -import { - runLocalAlbumBrowsePage, - runLocalAlbumsByGenres, - runLocalLosslessAlbums, - type AlbumBrowseSort, -} from '../utils/library/browseTextSearch'; +import { useAlbumBrowseFilters } from '../hooks/useAlbumBrowseFilters'; +import { useAlbumBrowseData } from '../hooks/useAlbumBrowseData'; +import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds'; +import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort'; import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; type SortType = AlbumBrowseSort; -type CompFilter = 'all' | 'only' | 'hide'; - -const PAGE_SIZE = 30; function sanitizeFilename(name: string): string { return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download'; } -async function fetchByGenres(genres: string[]): Promise { - const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); - return dedupeById(results.flat()); -} - export default function Albums() { const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); @@ -62,20 +50,56 @@ export default function Albums() { const auth = useAuthStore(); const serverId = useAuthStore(s => s.activeServerId ?? ''); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); + const catalogYears = useAlbumCatalogYearBounds(serverId, indexEnabled, musicLibraryFilterVersion); const downloadAlbum = useOfflineStore(s => s.downloadAlbum); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); - const [albums, setAlbums] = useState([]); - const [sort, setSort] = useState('alphabeticalByName'); - const [loading, setLoading] = useState(true); - const [page, setPage] = useState(0); - const [hasMore, setHasMore] = useState(true); - const [selectedGenres, setSelectedGenres] = useState([]); - const [yearFrom, setYearFrom] = useState(''); - const [yearTo, setYearTo] = useState(''); - const [compFilter, setCompFilter] = useState('all'); - const [starredOnly, setStarredOnly] = useState(false); - const [losslessOnly, setLosslessOnly] = useState(false); + const { + sort, + onSortChange, + selectedGenres, + setSelectedGenres, + yearFrom, + setYearFrom, + yearTo, + setYearTo, + compFilter, + 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(null); const gridMeasureRef = useRef(null); 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). 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 toggleSelectionMode = () => { @@ -178,15 +190,6 @@ export default function Albums() { 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(() => { if (starredOnly) return t('albums.noFavorites'); if (compFilter === 'only') return t('albums.noCompilations'); @@ -219,9 +222,9 @@ export default function Albums() { const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ sort, genreFiltered, - yearActive, - yearFrom, - yearTo, + yearFilterActive, + debouncedYearFields.from, + debouncedYearFields.to, compFilter, starredOnly, losslessOnly, @@ -229,144 +232,6 @@ export default function Albums() { 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(() => { if (!indexEnabled && losslessOnly) setLosslessOnly(false); }, [indexEnabled, losslessOnly]); @@ -386,11 +251,6 @@ export default function Albums() { return () => observer.disconnect(); }, [loadMore, scrollBodyEl]); - useEffect(() => { - if (!pendingClientFilterMatch || loading) return; - loadMore(); - }, [pendingClientFilterMatch, loading, loadMore]); - const sortOptions: { value: SortType; label: string }[] = [ { value: 'alphabeticalByName', label: t('albums.sortByName') }, { value: 'alphabeticalByArtist', label: t('albums.sortByArtist') }, @@ -424,21 +284,25 @@ export default function Albums() { ) : ( <> - {!yearActive && ( - - )} + { setYearFrom(from); setYearTo(to); }} /> - + @@ -464,6 +328,9 @@ export default function Albums() { {compFilter === 'all' ? t('albums.compilationLabel') : compFilter === 'only' ? t('albums.compilationOnly') : t('albums.compilationHide')} + {compFilter !== 'all' && ( + setCompFilter('all')} /> + )} )} @@ -500,11 +367,11 @@ export default function Albums() { perfFlags.disableMainstageVirtualLists, ]} > - {(loading && albums.length === 0) || pendingClientFilterMatch ? ( + {loading && albums.length === 0 ? (
- ) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !clientFilterActive && !losslessOnly ? ( + ) : !loading && albums.length === 0 && !serverFilterActive && !compFilterActive ? (
{t('common.libraryEmpty')}
@@ -512,7 +379,11 @@ export default function Albums() {
{t('losslessAlbums.empty')}
- ) : !loading && visibleAlbums.length === 0 && clientFilterActive ? ( + ) : !loading && visibleAlbums.length === 0 && pendingClientFilterMatch ? ( +
+
+
+ ) : !loading && visibleAlbums.length === 0 && (starredOnly || compFilterActive) ? (
{visibleEmptyMessage}
diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 77bbca00..1b6f105b 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -27,7 +27,7 @@ import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch'; import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { useArtistsInfiniteScroll } from '../hooks/useArtistsInfiniteScroll'; 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 { ArtistsListView } from '../components/artists/ArtistsListView'; @@ -96,17 +96,19 @@ export default function Artists() { let cancelled = false; setLoading(true); void (async () => { - if (indexEnabled && serverId) { - const local = await runLocalBrowseAllArtists(serverId); - if (!cancelled && local != null) { - setCatalogArtists(local); - setLoading(false); + try { + if (starredOnly) { + if (!cancelled) setCatalogArtists(await fetchNetworkStarredArtists()); return; } - } - try { - const data = await getArtists(); - if (!cancelled) setCatalogArtists(data); + if (indexEnabled && serverId) { + const local = await runLocalBrowseAllArtists(serverId); + if (!cancelled && local != null) { + setCatalogArtists(local); + return; + } + } + if (!cancelled) setCatalogArtists(await getArtists()); } catch { /* ignore */ } finally { @@ -116,7 +118,7 @@ export default function Artists() { return () => { cancelled = true; }; - }, [musicLibraryFilterVersion, indexEnabled, serverId]); + }, [musicLibraryFilterVersion, indexEnabled, serverId, starredOnly]); const { filtered, visible, hasMore, groups, letters, artistListFlatRows, diff --git a/src/pages/ComposerDetail.tsx b/src/pages/ComposerDetail.tsx index dc06a9c4..4443f4cf 100644 --- a/src/pages/ComposerDetail.tsx +++ b/src/pages/ComposerDetail.tsx @@ -99,8 +99,9 @@ export default function ComposerDetail() { setIsStarred(next); setStarredOverride(artist.id, next); try { - if (next) await star(artist.id, 'artist'); - else await unstar(artist.id, 'artist'); + const meta = { name: artist.name, albumCount: artist.albumCount }; + if (next) await star(artist.id, 'artist', meta); + else await unstar(artist.id, 'artist', meta); } catch (err) { console.warn('[psysonic] composer star failed:', err); setIsStarred(!next); diff --git a/src/pages/LosslessAlbums.tsx b/src/pages/LosslessAlbums.tsx index c8be8c6b..fd3afbba 100644 --- a/src/pages/LosslessAlbums.tsx +++ b/src/pages/LosslessAlbums.tsx @@ -2,7 +2,7 @@ import { buildDownloadUrl } from '../api/subsonicStreamUrl'; import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; 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 { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse'; @@ -24,7 +24,16 @@ import { VirtualCardGrid } from '../components/VirtualCardGrid'; import OverlayScrollArea from '../components/OverlayScrollArea'; import { LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; 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. */ const LOCAL_PAGE_SIZE = 30; @@ -45,6 +54,8 @@ export default function LosslessAlbums() { const activeServerId = useAuthStore(s => s.activeServerId); const serverId = useAuthStore(s => s.activeServerId ?? ''); 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 requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); 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. */ const [useLocalIndex, setUseLocalIndex] = useState(null); - const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums); - const selectedAlbums = albums.filter(a => selectedIds.has(a.id)); + const displayAlbums = useMemo(() => { + 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 clearSelection = () => { setSelectionMode(false); resetSelection(); }; @@ -76,10 +92,16 @@ export default function LosslessAlbums() { setScrollBodyEl(el); }, []); + const sortOptions: { value: AlbumBrowseSort; label: string }[] = [ + { value: 'alphabeticalByName', label: t('albums.sortByName') }, + { value: 'alphabeticalByArtist', label: t('albums.sortByArtist') }, + ]; + const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ unsupported, selectionMode, activeServerId, + sort, ]); const loadMoreNetwork = useCallback(async (onProgress?: (albums: SubsonicAlbum[]) => void) => { @@ -98,11 +120,18 @@ export default function LosslessAlbums() { }, []); const loadMoreLocal = useCallback(async () => { - const page = await runLocalLosslessAlbums(serverId, LOCAL_PAGE_SIZE, localOffset.current); - if (!page) return null; - localOffset.current += page.albums.length; - return page; - }, [serverId]); + const data = await runLocalAlbumBrowsePage( + serverId, + sort, + localOffset.current, + 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 () => { if (inFlight.current || useLocalIndex === null) return; @@ -151,13 +180,20 @@ export default function LosslessAlbums() { inFlight.current = true; try { 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 (local) { + if (data != null) { setUseLocalIndex(true); - localOffset.current = local.albums.length; - setAlbums(local.albums); - setHasMore(local.hasMore); + localOffset.current = data.length; + setAlbums(data); + setHasMore(data.length === LOCAL_PAGE_SIZE); return; } } @@ -182,7 +218,7 @@ export default function LosslessAlbums() { })(); return () => { cancelled = true; }; - }, [activeServerId, indexEnabled, loadMoreNetwork, serverId]); + }, [activeServerId, indexEnabled, loadMoreNetwork, serverId, sort]); useEffect(() => { if (!hasMore || useLocalIndex === null) return; @@ -271,6 +307,13 @@ export default function LosslessAlbums() { )}
+ {!(selectionMode && selectedIds.size > 0) && ( + setBrowseSort(serverId, value)} + /> + )} {selectionMode && selectedIds.size > 0 && ( <>