mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user