mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 != '';
|
||||
@@ -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 != '';
|
||||
@@ -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<TrackRow> = 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();
|
||||
|
||||
@@ -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<Vec<GenreAlbumCountDto>, 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<rusqlite::types::Value> =
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Distinct album counts per track genre — same grouping as genre album browse.
|
||||
#[tauri::command]
|
||||
pub fn library_get_genre_album_counts(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
library_scope: Option<String>,
|
||||
) -> Result<Vec<GenreAlbumCountDto>, 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());
|
||||
|
||||
@@ -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<crate::dto::LibraryGenreAlbumsResponse, String> {
|
||||
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>,
|
||||
|
||||
@@ -381,6 +381,47 @@ pub struct CatalogYearBoundsDto {
|
||||
pub max_year: Option<i32>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub sort: Vec<LibrarySortClause>,
|
||||
#[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<LibraryAlbumDto>,
|
||||
pub has_more: bool,
|
||||
pub total: Option<u32>,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// `library_purge_server` outcome.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -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<String> {
|
||||
s.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String {
|
||||
let mut keys: Vec<String> = 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<u32, rusqlite::Error> {
|
||||
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<LibraryAlbumDto> {
|
||||
let raw: Option<String> = 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<LibraryGenreAlbumsResponse, String> {
|
||||
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
|
||||
return Ok(LibraryGenreAlbumsResponse {
|
||||
albums: Vec::new(),
|
||||
has_more: false,
|
||||
total: None,
|
||||
source: "local".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let genre = req.genre.trim();
|
||||
if genre.is_empty() {
|
||||
return Ok(LibraryGenreAlbumsResponse {
|
||||
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<SqlValue> = 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::<rusqlite::Result<Vec<_>>>()?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CatalogYearBounds> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<CatalogYearBounds>('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<GenreAlbumCountRow[]> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<GenreAlbumCountRow[]>('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<LibraryGenreAlbumsResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryGenreAlbumsResponse>('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;
|
||||
|
||||
@@ -2,7 +2,9 @@ import { api, libraryFilterParams } from './subsonicClient';
|
||||
import type { SubsonicAlbum, SubsonicGenre, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getGenres(): Promise<SubsonicGenre[]> {
|
||||
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];
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<SubsonicAlbum[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<AlbumBrowseScrollSnapshot>,
|
||||
) {
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
|
||||
const restoredFromStashRef = useRef(false);
|
||||
const restoreKeyRef = useRef('');
|
||||
const restoreDisplayCountRef = useRef<number | undefined>(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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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ø',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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ă',
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export const genres = {
|
||||
albumsEmpty: 'В этом жанре альбомов нет.',
|
||||
loadMore: 'Ещё',
|
||||
back: 'Назад',
|
||||
playTooltip: 'Воспроизвести (удерживать для перемешивания)',
|
||||
shuffle: 'Перемешать',
|
||||
addToQueue: 'В очередь',
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export const genres = {
|
||||
albumsEmpty: '未找到该流派的专辑。',
|
||||
loadMore: '加载更多',
|
||||
back: '返回',
|
||||
playTooltip: '播放(长按随机播放)',
|
||||
shuffle: '随机播放',
|
||||
addToQueue: '添加到队列',
|
||||
};
|
||||
|
||||
+202
-82
@@ -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,90 +38,158 @@ export default function GenreDetail() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const navigate = useNavigate();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
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<AlbumBrowseScrollSnapshot>({ 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<number | null>(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 (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
|
||||
<div className="mainstage-inpage-toolbar">
|
||||
<div className="page-sticky-header mainstage-inpage-toolbar-row">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
onClick={handleBack}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.25rem' }}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>{t('genres.back')}</span>
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
|
||||
{!loading && albums.length > 0 && (
|
||||
{headerCount != null && headerCount > 0 && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
|
||||
<Disc3 size={14} style={{ color: 'var(--accent)' }} />
|
||||
{t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''}
|
||||
{t('genres.albumCount', { count: headerCount })}
|
||||
</span>
|
||||
)}
|
||||
{!loading && albums.length > 0 && (
|
||||
{showPlayback && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: 'auto' }}>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={bulkLoading}>
|
||||
{bulkLoading ? <Loader2 size={15} className="spin" /> : <Play size={15} />} {t('common.play')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleShuffleAll}
|
||||
type="button"
|
||||
className="btn btn-primary long-press-play-btn"
|
||||
{...pressBind}
|
||||
disabled={bulkLoading}
|
||||
data-tooltip={t('genres.shuffle')}
|
||||
data-tooltip={t('genres.playTooltip')}
|
||||
>
|
||||
<Shuffle size={16} />
|
||||
<LongPressWaveOverlay active={isHolding} size="compact" />
|
||||
<span className="long-press-play-btn__icon" style={{ gap: '0.35rem' }}>
|
||||
{bulkLoading ? <Loader2 size={15} className="spin" /> : <Play size={15} fill="currentColor" />}
|
||||
{t('common.play')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
@@ -122,29 +202,69 @@ export default function GenreDetail() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <p className="loading-text">{t('genres.albumsLoading')}</p>}
|
||||
{!loading && albums.length === 0 && <p className="loading-text">{t('genres.albumsEmpty')}</p>}
|
||||
|
||||
{albums.length > 0 && (
|
||||
<OverlayScrollArea
|
||||
className="mainstage-inpage-scroll"
|
||||
viewportClassName="mainstage-inpage-scroll__viewport"
|
||||
viewportId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
viewportRef={bindGenreDetailScrollBody}
|
||||
railInset="panel"
|
||||
measureDeps={[
|
||||
loading,
|
||||
displayAlbums.length,
|
||||
hasMore,
|
||||
genre,
|
||||
perfFlags.disableMainstageVirtualLists,
|
||||
]}
|
||||
>
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : !loading && displayAlbums.length === 0 ? (
|
||||
<p className="loading-text" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('genres.albumsEmpty')}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
||||
<VirtualCardGrid
|
||||
items={albums}
|
||||
items={displayAlbums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
layoutSignal={displayAlbums.length}
|
||||
scrollRootId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={album => <AlbumCard album={album} />}
|
||||
renderItem={album => (
|
||||
<AlbumCard
|
||||
album={album}
|
||||
observeScrollRootId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasMore && !loading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
|
||||
<button className="btn btn-surface" onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? t('common.loadingMore') : t('genres.loadMore')}
|
||||
</button>
|
||||
/>
|
||||
{hasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingMore} />
|
||||
)}
|
||||
</div>
|
||||
{isScrollRestorePending && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '3rem',
|
||||
background: 'var(--ctp-base)',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+33
-8
@@ -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<SubsonicGenre[]>([]);
|
||||
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<SubsonicGenre[]>(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 (
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, AlbumBrowseSort>,
|
||||
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<string, AlbumBrowseSort>,
|
||||
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`). */
|
||||
|
||||
@@ -36,10 +36,15 @@ export async function fetchLocalAlbumCatalogChunk(
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<AlbumBrowsePageResult | null> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -30,6 +30,13 @@ export async function fetchAlbumBrowseNetwork(
|
||||
pageSize: number,
|
||||
): Promise<AlbumBrowsePageResult> {
|
||||
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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AlbumBrowsePageResult | null> {
|
||||
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<AlbumBrowsePageResult> {
|
||||
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<AlbumBrowsePageResult> {
|
||||
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<number | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<SubsonicGenre[]> {
|
||||
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<SubsonicGenre[]> {
|
||||
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<Track[] | null> {
|
||||
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<Track[]> {
|
||||
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<number | null> {
|
||||
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<SubsonicGenre[]> {
|
||||
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<SubsonicGenre[]> => {
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, CacheEntry>();
|
||||
const inflight = new Map<string, Promise<SubsonicGenre[]>>();
|
||||
|
||||
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<SubsonicGenre[]> | undefined {
|
||||
return inflight.get(key);
|
||||
}
|
||||
|
||||
export function trackInflightGenreCatalog(key: string, promise: Promise<SubsonicGenre[]>): 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();
|
||||
}
|
||||
@@ -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<UnlistenFn> {
|
||||
}
|
||||
|
||||
function onSyncIdle(payload: LibrarySyncIdlePayload): void {
|
||||
if (payload.ok) invalidateGenreCatalogCache(payload.serverId);
|
||||
if (!waitingForIdle || waitingForIdle.serverId !== payload.serverId) return;
|
||||
const waiter = waitingForIdle;
|
||||
waitingForIdle = null;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user