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 && ( <>