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:
cucadmuh
2026-06-01 04:20:18 +03:00
committed by GitHub
parent d3e5a6b704
commit ddf10ee01d
42 changed files with 1952 additions and 139 deletions
@@ -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;