From ddf10ee01d96aa359be293e2024be44d672186d8 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 1 Jun 2026 04:20:18 +0300 Subject: [PATCH] feat(genres): local index genre browse with Subsonic fallback (#937) * feat(genres): genre detail browse via local index with aligned counts Move genre detail albums/play/shuffle onto the local library index with Albums-style in-page scroll, session restore, and genre-scoped stash. Unify genre album totals between the cloud and detail pages via library_get_genre_album_counts, and fix grouped browse totals to count distinct albums rather than matching tracks. * perf(genres): local genre browse with scoped counts cache Add dedicated Rust genre album pagination and indexes, slice-mode grid loading, library-filter-aware counts, and a long-lived in-memory catalog cache invalidated on sync so genre pages avoid repeated full-library SQL. * fix(genres): restore scroll after album back; play hold-to-shuffle Pin restore display count in refs so clearing the return stash no longer reloads the genre grid mid-restore. Load the first SQL page only (60 rows), use long-press on Play for shuffle, and add genre play tooltips. * fix(genres): fall back to Subsonic byGenre when local index unavailable Genre detail album grid now matches All Albums: try library_list_albums_by_genre first, then getAlbumsByGenre when the index is off, not ready, or errors. * docs: add CHANGELOG and credits for PR #937 --- CHANGELOG.md | 11 + .../010_genre_album_browse_index.sql | 8 + .../011_genre_album_name_browse_index.sql | 8 + .../psysonic-library/src/advanced_search.rs | 39 +- .../psysonic-library/src/browse_support.rs | 110 +++++- .../crates/psysonic-library/src/commands.rs | 10 + src-tauri/crates/psysonic-library/src/dto.rs | 41 ++ .../src/genre_album_browse.rs | 285 ++++++++++++++ src-tauri/crates/psysonic-library/src/lib.rs | 1 + src-tauri/src/lib.rs | 2 + src/api/library.ts | 58 +++ src/api/subsonicGenres.ts | 4 +- src/config/settingsCredits.ts | 1 + src/constants/appScroll.ts | 2 + src/hooks/useAlbumBrowseScrollRestore.ts | 42 ++- src/hooks/useGenreAlbumBrowse.ts | 179 +++++++++ src/hooks/useGenreDetailBrowse.ts | 79 ++++ src/locales/de/genres.ts | 1 + src/locales/en/genres.ts | 1 + src/locales/es/genres.ts | 1 + src/locales/fr/genres.ts | 1 + src/locales/nb/genres.ts | 1 + src/locales/nl/genres.ts | 1 + src/locales/ro/genres.ts | 1 + src/locales/ru/genres.ts | 1 + src/locales/zh/genres.ts | 1 + src/pages/GenreDetail.tsx | 354 ++++++++++++------ src/pages/Genres.tsx | 41 +- src/store/albumBrowseSessionStore.test.ts | 32 ++ src/store/albumBrowseSessionStore.ts | 67 +++- src/utils/library/albumBrowseLoad.ts | 9 +- src/utils/library/albumBrowseLocal.ts | 20 +- src/utils/library/albumBrowseNetwork.ts | 7 + src/utils/library/genreAlbumBrowse.test.ts | 99 +++++ src/utils/library/genreAlbumBrowse.ts | 105 ++++++ src/utils/library/genreBrowsePlayback.test.ts | 149 ++++++++ src/utils/library/genreBrowsePlayback.ts | 169 +++++++++ .../library/genreCatalogCountsCache.test.ts | 44 +++ src/utils/library/genreCatalogCountsCache.ts | 96 +++++ src/utils/library/librarySyncQueue.ts | 2 + .../navigation/albumDetailNavigation.test.ts | 2 +- src/utils/navigation/albumDetailNavigation.ts | 6 + 42 files changed, 1952 insertions(+), 139 deletions(-) create mode 100644 src-tauri/crates/psysonic-library/migrations/010_genre_album_browse_index.sql create mode 100644 src-tauri/crates/psysonic-library/migrations/011_genre_album_name_browse_index.sql create mode 100644 src-tauri/crates/psysonic-library/src/genre_album_browse.rs create mode 100644 src/hooks/useGenreAlbumBrowse.ts create mode 100644 src/hooks/useGenreDetailBrowse.ts create mode 100644 src/utils/library/genreAlbumBrowse.test.ts create mode 100644 src/utils/library/genreAlbumBrowse.ts create mode 100644 src/utils/library/genreBrowsePlayback.test.ts create mode 100644 src/utils/library/genreBrowsePlayback.ts create mode 100644 src/utils/library/genreCatalogCountsCache.test.ts create mode 100644 src/utils/library/genreCatalogCountsCache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be047954..9e98c6d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,6 +197,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Genres — local index browse with Subsonic fallback + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#937](https://github.com/Psychotoxical/psysonic/pull/937)** + +* **Genre detail** and the **Genres** cloud load album lists and counts from the local library index when it is ready; new SQLite indexes speed genre→album browse. +* When the index is disabled or not ready, the album grid falls back to Subsonic **byGenre** as before. +* Returning from an album restores genre-detail scroll; **Play** uses hold-to-shuffle like other browse pages. +* **Advanced Search** grouped album totals count distinct albums, not raw matching track rows. + + + ## Changed ### CI — hot-path coverage gates block merges diff --git a/src-tauri/crates/psysonic-library/migrations/010_genre_album_browse_index.sql b/src-tauri/crates/psysonic-library/migrations/010_genre_album_browse_index.sql new file mode 100644 index 00000000..3230146d --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/010_genre_album_browse_index.sql @@ -0,0 +1,8 @@ +-- Genre album browse: filter by (server, genre) then group by album_id. +CREATE INDEX IF NOT EXISTS idx_track_genre_album_browse + ON track(server_id, genre COLLATE NOCASE, album_id) + WHERE deleted = 0 + AND genre IS NOT NULL + AND TRIM(genre) != '' + AND album_id IS NOT NULL + AND album_id != ''; diff --git a/src-tauri/crates/psysonic-library/migrations/011_genre_album_name_browse_index.sql b/src-tauri/crates/psysonic-library/migrations/011_genre_album_name_browse_index.sql new file mode 100644 index 00000000..76e4099b --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/011_genre_album_name_browse_index.sql @@ -0,0 +1,8 @@ +-- Genre album browse sort: (server, genre, album name, album_id) covering walk. +CREATE INDEX IF NOT EXISTS idx_track_genre_album_name_browse + ON track(server_id, genre COLLATE NOCASE, album COLLATE NOCASE, album_id) + WHERE deleted = 0 + AND genre IS NOT NULL + AND TRIM(genre) != '' + AND album_id IS NOT NULL + AND album_id != ''; diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index ff9a8a36..07932a94 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -1086,7 +1086,17 @@ where let total = if skip_totals { 0u32 } else { - count_matching_rows(conn, from, &where_sql, &w.params, false)? + // Grouped browse totals must count distinct groups (album/artist rows), + // not raw track rows matching the WHERE clause. + let count_sql = format!( + "SELECT COUNT(*) FROM (SELECT 1 FROM {from} WHERE {where_sql} {group_sql})" + ); + let n: i64 = conn.query_row( + &count_sql, + rusqlite::params_from_iter(w.params.iter()), + |r| r.get(0), + )?; + n.max(0) as u32 }; let page_sql = format!( @@ -1535,6 +1545,33 @@ mod tests { assert!(resp.applied_filters.contains(&"genre".to_string())); } + #[test] + fn grouped_album_totals_count_distinct_albums_not_tracks() { + let store = LibraryStore::open_in_memory(); + let mut rows: Vec = Vec::new(); + for i in 0..6 { + let mut t = track("s1", &format!("t{i}"), &format!("Song {i}"), "X", "Alb One"); + t.genre = Some("Rock".into()); + rows.push(t); + } + for i in 6..10 { + let mut t = track("s1", &format!("t{i}"), &format!("Song {i}"), "Y", "Alb Two"); + t.genre = Some("Rock".into()); + rows.push(t); + } + TrackRepository::new(&store).upsert_batch(&rows).unwrap(); + let mut r = req("s1", &[EntityKind::Album]); + r.filters = vec![clause("genre", FilterOp::Eq, Some(json!("rock")), None)]; + r.limit = 1; + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1, "page is capped by limit"); + assert_eq!( + resp.totals.albums, 2, + "total must be distinct album groups, not matching track rows" + ); + assert_eq!(resp.totals.tracks, 0); + } + #[test] fn year_between_is_inclusive() { let store = LibraryStore::open_in_memory(); diff --git a/src-tauri/crates/psysonic-library/src/browse_support.rs b/src-tauri/crates/psysonic-library/src/browse_support.rs index c66e0b5d..877332a1 100644 --- a/src-tauri/crates/psysonic-library/src/browse_support.rs +++ b/src-tauri/crates/psysonic-library/src/browse_support.rs @@ -4,7 +4,9 @@ use rusqlite::params; use tauri::State; use crate::dto::CatalogYearBoundsDto; +use crate::dto::GenreAlbumCountDto; use crate::runtime::LibraryRuntime; +use crate::search::library_scope_equals_sql; use crate::store::LibraryStore; #[derive(Debug, Clone, serde::Deserialize)] @@ -104,6 +106,59 @@ pub fn library_get_catalog_year_bounds( catalog_year_bounds_for_server(&runtime.store, &server_id) } +pub(crate) fn genre_album_counts_for_server( + store: &LibraryStore, + server_id: &str, + library_scope: Option<&str>, +) -> Result, String> { + store + .with_read_conn(|conn| { + let mut sql = String::from( + "SELECT t.genre, COUNT(DISTINCT t.album_id) AS album_count, COUNT(*) AS song_count \ + FROM track t \ + WHERE t.server_id = ?1 AND t.deleted = 0 \ + AND t.genre IS NOT NULL AND TRIM(t.genre) != '' \ + AND t.album_id IS NOT NULL AND t.album_id != ''", + ); + let mut params: Vec = + vec![rusqlite::types::Value::Text(server_id.to_string())]; + if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) { + sql.push_str(&format!(" AND {}", library_scope_equals_sql("t"))); + params.push(rusqlite::types::Value::Text(scope.to_string())); + } + sql.push_str( + " GROUP BY t.genre COLLATE NOCASE \ + ORDER BY album_count DESC, t.genre COLLATE NOCASE ASC", + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(GenreAlbumCountDto { + value: r.get::<_, String>(0)?, + album_count: r.get::<_, i64>(1)?.max(0) as u32, + song_count: r.get::<_, i64>(2)?.max(0) as u32, + }) + })? + .collect::>>()?; + Ok(rows) + }) + .map_err(|e| e.to_string()) +} + +/// Distinct album counts per track genre — same grouping as genre album browse. +#[tauri::command] +pub fn library_get_genre_album_counts( + runtime: State<'_, LibraryRuntime>, + server_id: String, + library_scope: Option, +) -> Result, String> { + genre_album_counts_for_server( + &runtime.store, + &server_id, + library_scope.as_deref(), + ) +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -112,7 +167,10 @@ mod tests { use crate::runtime::LibraryRuntime; use crate::store::LibraryStore; - use super::{catalog_year_bounds_for_server, reconcile_album_stars, StarredAlbumReconcileItem}; + use super::{ + catalog_year_bounds_for_server, genre_album_counts_for_server, reconcile_album_stars, + StarredAlbumReconcileItem, + }; fn make_row(server: &str, id: &str, album_id: &str, track: i64) -> crate::repos::TrackRow { crate::repos::TrackRow { @@ -229,6 +287,56 @@ mod tests { assert_eq!(bounds.max_year, Some(2018)); } + #[test] + fn genre_album_counts_group_distinct_albums_per_genre() { + let store = Arc::new(LibraryStore::open_in_memory()); + let mut rock_one: Vec<_> = (0..3) + .map(|i| { + let mut t = make_row("s1", &format!("r{i}"), "al_rock_one", i + 1); + t.genre = Some("Rock".into()); + t + }) + .collect(); + let mut rock_two = make_row("s1", "r3", "al_rock_two", 1); + rock_two.genre = Some("Rock".into()); + let mut jazz = make_row("s1", "j1", "al_jazz", 1); + jazz.genre = Some("Jazz".into()); + rock_one.push(rock_two); + rock_one.push(jazz); + TrackRepository::new(&store) + .upsert_batch(&rock_one) + .unwrap(); + + let counts = genre_album_counts_for_server(&store, "s1", None).unwrap(); + assert_eq!(counts.len(), 2); + assert_eq!(counts[0].value, "Rock"); + assert_eq!(counts[0].album_count, 2); + assert_eq!(counts[0].song_count, 4); + assert_eq!(counts[1].value, "Jazz"); + assert_eq!(counts[1].album_count, 1); + assert_eq!(counts[1].song_count, 1); + } + + #[test] + fn genre_album_counts_respect_library_scope() { + let store = Arc::new(LibraryStore::open_in_memory()); + let mut scoped = make_row("s1", "r1", "al_a", 1); + scoped.genre = Some("Rock".into()); + scoped.library_id = Some("lib1".into()); + let mut other = make_row("s1", "r2", "al_b", 1); + other.genre = Some("Rock".into()); + other.library_id = Some("lib2".into()); + TrackRepository::new(&store) + .upsert_batch(&[scoped, other]) + .unwrap(); + + let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap(); + assert_eq!(counts.len(), 1); + assert_eq!(counts[0].value, "Rock"); + assert_eq!(counts[0].album_count, 1); + assert_eq!(counts[0].song_count, 1); + } + #[test] fn reconcile_album_stars_clears_all_when_server_list_empty() { let store = Arc::new(LibraryStore::open_in_memory()); diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index f52dfa3a..6b51714b 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -482,6 +482,16 @@ pub async fn library_list_lossless_albums( library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await } +#[tauri::command] +pub async fn library_list_albums_by_genre( + runtime: State<'_, LibraryRuntime>, + request: crate::dto::LibraryGenreAlbumsRequest, +) -> Result { + let store = Arc::clone(&runtime.store); + library_spawn_blocking(move || crate::genre_album_browse::list_albums_by_genre(&store, &request)) + .await +} + #[tauri::command] pub async fn library_get_artist_lossless_browse( runtime: State<'_, LibraryRuntime>, diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index d925b90c..cc03afca 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -381,6 +381,47 @@ pub struct CatalogYearBoundsDto { pub max_year: Option, } +/// Per-genre album/track totals from the local track catalog (Genres cloud + browse). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GenreAlbumCountDto { + pub value: String, + pub album_count: u32, + pub song_count: u32, +} + +/// `library_list_albums_by_genre` request — paginated genre album browse (local index). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryGenreAlbumsRequest { + pub server_id: String, + pub genre: String, + #[serde(default)] + pub library_scope: Option, + #[serde(default)] + pub sort: Vec, + #[serde(default = "default_genre_album_limit")] + pub limit: u32, + #[serde(default)] + pub offset: u32, + #[serde(default)] + pub include_total: bool, +} + +fn default_genre_album_limit() -> u32 { + 50 +} + +/// `library_list_albums_by_genre` response. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryGenreAlbumsResponse { + pub albums: Vec, + pub has_more: bool, + pub total: Option, + pub source: String, +} + /// `library_purge_server` outcome. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs new file mode 100644 index 00000000..a77f20ec --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs @@ -0,0 +1,285 @@ +//! Paginated genre → album browse from the local `track` index. +//! +//! Uses the same subquery shape as lossless album browse (single SQL round-trip, +//! LIMIT/OFFSET on grouped rows) instead of the heavier Advanced Search builder. + +use crate::dto::{ + LibraryAlbumDto, LibraryGenreAlbumsRequest, LibraryGenreAlbumsResponse, LibrarySortClause, + SortDir, +}; +use crate::search::library_scope_equals_sql; +use crate::store::LibraryStore; +use rusqlite::types::Value as SqlValue; +use serde_json::Value; + +fn trimmed_nonempty(s: Option<&str>) -> Option { + s.map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) +} + +fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String { + let mut keys: Vec = Vec::new(); + for s in sort { + let col = match s.field.as_str() { + "name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE", + "artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE", + "year" => "COALESCE(a.year, la.year)", + _ => continue, + }; + let dir = match s.dir { + SortDir::Asc => "ASC", + SortDir::Desc => "DESC", + }; + keys.push(format!("{col} {dir}")); + } + if keys.is_empty() { + keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string()); + } + keys.push("la.album_id ASC".to_string()); + format!("ORDER BY {}", keys.join(", ")) +} + +fn count_genre_albums( + conn: &rusqlite::Connection, + where_sql: &str, + params: &[SqlValue], +) -> Result { + let count_sql = format!( + "SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}" + ); + let n: i64 = conn.query_row( + &count_sql, + rusqlite::params_from_iter(params.iter()), + |r| r.get(0), + )?; + Ok(n.max(0) as u32) +} + +fn map_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let raw: Option = r.get(12)?; + Ok(LibraryAlbumDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + artist: r.get(3)?, + artist_id: r.get(4)?, + song_count: r.get(5)?, + duration_sec: r.get(6)?, + year: r.get(7)?, + genre: r.get(8)?, + cover_art_id: r.get(9)?, + starred_at: r.get(10)?, + synced_at: r.get(11)?, + raw_json: raw + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(Value::Null), + }) +} + +/// Paginated albums for one genre. Returns empty when the index has no matching tracks. +pub fn list_albums_by_genre( + store: &LibraryStore, + req: &LibraryGenreAlbumsRequest, +) -> Result { + if !crate::dto::track_index_nonempty(store, &req.server_id)? { + return Ok(LibraryGenreAlbumsResponse { + albums: Vec::new(), + has_more: false, + total: None, + source: "local".to_string(), + }); + } + + let genre = req.genre.trim(); + if genre.is_empty() { + return Ok(LibraryGenreAlbumsResponse { + albums: Vec::new(), + has_more: false, + total: None, + source: "local".to_string(), + }); + } + + let limit = req.limit.max(1); + let offset = req.offset; + let order_sql = genre_album_order_sql(&req.sort); + + let mut where_clauses = vec![ + "t.deleted = 0".to_string(), + "t.server_id = ?1".to_string(), + "t.album_id IS NOT NULL AND t.album_id != ''".to_string(), + "t.genre = ?2 COLLATE NOCASE".to_string(), + ]; + let mut params: Vec = vec![ + SqlValue::Text(req.server_id.clone()), + SqlValue::Text(genre.to_string()), + ]; + + if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + where_clauses.push(library_scope_equals_sql("t")); + params.push(SqlValue::Text(scope)); + } + + let where_sql = where_clauses.join(" AND "); + let sql = format!( + "SELECT \ + la.server_id, \ + la.album_id, \ + COALESCE(a.name, la.album_name), \ + COALESCE(a.artist, la.artist), \ + COALESCE(a.artist_id, la.artist_id), \ + COALESCE(a.song_count, la.track_count), \ + COALESCE(a.duration_sec, la.duration_sec), \ + COALESCE(a.year, la.year), \ + COALESCE(a.genre, la.genre), \ + COALESCE(a.cover_art_id, la.cover_art_id), \ + COALESCE(a.starred_at, la.starred_at), \ + COALESCE(a.synced_at, la.synced_at), \ + NULL \ + FROM ( \ + SELECT \ + t.server_id, \ + t.album_id, \ + MAX(t.album) AS album_name, \ + MAX(t.artist) AS artist, \ + MAX(t.artist_id) AS artist_id, \ + MAX(t.year) AS year, \ + MAX(t.genre) AS genre, \ + MAX(t.cover_art_id) AS cover_art_id, \ + MAX(t.starred_at) AS starred_at, \ + MAX(t.synced_at) AS synced_at, \ + COUNT(*) AS track_count, \ + COALESCE(SUM(t.duration_sec), 0) AS duration_sec \ + FROM track t \ + WHERE {where_sql} \ + GROUP BY t.server_id, t.album_id \ + ) la \ + LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \ + {order_sql} \ + LIMIT ? OFFSET ?" + ); + + let count_params = params.clone(); + params.push(SqlValue::Integer(limit as i64)); + params.push(SqlValue::Integer(offset as i64)); + + store.with_read_conn(|conn| { + let total = if req.include_total { + Some(count_genre_albums(conn, &where_sql, &count_params)?) + } else { + None + }; + + let mut stmt = conn.prepare(&sql)?; + let albums = stmt + .query_map(rusqlite::params_from_iter(params.iter()), map_row)? + .collect::>>()?; + let has_more = albums.len() as u32 == limit; + Ok(LibraryGenreAlbumsResponse { + albums, + has_more, + total, + source: "local".to_string(), + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::SortDir; + use crate::repos::{TrackRepository, TrackRow}; + + fn track(server: &str, id: &str, album_id: &str, genre: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: format!("T{id}"), + title_sort: None, + artist: Some("Artist".into()), + artist_id: Some("ar1".into()), + album: album_id.into(), + album_id: Some(album_id.into()), + album_artist: None, + duration_sec: 200, + track_number: Some(1), + disc_number: Some(1), + year: Some(2000), + genre: Some(genre.into()), + 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: Some("lib1".into()), + 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(), + } + } + + #[test] + fn list_albums_by_genre_respects_library_scope_and_total() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "al_a", "Rock"), + track("s1", "t2", "al_b", "Rock"), + { + let mut t = track("s1", "t3", "al_c", "Rock"); + t.library_id = Some("lib2".into()); + t + }, + ]) + .unwrap(); + + let scoped = list_albums_by_genre( + &store, + &LibraryGenreAlbumsRequest { + server_id: "s1".into(), + genre: "Rock".into(), + library_scope: Some("lib1".into()), + sort: vec![LibrarySortClause { + field: "name".into(), + dir: SortDir::Asc, + }], + limit: 10, + offset: 0, + include_total: true, + }, + ) + .unwrap(); + assert_eq!(scoped.total, Some(2)); + assert_eq!(scoped.albums.len(), 2); + + let all = list_albums_by_genre( + &store, + &LibraryGenreAlbumsRequest { + server_id: "s1".into(), + genre: "Rock".into(), + library_scope: None, + sort: vec![], + limit: 1, + offset: 0, + include_total: true, + }, + ) + .unwrap(); + assert_eq!(all.total, Some(3)); + assert!(all.has_more); + } +} diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index 1279abef..35be3a02 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -24,6 +24,7 @@ pub mod cross_server; pub mod dto; pub mod enrichment; pub mod filter; +pub mod genre_album_browse; pub mod mood_groups; pub mod live_search; pub mod lossless_albums; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 493cce85..caf0f481 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -707,6 +707,7 @@ pub fn run() { psysonic_library::commands::library_live_search, psysonic_library::commands::library_advanced_search, psysonic_library::commands::library_list_lossless_albums, + psysonic_library::commands::library_list_albums_by_genre, psysonic_library::commands::library_get_artist_lossless_browse, psysonic_library::commands::library_search_cross_server, psysonic_library::commands::library_get_track, @@ -727,6 +728,7 @@ pub fn run() { 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::browse_support::library_get_genre_album_counts, 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 e1556c40..1153657b 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -706,6 +706,12 @@ export type CatalogYearBounds = { maxYear: number | null; }; +export type GenreAlbumCountRow = { + value: string; + albumCount: number; + songCount: number; +}; + export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise { const indexKey = serverIndexKeyForId(args.serverId); return invoke('library_get_catalog_year_bounds', { @@ -713,6 +719,58 @@ export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise }); } +export function libraryGetGenreAlbumCounts(args: { + serverId: string; + libraryScope?: string; +}): Promise { + const indexKey = serverIndexKeyForId(args.serverId); + return invoke('library_get_genre_album_counts', { + serverId: indexKey, + libraryScope: args.libraryScope, + }); +} + +export type LibraryGenreAlbumsRequest = { + serverId: string; + genre: string; + libraryScope?: string | null; + sort?: LibrarySortClause[]; + limit?: number; + offset?: number; + includeTotal?: boolean; +}; + +export type LibraryGenreAlbumsResponse = { + albums: LibraryAlbumDto[]; + hasMore: boolean; + total?: number | null; + source: 'local'; +}; + +/** Paginated albums for one genre from the local track index. */ +export function libraryListAlbumsByGenre( + request: LibraryGenreAlbumsRequest, +): Promise { + const indexKey = serverIndexKeyForId(request.serverId); + return invoke('library_list_albums_by_genre', { + request: { + serverId: indexKey, + genre: request.genre, + libraryScope: request.libraryScope ?? undefined, + sort: request.sort ?? [], + limit: request.limit ?? 50, + offset: request.offset ?? 0, + includeTotal: request.includeTotal ?? false, + }, + }).then(response => ({ + ...response, + albums: response.albums.map(album => ({ + ...album, + serverId: mapServerIdFromIndexKey(album.serverId, request.serverId), + })), + })); +} + export type PlaySessionRecentDay = { date: string; totalListenedSec: number; diff --git a/src/api/subsonicGenres.ts b/src/api/subsonicGenres.ts index 3fb9d32b..3f10fc8f 100644 --- a/src/api/subsonicGenres.ts +++ b/src/api/subsonicGenres.ts @@ -2,7 +2,9 @@ import { api, libraryFilterParams } from './subsonicClient'; import type { SubsonicAlbum, SubsonicGenre, SubsonicSong } from './subsonicTypes'; export async function getGenres(): Promise { - const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view'); + const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view', { + ...libraryFilterParams(), + }); const raw = data.genres?.genre; if (!raw) return []; return Array.isArray(raw) ? raw : [raw]; diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 61199c22..defc1c65 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -141,6 +141,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Analytics: Opus waveform/LUFS/enrichment decode via symphonia-adapter-libopus in the analysis pipeline (PR #883)', 'Artist page: top-track thumbnails use the same album cover path and warm batch as the albums grid (PR #886)', 'Library browse navigation: session restore on back for Artists, Search/Tracks, New Releases, Random Albums; unified SearchBrowsePage (PR #936)', + 'Genres: local index genre browse with Subsonic fallback, aligned counts cache, scroll restore, hold-to-shuffle play (PR #937)', ], }, { diff --git a/src/constants/appScroll.ts b/src/constants/appScroll.ts index 9992fd84..76be0b24 100644 --- a/src/constants/appScroll.ts +++ b/src/constants/appScroll.ts @@ -8,6 +8,7 @@ export const NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID = 'new-releases-inpage-scrol export const RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'random-albums-inpage-scroll-viewport'; export const LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'lossless-albums-inpage-scroll-viewport'; export const COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID = 'composers-inpage-scroll-viewport'; +export const GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID = 'genre-detail-inpage-scroll-viewport'; export type AlbumGridInpageScrollSurface = 'albums' | 'new-releases' | 'random-albums'; @@ -39,5 +40,6 @@ export function readInpageScrollTop(viewportId: string): number { /** Resolve in-page viewport id for the current route pathname. */ export function mainRouteInpageScrollViewportId(pathname: string): string | undefined { const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname; + if (/^\/genres\/[^/]+$/.test(path)) return GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID; return MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH[path]; } diff --git a/src/hooks/useAlbumBrowseScrollRestore.ts b/src/hooks/useAlbumBrowseScrollRestore.ts index 8a74a9de..c764933d 100644 --- a/src/hooks/useAlbumBrowseScrollRestore.ts +++ b/src/hooks/useAlbumBrowseScrollRestore.ts @@ -1,7 +1,9 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom'; import { + clearGenreDetailReturnStash, peekAlbumBrowseScrollRestore, + peekGenreDetailScrollRestore, type AlbumBrowseSurface, useAlbumBrowseSessionStore, } from '../store/albumBrowseSessionStore'; @@ -14,7 +16,10 @@ type PendingScroll = { export type UseAlbumBrowseScrollRestoreArgs = { serverId: string; - surface: AlbumBrowseSurface; + /** Album grid browse surface (All Albums, New Releases, Random Albums). */ + surface?: AlbumBrowseSurface; + /** Genre detail page — uses genre-scoped stash instead of `surface`. */ + genreName?: string; scrollBodyEl: HTMLElement | null; displayAlbumsLength: number; loading: boolean; @@ -30,12 +35,29 @@ export type UseAlbumBrowseScrollRestoreResult = { function readPendingScrollRestore( serverId: string, - surface: AlbumBrowseSurface, + surface: AlbumBrowseSurface | undefined, + genreName: string | undefined, navigationType: NavigationType, locationState: unknown, ): PendingScroll | null { if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) return null; - return peekAlbumBrowseScrollRestore(serverId, surface); + if (genreName) return peekGenreDetailScrollRestore(serverId, genreName); + if (surface) return peekAlbumBrowseScrollRestore(serverId, surface); + return null; +} + +function clearScrollRestoreStash( + serverId: string, + surface: AlbumBrowseSurface | undefined, + genreName: string | undefined, +): void { + if (genreName) { + clearGenreDetailReturnStash(serverId, genreName); + return; + } + if (surface) { + useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface); + } } /** @@ -45,6 +67,7 @@ function readPendingScrollRestore( export function useAlbumBrowseScrollRestore({ serverId, surface, + genreName, scrollBodyEl, displayAlbumsLength, loading, @@ -60,11 +83,17 @@ export function useAlbumBrowseScrollRestore({ if (!initRef.current) { initRef.current = true; - pendingRef.current = readPendingScrollRestore(serverId, surface, navigationType, location.state); + pendingRef.current = readPendingScrollRestore( + serverId, + surface, + genreName, + navigationType, + location.state, + ); } const [isScrollRestorePending, setIsScrollRestorePending] = useState( - () => readPendingScrollRestore(serverId, surface, navigationType, location.state) !== null, + () => readPendingScrollRestore(serverId, surface, genreName, navigationType, location.state) !== null, ); useLayoutEffect(() => { @@ -84,7 +113,7 @@ export function useAlbumBrowseScrollRestore({ pendingRef.current = null; doneRef.current = true; setIsScrollRestorePending(false); - useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface); + clearScrollRestoreStash(serverId, surface, genreName); }, [ scrollBodyEl, displayAlbumsLength, @@ -94,6 +123,7 @@ export function useAlbumBrowseScrollRestore({ loadMore, serverId, surface, + genreName, ]); return { isScrollRestorePending }; diff --git a/src/hooks/useGenreAlbumBrowse.ts b/src/hooks/useGenreAlbumBrowse.ts new file mode 100644 index 00000000..af1357fd --- /dev/null +++ b/src/hooks/useGenreAlbumBrowse.ts @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { SubsonicAlbum } from '../api/subsonicTypes'; +import { dedupeById } from '../utils/dedupeById'; +import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort'; +import { + fetchGenreAlbumPage, + GENRE_ALBUM_CATALOG_CHUNK, + GENRE_ALBUM_FIRST_PAGE, +} from '../utils/library/genreAlbumBrowse'; +import { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll'; +import { useInpageScrollSentinel } from './useInpageScrollSentinel'; + +const CLIENT_SLICE_PAGE_SIZE = GENRE_ALBUM_FIRST_PAGE; + +function initialSqlPageSize(restoreDisplayCount?: number): number { + if (restoreDisplayCount != null && restoreDisplayCount > CLIENT_SLICE_PAGE_SIZE) { + return Math.min(restoreDisplayCount, GENRE_ALBUM_CATALOG_CHUNK); + } + return CLIENT_SLICE_PAGE_SIZE; +} + +export function useGenreAlbumBrowse( + serverId: string, + genre: string, + indexEnabled: boolean, + sort: AlbumBrowseSort, + musicLibraryFilterVersion: number, + getScrollRoot?: () => HTMLElement | null, + scrollRootEl?: HTMLElement | null, + restoreDisplayCount?: number, +) { + const [albums, setAlbums] = useState([]); + const [loading, setLoading] = useState(true); + const [catalogLoadingMore, setCatalogLoadingMore] = useState(false); + const [catalogHasMore, setCatalogHasMore] = useState(false); + const catalogOffsetRef = useRef(0); + const catalogLoadingRef = useRef(false); + const loadGenerationRef = useRef(0); + const loadingRef = useRef(false); + const loadPendingRef = useRef(false); + const loadMoreRef = useRef<() => void>(() => {}); + const browseSessionRef = useRef({ key: '', restoreDisplayCount: undefined as number | undefined }); + const browseKey = `${serverId}:${genre}`; + if (browseSessionRef.current.key !== browseKey) { + browseSessionRef.current = { + key: browseKey, + restoreDisplayCount: restoreDisplayCount, + }; + } + const sessionRestoreDisplayCount = browseSessionRef.current.restoreDisplayCount; + + const { + visibleCount, + loadingMore: sliceLoadingMore, + loadMore: sliceLoadMore, + } = useClientSliceInfiniteScroll({ + pageSize: CLIENT_SLICE_PAGE_SIZE, + resetDeps: [ + sort, + genre, + musicLibraryFilterVersion, + serverId, + indexEnabled, + ], + getScrollRoot, + scrollRootEl, + restoreDisplayCount: sessionRestoreDisplayCount, + }); + + const displayAlbums = useMemo( + () => albums.slice(0, visibleCount), + [albums, visibleCount], + ); + + const hasMore = visibleCount < albums.length || catalogHasMore; + const loadingMore = sliceLoadingMore || catalogLoadingMore; + + const loadCatalogChunk = useCallback(async ( + offset: number, + append: boolean, + pageSize: number = GENRE_ALBUM_CATALOG_CHUNK, + ) => { + if (catalogLoadingRef.current || !genre) return; + const generation = loadGenerationRef.current; + catalogLoadingRef.current = true; + setCatalogLoadingMore(true); + try { + const chunk = await fetchGenreAlbumPage( + serverId, + genre, + indexEnabled, + offset, + pageSize, + sort, + ); + if (generation !== loadGenerationRef.current) return; + if (append) { + setAlbums(prev => { + const merged = dedupeById([...prev, ...chunk.albums]); + catalogOffsetRef.current = merged.length; + return merged; + }); + } else { + setAlbums(chunk.albums); + catalogOffsetRef.current = chunk.albums.length; + } + setCatalogHasMore(chunk.hasMore); + } finally { + catalogLoadingRef.current = false; + if (generation === loadGenerationRef.current) { + setCatalogLoadingMore(false); + } + } + }, [serverId, genre, indexEnabled, sort]); + + useEffect(() => { + if (!genre) { + setAlbums([]); + setCatalogHasMore(false); + setLoading(false); + return; + } + + let cancelled = false; + loadGenerationRef.current += 1; + const generation = loadGenerationRef.current; + catalogOffsetRef.current = 0; + catalogLoadingRef.current = false; + loadingRef.current = true; + loadPendingRef.current = true; + setLoading(true); + setCatalogLoadingMore(false); + setCatalogHasMore(false); + setAlbums([]); + + const firstPageSize = initialSqlPageSize(sessionRestoreDisplayCount); + void loadCatalogChunk(0, false, firstPageSize).finally(() => { + if (cancelled || generation !== loadGenerationRef.current) return; + loadingRef.current = false; + loadPendingRef.current = false; + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [serverId, genre, indexEnabled, sort, musicLibraryFilterVersion, loadCatalogChunk]); + + const loadMore = useCallback(() => { + if (!genre || loadingRef.current || loadPendingRef.current) return; + if (visibleCount < albums.length) { + sliceLoadMore(); + return; + } + if (catalogHasMore && !catalogLoadingRef.current) { + void loadCatalogChunk(catalogOffsetRef.current, true); + } + }, [genre, visibleCount, albums.length, catalogHasMore, sliceLoadMore, loadCatalogChunk]); + + loadMoreRef.current = loadMore; + + const bindLoadMoreSentinel = useInpageScrollSentinel({ + active: hasMore, + getScrollRoot, + scrollRootEl, + onIntersect: () => loadMoreRef.current(), + drainSignal: loadingMore, + }); + + return { + albums, + displayAlbums, + loading, + loadingMore, + hasMore, + loadMore, + bindLoadMoreSentinel, + }; +} diff --git a/src/hooks/useGenreDetailBrowse.ts b/src/hooks/useGenreDetailBrowse.ts new file mode 100644 index 00000000..58992eba --- /dev/null +++ b/src/hooks/useGenreDetailBrowse.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef, type RefObject } from 'react'; +import { useLocation, useNavigationType } from 'react-router-dom'; +import { GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID, readInpageScrollTop } from '../constants/appScroll'; +import { + DEFAULT_ALBUM_BROWSE_RETURN_FILTERS, + albumBrowseSortForServer, + clearGenreDetailReturnStash, + genreDetailGenreFromPath, + isAlbumDetailPath, + isGenreDetailPath, + peekGenreDetailScrollRestore, + stashGenreDetailReturnFilters, + useAlbumBrowseSessionStore, +} from '../store/albumBrowseSessionStore'; +import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation'; +import type { AlbumBrowseScrollSnapshot } from './useAlbumBrowseFilters'; + +/** Genre detail: locked genre filter + leave/restore session (same contract as All Albums). */ +export function useGenreDetailBrowse( + serverId: string, + genreName: string, + scrollSnapshotRef?: RefObject, +) { + const navigationType = useNavigationType(); + const location = useLocation(); + const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId)); + const restoredFromStashRef = useRef(false); + const restoreKeyRef = useRef(''); + const restoreDisplayCountRef = useRef(undefined); + const restoreKey = `${serverId}:${genreName}`; + if (restoreKeyRef.current !== restoreKey) { + restoreKeyRef.current = restoreKey; + restoreDisplayCountRef.current = peekGenreDetailScrollRestore(serverId, genreName)?.displayCount; + } + + useEffect(() => { + restoredFromStashRef.current = false; + }, [serverId, genreName]); + + useEffect(() => { + if (!serverId || !genreName) return; + + if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) { + restoredFromStashRef.current = true; + return; + } + + if (restoredFromStashRef.current) return; + + clearGenreDetailReturnStash(serverId, genreName); + }, [serverId, genreName, navigationType, location.state]); + + useEffect(() => { + return () => { + if (!serverId || !genreName) return; + const path = window.location.pathname; + if (isAlbumDetailPath(path)) { + const snapshot = scrollSnapshotRef?.current; + const scrollTop = Math.max( + readInpageScrollTop(GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID), + snapshot?.scrollTop ?? 0, + ); + stashGenreDetailReturnFilters(serverId, genreName, { + ...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS, + selectedGenres: [genreName], + scrollTop, + displayCount: snapshot?.displayCount, + }); + } else if (!isGenreDetailPath(path) || genreDetailGenreFromPath(path) !== genreName) { + clearGenreDetailReturnStash(serverId, genreName); + } + }; + }, [serverId, genreName, scrollSnapshotRef]); + + return { + sort, + restoreDisplayCount: restoreDisplayCountRef.current, + }; +} diff --git a/src/locales/de/genres.ts b/src/locales/de/genres.ts index 4019c54d..cfe0bee9 100644 --- a/src/locales/de/genres.ts +++ b/src/locales/de/genres.ts @@ -9,6 +9,7 @@ export const genres = { albumsEmpty: 'Keine Alben in diesem Genre gefunden.', loadMore: 'Mehr laden', back: 'Zurück', + playTooltip: 'Abspielen (halten zum Mischen)', shuffle: 'Zufallswiedergabe', addToQueue: 'Zur Warteschlange', }; diff --git a/src/locales/en/genres.ts b/src/locales/en/genres.ts index 77ad13d1..7b7b18fd 100644 --- a/src/locales/en/genres.ts +++ b/src/locales/en/genres.ts @@ -9,6 +9,7 @@ export const genres = { albumsEmpty: 'No albums found for this genre.', loadMore: 'Load more', back: 'Back', + playTooltip: 'Play (hold to shuffle)', shuffle: 'Shuffle', addToQueue: 'Add to queue', }; diff --git a/src/locales/es/genres.ts b/src/locales/es/genres.ts index 73093fbd..7f9548b3 100644 --- a/src/locales/es/genres.ts +++ b/src/locales/es/genres.ts @@ -9,6 +9,7 @@ export const genres = { albumsEmpty: 'No se encontraron álbumes para este género.', loadMore: 'Cargar más', back: 'Volver', + playTooltip: 'Reproducir (mantener para aleatorio)', shuffle: 'Aleatorio', addToQueue: 'Agregar a la cola', }; diff --git a/src/locales/fr/genres.ts b/src/locales/fr/genres.ts index 4b02f7de..af2de6dc 100644 --- a/src/locales/fr/genres.ts +++ b/src/locales/fr/genres.ts @@ -9,6 +9,7 @@ export const genres = { albumsEmpty: 'Aucun album trouvé pour ce genre.', loadMore: 'Charger plus', back: 'Retour', + playTooltip: 'Lire (maintenir pour mélanger)', shuffle: 'Aléatoire', addToQueue: 'Ajouter à la file', }; diff --git a/src/locales/nb/genres.ts b/src/locales/nb/genres.ts index 3f316503..458ea683 100644 --- a/src/locales/nb/genres.ts +++ b/src/locales/nb/genres.ts @@ -9,6 +9,7 @@ export const genres = { albumsEmpty: 'Ingen album funnet for denne sjangeren.', loadMore: 'Last mer', back: 'Tilbake', + playTooltip: 'Spill av (hold for tilfeldig rekkefølge)', shuffle: 'Bland', addToQueue: 'Legg til i kø', }; diff --git a/src/locales/nl/genres.ts b/src/locales/nl/genres.ts index 298f5b94..92bfff01 100644 --- a/src/locales/nl/genres.ts +++ b/src/locales/nl/genres.ts @@ -9,6 +9,7 @@ export const genres = { albumsEmpty: 'Geen albums gevonden voor dit genre.', loadMore: 'Meer laden', back: 'Terug', + playTooltip: 'Afspelen (ingedrukt houden om te shufflen)', shuffle: 'Willekeurig', addToQueue: 'Aan wachtrij toevoegen', }; diff --git a/src/locales/ro/genres.ts b/src/locales/ro/genres.ts index 57be270e..8fbf1343 100644 --- a/src/locales/ro/genres.ts +++ b/src/locales/ro/genres.ts @@ -9,6 +9,7 @@ export const genres = { albumsEmpty: 'Niciun album găsit pentru acest gen.', loadMore: 'Încarcă mai mult', back: 'Înapoi', + playTooltip: 'Redă (ține apăsat pentru amestecare)', shuffle: 'Amestecă', addToQueue: 'Adaugă la coadă', }; diff --git a/src/locales/ru/genres.ts b/src/locales/ru/genres.ts index d9da2aed..4d226a28 100644 --- a/src/locales/ru/genres.ts +++ b/src/locales/ru/genres.ts @@ -11,6 +11,7 @@ export const genres = { albumsEmpty: 'В этом жанре альбомов нет.', loadMore: 'Ещё', back: 'Назад', + playTooltip: 'Воспроизвести (удерживать для перемешивания)', shuffle: 'Перемешать', addToQueue: 'В очередь', }; diff --git a/src/locales/zh/genres.ts b/src/locales/zh/genres.ts index 2a5b10fa..c2c8891c 100644 --- a/src/locales/zh/genres.ts +++ b/src/locales/zh/genres.ts @@ -9,6 +9,7 @@ export const genres = { albumsEmpty: '未找到该流派的专辑。', loadMore: '加载更多', back: '返回', + playTooltip: '播放(长按随机播放)', shuffle: '随机播放', addToQueue: '添加到队列', }; diff --git a/src/pages/GenreDetail.tsx b/src/pages/GenreDetail.tsx index 7aee798a..12e3689a 100644 --- a/src/pages/GenreDetail.tsx +++ b/src/pages/GenreDetail.tsx @@ -1,24 +1,36 @@ -import { getAlbumsByGenre, fetchAllSongsByGenre } from '../api/subsonicGenres'; -import type { SubsonicAlbum } from '../api/subsonicTypes'; -import React, { useEffect, useState, useCallback } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { ArrowLeft, Disc3, Play, Shuffle, ListPlus, Loader2 } from 'lucide-react'; -import { useAuthStore } from '../store/authStore'; -import { usePlayerStore } from '../store/playerStore'; +import { ArrowLeft, Disc3, Play, ListPlus, Loader2 } from 'lucide-react'; import AlbumCard from '../components/AlbumCard'; -import { songToTrack } from '../utils/playback/songToTrack'; -import { runBulkPlayAll, runBulkShuffle, runBulkEnqueue } from '../utils/playback/runBulkPlay'; -import { usePerfProbeFlags } from '../utils/perf/perfFlags'; -import { albumGridWarmCovers } from '../cover/layoutSizes'; +import { LongPressWaveOverlay } from '../components/LongPressWaveOverlay'; +import InpageScrollSentinel from '../components/InpageScrollSentinel'; +import OverlayScrollArea from '../components/OverlayScrollArea'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; - -const PAGE_SIZE = 50; -// Bulk play/shuffle pulls a bounded slice of the genre. The queue resolver -// (queueTrackResolver) holds a 500-entry LRU; seeding a larger queue evicts the -// earliest tracks, which then render as "…"/0:00 placeholders until lazily -// re-resolved. Keep the slice within that budget so the whole queue stays warm. -const GENRE_QUEUE_CAP = 500; +import { GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; +import { albumGridWarmCovers } from '../cover/layoutSizes'; +import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters'; +import { useGenreAlbumBrowse } from '../hooks/useGenreAlbumBrowse'; +import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore'; +import { useGenreDetailBrowse } from '../hooks/useGenreDetailBrowse'; +import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport'; +import { useLongPressAction } from '../hooks/useLongPressAction'; +import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; +import { useAuthStore } from '../store/authStore'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { usePlayerStore } from '../store/playerStore'; +import { + fetchGenreAlbumCount, + fetchGenreTracksForPlayback, +} from '../utils/library/genreBrowsePlayback'; +import { lookupGenreAlbumCount } from '../utils/library/genreCatalogCountsCache'; +import { libraryScopeForServer } from '../api/subsonicClient'; +import { + readAlbumBrowseRestore, + readAlbumDetailReturnTo, +} from '../utils/navigation/albumDetailNavigation'; +import { usePerfProbeFlags } from '../utils/perf/perfFlags'; +import { runBulkEnqueue, runBulkPlayAll, runBulkShuffle } from '../utils/playback/runBulkPlay'; export default function GenreDetail() { const { name } = useParams<{ name: string }>(); @@ -26,125 +38,233 @@ export default function GenreDetail() { const { t } = useTranslation(); const perfFlags = usePerfProbeFlags(); const navigate = useNavigate(); - const [albums, setAlbums] = useState([]); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [hasMore, setHasMore] = useState(true); - const [offset, setOffset] = useState(0); - const [bulkLoading, setBulkLoading] = useState(false); + const location = useLocation(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const serverId = useAuthStore(s => s.activeServerId ?? ''); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue); - const fetchGenreTracks = useCallback( - () => fetchAllSongsByGenre(genre, GENRE_QUEUE_CAP).then(songs => songs.map(songToTrack)), - [genre], + const scrollSnapshotRef = useRef({ scrollTop: 0, displayCount: 0 }); + + const { sort, restoreDisplayCount } = useGenreDetailBrowse(serverId, genre, scrollSnapshotRef); + + const { + scrollBodyEl, + bindScrollBody: bindGenreDetailScrollBody, + getScrollRoot, + } = useInpageScrollViewport(); + + const { + albums, + loading, + loadingMore, + hasMore, + displayAlbums, + bindLoadMoreSentinel, + loadMore, + } = useGenreAlbumBrowse( + serverId, + genre, + indexEnabled, + sort, + musicLibraryFilterVersion, + getScrollRoot, + scrollBodyEl, + restoreDisplayCount, ); + + useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length); + + const { isScrollRestorePending } = useAlbumBrowseScrollRestore({ + serverId, + genreName: genre, + scrollBodyEl, + displayAlbumsLength: displayAlbums.length, + loading, + loadingMore, + hasMore, + loadMore, + }); + + useEffect(() => { + if (isScrollRestorePending || !readAlbumBrowseRestore(location.state)) return; + navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null }); + }, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]); + + const [albumCount, setAlbumCount] = useState(null); + const [bulkLoading, setBulkLoading] = useState(false); + + useEffect(() => { + if (!genre || !serverId) return; + const cached = lookupGenreAlbumCount(serverId, genre, libraryScopeForServer(serverId)); + if (cached != null) setAlbumCount(cached); + }, [serverId, genre, musicLibraryFilterVersion]); + + useEffect(() => { + if (!genre || loading) return; + const cached = lookupGenreAlbumCount(serverId, genre, libraryScopeForServer(serverId)); + if (cached != null) return; + let cancelled = false; + const timer = window.setTimeout(() => { + void fetchGenreAlbumCount(serverId, genre, indexEnabled, sort).then(count => { + if (!cancelled) setAlbumCount(count); + }); + }, 0); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [serverId, genre, indexEnabled, sort, musicLibraryFilterVersion, loading]); + + const fetchGenreTracks = useCallback( + (shuffle?: boolean) => fetchGenreTracksForPlayback(serverId, genre, { + shuffle, + indexEnabled, + }), + [serverId, genre, indexEnabled], + ); + const handlePlayAll = useCallback( - () => runBulkPlayAll({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, playTrack }), + () => runBulkPlayAll({ fetchTracks: () => fetchGenreTracks(false), setLoading: setBulkLoading, playTrack }), [fetchGenreTracks, playTrack], ); const handleShuffleAll = useCallback( - () => runBulkShuffle({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, playTrack }), + () => runBulkShuffle({ fetchTracks: () => fetchGenreTracks(true), setLoading: setBulkLoading, playTrack }), [fetchGenreTracks, playTrack], ); const handleEnqueueAll = useCallback( - () => runBulkEnqueue({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, enqueue }), + () => runBulkEnqueue({ fetchTracks: () => fetchGenreTracks(false), setLoading: setBulkLoading, enqueue }), [fetchGenreTracks, enqueue], ); - useEffect(() => { - setAlbums([]); - setOffset(0); - setHasMore(true); - setLoading(true); - getAlbumsByGenre(genre, PAGE_SIZE, 0) - .then(data => { - setAlbums(data); - setHasMore(data.length === PAGE_SIZE); - setOffset(PAGE_SIZE); - }) - .finally(() => setLoading(false)); - }, [genre]); + const { isHolding, pressBind } = useLongPressAction({ + onShortPress: handlePlayAll, + onLongPress: handleShuffleAll, + }); - const loadMore = useCallback(() => { - if (loadingMore || !hasMore) return; - setLoadingMore(true); - getAlbumsByGenre(genre, PAGE_SIZE, offset) - .then(data => { - setAlbums(prev => [...prev, ...data]); - setHasMore(data.length === PAGE_SIZE); - setOffset(prev => prev + PAGE_SIZE); - }) - .finally(() => setLoadingMore(false)); - }, [genre, offset, loadingMore, hasMore]); + const handleBack = useCallback(() => { + navigate(readAlbumDetailReturnTo(location.state) ?? '/genres'); + }, [navigate, location.state]); + + const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [genre, albumCount, bulkLoading]); + + const headerCount = useMemo(() => { + if (!loading && !hasMore && albums.length > 0) return albums.length; + if (albumCount != null) return albumCount; + if (loading) return null; + return displayAlbums.length > 0 ? displayAlbums.length : null; + }, [loading, hasMore, albums.length, albumCount, displayAlbums.length]); + const showPlayback = !loading && (displayAlbums.length > 0 || (albumCount ?? 0) > 0); return ( -
- - -
-

{genre}

- {!loading && albums.length > 0 && ( - - - {t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''} - - )} - {!loading && albums.length > 0 && ( -
- - - -
- )} +
+
+
+ +

{genre}

+ {headerCount != null && headerCount > 0 && ( + + + {t('genres.albumCount', { count: headerCount })} + + )} + {showPlayback && ( +
+ + +
+ )} +
- {loading &&

{t('genres.albumsLoading')}

} - {!loading && albums.length === 0 &&

{t('genres.albumsEmpty')}

} - - {albums.length > 0 && ( - a.id} - rowVariant="album" - disableVirtualization={perfFlags.disableMainstageVirtualLists} - layoutSignal={albums.length} - warmGridCovers={albumGridWarmCovers()} - renderItem={album => } - /> - )} - - {hasMore && !loading && ( -
- -
- )} + + {loading && albums.length === 0 ? ( +
+
+
+ ) : !loading && displayAlbums.length === 0 ? ( +

+ {t('genres.albumsEmpty')} +

+ ) : ( +
+
+ a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={displayAlbums.length} + scrollRootId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID} + warmGridCovers={albumGridWarmCovers()} + renderItem={album => ( + + )} + /> + {hasMore && ( + + )} +
+ {isScrollRestorePending && ( +
+
+
+ )} +
+ )} +
); } diff --git a/src/pages/Genres.tsx b/src/pages/Genres.tsx index f9c58657..2f5f5117 100644 --- a/src/pages/Genres.tsx +++ b/src/pages/Genres.tsx @@ -1,10 +1,14 @@ -import { getGenres } from '../api/subsonicGenres'; import type { SubsonicGenre } from '../api/subsonicTypes'; import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Tags } from 'lucide-react'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; +import { useAuthStore } from '../store/authStore'; +import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { fetchGenreCatalog } from '../utils/library/genreBrowsePlayback'; +import { libraryScopeForServer } from '../api/subsonicClient'; +import { peekGenreCatalogCache } from '../utils/library/genreCatalogCountsCache'; const CTP_COLORS = [ 'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)', @@ -26,14 +30,35 @@ const FONT_MAX_REM = 1.7; export default function Genres() { const { t } = useTranslation(); const navigate = useNavigate(); - const [rawGenres, setRawGenres] = useState([]); - const [loading, setLoading] = useState(true); + const serverId = useAuthStore(s => s.activeServerId ?? ''); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); + const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const libraryScope = libraryScopeForServer(serverId); + const cachedGenres = serverId ? peekGenreCatalogCache(serverId, libraryScope, true) : null; + const [rawGenres, setRawGenres] = useState(cachedGenres ?? []); + const [loading, setLoading] = useState(!cachedGenres); useEffect(() => { - getGenres() - .then(data => setRawGenres(data)) - .finally(() => setLoading(false)); - }, []); + let cancelled = false; + const scope = libraryScopeForServer(serverId); + const cached = serverId ? peekGenreCatalogCache(serverId, scope, true) : null; + if (cached) { + setRawGenres(cached); + setLoading(false); + } else { + setLoading(true); + } + void fetchGenreCatalog(serverId, indexEnabled) + .then(data => { + if (!cancelled) setRawGenres(data); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [serverId, indexEnabled, musicLibraryFilterVersion]); const genres = useMemo( () => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount), @@ -62,7 +87,7 @@ export default function Genres() { const handleGenreClick = (genreValue: string) => { const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); if (el) sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop)); - navigate(`/genres/${encodeURIComponent(genreValue)}`); + navigate(`/genres/${encodeURIComponent(genreValue)}`, { state: { returnTo: '/genres' } }); }; return ( diff --git a/src/store/albumBrowseSessionStore.test.ts b/src/store/albumBrowseSessionStore.test.ts index d21c5391..d1df1cde 100644 --- a/src/store/albumBrowseSessionStore.test.ts +++ b/src/store/albumBrowseSessionStore.test.ts @@ -5,8 +5,14 @@ import { DEFAULT_ALBUM_BROWSE_SORT, albumBrowseSortForServer, albumBrowseSurfaceForPath, + clearGenreDetailReturnStash, isAlbumDetailPath, + isGenreDetailPath, + genreDetailGenreFromPath, peekAlbumBrowseScrollRestore, + peekGenreDetailReturnStash, + peekGenreDetailScrollRestore, + stashGenreDetailReturnFilters, useAlbumBrowseSessionStore, } from './albumBrowseSessionStore'; @@ -97,6 +103,22 @@ describe('albumBrowseSessionStore', () => { const { sortByServer } = useAlbumBrowseSessionStore.getState(); expect(albumBrowseSortForServer(sortByServer, 'unknown')).toBe(DEFAULT_ALBUM_BROWSE_SORT); }); + + it('stashes genre detail leave snapshot separately from album grid surfaces', () => { + stashGenreDetailReturnFilters('srv-a', 'Rock', { + ...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS, + selectedGenres: ['Rock'], + scrollTop: 640, + displayCount: 90, + }); + expect(peekGenreDetailReturnStash('srv-a', 'Rock')?.scrollTop).toBe(640); + expect(peekGenreDetailScrollRestore('srv-a', 'Rock')).toEqual({ + scrollTop: 640, + displayCount: 90, + }); + clearGenreDetailReturnStash('srv-a', 'Rock'); + expect(peekGenreDetailReturnStash('srv-a', 'Rock')).toBeNull(); + }); }); describe('isAlbumDetailPath', () => { @@ -109,6 +131,16 @@ describe('isAlbumDetailPath', () => { }); }); +describe('isGenreDetailPath', () => { + it('matches single genre detail routes only', () => { + expect(isGenreDetailPath('/genres/Rock')).toBe(true); + expect(isGenreDetailPath('/genres/Rock%20%26%20Roll')).toBe(true); + expect(isGenreDetailPath('/genres')).toBe(false); + expect(isGenreDetailPath('/genres/Rock/albums')).toBe(false); + expect(genreDetailGenreFromPath('/genres/Rock%20%26%20Roll')).toBe('Rock & Roll'); + }); +}); + describe('albumBrowseSurfaceForPath', () => { it('maps album grid browse routes', () => { expect(albumBrowseSurfaceForPath('/albums')).toBe('albums'); diff --git a/src/store/albumBrowseSessionStore.ts b/src/store/albumBrowseSessionStore.ts index 370584a2..d97fd609 100644 --- a/src/store/albumBrowseSessionStore.ts +++ b/src/store/albumBrowseSessionStore.ts @@ -58,6 +58,10 @@ function returnStashKey(serverId: string, surface: AlbumBrowseSurface): string { return `${serverId}:${surface}`; } +function genreDetailStashKey(serverId: string, genreName: string): string { + return `${serverId}:genre-detail:${genreName}`; +} + function sortEntryFor( sortByServer: Record, serverId: string, @@ -132,6 +136,55 @@ export function peekAlbumBrowseScrollRestore( }; } +/** Genre detail leave-restore (scoped per genre name). */ +export function stashGenreDetailReturnFilters( + serverId: string, + genreName: string, + filters: AlbumBrowseReturnFilters, +): void { + if (!serverId || !genreName) return; + const key = genreDetailStashKey(serverId, genreName); + useAlbumBrowseSessionStore.setState((s) => ({ + returnStashByKey: { + ...s.returnStashByKey, + [key]: cloneReturnFilters(filters), + }, + })); +} + +export function clearGenreDetailReturnStash(serverId: string, genreName: string): void { + if (!serverId || !genreName) return; + const key = genreDetailStashKey(serverId, genreName); + useAlbumBrowseSessionStore.setState((s) => { + const next = { ...s.returnStashByKey }; + delete next[key]; + return { returnStashByKey: next }; + }); +} + +export function peekGenreDetailReturnStash( + serverId: string, + genreName: string, +): AlbumBrowseReturnFilters | null { + if (!serverId || !genreName) return null; + const stash = useAlbumBrowseSessionStore.getState().returnStashByKey[genreDetailStashKey(serverId, genreName)]; + if (!stash) return null; + return cloneReturnFilters(stash); +} + +export function peekGenreDetailScrollRestore( + serverId: string, + genreName: string, +): { scrollTop: number; displayCount: number } | null { + const stash = peekGenreDetailReturnStash(serverId, genreName); + if (!stash) return null; + if (typeof stash.scrollTop !== 'number' || typeof stash.displayCount !== 'number') return null; + return { + scrollTop: Math.max(0, stash.scrollTop), + displayCount: Math.max(0, stash.displayCount), + }; +} + export function albumBrowseSortForServer( sortByServer: Record, serverId: string, @@ -151,7 +204,19 @@ export function albumBrowseSurfaceForPath(pathname: string): AlbumBrowseSurface /** True when pathname is a single album detail route (`/album/:id`). */ export function isAlbumDetailPath(pathname: string): boolean { - return /^\/album\/[^/]+\/?$/.test(pathname); + return /^\/album\/[^/]+\/?$/.test(pathname.split('?')[0]?.replace(/\/$/, '') || pathname); +} + +/** Single genre detail route (`/genres/:name`), not the genre cloud (`/genres`). */ +export function isGenreDetailPath(pathname: string): boolean { + const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname; + return /^\/genres\/[^/]+$/.test(path); +} + +export function genreDetailGenreFromPath(pathname: string): string | null { + const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname; + const match = path.match(/^\/genres\/([^/]+)$/); + return match ? decodeURIComponent(match[1]) : null; } /** True when pathname is a single artist detail route (`/artist/:id`). */ diff --git a/src/utils/library/albumBrowseLoad.ts b/src/utils/library/albumBrowseLoad.ts index 023273b7..3a97f0e4 100644 --- a/src/utils/library/albumBrowseLoad.ts +++ b/src/utils/library/albumBrowseLoad.ts @@ -36,10 +36,15 @@ export async function fetchLocalAlbumCatalogChunk( offset: number, chunkSize: number, ): Promise { - const limit = query.genres.length > 0 && offset === 0 ? GENRE_ALBUM_FETCH_LIMIT : chunkSize; - if (query.genres.length > 0 && offset > 0) { + const singleGenre = query.genres.length === 1; + if (query.genres.length > 1 && offset > 0) { return { albums: [], hasMore: false }; } + const limit = singleGenre + ? chunkSize + : query.genres.length > 0 && offset === 0 + ? GENRE_ALBUM_FETCH_LIMIT + : chunkSize; return runLocalAlbumBrowse(serverId, query, offset, limit); } diff --git a/src/utils/library/albumBrowseLocal.ts b/src/utils/library/albumBrowseLocal.ts index 5964fcfd..e1890343 100644 --- a/src/utils/library/albumBrowseLocal.ts +++ b/src/utils/library/albumBrowseLocal.ts @@ -1,4 +1,4 @@ -import { libraryAdvancedSearch } from '../../api/library'; +import { libraryAdvancedSearch, libraryListAlbumsByGenre } from '../../api/library'; import type { SubsonicAlbum } from '../../api/subsonicTypes'; import { libraryScopeForServer } from '../../api/subsonicClient'; import { dedupeById } from '../dedupeById'; @@ -29,6 +29,24 @@ export async function runLocalAlbumBrowse( const starredOnly = useServerStarredIds ? undefined : (query.starredOnly || undefined); if (query.genres.length > 0) { + if (query.genres.length === 1) { + try { + const resp = await libraryListAlbumsByGenre({ + serverId, + genre: query.genres[0], + libraryScope: scope, + sort: albumSortClauses(query.sort), + limit: pageSize, + offset, + }); + if (resp.source !== 'local') return null; + let albums = resp.albums.map(albumToAlbum); + if (useServerStarredIds) albums = markServerStarredAlbums(albums); + return { albums, hasMore: resp.hasMore }; + } catch { + return null; + } + } if (offset > 0) return { albums: [], hasMore: false }; try { const pages = await Promise.all( diff --git a/src/utils/library/albumBrowseNetwork.ts b/src/utils/library/albumBrowseNetwork.ts index a6787c18..1ceff285 100644 --- a/src/utils/library/albumBrowseNetwork.ts +++ b/src/utils/library/albumBrowseNetwork.ts @@ -30,6 +30,13 @@ export async function fetchAlbumBrowseNetwork( pageSize: number, ): Promise { if (query.genres.length > 0) { + if (query.genres.length === 1) { + const data = applyNetworkPostFilters( + await getAlbumsByGenre(query.genres[0], pageSize, offset), + query, + ); + return { albums: data, hasMore: data.length === pageSize }; + } if (offset > 0) return { albums: [], hasMore: false }; const data = applyNetworkPostFilters(await fetchByGenres(query.genres), query); return { albums: data, hasMore: false }; diff --git a/src/utils/library/genreAlbumBrowse.test.ts b/src/utils/library/genreAlbumBrowse.test.ts new file mode 100644 index 00000000..d6d711f7 --- /dev/null +++ b/src/utils/library/genreAlbumBrowse.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetchGenreAlbumPage, fetchGenreAlbumTotal } from './genreAlbumBrowse'; + +vi.mock('../../api/library', () => ({ + libraryListAlbumsByGenre: vi.fn(), +})); + +vi.mock('../../api/subsonicGenres', () => ({ + getAlbumsByGenre: vi.fn(), +})); + +vi.mock('../../api/subsonicClient', () => ({ + libraryScopeForServer: vi.fn(() => 'lib-a'), +})); + +vi.mock('./libraryReady', () => ({ + libraryIsReady: vi.fn(), +})); + +import { libraryListAlbumsByGenre } from '../../api/library'; +import { getAlbumsByGenre } from '../../api/subsonicGenres'; +import { libraryIsReady } from './libraryReady'; + +describe('genreAlbumBrowse', () => { + beforeEach(() => { + vi.mocked(libraryIsReady).mockReset(); + vi.mocked(libraryListAlbumsByGenre).mockReset(); + vi.mocked(getAlbumsByGenre).mockReset(); + }); + + it('loads albums from the local genre browse command when the index is ready', async () => { + vi.mocked(libraryIsReady).mockResolvedValue(true); + vi.mocked(libraryListAlbumsByGenre).mockResolvedValue({ + source: 'local', + hasMore: true, + albums: [{ + serverId: 'srv-1', + id: 'al-1', + name: 'Album', + artist: 'Artist', + artistId: 'ar-1', + songCount: 8, + durationSec: 100, + syncedAt: 0, + rawJson: {}, + }], + }); + + const page = await fetchGenreAlbumPage('srv-1', 'Rock', true, 0, 60, 'alphabeticalByName'); + + expect(libraryListAlbumsByGenre).toHaveBeenCalledWith(expect.objectContaining({ + serverId: 'srv-1', + genre: 'Rock', + libraryScope: 'lib-a', + offset: 0, + limit: 60, + })); + expect(getAlbumsByGenre).not.toHaveBeenCalled(); + expect(page.albums).toHaveLength(1); + expect(page.hasMore).toBe(true); + }); + + it('falls back to Subsonic byGenre when the local index is unavailable', async () => { + vi.mocked(libraryIsReady).mockResolvedValue(false); + vi.mocked(getAlbumsByGenre).mockResolvedValue([ + { id: 'al-1', name: 'A', artist: 'X', artistId: 'x', songCount: 1, duration: 1 }, + ]); + + const page = await fetchGenreAlbumPage('srv-1', 'Rock', true, 0, 60, 'alphabeticalByName'); + + expect(libraryListAlbumsByGenre).not.toHaveBeenCalled(); + expect(getAlbumsByGenre).toHaveBeenCalledWith('Rock', 60, 0); + expect(page.albums).toHaveLength(1); + }); + + it('uses Subsonic when the local index is disabled', async () => { + vi.mocked(getAlbumsByGenre).mockResolvedValue([ + { id: 'al-1', name: 'A', artist: 'X', artistId: 'x', songCount: 1, duration: 1 }, + ]); + + const page = await fetchGenreAlbumPage('srv-1', 'Rock', false, 0, 60, 'alphabeticalByName'); + + expect(libraryIsReady).not.toHaveBeenCalled(); + expect(getAlbumsByGenre).toHaveBeenCalledWith('Rock', 60, 0); + expect(page.albums).toHaveLength(1); + }); + + it('reads album totals from the local genre browse command when needed', async () => { + vi.mocked(libraryIsReady).mockResolvedValue(true); + vi.mocked(libraryListAlbumsByGenre).mockResolvedValue({ + source: 'local', + hasMore: false, + total: 42, + albums: [], + }); + + await expect(fetchGenreAlbumTotal('srv-1', 'Rock', true, 'alphabeticalByName')).resolves.toBe(42); + }); +}); diff --git a/src/utils/library/genreAlbumBrowse.ts b/src/utils/library/genreAlbumBrowse.ts new file mode 100644 index 00000000..a247ad23 --- /dev/null +++ b/src/utils/library/genreAlbumBrowse.ts @@ -0,0 +1,105 @@ +import { getAlbumsByGenre } from '../../api/subsonicGenres'; +import { libraryListAlbumsByGenre } from '../../api/library'; +import { libraryScopeForServer } from '../../api/subsonicClient'; +import { albumToAlbum } from './advancedSearchLocal'; +import { albumSortClauses, sortSubsonicAlbums, type AlbumBrowseSort } from './albumBrowseSort'; +import type { AlbumBrowsePageResult } from './albumBrowseTypes'; +import { libraryIsReady } from './libraryReady'; + +/** First paint — one visible slice only. */ +export const GENRE_ALBUM_FIRST_PAGE = 60; +/** Background SQL chunk when the in-memory buffer is exhausted. */ +export const GENRE_ALBUM_CATALOG_CHUNK = 200; + +async function fetchLocalGenreAlbumPage( + serverId: string, + genre: string, + offset: number, + pageSize: number, + sort: AlbumBrowseSort, +): Promise { + const scope = libraryScopeForServer(serverId) ?? undefined; + if (!(await libraryIsReady(serverId))) return null; + try { + const resp = await libraryListAlbumsByGenre({ + serverId, + genre, + libraryScope: scope, + sort: albumSortClauses(sort), + limit: pageSize, + offset, + }); + if (resp.source !== 'local') return null; + return { + albums: resp.albums.map(albumToAlbum), + hasMore: resp.hasMore, + }; + } catch { + return null; + } +} + +async function fetchNetworkGenreAlbumPage( + genre: string, + offset: number, + pageSize: number, + sort: AlbumBrowseSort, +): Promise { + try { + const albums = await getAlbumsByGenre(genre, pageSize, offset); + return { + albums: sortSubsonicAlbums(albums, sort), + hasMore: albums.length === pageSize, + }; + } catch { + return { albums: [], hasMore: false }; + } +} + +/** Album grid for genre detail — local index when ready, else Subsonic `byGenre`. */ +export async function fetchGenreAlbumPage( + serverId: string, + genre: string, + indexEnabled: boolean, + offset: number, + pageSize: number, + sort: AlbumBrowseSort, +): Promise { + if (!serverId || !genre.trim()) { + return { albums: [], hasMore: false }; + } + + if (indexEnabled) { + const local = await fetchLocalGenreAlbumPage(serverId, genre, offset, pageSize, sort); + if (local != null) return local; + } + + return fetchNetworkGenreAlbumPage(genre, offset, pageSize, sort); +} + +export async function fetchGenreAlbumTotal( + serverId: string, + genre: string, + indexEnabled: boolean, + sort: AlbumBrowseSort, +): Promise { + if (!genre.trim()) return null; + if (indexEnabled && serverId && (await libraryIsReady(serverId))) { + const scope = libraryScopeForServer(serverId) ?? undefined; + try { + const resp = await libraryListAlbumsByGenre({ + serverId, + genre, + libraryScope: scope, + sort: albumSortClauses(sort), + limit: 1, + offset: 0, + includeTotal: true, + }); + if (resp.source === 'local' && resp.total != null) return resp.total; + } catch { + return null; + } + } + return null; +} diff --git a/src/utils/library/genreBrowsePlayback.test.ts b/src/utils/library/genreBrowsePlayback.test.ts new file mode 100644 index 00000000..31454325 --- /dev/null +++ b/src/utils/library/genreBrowsePlayback.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + fetchGenreAlbumCount, + fetchGenreCatalog, + fetchGenreTracksForPlayback, + fetchLocalGenreTracksForPlayback, + GENRE_PLAYBACK_QUEUE_CAP, +} from './genreBrowsePlayback'; + +vi.mock('../../api/library', () => ({ + libraryAdvancedSearch: vi.fn(), + libraryGetGenreAlbumCounts: vi.fn(), +})); + +vi.mock('../../api/subsonicGenres', () => ({ + fetchAllSongsByGenre: vi.fn(), + getGenres: vi.fn(), +})); + +vi.mock('../../api/subsonicClient', () => ({ + libraryScopeForServer: vi.fn(() => 'music'), +})); + +vi.mock('./libraryReady', () => ({ + libraryIsReady: vi.fn(), +})); + +vi.mock('./genreAlbumBrowse', () => ({ + fetchGenreAlbumTotal: vi.fn(), +})); + +import { libraryAdvancedSearch, libraryGetGenreAlbumCounts } from '../../api/library'; +import { fetchAllSongsByGenre, getGenres } from '../../api/subsonicGenres'; +import { fetchGenreAlbumTotal } from './genreAlbumBrowse'; +import { resetGenreCatalogCountsCacheForTests } from './genreCatalogCountsCache'; +import { libraryIsReady } from './libraryReady'; + +describe('genreBrowsePlayback', () => { + beforeEach(() => { + resetGenreCatalogCountsCacheForTests(); + vi.mocked(libraryIsReady).mockReset(); + vi.mocked(libraryAdvancedSearch).mockReset(); + vi.mocked(libraryGetGenreAlbumCounts).mockReset(); + vi.mocked(fetchAllSongsByGenre).mockReset(); + vi.mocked(getGenres).mockReset(); + vi.mocked(fetchGenreAlbumTotal).mockReset(); + }); + + it('requests random local tracks for shuffle', async () => { + vi.mocked(libraryIsReady).mockResolvedValue(true); + vi.mocked(libraryAdvancedSearch).mockResolvedValue({ + source: 'local', + tracks: [{ + serverId: 'srv-1', + id: 't1', + title: 'A', + artist: 'X', + album: 'B', + albumId: 'a1', + durationSec: 1, + coverArtId: 'c1', + syncedAt: 0, + rawJson: {}, + }], + albums: [], + artists: [], + totals: { tracks: 1, albums: 1, artists: 1 }, + appliedFilters: ['genre'], + }); + + await fetchLocalGenreTracksForPlayback('srv-1', 'Rock', { shuffle: true, cap: 100 }); + + expect(libraryAdvancedSearch).toHaveBeenCalledWith(expect.objectContaining({ + serverId: 'srv-1', + entityTypes: ['track'], + filters: [{ field: 'genre', op: 'eq', value: 'Rock' }], + sort: [{ field: 'random', dir: 'asc' }], + limit: 100, + skipTotals: true, + })); + }); + + it('falls back to Navidrome when local index is unavailable', async () => { + vi.mocked(libraryIsReady).mockResolvedValue(false); + vi.mocked(fetchAllSongsByGenre).mockResolvedValue([ + { id: 's1', title: 'Song', artist: 'A', album: 'B', albumId: 'a1', duration: 200, coverArt: 'c1' }, + ]); + + const tracks = await fetchGenreTracksForPlayback('srv-1', 'Jazz', { shuffle: false, indexEnabled: true }); + + expect(fetchAllSongsByGenre).toHaveBeenCalledWith('Jazz', GENRE_PLAYBACK_QUEUE_CAP); + expect(tracks).toHaveLength(1); + }); + + it('reads album totals from cached genre catalog', async () => { + vi.mocked(libraryIsReady).mockResolvedValue(true); + vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([ + { value: 'Rock', albumCount: 42, songCount: 900 }, + ]); + await fetchGenreCatalog('srv-1', true); + + await expect(fetchGenreAlbumCount('srv-1', 'Rock', true)).resolves.toBe(42); + expect(fetchGenreAlbumTotal).not.toHaveBeenCalled(); + expect(getGenres).not.toHaveBeenCalled(); + }); + + it('falls back to per-genre total when catalog cache is empty', async () => { + vi.mocked(fetchGenreAlbumTotal).mockResolvedValue(42); + + await expect(fetchGenreAlbumCount('srv-1', 'Rock', true)).resolves.toBe(42); + }); + + it('falls back to scoped genre list album count when local index is off', async () => { + vi.mocked(fetchGenreAlbumTotal).mockResolvedValue(null); + vi.mocked(getGenres).mockResolvedValue([ + { value: 'Rock', songCount: 100, albumCount: 7 }, + ]); + + await expect(fetchGenreAlbumCount('srv-1', 'Rock', false)).resolves.toBe(7); + }); + + it('loads genre cloud from local index when ready', async () => { + vi.mocked(libraryIsReady).mockResolvedValue(true); + vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([ + { value: 'Rock', albumCount: 42, songCount: 900 }, + ]); + + await expect(fetchGenreCatalog('srv-1', true)).resolves.toEqual([ + { value: 'Rock', albumCount: 42, songCount: 900 }, + ]); + expect(libraryGetGenreAlbumCounts).toHaveBeenCalledWith({ + serverId: 'srv-1', + libraryScope: 'music', + }); + expect(getGenres).not.toHaveBeenCalled(); + }); + + it('reuses cached genre catalog without repeating SQL', async () => { + vi.mocked(libraryIsReady).mockResolvedValue(true); + vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([ + { value: 'Rock', albumCount: 42, songCount: 900 }, + ]); + + await fetchGenreCatalog('srv-1', true); + await fetchGenreCatalog('srv-1', true); + + expect(libraryGetGenreAlbumCounts).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/utils/library/genreBrowsePlayback.ts b/src/utils/library/genreBrowsePlayback.ts new file mode 100644 index 00000000..3ae37281 --- /dev/null +++ b/src/utils/library/genreBrowsePlayback.ts @@ -0,0 +1,169 @@ +/** + * Genre-detail bulk play/shuffle against the local library index. + */ +import { libraryAdvancedSearch, libraryGetGenreAlbumCounts, type LibrarySortClause } from '../../api/library'; +import { fetchAllSongsByGenre, getGenres } from '../../api/subsonicGenres'; +import type { SubsonicGenre } from '../../api/subsonicTypes'; +import { libraryScopeForServer } from '../../api/subsonicClient'; +import type { Track } from '../../store/playerStoreTypes'; +import { songToTrack } from '../playback/songToTrack'; +import { shuffleArray } from '../playback/shuffleArray'; +import { trackToSong } from './advancedSearchLocal'; +import { albumSortClauses, type AlbumBrowseSort } from './albumBrowseSort'; +import { + genreCatalogCacheKey, + getInflightGenreCatalog, + lookupGenreAlbumCount, + peekGenreCatalogCache, + trackInflightGenreCatalog, + writeGenreCatalogCache, +} from './genreCatalogCountsCache'; +import { fetchGenreAlbumTotal } from './genreAlbumBrowse'; +import { libraryIsReady } from './libraryReady'; + +async function loadLocalGenreCatalogRows( + serverId: string, + libraryScope: string | undefined, +): Promise { + const rows = await libraryGetGenreAlbumCounts({ + serverId, + libraryScope, + }); + return rows.map(row => ({ + value: row.value, + albumCount: row.albumCount, + songCount: row.songCount, + })); +} + +async function fetchLocalGenreCatalog( + serverId: string, + libraryScope: string | undefined, +): Promise { + const genres = await loadLocalGenreCatalogRows(serverId, libraryScope); + writeGenreCatalogCache(serverId, libraryScope, genres); + return genres; +} + +/** Matches queueTrackResolver CACHE_CAP — whole seeded queue stays warm. */ +export const GENRE_PLAYBACK_QUEUE_CAP = 500; + +const PLAY_ORDER: LibrarySortClause[] = [ + { field: 'title', dir: 'asc' }, + { field: 'artist', dir: 'asc' }, +]; + +const SHUFFLE_ORDER: LibrarySortClause[] = [{ field: 'random', dir: 'asc' }]; + +export async function fetchLocalGenreTracksForPlayback( + serverId: string | null | undefined, + genre: string, + options: { shuffle?: boolean; cap?: number } = {}, +): Promise { + const cap = options.cap ?? GENRE_PLAYBACK_QUEUE_CAP; + if (!serverId || !genre.trim() || !(await libraryIsReady(serverId))) return null; + try { + const resp = await libraryAdvancedSearch({ + serverId, + libraryScope: libraryScopeForServer(serverId) ?? undefined, + entityTypes: ['track'], + filters: [{ field: 'genre', op: 'eq', value: genre }], + sort: options.shuffle ? SHUFFLE_ORDER : PLAY_ORDER, + limit: cap, + offset: 0, + skipTotals: true, + }); + if (resp.source !== 'local') return null; + return resp.tracks.map(t => songToTrack(trackToSong(t))); + } catch { + return null; + } +} + +export async function fetchGenreTracksForPlayback( + serverId: string | null | undefined, + genre: string, + options: { shuffle?: boolean; cap?: number; indexEnabled?: boolean } = {}, +): Promise { + const cap = options.cap ?? GENRE_PLAYBACK_QUEUE_CAP; + const shuffle = !!options.shuffle; + if (options.indexEnabled !== false) { + const local = await fetchLocalGenreTracksForPlayback(serverId, genre, { shuffle, cap }); + if (local) return local; + } + const songs = await fetchAllSongsByGenre(genre, cap); + const tracks = songs.map(songToTrack); + return shuffle ? shuffleArray(tracks) : tracks; +} + +export async function fetchGenreAlbumCount( + serverId: string | null | undefined, + genre: string, + indexEnabled: boolean, + sort: AlbumBrowseSort = 'alphabeticalByName', +): Promise { + if (!genre.trim()) return null; + if (indexEnabled && serverId) { + const scope = libraryScopeForServer(serverId); + const cached = lookupGenreAlbumCount(serverId, genre, scope); + if (cached != null) return cached; + const inflight = getInflightGenreCatalog(genreCatalogCacheKey(serverId, scope)); + if (inflight) { + const catalog = await inflight; + const match = catalog.find(g => g.value.localeCompare(genre, undefined, { sensitivity: 'accent' }) === 0); + if (match?.albumCount != null) return match.albumCount; + } + const localTotal = await fetchGenreAlbumTotal(serverId, genre, indexEnabled, sort); + if (localTotal != null) return localTotal; + return null; + } + try { + const genres = await getGenres(); + const match = genres.find(g => g.value.localeCompare(genre, undefined, { sensitivity: 'accent' }) === 0); + return match?.albumCount ?? null; + } catch { + return null; + } +} + +/** Genres cloud + detail header: local index counts when ready, else Navidrome `getGenres`. */ +export async function fetchGenreCatalog( + serverId: string | null | undefined, + indexEnabled: boolean, +): Promise { + if (!serverId) return getGenres(); + + const scope = libraryScopeForServer(serverId); + const cacheKey = genreCatalogCacheKey(serverId, scope); + const fresh = peekGenreCatalogCache(serverId, scope, false); + if (fresh) return fresh; + + const stale = peekGenreCatalogCache(serverId, scope, true); + const inflight = getInflightGenreCatalog(cacheKey); + if (inflight) { + if (stale) return stale; + return inflight; + } + + const load = async (): Promise => { + if (indexEnabled && (await libraryIsReady(serverId))) { + try { + return await fetchLocalGenreCatalog(serverId, scope); + } catch { + /* network fallback */ + } + } + const genres = await getGenres(); + writeGenreCatalogCache(serverId, scope, genres); + return genres; + }; + + const promise = load(); + trackInflightGenreCatalog(cacheKey, promise); + + if (stale) { + void promise.catch(() => {}); + return stale; + } + return promise; +} diff --git a/src/utils/library/genreCatalogCountsCache.test.ts b/src/utils/library/genreCatalogCountsCache.test.ts new file mode 100644 index 00000000..41e61bf7 --- /dev/null +++ b/src/utils/library/genreCatalogCountsCache.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + genreCatalogCacheKey, + invalidateGenreCatalogCache, + lookupGenreAlbumCount, + peekGenreCatalogCache, + resetGenreCatalogCountsCacheForTests, + writeGenreCatalogCache, +} from './genreCatalogCountsCache'; + +describe('genreCatalogCountsCache', () => { + afterEach(() => { + resetGenreCatalogCountsCacheForTests(); + }); + + it('keys by server and library scope', () => { + expect(genreCatalogCacheKey('srv-1', undefined)).toBe('srv-1:all'); + expect(genreCatalogCacheKey('srv-1', 'lib-a')).toBe('srv-1:lib-a'); + }); + + it('serves fresh and stale catalog entries', () => { + const genres = [{ value: 'Rock', albumCount: 3, songCount: 10 }]; + writeGenreCatalogCache('srv-1', 'lib-a', genres); + expect(peekGenreCatalogCache('srv-1', 'lib-a')).toEqual(genres); + expect(peekGenreCatalogCache('srv-1', 'lib-a', true)).toEqual(genres); + expect(peekGenreCatalogCache('srv-1', 'lib-b')).toBeNull(); + }); + + it('looks up album counts from cached catalog', () => { + writeGenreCatalogCache('srv-1', 'all', [ + { value: 'Rock', albumCount: 12, songCount: 40 }, + ]); + expect(lookupGenreAlbumCount('srv-1', 'rock', 'all')).toBe(12); + expect(lookupGenreAlbumCount('srv-1', 'Jazz', 'all')).toBeNull(); + }); + + it('invalidates per server', () => { + writeGenreCatalogCache('srv-1', 'all', [{ value: 'A', albumCount: 1, songCount: 1 }]); + writeGenreCatalogCache('srv-2', 'all', [{ value: 'B', albumCount: 2, songCount: 2 }]); + invalidateGenreCatalogCache('srv-1'); + expect(peekGenreCatalogCache('srv-1', 'all')).toBeNull(); + expect(peekGenreCatalogCache('srv-2', 'all')).not.toBeNull(); + }); +}); diff --git a/src/utils/library/genreCatalogCountsCache.ts b/src/utils/library/genreCatalogCountsCache.ts new file mode 100644 index 00000000..25e4849e --- /dev/null +++ b/src/utils/library/genreCatalogCountsCache.ts @@ -0,0 +1,96 @@ +import type { SubsonicGenre } from '../../api/subsonicTypes'; +import { resolveServerIdForIndexKey } from '../server/serverLookup'; + +/** Fresh hits skip SQLite entirely. */ +const FRESH_TTL_MS = 60 * 60 * 1000; +/** Stale entries still render while a background refresh runs. */ +const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +type CacheEntry = { + genres: SubsonicGenre[]; + fetchedAt: number; +}; + +const cache = new Map(); +const inflight = new Map>(); + +export function genreCatalogCacheKey(serverId: string, libraryScope?: string): string { + const resolved = resolveServerIdForIndexKey(serverId); + const folder = libraryScope?.trim() ? libraryScope.trim() : 'all'; + return `${resolved}:${folder}`; +} + +function entryAge(entry: CacheEntry): number { + return Date.now() - entry.fetchedAt; +} + +function findGenreInCatalog(genres: SubsonicGenre[], genre: string): SubsonicGenre | undefined { + return genres.find(g => g.value.localeCompare(genre, undefined, { sensitivity: 'accent' }) === 0); +} + +export function peekGenreCatalogCache( + serverId: string, + libraryScope?: string, + allowStale = false, +): SubsonicGenre[] | null { + const entry = cache.get(genreCatalogCacheKey(serverId, libraryScope)); + if (!entry) return null; + const age = entryAge(entry); + if (age <= FRESH_TTL_MS) return entry.genres; + if (allowStale && age <= STALE_TTL_MS) return entry.genres; + return null; +} + +export function lookupGenreAlbumCount( + serverId: string, + genre: string, + libraryScope?: string, +): number | null { + const entry = cache.get(genreCatalogCacheKey(serverId, libraryScope)); + if (!entry || entryAge(entry) > STALE_TTL_MS) return null; + return findGenreInCatalog(entry.genres, genre)?.albumCount ?? null; +} + +export function writeGenreCatalogCache( + serverId: string, + libraryScope: string | undefined, + genres: SubsonicGenre[], +): void { + cache.set(genreCatalogCacheKey(serverId, libraryScope), { + genres, + fetchedAt: Date.now(), + }); +} + +export function invalidateGenreCatalogCache(serverId?: string): void { + if (!serverId) { + cache.clear(); + inflight.clear(); + return; + } + const resolved = resolveServerIdForIndexKey(serverId); + const prefix = `${resolved}:`; + for (const key of [...cache.keys()]) { + if (key.startsWith(prefix)) cache.delete(key); + } + for (const key of [...inflight.keys()]) { + if (key.startsWith(prefix)) inflight.delete(key); + } +} + +export function getInflightGenreCatalog(key: string): Promise | undefined { + return inflight.get(key); +} + +export function trackInflightGenreCatalog(key: string, promise: Promise): void { + inflight.set(key, promise); + void promise.finally(() => { + if (inflight.get(key) === promise) inflight.delete(key); + }); +} + +/** Test-only reset. */ +export function resetGenreCatalogCountsCacheForTests(): void { + cache.clear(); + inflight.clear(); +} diff --git a/src/utils/library/librarySyncQueue.ts b/src/utils/library/librarySyncQueue.ts index d7984fa4..cf50d1b3 100644 --- a/src/utils/library/librarySyncQueue.ts +++ b/src/utils/library/librarySyncQueue.ts @@ -7,6 +7,7 @@ import { } from '../../api/library'; import type { UnlistenFn } from '@tauri-apps/api/event'; import { libraryDevEnabled, logLibrarySync } from './libraryDevLog'; +import { invalidateGenreCatalogCache } from './genreCatalogCountsCache'; export type LibrarySyncQueueKind = 'full' | 'delta' | 'verify'; @@ -44,6 +45,7 @@ function ensureIdleListener(): Promise { } function onSyncIdle(payload: LibrarySyncIdlePayload): void { + if (payload.ok) invalidateGenreCatalogCache(payload.serverId); if (!waitingForIdle || waitingForIdle.serverId !== payload.serverId) return; const waiter = waitingForIdle; waitingForIdle = null; diff --git a/src/utils/navigation/albumDetailNavigation.test.ts b/src/utils/navigation/albumDetailNavigation.test.ts index 6d0b2cc4..50d68105 100644 --- a/src/utils/navigation/albumDetailNavigation.test.ts +++ b/src/utils/navigation/albumDetailNavigation.test.ts @@ -64,7 +64,7 @@ describe('albumDetailNavigation', () => { it('navigates back to saved returnTo', () => { const navigate = vi.fn(); navigateAlbumDetailBack(navigate, { state: { returnTo: '/genres/Rock' } }); - expect(navigate).toHaveBeenCalledWith('/genres/Rock', undefined); + expect(navigate).toHaveBeenCalledWith('/genres/Rock', { state: { albumBrowseRestore: true } }); }); it('flags All Albums return for browse restore', () => { diff --git a/src/utils/navigation/albumDetailNavigation.ts b/src/utils/navigation/albumDetailNavigation.ts index 6b8b101c..febdac6e 100644 --- a/src/utils/navigation/albumDetailNavigation.ts +++ b/src/utils/navigation/albumDetailNavigation.ts @@ -113,8 +113,14 @@ function isArtistsBrowseReturnPath(path: string): boolean { return path === '/artists' || path.startsWith('/artists?'); } +function isGenreDetailReturnPath(path: string): boolean { + const bare = path.split('?')[0]?.replace(/\/$/, '') || path; + return /^\/genres\/[^/]+$/.test(bare); +} + function browseReturnRestoreState(returnTo: string): AlbumsBrowseRestoreLocationState | undefined { if (isAlbumGridBrowseReturnPath(returnTo)) return albumBrowseRestoreNavigationState(); + if (isGenreDetailReturnPath(returnTo)) return albumBrowseRestoreNavigationState(); if (isArtistsBrowseReturnPath(returnTo)) return artistBrowseRestoreNavigationState(); if (isSearchReturnPath(returnTo)) return advancedSearchRestoreNavigationState(); return undefined;