mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(library): local lossless index, filters, and conserve dedicated page (#871)
* feat(library): local lossless index, filters, and conserve dedicated page Add SQLite-backed lossless album browse and advanced-search filtering, wire All Albums and artist/album lossless drill-down mode, and hide the standalone /lossless-albums nav entry from sidebar visibility settings (conserved route, default off). * docs(release): note lossless local index in CHANGELOG and credits (PR #871)
This commit is contained in:
@@ -296,6 +296,11 @@ fn build_album(
|
||||
skip_totals: bool,
|
||||
applied: &mut BTreeSet<String>,
|
||||
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
|
||||
if scalar_requires_lossless_track_grouping(scalar) {
|
||||
return build_album_from_tracks(
|
||||
store, req, text, scalar, limit, offset, skip_totals, applied, true,
|
||||
);
|
||||
}
|
||||
if !scalar_requires_track_derived_entities(scalar) {
|
||||
let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
|
||||
if !table.0.is_empty() || table.1 > 0 {
|
||||
@@ -305,7 +310,9 @@ fn build_album(
|
||||
if let Some(q) = text.and_then(fts_album_prefix_match_query) {
|
||||
return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
|
||||
}
|
||||
build_album_from_tracks(store, req, text, scalar, limit, offset, skip_totals, applied)
|
||||
build_album_from_tracks(
|
||||
store, req, text, scalar, limit, offset, skip_totals, applied, false,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -365,14 +372,17 @@ fn build_album_from_tracks(
|
||||
offset: u32,
|
||||
skip_totals: bool,
|
||||
applied: &mut BTreeSet<String>,
|
||||
include_album_table_rows: bool,
|
||||
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
|
||||
let mut w = WhereBuilder::new();
|
||||
w.push_raw("t.deleted = 0");
|
||||
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
|
||||
w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
|
||||
w.push_raw(
|
||||
"NOT EXISTS (SELECT 1 FROM album a WHERE a.server_id = t.server_id AND a.id = t.album_id)",
|
||||
);
|
||||
if !include_album_table_rows {
|
||||
w.push_raw(
|
||||
"NOT EXISTS (SELECT 1 FROM album a WHERE a.server_id = t.server_id AND a.id = t.album_id)",
|
||||
);
|
||||
}
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
let clause = library_scope_equals_sql("t");
|
||||
w.push_param(&clause, SqlValue::Text(scope));
|
||||
@@ -395,7 +405,7 @@ fn build_album_from_tracks(
|
||||
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
|
||||
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
|
||||
MAX(t.starred_at), MAX(t.synced_at)";
|
||||
let order = order_clause(&req.sort, EntityKind::Album).unwrap_or_else(|| {
|
||||
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
|
||||
"ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string()
|
||||
});
|
||||
query_grouped_rows(
|
||||
@@ -743,6 +753,12 @@ fn scalar_requires_track_derived_entities(scalar: &[&LibraryFilterClause]) -> bo
|
||||
.any(|c| matches!(c.field.as_str(), "mood_group" | "mood_tag"))
|
||||
}
|
||||
|
||||
/// Lossless is defined on track `suffix`; year/genre filters must apply to the
|
||||
/// same track rows, not stale `album` table metadata.
|
||||
fn scalar_requires_lossless_track_grouping(scalar: &[&LibraryFilterClause]) -> bool {
|
||||
scalar.iter().any(|c| c.field == "lossless")
|
||||
}
|
||||
|
||||
/// Resolve one scalar clause to a WHERE fragment for `entity`. `Ok(None)`
|
||||
/// means the field is known but doesn't route to this entity (§5.13.3 skip).
|
||||
fn resolve_clause(
|
||||
@@ -774,6 +790,24 @@ fn resolve_clause(
|
||||
("mood_group" | "mood_tag", EntityKind::Track) => {
|
||||
return crate::advanced_search_mood::resolve_mood_clause(c);
|
||||
}
|
||||
("lossless", EntityKind::Track) => {
|
||||
return Ok(Some(SqlFragment {
|
||||
sql: crate::lossless_formats::track_is_lossless_sql("t"),
|
||||
params: vec![],
|
||||
}));
|
||||
}
|
||||
("lossless", EntityKind::Album) => {
|
||||
return Ok(Some(SqlFragment {
|
||||
sql: crate::lossless_formats::album_has_lossless_track_sql("a"),
|
||||
params: vec![],
|
||||
}));
|
||||
}
|
||||
("lossless", EntityKind::Artist) => {
|
||||
return Ok(Some(SqlFragment {
|
||||
sql: crate::lossless_formats::artist_has_lossless_track_sql("ar"),
|
||||
params: vec![],
|
||||
}));
|
||||
}
|
||||
// `text` is handled by the entity builder (FTS / LIKE), never here.
|
||||
("text", _) => return Ok(None),
|
||||
// Registered but no v1 SQL builder (user_rating / suffix / bit_rate).
|
||||
@@ -1079,6 +1113,31 @@ fn order_clause(sort: &[LibrarySortClause], entity: EntityKind) -> Option<String
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort for album rows aggregated from `track t` (`GROUP BY t.album_id`).
|
||||
/// Must not reference `album a` — that alias is absent in this query shape.
|
||||
fn album_order_from_track_groups(sort: &[LibrarySortClause]) -> Option<String> {
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
for s in sort {
|
||||
let col = match s.field.as_str() {
|
||||
"name" => "MAX(t.album) COLLATE NOCASE",
|
||||
"artist" => "MAX(t.artist) COLLATE NOCASE",
|
||||
"year" => "MAX(t.year)",
|
||||
"random" => "RANDOM()",
|
||||
_ => continue,
|
||||
};
|
||||
let dir = match s.dir {
|
||||
SortDir::Asc => "ASC",
|
||||
SortDir::Desc => "DESC",
|
||||
};
|
||||
keys.push(format!("{col} {dir}"));
|
||||
}
|
||||
if keys.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("ORDER BY {}", keys.join(", ")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Allowlist of sortable fields per entity → trusted column expression.
|
||||
/// Unknown sort fields are ignored (fall back to the default order).
|
||||
fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> {
|
||||
@@ -1585,6 +1644,105 @@ mod tests {
|
||||
assert!(err.contains("unknown filter field"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_filter_returns_only_lossless_tracks() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut flac = track("s1", "t1", "A", "X", "Alb");
|
||||
flac.suffix = Some("flac".into());
|
||||
let mut mp3 = track("s1", "t2", "B", "X", "Alb");
|
||||
mp3.suffix = Some("mp3".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[flac, mp3])
|
||||
.unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Track]);
|
||||
r.filters = vec![clause("lossless", FilterOp::IsTrue, None, None)];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.tracks.len(), 1);
|
||||
assert_eq!(resp.tracks[0].id, "t1");
|
||||
assert!(resp.applied_filters.contains(&"lossless".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_filter_on_album_entity_requires_lossless_track() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_album(&store, "s1", "al1", "Lossless Album", None, None);
|
||||
insert_album(&store, "s1", "al2", "Lossy Album", None, None);
|
||||
let mut flac = track("s1", "t1", "A", "X", "Alb");
|
||||
flac.album_id = Some("al1".into());
|
||||
flac.suffix = Some("flac".into());
|
||||
let mut mp3 = track("s1", "t2", "B", "Y", "Alb2");
|
||||
mp3.album_id = Some("al2".into());
|
||||
mp3.suffix = Some("mp3".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[flac, mp3])
|
||||
.unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Album]);
|
||||
r.filters = vec![clause("lossless", FilterOp::IsTrue, None, None)];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "al1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_and_year_filters_use_track_year_when_album_table_differs() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_album(&store, "s1", "al1", "Hi-Res Album", Some(1990), None);
|
||||
let mut flac = track("s1", "t1", "Track", "Art", "Alb");
|
||||
flac.album_id = Some("al1".into());
|
||||
flac.suffix = Some("flac".into());
|
||||
flac.year = Some(2022);
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[flac])
|
||||
.unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Album]);
|
||||
r.filters = vec![
|
||||
clause("year", FilterOp::Between, Some(json!(2020)), Some(json!(2024))),
|
||||
clause("lossless", FilterOp::IsTrue, None, None),
|
||||
];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "al1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_album_browse_with_name_sort_returns_rows() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut flac = track("s1", "t1", "Track", "Art", "Zebra Album");
|
||||
flac.suffix = Some("flac".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[flac])
|
||||
.unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Album]);
|
||||
r.filters = vec![clause("lossless", FilterOp::IsTrue, None, None)];
|
||||
r.sort = vec![LibrarySortClause {
|
||||
field: "name".into(),
|
||||
dir: SortDir::Asc,
|
||||
}];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_filter_on_artist_entity_requires_lossless_track() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artist(&store, "s1", "ar1", "Lossless Artist");
|
||||
insert_artist(&store, "s1", "ar2", "Lossy Artist");
|
||||
let mut flac = track("s1", "t1", "A", "Lossless Artist", "Alb");
|
||||
flac.artist_id = Some("ar1".into());
|
||||
flac.suffix = Some("flac".into());
|
||||
let mut mp3 = track("s1", "t2", "B", "Lossy Artist", "Alb2");
|
||||
mp3.artist_id = Some("ar2".into());
|
||||
mp3.suffix = Some("mp3".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[flac, mp3])
|
||||
.unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Artist]);
|
||||
r.filters = vec![clause("lossless", FilterOp::IsTrue, None, None)];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.artists.len(), 1);
|
||||
assert_eq!(resp.artists[0].id, "ar1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_but_unbuilt_field_is_an_error() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Artist discography slice — lossless albums and tracks from the local index.
|
||||
|
||||
use crate::dto::{
|
||||
LibraryAlbumDto, LibraryArtistLosslessBrowseRequest, LibraryArtistLosslessBrowseResponse,
|
||||
LibraryTrackDto,
|
||||
};
|
||||
use crate::lossless_formats::track_is_lossless_sql;
|
||||
use crate::search::{aliased_track_columns, 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)
|
||||
}
|
||||
|
||||
pub fn get_artist_lossless_browse(
|
||||
store: &LibraryStore,
|
||||
req: &LibraryArtistLosslessBrowseRequest,
|
||||
) -> Result<LibraryArtistLosslessBrowseResponse, String> {
|
||||
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
|
||||
return Ok(empty_response());
|
||||
}
|
||||
|
||||
let lossless_sql = track_is_lossless_sql("t");
|
||||
let mut track_where = vec![
|
||||
"t.deleted = 0".to_string(),
|
||||
"t.server_id = ?1".to_string(),
|
||||
"t.artist_id = ?2".to_string(),
|
||||
lossless_sql,
|
||||
];
|
||||
let mut track_params: Vec<SqlValue> = vec![
|
||||
SqlValue::Text(req.server_id.clone()),
|
||||
SqlValue::Text(req.artist_id.clone()),
|
||||
];
|
||||
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
let clause = library_scope_equals_sql("t");
|
||||
track_where.push(clause);
|
||||
track_params.push(SqlValue::Text(scope));
|
||||
}
|
||||
|
||||
let track_where_sql = track_where.join(" AND ");
|
||||
let track_cols = aliased_track_columns("t");
|
||||
let tracks_sql = format!(
|
||||
"SELECT {track_cols} FROM track t \
|
||||
WHERE {track_where_sql} \
|
||||
ORDER BY t.album COLLATE NOCASE ASC, \
|
||||
COALESCE(t.disc_number, 1) ASC, \
|
||||
COALESCE(t.track_number, 0) ASC, \
|
||||
t.title COLLATE NOCASE ASC"
|
||||
);
|
||||
|
||||
let tracks = store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&tracks_sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(track_params.iter()), |r| {
|
||||
Ok(LibraryTrackDto::from_row(&crate::repos::row_to_track_row(r)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
})?;
|
||||
|
||||
let mut album_where = vec![
|
||||
"t.deleted = 0".to_string(),
|
||||
"t.server_id = ?1".to_string(),
|
||||
"t.artist_id = ?2".to_string(),
|
||||
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
|
||||
track_is_lossless_sql("t"),
|
||||
];
|
||||
let mut album_params: Vec<SqlValue> = vec![
|
||||
SqlValue::Text(req.server_id.clone()),
|
||||
SqlValue::Text(req.artist_id.clone()),
|
||||
];
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
let clause = library_scope_equals_sql("t");
|
||||
album_where.push(clause);
|
||||
album_params.push(SqlValue::Text(scope));
|
||||
}
|
||||
let album_where_sql = album_where.join(" AND ");
|
||||
|
||||
let albums_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, \
|
||||
(SELECT COUNT(*) FROM track c \
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id \
|
||||
AND c.artist_id = t.artist_id AND c.deleted = 0) AS track_count, \
|
||||
(SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c \
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id \
|
||||
AND c.artist_id = t.artist_id AND c.deleted = 0) AS duration_sec, \
|
||||
MAX(COALESCE(CAST(json_extract(t.raw_json, '$.bitDepth') AS INTEGER), 0)) AS max_bit_depth \
|
||||
FROM track t \
|
||||
WHERE {album_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 BY la.max_bit_depth DESC, \
|
||||
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, \
|
||||
la.album_id ASC"
|
||||
);
|
||||
|
||||
let albums = store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&albums_sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(album_params.iter()), map_album_row)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
})?;
|
||||
|
||||
Ok(LibraryArtistLosslessBrowseResponse {
|
||||
albums,
|
||||
tracks,
|
||||
source: "local".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_response() -> LibraryArtistLosslessBrowseResponse {
|
||||
LibraryArtistLosslessBrowseResponse {
|
||||
albums: Vec::new(),
|
||||
tracks: Vec::new(),
|
||||
source: "local".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_album_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
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
|
||||
fn lossless_track(
|
||||
server: &str,
|
||||
id: &str,
|
||||
artist_id: &str,
|
||||
album_id: &str,
|
||||
title: &str,
|
||||
) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server.into(),
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
title_sort: None,
|
||||
artist: Some("Artist".into()),
|
||||
artist_id: Some(artist_id.into()),
|
||||
album: "Album".into(),
|
||||
album_id: Some(album_id.into()),
|
||||
album_artist: Some("Artist".into()),
|
||||
duration_sec: 200,
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
year: Some(2020),
|
||||
genre: None,
|
||||
suffix: Some("flac".into()),
|
||||
bit_rate: Some(1000),
|
||||
size_bytes: None,
|
||||
cover_art_id: Some(album_id.into()),
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: Some(5),
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: None,
|
||||
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: r#"{"bitDepth":24}"#.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_lossless_albums_and_tracks_for_artist() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
lossless_track("s1", "t1", "ar1", "al1", "One"),
|
||||
lossless_track("s1", "t2", "ar1", "al2", "Two"),
|
||||
])
|
||||
.unwrap();
|
||||
let mut mp3 = lossless_track("s1", "t3", "ar1", "al3", "Three");
|
||||
mp3.suffix = Some("mp3".into());
|
||||
TrackRepository::new(&store).upsert_batch(&[mp3]).unwrap();
|
||||
|
||||
let resp = get_artist_lossless_browse(
|
||||
&store,
|
||||
&LibraryArtistLosslessBrowseRequest {
|
||||
server_id: "s1".into(),
|
||||
artist_id: "ar1".into(),
|
||||
library_scope: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.albums.len(), 2);
|
||||
assert_eq!(resp.tracks.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -436,6 +436,22 @@ pub async fn library_advanced_search(
|
||||
advanced_search::run_advanced_search(&runtime.store, &request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_list_lossless_albums(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: crate::dto::LibraryLosslessAlbumsRequest,
|
||||
) -> Result<crate::dto::LibraryLosslessAlbumsResponse, String> {
|
||||
crate::lossless_albums::list_lossless_albums(&runtime.store, &request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_get_artist_lossless_browse(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: crate::dto::LibraryArtistLosslessBrowseRequest,
|
||||
) -> Result<crate::dto::LibraryArtistLosslessBrowseResponse, String> {
|
||||
crate::artist_lossless_browse::get_artist_lossless_browse(&runtime.store, &request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_live_search(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
|
||||
@@ -541,6 +541,51 @@ pub struct LibraryLiveSearchResponse {
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// `library_list_lossless_albums` request — paginated lossless browse (local index).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryLosslessAlbumsRequest {
|
||||
pub server_id: String,
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
#[serde(default = "default_lossless_limit")]
|
||||
pub limit: u32,
|
||||
#[serde(default)]
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
fn default_lossless_limit() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
/// `library_list_lossless_albums` response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryLosslessAlbumsResponse {
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub has_more: bool,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// `library_get_artist_lossless_browse` request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryArtistLosslessBrowseRequest {
|
||||
pub server_id: String,
|
||||
pub artist_id: String,
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
}
|
||||
|
||||
/// Lossless albums + tracks for one artist (local index).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryArtistLosslessBrowseResponse {
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub tracks: Vec<LibraryTrackDto>,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// `library_search_cross_server` response (§5.5B / §5.9).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -122,6 +122,12 @@ pub const FILTER_FIELD_REGISTRY: &[FilterField] = &[
|
||||
ops: &[FilterOp::Eq, FilterOp::In],
|
||||
status: FilterStatus::Planned,
|
||||
},
|
||||
FilterField {
|
||||
id: "lossless",
|
||||
entities: &[EntityKind::Track, EntityKind::Album, EntityKind::Artist],
|
||||
ops: &[FilterOp::IsTrue],
|
||||
status: FilterStatus::V1,
|
||||
},
|
||||
FilterField {
|
||||
id: "bit_rate",
|
||||
entities: &[EntityKind::Track],
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) mod bulk_ingest;
|
||||
pub mod advanced_search;
|
||||
mod advanced_search_mood;
|
||||
pub mod analysis_backfill;
|
||||
pub mod artist_lossless_browse;
|
||||
pub mod cover_backfill;
|
||||
pub mod canonical;
|
||||
pub mod commands;
|
||||
@@ -20,6 +21,8 @@ pub mod enrichment;
|
||||
pub mod filter;
|
||||
pub mod mood_groups;
|
||||
pub mod live_search;
|
||||
pub mod lossless_albums;
|
||||
pub mod lossless_formats;
|
||||
pub mod payload;
|
||||
pub mod repos;
|
||||
pub mod runtime;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Lossless album browse from the local `track` index (§5.13 extension).
|
||||
//!
|
||||
//! Mirrors the frontend allowlist in `src/utils/library/losslessFormats.ts`.
|
||||
|
||||
use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse};
|
||||
use crate::lossless_formats::track_is_lossless_sql;
|
||||
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)
|
||||
}
|
||||
|
||||
/// Paginated lossless albums for one server. Returns empty when the index has
|
||||
/// no matching tracks — caller may fall back to the Navidrome song-stream walk.
|
||||
pub fn list_lossless_albums(
|
||||
store: &LibraryStore,
|
||||
req: &LibraryLosslessAlbumsRequest,
|
||||
) -> Result<LibraryLosslessAlbumsResponse, String> {
|
||||
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
|
||||
return Ok(empty_response());
|
||||
}
|
||||
|
||||
let limit = req.limit.max(1);
|
||||
let offset = req.offset;
|
||||
let lossless_sql = track_is_lossless_sql("t");
|
||||
|
||||
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(),
|
||||
lossless_sql,
|
||||
];
|
||||
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
|
||||
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
let clause = library_scope_equals_sql("t");
|
||||
where_clauses.push(clause);
|
||||
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, \
|
||||
(SELECT COUNT(*) FROM track c \
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0) AS track_count, \
|
||||
(SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c \
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0) AS duration_sec, \
|
||||
MAX(COALESCE(CAST(json_extract(t.raw_json, '$.bitDepth') AS INTEGER), 0)) AS max_bit_depth \
|
||||
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 BY la.max_bit_depth DESC, \
|
||||
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, \
|
||||
la.album_id ASC \
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
params.push(SqlValue::Integer(limit as i64));
|
||||
params.push(SqlValue::Integer(offset as i64));
|
||||
|
||||
let albums = store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), map_row)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
})?;
|
||||
|
||||
let has_more = albums.len() as u32 == limit;
|
||||
Ok(LibraryLosslessAlbumsResponse {
|
||||
albums,
|
||||
has_more,
|
||||
source: "local".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_response() -> LibraryLosslessAlbumsResponse {
|
||||
LibraryLosslessAlbumsResponse {
|
||||
albums: Vec::new(),
|
||||
has_more: false,
|
||||
source: "local".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
|
||||
fn track_with_suffix(
|
||||
server: &str,
|
||||
id: &str,
|
||||
album_id: &str,
|
||||
album: &str,
|
||||
suffix: &str,
|
||||
bit_depth: i64,
|
||||
) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server.into(),
|
||||
id: id.into(),
|
||||
title: format!("Track {id}"),
|
||||
title_sort: None,
|
||||
artist: Some("Artist".into()),
|
||||
artist_id: Some("ar1".into()),
|
||||
album: album.into(),
|
||||
album_id: Some(album_id.into()),
|
||||
album_artist: Some("Artist".into()),
|
||||
duration_sec: 200,
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
year: Some(2020),
|
||||
genre: Some("Rock".into()),
|
||||
suffix: Some(suffix.into()),
|
||||
bit_rate: Some(1000),
|
||||
size_bytes: None,
|
||||
cover_art_id: Some(album_id.into()),
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: None,
|
||||
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: format!(r#"{{"bitDepth":{bit_depth}}}"#),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_album(store: &LibraryStore, server: &str, id: &str, name: &str) {
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"INSERT INTO album (server_id, id, name, artist, song_count, duration_sec, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, ?3, 'Artist', 2, 400, 1, '{}')",
|
||||
rusqlite::params![server, id, name],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn req(server: &str, limit: u32, offset: u32) -> LibraryLosslessAlbumsRequest {
|
||||
LibraryLosslessAlbumsRequest {
|
||||
server_id: server.into(),
|
||||
library_scope: None,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_albums_with_lossless_suffix_only() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track_with_suffix("s1", "t1", "al_flac", "Hi-Res", "flac", 24),
|
||||
track_with_suffix("s1", "t2", "al_mp3", "Lossy", "mp3", 0),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let resp = list_lossless_albums(&store, &req("s1", 50, 0)).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "al_flac");
|
||||
assert_eq!(resp.albums[0].name, "Hi-Res");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_by_bit_depth_desc_then_name() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track_with_suffix("s1", "t1", "al_16", "Sixteen", "flac", 16),
|
||||
track_with_suffix("s1", "t2", "al_24", "Twenty-Four", "flac", 24),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let resp = list_lossless_albums(&store, &req("s1", 50, 0)).unwrap();
|
||||
assert_eq!(resp.albums.len(), 2);
|
||||
assert_eq!(resp.albums[0].id, "al_24");
|
||||
assert_eq!(resp.albums[1].id, "al_16");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_album_table_metadata_when_present() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_album(&store, "s1", "al1", "Album Table Name");
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track_with_suffix("s1", "t1", "al1", "Track Title", "flac", 16)])
|
||||
.unwrap();
|
||||
|
||||
let resp = list_lossless_albums(&store, &req("s1", 50, 0)).unwrap();
|
||||
assert_eq!(resp.albums[0].name, "Album Table Name");
|
||||
assert_eq!(resp.albums[0].song_count, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_scope_narrows_results() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut a = track_with_suffix("s1", "t1", "al1", "A", "flac", 16);
|
||||
a.library_id = Some("lib1".into());
|
||||
let mut b = track_with_suffix("s1", "t2", "al2", "B", "flac", 16);
|
||||
b.library_id = Some("lib2".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[a, b])
|
||||
.unwrap();
|
||||
|
||||
let mut scoped = req("s1", 50, 0);
|
||||
scoped.library_scope = Some("lib1".into());
|
||||
let resp = list_lossless_albums(&store, &scoped).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "al1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_sets_has_more() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track_with_suffix("s1", "t1", "al1", "A", "flac", 16),
|
||||
track_with_suffix("s1", "t2", "al2", "B", "flac", 16),
|
||||
track_with_suffix("s1", "t3", "al3", "C", "flac", 16),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let page1 = list_lossless_albums(&store, &req("s1", 2, 0)).unwrap();
|
||||
assert_eq!(page1.albums.len(), 2);
|
||||
assert!(page1.has_more);
|
||||
|
||||
let page2 = list_lossless_albums(&store, &req("s1", 2, 2)).unwrap();
|
||||
assert_eq!(page2.albums.len(), 1);
|
||||
assert!(!page2.has_more);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Shared lossless container allowlist — keep in sync with
|
||||
//! `src/utils/library/losslessFormats.ts` and `LOSSLESS_SUFFIXES` in
|
||||
//! `src/api/navidromeBrowse.ts`.
|
||||
|
||||
/// File extensions for containers that are *only* lossless (no lossy variant).
|
||||
pub const LOSSLESS_SUFFIXES: &[&str] = &[
|
||||
"flac", "wav", "wave", "aiff", "aif", "dsf", "dff", "ape", "wv", "shn", "tta",
|
||||
];
|
||||
|
||||
/// `LOWER(alias.suffix) IN ('flac', …)` for SQL WHERE clauses.
|
||||
pub fn track_is_lossless_sql(table_alias: &str) -> String {
|
||||
let list = LOSSLESS_SUFFIXES
|
||||
.iter()
|
||||
.map(|s| format!("'{s}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("LOWER({table_alias}.suffix) IN ({list})")
|
||||
}
|
||||
|
||||
/// Album has at least one indexed lossless track (same allowlist as browse).
|
||||
pub fn album_has_lossless_track_sql(album_table_alias: &str) -> String {
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM track lt \
|
||||
WHERE lt.server_id = {album_table_alias}.server_id \
|
||||
AND lt.album_id = {album_table_alias}.id \
|
||||
AND lt.deleted = 0 \
|
||||
AND {})",
|
||||
track_is_lossless_sql("lt")
|
||||
)
|
||||
}
|
||||
|
||||
/// Artist has at least one indexed lossless track credited to `artist_id`.
|
||||
pub fn artist_has_lossless_track_sql(artist_table_alias: &str) -> String {
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM track lt \
|
||||
WHERE lt.server_id = {artist_table_alias}.server_id \
|
||||
AND lt.artist_id = {artist_table_alias}.id \
|
||||
AND lt.deleted = 0 \
|
||||
AND {})",
|
||||
track_is_lossless_sql("lt")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn track_is_lossless_sql_lists_all_suffixes() {
|
||||
let sql = track_is_lossless_sql("t");
|
||||
assert!(sql.contains("'flac'"));
|
||||
assert!(sql.contains("'tta'"));
|
||||
assert!(sql.starts_with("LOWER(t.suffix) IN ("));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user