Files
psysonic/src-tauri/crates/psysonic-library/src/genre_album_browse.rs
T
Frank Stellmacher 47e16ebfef fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket (#977)
* fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket

Three zunoz reports, one change:

- Queue now-playing card: the artist and album links now underline on hover
  (not just recolour), matching clickable names everywhere else. The album
  link sits on .queue-current-sub itself; artist links are nested .is-link
  spans — both selectors covered.

- Genre album cards split multi-artist credits into individual links like the
  rest of the app. Root cause was the local-index genre query hardcoding NULL
  for the album raw_json column, so OpenSubsonic artists[] never reached the
  card and it fell back to the flat "A • B" single link. Select a.raw_json
  instead (parity with the All Albums / advanced-search album queries).

- Artists page alphabet index: # is now digits-only, and a new "Other" bucket
  collects accented Latin (Æ/Ø/Å…) and non-Latin scripts (CJK, Cyrillic, …)
  that previously fell into the # catch-all. Adds artistBucketKey/compareBuckets
  helpers (unit-tested), an OTHER bucket sorted last, and the artists.other
  label across 9 locales.

* docs(changelog): queue link hover, genre card artist split, Artists Other bucket (#977)
2026-06-04 03:13:45 +02:00

286 lines
8.9 KiB
Rust

//! 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), \
a.raw_json \
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);
}
}