mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +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:
@@ -102,6 +102,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Lossless — local index browse, filters, and drill-down
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#871](https://github.com/Psychotoxical/psysonic/pull/871)**
|
||||
|
||||
* **Local index:** `library_list_lossless_albums` queries indexed tracks by lossless suffix allowlist; `/lossless-albums` and Home rail use SQLite when the library index is ready, with Navidrome bit_depth walk as fallback.
|
||||
* **Advanced Search:** `lossless is true` on tracks, albums, and artists (local + network); artist/album links open detail with `?lossless=1` and a lossless-mode banner.
|
||||
* **All Albums:** lossless toggle (local index only — plain lossless, year, and genre combinations).
|
||||
* **Sidebar:** dedicated Lossless page route conserved; nav entry hidden by default and removed from visibility settings.
|
||||
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Linux — session GDK, WebKitGTK mitigations, and Wayland text
|
||||
|
||||
@@ -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 ("));
|
||||
}
|
||||
}
|
||||
@@ -701,6 +701,8 @@ pub fn run() {
|
||||
psysonic_library::commands::library_search,
|
||||
psysonic_library::commands::library_live_search,
|
||||
psysonic_library::commands::library_advanced_search,
|
||||
psysonic_library::commands::library_list_lossless_albums,
|
||||
psysonic_library::commands::library_get_artist_lossless_browse,
|
||||
psysonic_library::commands::library_search_cross_server,
|
||||
psysonic_library::commands::library_get_track,
|
||||
psysonic_library::commands::library_get_tracks_batch,
|
||||
|
||||
@@ -375,6 +375,73 @@ export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<Li
|
||||
}));
|
||||
}
|
||||
|
||||
export interface LibraryLosslessAlbumsRequest {
|
||||
serverId: string;
|
||||
libraryScope?: string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface LibraryLosslessAlbumsResponse {
|
||||
albums: LibraryAlbumDto[];
|
||||
hasMore: boolean;
|
||||
source: 'local';
|
||||
}
|
||||
|
||||
/** Paginated lossless albums from the local track index. */
|
||||
export function libraryListLosslessAlbums(
|
||||
request: LibraryLosslessAlbumsRequest,
|
||||
): Promise<LibraryLosslessAlbumsResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryLosslessAlbumsResponse>('library_list_lossless_albums', {
|
||||
request: {
|
||||
serverId: indexKey,
|
||||
libraryScope: request.libraryScope ?? undefined,
|
||||
limit: request.limit,
|
||||
offset: request.offset,
|
||||
},
|
||||
}).then(response => ({
|
||||
...response,
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface LibraryArtistLosslessBrowseRequest {
|
||||
serverId: string;
|
||||
artistId: string;
|
||||
libraryScope?: string | null;
|
||||
}
|
||||
|
||||
export interface LibraryArtistLosslessBrowseResponse {
|
||||
albums: LibraryAlbumDto[];
|
||||
tracks: LibraryTrackDto[];
|
||||
source: 'local';
|
||||
}
|
||||
|
||||
/** Lossless albums + tracks for one artist from the local index. */
|
||||
export function libraryGetArtistLosslessBrowse(
|
||||
request: LibraryArtistLosslessBrowseRequest,
|
||||
): Promise<LibraryArtistLosslessBrowseResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryArtistLosslessBrowseResponse>('library_get_artist_lossless_browse', {
|
||||
request: {
|
||||
serverId: indexKey,
|
||||
artistId: request.artistId,
|
||||
libraryScope: request.libraryScope ?? undefined,
|
||||
},
|
||||
}).then(response => ({
|
||||
...response,
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
tracks: mapTracksServerId(response.tracks, request.serverId),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cross-server FTS union over the given servers, or all `ready` ones (§5.5B). */
|
||||
export function librarySearchCrossServer(args: {
|
||||
query: string;
|
||||
|
||||
@@ -32,6 +32,8 @@ interface AlbumCardProps {
|
||||
displayCssPx?: number;
|
||||
/** @deprecated Use displayCssPx — kept for call-site transition only */
|
||||
artworkSize?: number;
|
||||
/** Appended to `/album/:id`, e.g. `lossless=1`. */
|
||||
linkQuery?: string;
|
||||
/** In-page scroll viewport (`VirtualCardGrid` `scrollRootId`) for cover IO priority. */
|
||||
observeScrollRootId?: string;
|
||||
/** `high` for bounded grids (Random Albums, …) — skip defer-until-visible. */
|
||||
@@ -50,6 +52,7 @@ function AlbumCard({
|
||||
artworkSize: _artworkSize,
|
||||
observeScrollRootId,
|
||||
ensurePriority,
|
||||
linkQuery,
|
||||
}: AlbumCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -72,7 +75,7 @@ function AlbumCard({
|
||||
|
||||
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
||||
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
||||
navigate(`/album/${album.id}`);
|
||||
navigate(linkQuery ? `/album/${album.id}?${linkQuery}` : `/album/${album.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,8 @@ interface Props {
|
||||
artworkSize?: number;
|
||||
windowArtworkByViewport?: boolean;
|
||||
initialArtworkBudget?: number;
|
||||
/** Appended to `/album/:id` links, e.g. `lossless=1`. */
|
||||
albumLinkQuery?: string;
|
||||
}
|
||||
|
||||
export default function AlbumRow({
|
||||
@@ -38,6 +40,7 @@ export default function AlbumRow({
|
||||
artworkSize,
|
||||
windowArtworkByViewport = false,
|
||||
initialArtworkBudget = 8,
|
||||
albumLinkQuery,
|
||||
}: Props) {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
|
||||
@@ -167,6 +170,7 @@ export default function AlbumRow({
|
||||
key={a.id}
|
||||
album={a}
|
||||
showRating={showRating}
|
||||
linkQuery={albumLinkQuery}
|
||||
disableArtwork={
|
||||
artworkDisabled ||
|
||||
(windowArtworkByViewport && idx >= artworkBudget)
|
||||
|
||||
@@ -9,15 +9,18 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
/** Appended to `/artist/:id`, e.g. `lossless=1`. */
|
||||
linkQuery?: string;
|
||||
}
|
||||
|
||||
export default function ArtistCardLocal({ artist }: Props) {
|
||||
export default function ArtistCardLocal({ artist, linkQuery }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
const href = linkQuery ? `/artist/${artist.id}?${linkQuery}` : `/artist/${artist.id}`;
|
||||
|
||||
return (
|
||||
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
|
||||
<div className="artist-card" onClick={() => navigate(href)}>
|
||||
<div className="artist-card-avatar">
|
||||
{artist.coverArt || artist.id ? (
|
||||
<CoverArtImage
|
||||
|
||||
@@ -9,9 +9,10 @@ interface Props {
|
||||
artists: SubsonicArtist[];
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
artistLinkQuery?: string;
|
||||
}
|
||||
|
||||
export default function ArtistRow({ title, artists, moreLink, moreText }: Props) {
|
||||
export default function ArtistRow({ title, artists, moreLink, moreText, artistLinkQuery }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
@@ -54,7 +55,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText }: Props)
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
|
||||
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} linkQuery={artistLinkQuery} />)}
|
||||
{moreLink && (
|
||||
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
|
||||
import AlbumRow from './AlbumRow';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { runLocalLosslessAlbums } from '../utils/library/browseTextSearch';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
|
||||
interface Props {
|
||||
disableArtwork?: boolean;
|
||||
@@ -22,11 +25,20 @@ export default function LosslessAlbumsRail({
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId ?? ''));
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (indexEnabled && activeServerId) {
|
||||
const local = await runLocalLosslessAlbums(activeServerId, TARGET_ALBUMS, 0);
|
||||
if (cancelled) return;
|
||||
if (local && local.albums.length > 0) {
|
||||
setAlbums(local.albums);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const page = await ndListLosslessAlbumsPage({ targetNewAlbums: TARGET_ALBUMS });
|
||||
if (cancelled) return;
|
||||
@@ -36,7 +48,7 @@ export default function LosslessAlbumsRail({
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [activeServerId]);
|
||||
}, [activeServerId, indexEnabled]);
|
||||
|
||||
if (albums.length === 0) return null;
|
||||
|
||||
@@ -49,6 +61,7 @@ export default function LosslessAlbumsRail({
|
||||
artworkSize={artworkSize}
|
||||
windowArtworkByViewport={windowArtworkByViewport}
|
||||
initialArtworkBudget={initialArtworkBudget}
|
||||
albumLinkQuery={LOSSLESS_MODE_QUERY}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Gem } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
export default function LosslessFilterButton({ active, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = active ? t('albums.losslessTooltipOn') : t('albums.losslessTooltipOff');
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
|
||||
onClick={() => onChange(!active)}
|
||||
aria-pressed={active}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem', ...activeStyle,
|
||||
}}
|
||||
>
|
||||
<Gem size={14} />
|
||||
{t('albums.losslessLabel')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Gem } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function LosslessModeBanner() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleExit = () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete('lossless');
|
||||
const qs = params.toString();
|
||||
navigate({ pathname: location.pathname, search: qs ? `?${qs}` : '' }, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lossless-mode-banner" role="status">
|
||||
<Gem size={16} aria-hidden className="lossless-mode-banner__icon" />
|
||||
<span>{t('losslessAlbums.modeBanner')}</span>
|
||||
<button type="button" className="btn btn-ghost lossless-mode-banner__exit" onClick={handleExit}>
|
||||
{t('losslessAlbums.modeBannerExit')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,12 @@ interface Props {
|
||||
topSongs: SubsonicSong[];
|
||||
marginTop: string;
|
||||
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
|
||||
losslessOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function ArtistDetailTopTracks({ topSongs, marginTop, playTopSongWithContinuation }: Props) {
|
||||
export default function ArtistDetailTopTracks({
|
||||
topSongs, marginTop, playTopSongWithContinuation, losslessOnly = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
@@ -27,7 +30,7 @@ export default function ArtistDetailTopTracks({ topSongs, marginTop, playTopSong
|
||||
return (
|
||||
<Fragment>
|
||||
<h2 className="section-title" style={{ marginTop, marginBottom: '1rem' }}>
|
||||
{t('artistDetail.topTracks')}
|
||||
{t(losslessOnly ? 'artistDetail.topTracksLossless' : 'artistDetail.topTracks')}
|
||||
</h2>
|
||||
<div className="tracklist" data-preview-loc="artist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSidebarStore, SidebarItemConfig } from '../../store/sidebarStore';
|
||||
import { useSidebarStore, SidebarItemConfig, CONSERVED_SIDEBAR_NAV_IDS } from '../../store/sidebarStore';
|
||||
import { useLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
|
||||
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||
import { applySidebarDropReorder } from '../../utils/componentHelpers/sidebarNavReorder';
|
||||
@@ -43,6 +43,7 @@ export function SidebarCustomizer() {
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
|
||||
const libraryItems = items.filter(cfg => {
|
||||
if (CONSERVED_SIDEBAR_NAV_IDS.has(cfg.id)) return false;
|
||||
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
|
||||
@@ -131,6 +131,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Server index-key rebuild follow-up: startup-safe migration orchestration, per-server analysis strategy controls, playback/cache scope hardening, and backup/restore for library databases with blocking progress UX (PR #864)',
|
||||
'Live Search: server-scoped local FTS, multi-server hit fix, and local vs search3 race merge (PR #868)',
|
||||
'Cover art pipeline: tier ladder, WebP disk cache, dense-grid prefetch, Settings cover cache budget (PR #869)',
|
||||
'Lossless: local index browse, Advanced Search and All Albums filters, artist/album drill-down mode, conserved sidebar page (PR #871)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,13 @@ import type {
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
|
||||
} from '../api/subsonicTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
|
||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||
|
||||
export interface UseArtistDetailDataOptions {
|
||||
/** When true, albums and top tracks are limited to lossless containers (local index preferred). */
|
||||
losslessOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ArtistDetailDataResult {
|
||||
artist: SubsonicArtist | null;
|
||||
@@ -18,9 +25,27 @@ export interface ArtistDetailDataResult {
|
||||
featuredLoading: boolean;
|
||||
isStarred: boolean;
|
||||
setIsStarred: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
losslessOnly: boolean;
|
||||
}
|
||||
|
||||
export function useArtistDetailData(id: string | undefined): ArtistDetailDataResult {
|
||||
function filterNetworkArtistToLossless(
|
||||
albums: SubsonicAlbum[],
|
||||
songs: SubsonicSong[],
|
||||
): { albums: SubsonicAlbum[]; songs: SubsonicSong[] } {
|
||||
const losslessSongs = songs.filter(s => isLosslessSuffix(s.suffix));
|
||||
const albumIds = new Set(losslessSongs.map(s => s.albumId).filter(Boolean));
|
||||
return {
|
||||
albums: albums.filter(a => albumIds.has(a.id)),
|
||||
songs: losslessSongs,
|
||||
};
|
||||
}
|
||||
|
||||
export function useArtistDetailData(
|
||||
id: string | undefined,
|
||||
options: UseArtistDetailDataOptions = {},
|
||||
): ArtistDetailDataResult {
|
||||
const losslessOnly = options.losslessOnly ?? false;
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||
);
|
||||
@@ -30,9 +55,6 @@ export function useArtistDetailData(id: string | undefined): ArtistDetailDataRes
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
|
||||
// Tuple gates `info` on id-match so a CachedImage-style consumer (shared
|
||||
// ArtistCard) can never see info from a previously-viewed artist paired
|
||||
// with the current `id`. Same pattern as `useNowPlayingFetchers`.
|
||||
const [infoEntry, setInfoEntry] = useState<{ id: string; value: SubsonicArtistInfo | null } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
@@ -46,22 +68,51 @@ export function useArtistDetailData(id: string | undefined): ArtistDetailDataRes
|
||||
setInfoEntry(null);
|
||||
setTopSongs([]);
|
||||
setFeaturedAlbums([]);
|
||||
getArtist(id).then(artistData => {
|
||||
if (cancelled) return;
|
||||
setArtist(artistData.artist);
|
||||
setAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
// Render the page immediately from local data
|
||||
setLoading(false);
|
||||
|
||||
getTopSongs(artistData.artist.name).then(songsData => {
|
||||
if (!cancelled) setTopSongs(songsData ?? []);
|
||||
}).catch(() => {});
|
||||
}).catch(err => {
|
||||
if (!cancelled) { console.error(err); setLoading(false); }
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
if (losslessOnly && serverId) {
|
||||
const local = await runLocalArtistLosslessBrowse(serverId, id);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
const artistData = await getArtist(id).catch(() => null);
|
||||
if (cancelled) return;
|
||||
if (artistData) {
|
||||
setArtist(artistData.artist);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
}
|
||||
setAlbums(local.albums);
|
||||
setTopSongs([...local.songs].sort((a, b) => (b.playCount ?? 0) - (a.playCount ?? 0)));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const artistData = await getArtist(id);
|
||||
if (cancelled) return;
|
||||
setArtist(artistData.artist);
|
||||
let nextAlbums = artistData.albums;
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
setLoading(false);
|
||||
|
||||
const songsData = await getTopSongs(artistData.artist.name).catch(() => [] as SubsonicSong[]);
|
||||
if (cancelled) return;
|
||||
let nextSongs = songsData ?? [];
|
||||
if (losslessOnly) {
|
||||
({ albums: nextAlbums, songs: nextSongs } = filterNetworkArtistToLossless(nextAlbums, nextSongs));
|
||||
}
|
||||
setAlbums(nextAlbums);
|
||||
setTopSongs(nextSongs);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
}, [id, losslessOnly, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -80,7 +131,6 @@ export function useArtistDetailData(id: string | undefined): ArtistDetailDataRes
|
||||
return () => { cancelled = true; };
|
||||
}, [id, audiomuseNavidromeEnabled]);
|
||||
|
||||
// "Also Featured On" — loaded in background after main content renders
|
||||
useEffect(() => {
|
||||
if (!id || !artist) return;
|
||||
const ownAlbumIds = new Set(albums.map(a => a.id));
|
||||
@@ -88,9 +138,12 @@ export function useArtistDetailData(id: string | undefined): ArtistDetailDataRes
|
||||
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
|
||||
.catch(() => ({ songs: [], albums: [], artists: [] }))
|
||||
.then(searchResults => {
|
||||
const featuredSongs = (searchResults.songs ?? []).filter(
|
||||
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
|
||||
let featuredSongs = (searchResults.songs ?? []).filter(
|
||||
song => song.artistId === id && !ownAlbumIds.has(song.albumId),
|
||||
);
|
||||
if (losslessOnly) {
|
||||
featuredSongs = featuredSongs.filter(s => isLosslessSuffix(s.suffix));
|
||||
}
|
||||
const albumMap = new Map<string, SubsonicAlbum>();
|
||||
featuredSongs.forEach(song => {
|
||||
if (!albumMap.has(song.albumId)) {
|
||||
@@ -114,7 +167,7 @@ export function useArtistDetailData(id: string | undefined): ArtistDetailDataRes
|
||||
setFeaturedLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artist?.id, musicLibraryFilterVersion]);
|
||||
}, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums]);
|
||||
|
||||
const info = infoEntry && infoEntry.id === id ? infoEntry.value : null;
|
||||
|
||||
@@ -122,5 +175,6 @@ export function useArtistDetailData(id: string | undefined): ArtistDetailDataRes
|
||||
artist, setArtist, albums, topSongs, info, featuredAlbums,
|
||||
loading, artistInfoLoading, featuredLoading,
|
||||
isStarred, setIsStarred,
|
||||
losslessOnly,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export const albums = {
|
||||
compilationTooltipAll: 'Alle Alben · klicken: nur Sampler',
|
||||
compilationTooltipOnly: 'Nur Sampler · klicken: Sampler ausblenden',
|
||||
compilationTooltipHide: 'Sampler ausgeblendet · klicken: alle zeigen',
|
||||
losslessLabel: 'Lossless',
|
||||
losslessTooltipOn: 'Nur Lossless-Alben · klicken: alle anzeigen',
|
||||
losslessTooltipOff: 'Nur Lossless-Alben anzeigen',
|
||||
select: 'Mehrfachauswahl',
|
||||
startSelect: 'Mehrfachauswahl aktivieren',
|
||||
cancelSelect: 'Abbrechen',
|
||||
@@ -29,4 +32,7 @@ export const albums = {
|
||||
downloadZipFailed: 'Download fehlgeschlagen: {{name}}',
|
||||
offlineQueuing: '{{count}} Album(s) für Offline einreihen…',
|
||||
offlineFailed: '{{name}} konnte nicht offline hinzugefügt werden',
|
||||
noFavorites: 'Keine Lieblingsalben entsprechen den aktuellen Filtern.',
|
||||
noCompilations: 'Keine Sampler entsprechen den aktuellen Filtern.',
|
||||
noMatchingFilters: 'Keine Alben entsprechen den aktuellen Filtern.',
|
||||
};
|
||||
|
||||
@@ -2,4 +2,6 @@ export const losslessAlbums = {
|
||||
empty: 'Noch keine Lossless-Alben in dieser Bibliothek.',
|
||||
unsupported: 'Dieser Server liefert keine Metadaten, mit denen sich Lossless-Alben erkennen lassen.',
|
||||
slowFetchHint: 'Lädt langsamer als andere Album-Seiten — Psysonic geht den gesamten Songkatalog nach Qualität durch.',
|
||||
modeBanner: 'Lossless-Modus — es werden nur FLAC, WAV und andere verlustfreie Formate angezeigt.',
|
||||
modeBannerExit: 'Alle Formate anzeigen',
|
||||
};
|
||||
|
||||
@@ -23,8 +23,9 @@ export const search = {
|
||||
advancedYearFrom: 'von',
|
||||
advancedYearTo: 'bis',
|
||||
advancedBpm: 'BPM',
|
||||
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||
advancedBpmClear: 'BPM-Filter zurücksetzen',
|
||||
advancedLossless: 'Format',
|
||||
advancedLosslessOnly: 'Nur Lossless',
|
||||
bpmSourceTag: 'BPM from file tag',
|
||||
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||
advancedMoodGroup: 'Mood',
|
||||
|
||||
@@ -14,6 +14,9 @@ export const albums = {
|
||||
compilationTooltipAll: 'All albums · click: only compilations',
|
||||
compilationTooltipOnly: 'Only compilations · click: hide compilations',
|
||||
compilationTooltipHide: 'Compilations hidden · click: show all',
|
||||
losslessLabel: 'Lossless',
|
||||
losslessTooltipOn: 'Lossless albums only · click to show all',
|
||||
losslessTooltipOff: 'Show lossless albums only',
|
||||
select: 'Multi-select',
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
@@ -29,4 +32,7 @@ export const albums = {
|
||||
downloadZipFailed: 'Failed to download {{name}}',
|
||||
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
|
||||
offlineFailed: 'Failed to add {{name}} offline',
|
||||
noFavorites: 'No favorite albums match the current filters.',
|
||||
noCompilations: 'No compilations match the current filters.',
|
||||
noMatchingFilters: 'No albums match the current filters.',
|
||||
};
|
||||
|
||||
@@ -10,7 +10,9 @@ export const artistDetail = {
|
||||
noRadio: 'No similar tracks found for this artist.',
|
||||
notFound: 'Artist not found.',
|
||||
albumsBy: 'Albums by {{name}}',
|
||||
albumsByLossless: 'Lossless albums by {{name}}',
|
||||
topTracks: 'Top Tracks',
|
||||
topTracksLossless: 'Lossless Tracks',
|
||||
noAlbums: 'No albums found.',
|
||||
trackTitle: 'Title',
|
||||
trackAlbum: 'Album',
|
||||
|
||||
@@ -2,4 +2,6 @@ export const losslessAlbums = {
|
||||
empty: 'No lossless albums in this library yet.',
|
||||
unsupported: 'This server does not expose the metadata needed to find lossless albums.',
|
||||
slowFetchHint: 'Loads slower than other album pages — Psysonic walks the full song catalog by quality.',
|
||||
modeBanner: 'Lossless mode — showing FLAC, WAV, and other lossless formats only.',
|
||||
modeBannerExit: 'Show all formats',
|
||||
};
|
||||
|
||||
@@ -23,8 +23,9 @@ export const search = {
|
||||
advancedYearFrom: 'from',
|
||||
advancedYearTo: 'to',
|
||||
advancedBpm: 'BPM',
|
||||
advancedBpmLocalNote: 'Measured analysis BPM takes priority over file tags when the local index is enabled',
|
||||
advancedBpmClear: 'Clear BPM filter',
|
||||
advancedLossless: 'Format',
|
||||
advancedLosslessOnly: 'Lossless only',
|
||||
bpmSourceTag: 'BPM from file tag',
|
||||
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||
advancedMoodGroup: 'Mood',
|
||||
|
||||
@@ -14,6 +14,9 @@ export const albums = {
|
||||
compilationTooltipAll: 'Todos los álbumes · clic: solo recopilatorios',
|
||||
compilationTooltipOnly: 'Solo recopilatorios · clic: ocultar recopilatorios',
|
||||
compilationTooltipHide: 'Recopilatorios ocultos · clic: mostrar todo',
|
||||
losslessLabel: 'Lossless',
|
||||
losslessTooltipOn: 'Solo álbumes lossless · clic: mostrar todo',
|
||||
losslessTooltipOff: 'Mostrar solo álbumes lossless',
|
||||
select: 'Selección múltiple',
|
||||
startSelect: 'Activar selección múltiple',
|
||||
cancelSelect: 'Cancelar',
|
||||
@@ -29,5 +32,8 @@ export const albums = {
|
||||
downloadZipFailed: 'Error al descargar {{name}}',
|
||||
offlineQueuing: 'Encolando {{count}} álbum(es) para offline…',
|
||||
offlineFailed: 'Error al agregar {{name}} offline',
|
||||
noFavorites: 'Ningún álbum favorito coincide con los filtros actuales.',
|
||||
noCompilations: 'Ninguna compilación coincide con los filtros actuales.',
|
||||
noMatchingFilters: 'Ningún álbum coincide con los filtros actuales.',
|
||||
addToPlaylist: 'Agregar a Lista de Reproducción',
|
||||
};
|
||||
|
||||
@@ -2,4 +2,6 @@ export const losslessAlbums = {
|
||||
empty: 'Aún no hay álbumes sin pérdidas en esta biblioteca.',
|
||||
unsupported: 'Este servidor no expone los metadatos necesarios para encontrar álbumes sin pérdidas.',
|
||||
slowFetchHint: 'Carga más lento que otras páginas de álbumes — Psysonic recorre todo el catálogo de canciones por calidad.',
|
||||
modeBanner: 'Modo sin pérdidas — solo se muestran FLAC, WAV y otros formatos lossless.',
|
||||
modeBannerExit: 'Mostrar todos los formatos',
|
||||
};
|
||||
|
||||
@@ -23,8 +23,9 @@ export const search = {
|
||||
advancedYearFrom: 'desde',
|
||||
advancedYearTo: 'hasta',
|
||||
advancedBpm: 'BPM',
|
||||
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||
advancedBpmClear: 'Limpiar filtro BPM',
|
||||
advancedLossless: 'Formato',
|
||||
advancedLosslessOnly: 'Solo sin pérdida',
|
||||
bpmSourceTag: 'BPM from file tag',
|
||||
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||
advancedMoodGroup: 'Mood',
|
||||
|
||||
@@ -14,6 +14,9 @@ export const albums = {
|
||||
compilationTooltipAll: 'Tous les albums · clic : uniquement compilations',
|
||||
compilationTooltipOnly: 'Uniquement compilations · clic : masquer compilations',
|
||||
compilationTooltipHide: 'Compilations masquées · clic : tout afficher',
|
||||
losslessLabel: 'Lossless',
|
||||
losslessTooltipOn: 'Albums lossless uniquement · clic : tout afficher',
|
||||
losslessTooltipOff: 'Afficher les albums lossless uniquement',
|
||||
select: 'Sélection multiple',
|
||||
startSelect: 'Activer la sélection multiple',
|
||||
cancelSelect: 'Annuler',
|
||||
@@ -29,4 +32,7 @@ export const albums = {
|
||||
downloadZipFailed: 'Échec du téléchargement de {{name}}',
|
||||
offlineQueuing: 'Mise en file d\'attente de {{count}} album(s) hors ligne…',
|
||||
offlineFailed: 'Échec de l\'ajout de {{name}} hors ligne',
|
||||
noFavorites: 'Aucun album favori ne correspond aux filtres actuels.',
|
||||
noCompilations: 'Aucune compilation ne correspond aux filtres actuels.',
|
||||
noMatchingFilters: 'Aucun album ne correspond aux filtres actuels.',
|
||||
};
|
||||
|
||||
@@ -2,4 +2,6 @@ export const losslessAlbums = {
|
||||
empty: 'Aucun album lossless dans cette bibliothèque pour l\'instant.',
|
||||
unsupported: 'Ce serveur n\'expose pas les métadonnées nécessaires pour trouver des albums lossless.',
|
||||
slowFetchHint: 'Chargement plus lent que les autres pages d\'albums — Psysonic parcourt tout le catalogue par qualité.',
|
||||
modeBanner: 'Mode lossless — affichage de FLAC, WAV et autres formats sans perte uniquement.',
|
||||
modeBannerExit: 'Afficher tous les formats',
|
||||
};
|
||||
|
||||
@@ -23,8 +23,9 @@ export const search = {
|
||||
advancedYearFrom: 'de',
|
||||
advancedYearTo: 'à',
|
||||
advancedBpm: 'BPM',
|
||||
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||
advancedBpmClear: 'Effacer le filtre BPM',
|
||||
advancedLossless: 'Format',
|
||||
advancedLosslessOnly: 'Lossless uniquement',
|
||||
bpmSourceTag: 'BPM from file tag',
|
||||
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||
advancedMoodGroup: 'Mood',
|
||||
|
||||
@@ -14,6 +14,9 @@ export const albums = {
|
||||
compilationTooltipAll: 'Alle album · klikk: kun samleplater',
|
||||
compilationTooltipOnly: 'Kun samleplater · klikk: skjul samleplater',
|
||||
compilationTooltipHide: 'Samleplater skjult · klikk: vis alle',
|
||||
losslessLabel: 'Lossless',
|
||||
losslessTooltipOn: 'Kun lossless-album · klikk: vis alle',
|
||||
losslessTooltipOff: 'Vis kun lossless-album',
|
||||
select: 'Multivalg',
|
||||
startSelect: 'Aktiver multivalg',
|
||||
cancelSelect: 'Avbryt',
|
||||
@@ -29,4 +32,7 @@ export const albums = {
|
||||
downloadZipFailed: 'Kunne ikke laste ned {{name}}',
|
||||
offlineQueuing: 'Legger {{count}} album i kø for offline…',
|
||||
offlineFailed: 'Kunne ikke legge til {{name}} offline',
|
||||
noFavorites: 'Ingen favorittalbum samsvarer med gjeldende filtre.',
|
||||
noCompilations: 'Ingen samleplater samsvarer med gjeldende filtre.',
|
||||
noMatchingFilters: 'Ingen album samsvarer med gjeldende filtre.',
|
||||
};
|
||||
|
||||
@@ -2,4 +2,6 @@ export const losslessAlbums = {
|
||||
empty: 'Ingen lossless-album i dette biblioteket ennå.',
|
||||
unsupported: 'Denne serveren eksponerer ikke metadata som trengs for å finne lossless-album.',
|
||||
slowFetchHint: 'Lastes saktere enn andre albumsider — Psysonic går gjennom hele sangkatalogen etter kvalitet.',
|
||||
modeBanner: 'Lossless-modus — viser kun FLAC, WAV og andre tapsfrie formater.',
|
||||
modeBannerExit: 'Vis alle formater',
|
||||
};
|
||||
|
||||
@@ -23,8 +23,9 @@ export const search = {
|
||||
advancedYearFrom: 'fra',
|
||||
advancedYearTo: 'til',
|
||||
advancedBpm: 'BPM',
|
||||
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||
advancedBpmClear: 'Tøm BPM-filter',
|
||||
advancedLossless: 'Format',
|
||||
advancedLosslessOnly: 'Kun lossless',
|
||||
bpmSourceTag: 'BPM from file tag',
|
||||
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||
advancedMoodGroup: 'Mood',
|
||||
|
||||
@@ -14,6 +14,9 @@ export const albums = {
|
||||
compilationTooltipAll: 'Alle albums · klik: alleen compilaties',
|
||||
compilationTooltipOnly: 'Alleen compilaties · klik: compilaties verbergen',
|
||||
compilationTooltipHide: 'Compilaties verborgen · klik: toon alles',
|
||||
losslessLabel: 'Lossless',
|
||||
losslessTooltipOn: 'Alleen lossless-albums · klik: toon alles',
|
||||
losslessTooltipOff: 'Alleen lossless-albums tonen',
|
||||
select: 'Meervoudige selectie',
|
||||
startSelect: 'Meervoudige selectie inschakelen',
|
||||
cancelSelect: 'Annuleren',
|
||||
@@ -29,4 +32,7 @@ export const albums = {
|
||||
downloadZipFailed: 'Downloaden van {{name}} mislukt',
|
||||
offlineQueuing: '{{count}} album(s) in wachtrij voor offline…',
|
||||
offlineFailed: 'Toevoegen van {{name}} offline mislukt',
|
||||
noFavorites: 'Geen favoriete albums komen overeen met de huidige filters.',
|
||||
noCompilations: 'Geen compilaties komen overeen met de huidige filters.',
|
||||
noMatchingFilters: 'Geen albums komen overeen met de huidige filters.',
|
||||
};
|
||||
|
||||
@@ -2,4 +2,6 @@ export const losslessAlbums = {
|
||||
empty: 'Nog geen lossless-albums in deze bibliotheek.',
|
||||
unsupported: 'Deze server biedt geen metadata om lossless-albums te vinden.',
|
||||
slowFetchHint: 'Laadt langzamer dan andere albumpagina\'s — Psysonic doorloopt de volledige songcatalogus op kwaliteit.',
|
||||
modeBanner: 'Lossless-modus — alleen FLAC, WAV en andere verliesvrije formaten.',
|
||||
modeBannerExit: 'Alle formaten tonen',
|
||||
};
|
||||
|
||||
@@ -23,8 +23,9 @@ export const search = {
|
||||
advancedYearFrom: 'van',
|
||||
advancedYearTo: 'tot',
|
||||
advancedBpm: 'BPM',
|
||||
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||
advancedBpmClear: 'BPM-filter wissen',
|
||||
advancedLossless: 'Formaat',
|
||||
advancedLosslessOnly: 'Alleen lossless',
|
||||
bpmSourceTag: 'BPM from file tag',
|
||||
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||
advancedMoodGroup: 'Mood',
|
||||
|
||||
@@ -14,6 +14,9 @@ export const albums = {
|
||||
compilationTooltipAll: 'Toate albumele · clic: doar compilații',
|
||||
compilationTooltipOnly: 'Doar compilații · clic: ascunde compilațiile',
|
||||
compilationTooltipHide: 'Compilații ascunse · clic: arată toate',
|
||||
losslessLabel: 'Lossless',
|
||||
losslessTooltipOn: 'Doar albume lossless · clic: arată toate',
|
||||
losslessTooltipOff: 'Arată doar albume lossless',
|
||||
select: 'Multi-selecție',
|
||||
startSelect: 'Activează multi-selecția',
|
||||
cancelSelect: 'Anulează',
|
||||
@@ -29,4 +32,7 @@ export const albums = {
|
||||
downloadZipFailed: 'Nu s-a reușit descărcarea {{name}}',
|
||||
offlineQueuing: 'Se adaugă în coadă {{count}} album(e) pentru offline…',
|
||||
offlineFailed: 'Nu s-a reușit adăugarea {{name}} offline',
|
||||
noFavorites: 'Niciun album favorit nu corespunde filtrelor curente.',
|
||||
noCompilations: 'Nicio compilație nu corespunde filtrelor curente.',
|
||||
noMatchingFilters: 'Niciun album nu corespunde filtrelor curente.',
|
||||
};
|
||||
|
||||
@@ -2,4 +2,6 @@ export const losslessAlbums = {
|
||||
empty: 'Niciun album lossless în această librărie încă.',
|
||||
unsupported: 'Acest server nu expune metadata necesară pentru a găsi albumele lossless.',
|
||||
slowFetchHint: 'Se încarcă mai încet decât alte pagini de album — Psysonic parcurge prin tot catalogul pieselor după calitate.',
|
||||
modeBanner: 'Mod lossless — se afișează doar FLAC, WAV și alte formate fără pierderi.',
|
||||
modeBannerExit: 'Afișează toate formatele',
|
||||
};
|
||||
|
||||
@@ -23,8 +23,9 @@ export const search = {
|
||||
advancedYearFrom: 'de la',
|
||||
advancedYearTo: 'până la',
|
||||
advancedBpm: 'BPM',
|
||||
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||
advancedBpmClear: 'Golește filtrul BPM',
|
||||
advancedLossless: 'Format',
|
||||
advancedLosslessOnly: 'Doar lossless',
|
||||
bpmSourceTag: 'BPM from file tag',
|
||||
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||
advancedMoodGroup: 'Mood',
|
||||
|
||||
@@ -14,6 +14,9 @@ export const albums = {
|
||||
compilationTooltipAll: 'Все альбомы · клик: только сборники',
|
||||
compilationTooltipOnly: 'Только сборники · клик: скрыть сборники',
|
||||
compilationTooltipHide: 'Сборники скрыты · клик: показать всё',
|
||||
losslessLabel: 'Lossless',
|
||||
losslessTooltipOn: 'Только lossless-альбомы · клик: показать все',
|
||||
losslessTooltipOff: 'Показать только lossless-альбомы',
|
||||
select: 'Множественный выбор',
|
||||
startSelect: 'Включить множественный выбор',
|
||||
cancelSelect: 'Отмена',
|
||||
@@ -33,4 +36,7 @@ export const albums = {
|
||||
downloadZipFailed: 'Не удалось скачать {{name}}',
|
||||
offlineQueuing: 'Добавление {{count}} альбом(ов) в офлайн…',
|
||||
offlineFailed: 'Не удалось добавить {{name}} офлайн',
|
||||
noFavorites: 'Нет избранных альбомов с текущими фильтрами.',
|
||||
noCompilations: 'Нет сборников с текущими фильтрами.',
|
||||
noMatchingFilters: 'Нет альбомов с текущими фильтрами.',
|
||||
};
|
||||
|
||||
@@ -10,7 +10,9 @@ export const artistDetail = {
|
||||
noRadio: 'Похожие треки для этого исполнителя не найдены.',
|
||||
notFound: 'Исполнитель не найден.',
|
||||
albumsBy: 'Альбомы — {{name}}',
|
||||
albumsByLossless: 'Lossless-альбомы — {{name}}',
|
||||
topTracks: 'Популярные треки',
|
||||
topTracksLossless: 'Lossless-треки',
|
||||
noAlbums: 'Альбомов нет.',
|
||||
trackTitle: 'Название',
|
||||
trackAlbum: 'Альбом',
|
||||
|
||||
@@ -2,4 +2,6 @@ export const losslessAlbums = {
|
||||
empty: 'В этой библиотеке пока нет lossless-альбомов.',
|
||||
unsupported: 'Этот сервер не предоставляет метаданные, необходимые для поиска lossless-альбомов.',
|
||||
slowFetchHint: 'Загружается медленнее других страниц альбомов — Psysonic проходит весь каталог песен по качеству.',
|
||||
modeBanner: 'Режим lossless — показаны только FLAC, WAV и другие форматы без потерь.',
|
||||
modeBannerExit: 'Показать все форматы',
|
||||
};
|
||||
|
||||
@@ -23,8 +23,9 @@ export const search = {
|
||||
advancedYearFrom: 'от',
|
||||
advancedYearTo: 'до',
|
||||
advancedBpm: 'BPM',
|
||||
advancedBpmLocalNote: 'Тег BPM и измеренный анализ — при включённом локальном индексе; анализ приоритетнее тега',
|
||||
advancedBpmClear: 'Сбросить BPM',
|
||||
advancedLossless: 'Формат',
|
||||
advancedLosslessOnly: 'Только lossless',
|
||||
bpmSourceTag: 'BPM из тега файла',
|
||||
bpmSourceAnalysis: 'BPM из анализа аудио',
|
||||
advancedMoodGroup: 'Настроение',
|
||||
|
||||
@@ -14,6 +14,9 @@ export const albums = {
|
||||
compilationTooltipAll: '所有专辑 · 点击:仅合辑',
|
||||
compilationTooltipOnly: '仅合辑 · 点击:隐藏合辑',
|
||||
compilationTooltipHide: '已隐藏合辑 · 点击:显示全部',
|
||||
losslessLabel: '无损',
|
||||
losslessTooltipOn: '仅无损专辑 · 点击:显示全部',
|
||||
losslessTooltipOff: '仅显示无损专辑',
|
||||
select: '多选',
|
||||
startSelect: '启用多选',
|
||||
cancelSelect: '取消',
|
||||
@@ -29,4 +32,7 @@ export const albums = {
|
||||
downloadZipFailed: '下载 {{name}} 失败',
|
||||
offlineQueuing: '正在将 {{count}} 张专辑加入离线队列…',
|
||||
offlineFailed: '添加 {{name}} 离线失败',
|
||||
noFavorites: '没有符合当前筛选条件的收藏专辑。',
|
||||
noCompilations: '没有符合当前筛选条件的合辑。',
|
||||
noMatchingFilters: '没有符合当前筛选条件的专辑。',
|
||||
};
|
||||
|
||||
@@ -2,4 +2,6 @@ export const losslessAlbums = {
|
||||
empty: '此媒体库中还没有无损专辑。',
|
||||
unsupported: '此服务器未公开查找无损专辑所需的元数据。',
|
||||
slowFetchHint: '加载比其他专辑页面慢 — Psysonic 按音质遍历整个歌曲目录。',
|
||||
modeBanner: '无损模式 — 仅显示 FLAC、WAV 等无损格式。',
|
||||
modeBannerExit: '显示所有格式',
|
||||
};
|
||||
|
||||
@@ -23,8 +23,9 @@ export const search = {
|
||||
advancedYearFrom: '从',
|
||||
advancedYearTo: '至',
|
||||
advancedBpm: 'BPM',
|
||||
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||
advancedBpmClear: '清除 BPM 筛选',
|
||||
advancedLossless: '格式',
|
||||
advancedLosslessOnly: '仅无损',
|
||||
bpmSourceTag: 'BPM from file tag',
|
||||
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||
advancedMoodGroup: 'Mood',
|
||||
|
||||
@@ -14,6 +14,8 @@ import StarFilterButton from '../components/StarFilterButton';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { runLocalAdvancedSearch, loadMoreLocalSongs, runNetworkAdvancedTextSearch } from '../utils/library/advancedSearchLocal';
|
||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from '../utils/library/trackEnrichment';
|
||||
import { raceSearchSources } from '../utils/library/searchRace';
|
||||
import { logLibrarySearch } from '../utils/library/libraryDevLog';
|
||||
@@ -32,6 +34,7 @@ interface SearchOpts {
|
||||
bpmFrom: string;
|
||||
bpmTo: string;
|
||||
moodGroup: string;
|
||||
losslessOnly: boolean;
|
||||
resultType: ResultType;
|
||||
}
|
||||
|
||||
@@ -59,6 +62,7 @@ export default function AdvancedSearch() {
|
||||
const [bpmFrom, setBpmFrom] = useState('');
|
||||
const [bpmTo, setBpmTo] = useState('');
|
||||
const [moodGroup, setMoodGroup] = useState('');
|
||||
const [losslessOnly, setLosslessOnly] = useState(false);
|
||||
const [resultType, setResultType] = useState<ResultType>('all');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
@@ -104,6 +108,7 @@ export default function AdvancedSearch() {
|
||||
to: number | null,
|
||||
bpmLo: number | null,
|
||||
bpmHi: number | null,
|
||||
lossless = false,
|
||||
): SubsonicSong[] => {
|
||||
let r = list;
|
||||
if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
|
||||
@@ -111,6 +116,7 @@ export default function AdvancedSearch() {
|
||||
if (to !== null) r = r.filter(s => !s.year || s.year <= to);
|
||||
if (bpmLo !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm >= bpmLo);
|
||||
if (bpmHi !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm <= bpmHi);
|
||||
if (lossless) r = r.filter(s => isLosslessSuffix(s.suffix));
|
||||
return r;
|
||||
};
|
||||
|
||||
@@ -129,10 +135,12 @@ export default function AdvancedSearch() {
|
||||
const searchT0 = performance.now();
|
||||
const moodFilterActive = MOOD_UI_ENABLED && !!opts.moodGroup;
|
||||
const bpmFilterActive = !!(opts.bpmFrom || opts.bpmTo);
|
||||
const losslessFilterActive = opts.losslessOnly;
|
||||
const trackOnlyFilterActive = moodFilterActive || bpmFilterActive;
|
||||
|
||||
// Track-only filters (BPM dual-storage, mood) need the local index for full coverage.
|
||||
if (q && serverId && indexEnabled && !trackOnlyFilterActive) {
|
||||
// Lossless skips the race — network search3 cannot filter albums by format reliably.
|
||||
if (q && serverId && indexEnabled && !trackOnlyFilterActive && !losslessFilterActive) {
|
||||
try {
|
||||
const winner = await raceSearchSources(
|
||||
[
|
||||
@@ -205,13 +213,13 @@ export default function AdvancedSearch() {
|
||||
setLocalMode(false);
|
||||
}
|
||||
|
||||
if (trackOnlyFilterActive && !indexEnabled) {
|
||||
if ((trackOnlyFilterActive || losslessFilterActive) && !indexEnabled) {
|
||||
setResults({ artists: [], albums: [], songs: [] });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { genre: g, yearFrom: yf, yearTo: yt, bpmFrom: bf, bpmTo: bt, resultType: rt } = opts;
|
||||
const { genre: g, yearFrom: yf, yearTo: yt, bpmFrom: bf, bpmTo: bt, losslessOnly: lossless, resultType: rt } = opts;
|
||||
const from = yf ? parseInt(yf) : null;
|
||||
const to = yt ? parseInt(yt) : null;
|
||||
const bpmLo = bf ? parseInt(bf) : null;
|
||||
@@ -226,7 +234,7 @@ export default function AdvancedSearch() {
|
||||
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL });
|
||||
artists = r.artists;
|
||||
albums = r.albums;
|
||||
songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi);
|
||||
songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi, lossless);
|
||||
|
||||
if (g) {
|
||||
albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
|
||||
@@ -237,6 +245,12 @@ export default function AdvancedSearch() {
|
||||
if (to !== null) {
|
||||
albums = albums.filter(a => !a.year || a.year <= to);
|
||||
}
|
||||
if (lossless) {
|
||||
const albumIds = new Set(songs.map(s => s.albumId).filter(Boolean));
|
||||
albums = albums.filter(a => albumIds.has(a.id));
|
||||
const artistIds = new Set(songs.map(s => s.artistId).filter(Boolean));
|
||||
artists = artists.filter(a => artistIds.has(a.id));
|
||||
}
|
||||
|
||||
// Only the free-text branch supports server-side pagination via search3 offset.
|
||||
// If the server returned a full page, more probably exist.
|
||||
@@ -249,7 +263,7 @@ export default function AdvancedSearch() {
|
||||
]);
|
||||
albums = albumRes as SubsonicAlbum[];
|
||||
songs = songRes as SubsonicSong[];
|
||||
songs = applySongFilters(songs, g, from, to, bpmLo, bpmHi);
|
||||
songs = applySongFilters(songs, g, from, to, bpmLo, bpmHi, lossless);
|
||||
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
|
||||
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
||||
if (songs.length > 0) setGenreNote(true);
|
||||
@@ -300,6 +314,7 @@ export default function AdvancedSearch() {
|
||||
bpmFrom: '',
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
losslessOnly: false,
|
||||
resultType: 'all',
|
||||
});
|
||||
}
|
||||
@@ -335,7 +350,15 @@ export default function AdvancedSearch() {
|
||||
const bpmLo = activeSearch.bpmFrom ? parseInt(activeSearch.bpmFrom) : null;
|
||||
const bpmHi = activeSearch.bpmTo ? parseInt(activeSearch.bpmTo) : null;
|
||||
const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset);
|
||||
const filtered = applySongFilters(page, g, from, to, bpmLo, bpmHi);
|
||||
const filtered = applySongFilters(
|
||||
page,
|
||||
g,
|
||||
from,
|
||||
to,
|
||||
bpmLo,
|
||||
bpmHi,
|
||||
activeSearch.losslessOnly,
|
||||
);
|
||||
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev);
|
||||
setSongsServerOffset(o => o + page.length);
|
||||
// No more pages when the server returned a non-full page (regardless of how many survived filtering).
|
||||
@@ -368,6 +391,7 @@ export default function AdvancedSearch() {
|
||||
bpmFrom,
|
||||
bpmTo,
|
||||
moodGroup,
|
||||
losslessOnly,
|
||||
resultType: effectiveType,
|
||||
});
|
||||
};
|
||||
@@ -464,7 +488,7 @@ export default function AdvancedSearch() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 3: BPM (tag + measured enrichment via local index) */}
|
||||
{/* Row 3: BPM (tag + measured enrichment) */}
|
||||
{indexEnabled && (
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||||
@@ -519,9 +543,32 @@ export default function AdvancedSearch() {
|
||||
{t('search.advancedBpmClear')}
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('search.advancedBpmLocalNote')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lossless — suffix allowlist (FLAC, WAV, …) */}
|
||||
{indexEnabled && (
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||||
{t('search.advancedLossless')}
|
||||
</span>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.45rem',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={losslessOnly}
|
||||
onChange={e => setLosslessOnly(e.target.checked)}
|
||||
/>
|
||||
{t('search.advancedLosslessOnly')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -594,6 +641,7 @@ export default function AdvancedSearch() {
|
||||
<ArtistRow
|
||||
title={`${t('search.artists')} (${filteredResults.artists.length})`}
|
||||
artists={filteredResults.artists}
|
||||
artistLinkQuery={activeSearch?.losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -601,6 +649,7 @@ export default function AdvancedSearch() {
|
||||
<AlbumRow
|
||||
title={`${t('search.albums')} (${filteredResults.albums.length})`}
|
||||
albums={filteredResults.albums}
|
||||
albumLinkQuery={activeSearch?.losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+26
-13
@@ -6,7 +6,7 @@ import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -31,12 +31,17 @@ import { deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArt
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import LosslessModeBanner from '../components/LosslessModeBanner';
|
||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||
import { isLosslessMode } from '../utils/library/losslessMode';
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const losslessOnly = isLosslessMode(searchParams);
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
@@ -79,10 +84,16 @@ export default function AlbumDetail() {
|
||||
if (album && album.album.id === id) setAlbumEntityRating(album.album.userRating ?? 0);
|
||||
}, [id, album?.album.id, album?.album.userRating]);
|
||||
|
||||
const effectiveSongs = useMemo(() => {
|
||||
if (!album?.songs) return undefined;
|
||||
if (!losslessOnly) return album.songs;
|
||||
return album.songs.filter(s => isLosslessSuffix(s.suffix));
|
||||
}, [album?.songs, losslessOnly]);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (!album) return;
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
@@ -91,9 +102,9 @@ const handlePlayAll = () => {
|
||||
};
|
||||
|
||||
const handleEnqueueAll = () => {
|
||||
if (!album) return;
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
@@ -102,9 +113,9 @@ const handleEnqueueAll = () => {
|
||||
};
|
||||
|
||||
const handleShuffleAll = () => {
|
||||
if (!album) return;
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
@@ -117,9 +128,9 @@ const handleShuffleAll = () => {
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
if (orbitActive) { queueHint(); return; }
|
||||
if (!album) return;
|
||||
if (!album || !effectiveSongs) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const tracks = effectiveSongs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
@@ -230,8 +241,8 @@ const handleShuffleAll = () => {
|
||||
// If we can't check, proceed anyway
|
||||
}
|
||||
setOfflineStorageFull(false);
|
||||
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, album.songs, serverId);
|
||||
}, [album, auth.maxCacheMb, downloadAlbum, serverId]);
|
||||
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, effectiveSongs ?? album.songs, serverId);
|
||||
}, [album, auth.maxCacheMb, downloadAlbum, serverId, effectiveSongs]);
|
||||
|
||||
const handleRemoveOffline = () => {
|
||||
if (!album) return;
|
||||
@@ -245,7 +256,7 @@ const handleShuffleAll = () => {
|
||||
]), [starredSongs, starredOverrides]);
|
||||
|
||||
const { sortKey, sortDir, handleSort, displayedSongs } = useAlbumDetailSort({
|
||||
songs: album?.songs,
|
||||
songs: effectiveSongs,
|
||||
filterText,
|
||||
starredSongs: mergedStarredSongs,
|
||||
ratings,
|
||||
@@ -271,7 +282,8 @@ const handleShuffleAll = () => {
|
||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||
|
||||
const { album: info, songs } = album;
|
||||
const { album: info } = album;
|
||||
const songs = effectiveSongs ?? [];
|
||||
const headerArtistRefs = deriveAlbumHeaderArtistRefs(info, songs);
|
||||
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
|
||||
|
||||
@@ -302,6 +314,7 @@ const handleShuffleAll = () => {
|
||||
onEntityRatingChange={handleAlbumEntityRating}
|
||||
entityRatingSupport={albumEntityRatingSupport}
|
||||
/>
|
||||
{losslessOnly && <LosslessModeBanner />}
|
||||
{offlineStorageFull && (
|
||||
<div className="offline-storage-full-banner" role="alert">
|
||||
<span>{t('albumDetail.offlineStorageFull', { mb: auth.maxCacheMb })}</span>
|
||||
|
||||
+112
-9
@@ -15,6 +15,7 @@ import { computeCardGridColumnCount } from '../utils/cardGridLayout';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import YearFilterButton from '../components/YearFilterButton';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
import LosslessFilterButton from '../components/LosslessFilterButton';
|
||||
import SortDropdown from '../components/SortDropdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
@@ -35,8 +36,10 @@ import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import {
|
||||
runLocalAlbumBrowsePage,
|
||||
runLocalAlbumsByGenres,
|
||||
runLocalLosslessAlbums,
|
||||
type AlbumBrowseSort,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
|
||||
type SortType = AlbumBrowseSort;
|
||||
type CompFilter = 'all' | 'only' | 'hide';
|
||||
@@ -72,6 +75,7 @@ export default function Albums() {
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const [compFilter, setCompFilter] = useState<CompFilter>('all');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [losslessOnly, setLosslessOnly] = useState(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const gridMeasureRef = useRef<HTMLDivElement>(null);
|
||||
const maxGridCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns));
|
||||
@@ -90,6 +94,7 @@ export default function Albums() {
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const clientFilterActive = starredOnly || compFilter !== 'all';
|
||||
const visibleAlbums = useMemo(() => {
|
||||
let out = albums;
|
||||
if (compFilter === 'only') out = out.filter(a => a.isCompilation);
|
||||
@@ -179,6 +184,15 @@ export default function Albums() {
|
||||
const toNum = parseInt(yearTo, 10);
|
||||
const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
|
||||
|
||||
const pendingClientFilterMatch =
|
||||
clientFilterActive && visibleAlbums.length === 0 && hasMore && !genreFiltered;
|
||||
|
||||
const visibleEmptyMessage = useMemo(() => {
|
||||
if (starredOnly) return t('albums.noFavorites');
|
||||
if (compFilter === 'only') return t('albums.noCompilations');
|
||||
return t('albums.noMatchingFilters');
|
||||
}, [starredOnly, compFilter, t]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = gridMeasureRef.current;
|
||||
if (!el) return;
|
||||
@@ -210,6 +224,7 @@ export default function Albums() {
|
||||
yearTo,
|
||||
compFilter,
|
||||
starredOnly,
|
||||
losslessOnly,
|
||||
selectionMode,
|
||||
selectedGenres,
|
||||
]);
|
||||
@@ -219,9 +234,47 @@ export default function Albums() {
|
||||
offset: number,
|
||||
append = false,
|
||||
yearFilter?: { from: number; to: number },
|
||||
lossless = false,
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (lossless) {
|
||||
if (!indexEnabled || !serverId) {
|
||||
setAlbums([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
if (!yearFilter) {
|
||||
const page = await runLocalLosslessAlbums(serverId, PAGE_SIZE, offset);
|
||||
if (!page) {
|
||||
setAlbums([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
if (append) setAlbums(prev => dedupeById([...prev, ...page.albums]));
|
||||
else setAlbums(page.albums);
|
||||
setHasMore(page.hasMore);
|
||||
return;
|
||||
}
|
||||
const data = await runLocalAlbumBrowsePage(
|
||||
serverId,
|
||||
sortType,
|
||||
offset,
|
||||
PAGE_SIZE,
|
||||
yearFilter,
|
||||
true,
|
||||
);
|
||||
if (data == null) {
|
||||
setAlbums([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
if (append) setAlbums(prev => [...prev, ...data]);
|
||||
else setAlbums(data);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
let data: SubsonicAlbum[] | null = null;
|
||||
if (indexEnabled && serverId) {
|
||||
data = await runLocalAlbumBrowsePage(
|
||||
@@ -230,6 +283,7 @@ export default function Albums() {
|
||||
offset,
|
||||
PAGE_SIZE,
|
||||
yearFilter,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (data == null) {
|
||||
@@ -245,9 +299,25 @@ export default function Albums() {
|
||||
}
|
||||
}, [musicLibraryFilterVersion, indexEnabled, serverId]);
|
||||
|
||||
const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => {
|
||||
const loadFiltered = useCallback(async (
|
||||
genres: string[],
|
||||
sortType: SortType,
|
||||
lossless: boolean,
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (lossless) {
|
||||
if (!indexEnabled || !serverId) {
|
||||
setAlbums([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
const data = await runLocalAlbumsByGenres(serverId, genres, sortType, undefined, true);
|
||||
setAlbums(data ?? []);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let data: SubsonicAlbum[] | null = null;
|
||||
if (indexEnabled && serverId) {
|
||||
data = await runLocalAlbumsByGenres(serverId, genres, sortType);
|
||||
@@ -270,21 +340,36 @@ export default function Albums() {
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
if (genreFiltered) {
|
||||
loadFiltered(selectedGenres, sort);
|
||||
loadFiltered(selectedGenres, sort, losslessOnly);
|
||||
} else if (yearActive) {
|
||||
load(sort, 0, false, { from: fromNum, to: toNum });
|
||||
load(sort, 0, false, { from: fromNum, to: toNum }, losslessOnly);
|
||||
} else {
|
||||
load(sort, 0);
|
||||
load(sort, 0, false, undefined, losslessOnly);
|
||||
}
|
||||
}, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, load, loadFiltered]);
|
||||
}, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, losslessOnly, load, loadFiltered]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore || genreFiltered) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
const yf = yearActive ? { from: fromNum, to: toNum } : undefined;
|
||||
load(sort, next * PAGE_SIZE, true, yf);
|
||||
}, [loading, hasMore, page, sort, load, genreFiltered, yearActive, fromNum, toNum]);
|
||||
load(sort, next * PAGE_SIZE, true, yf, losslessOnly);
|
||||
}, [
|
||||
loading,
|
||||
hasMore,
|
||||
page,
|
||||
sort,
|
||||
load,
|
||||
genreFiltered,
|
||||
yearActive,
|
||||
fromNum,
|
||||
toNum,
|
||||
losslessOnly,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!indexEnabled && losslessOnly) setLosslessOnly(false);
|
||||
}, [indexEnabled, losslessOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = observerTarget.current;
|
||||
@@ -301,6 +386,11 @@ export default function Albums() {
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, scrollBodyEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingClientFilterMatch || loading) return;
|
||||
loadMore();
|
||||
}, [pendingClientFilterMatch, loading, loadMore]);
|
||||
|
||||
const sortOptions: { value: SortType; label: string }[] = [
|
||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
||||
@@ -352,6 +442,10 @@ export default function Albums() {
|
||||
|
||||
<StarFilterButton active={starredOnly} onChange={setStarredOnly} />
|
||||
|
||||
{indexEnabled && (
|
||||
<LosslessFilterButton active={losslessOnly} onChange={setLosslessOnly} />
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
|
||||
onClick={cycleCompFilter}
|
||||
@@ -406,14 +500,22 @@ export default function Albums() {
|
||||
perfFlags.disableMainstageVirtualLists,
|
||||
]}
|
||||
>
|
||||
{loading && albums.length === 0 ? (
|
||||
{(loading && albums.length === 0) || pendingClientFilterMatch ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !starredOnly && compFilter === 'all' ? (
|
||||
) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !clientFilterActive && !losslessOnly ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('common.libraryEmpty')}
|
||||
</div>
|
||||
) : !loading && albums.length === 0 && losslessOnly ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('losslessAlbums.empty')}
|
||||
</div>
|
||||
) : !loading && visibleAlbums.length === 0 && clientFilterActive ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{visibleEmptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!perfFlags.disableMainstageGridCards && (
|
||||
@@ -433,6 +535,7 @@ export default function Albums() {
|
||||
<AlbumCard
|
||||
album={a}
|
||||
displayCssPx={albumCellDisplayCssPx}
|
||||
linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Square, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronRight, ChevronUp, Share2, AudioLines } from 'lucide-react';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
@@ -37,21 +37,25 @@ import ArtistDetailHero from '../components/artistDetail/ArtistDetailHero';
|
||||
import ArtistDetailTopTracks from '../components/artistDetail/ArtistDetailTopTracks';
|
||||
import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailSimilarArtists';
|
||||
import ArtistCard from '../components/nowPlaying/ArtistCard';
|
||||
import LosslessModeBanner from '../components/LosslessModeBanner';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
|
||||
|
||||
export default function ArtistDetail() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const losslessOnly = searchParams.get('lossless') === '1';
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
artist, setArtist, albums, topSongs, info, featuredAlbums,
|
||||
loading, artistInfoLoading, featuredLoading,
|
||||
isStarred, setIsStarred,
|
||||
} = useArtistDetailData(id);
|
||||
} = useArtistDetailData(id, { losslessOnly });
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [playAllLoading, setPlayAllLoading] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
@@ -270,6 +274,8 @@ export default function ArtistDetail() {
|
||||
setHeaderCoverFailed={setHeaderCoverFailed}
|
||||
/>
|
||||
|
||||
{losslessOnly && <LosslessModeBanner />}
|
||||
|
||||
{/* User-reorderable sections — order + visibility configured in Settings.
|
||||
* Each case renders the same JSX it did pre-refactor; only `marginTop`
|
||||
* (now derived from the actual render order) and the outer wrapper changed. */}
|
||||
@@ -294,6 +300,7 @@ export default function ArtistDetail() {
|
||||
topSongs={topSongs}
|
||||
marginTop={sectionMt('topTracks')}
|
||||
playTopSongWithContinuation={playTopSongWithContinuation}
|
||||
losslessOnly={losslessOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -314,7 +321,9 @@ export default function ArtistDetail() {
|
||||
case 'albums': return (
|
||||
<Fragment key="albums">
|
||||
<h2 className="section-title" style={{ marginTop: sectionMt('albums'), marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||
{losslessOnly
|
||||
? t('artistDetail.albumsByLossless', { name: artist.name })
|
||||
: t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
{albums.length > 0 ? (
|
||||
groupedAlbums.length === 1 ? (
|
||||
@@ -326,7 +335,9 @@ export default function ArtistDetail() {
|
||||
layoutSignal={albums.length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
renderItem={a => (
|
||||
<AlbumCard album={a} linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined} />
|
||||
)}
|
||||
/>
|
||||
) : groupedAlbums.map(([label, group]) => (
|
||||
<div key={label} className="artist-release-group">
|
||||
@@ -342,7 +353,9 @@ export default function ArtistDetail() {
|
||||
layoutSignal={group.length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
renderItem={a => (
|
||||
<AlbumCard album={a} linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -22,16 +23,16 @@ import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
import { LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { runLocalLosslessAlbums } from '../utils/library/browseTextSearch';
|
||||
|
||||
/** Per-loadMore budget — tuned for snappy initial paint over completeness.
|
||||
* 100 songs ≈ 500 KB response (Navidrome's /api/song carries lyrics/tags/
|
||||
* participants and ignores `_fields`); 2 internal pages = ~1 MB worst case
|
||||
* per loadMore, much faster than the rail's 5×200 = 1000-song budget. The
|
||||
* page makes up for the smaller batch by triggering a fresh loadMore on
|
||||
* scroll, so the user sees albums sooner instead of waiting on a fat call. */
|
||||
const PAGE_TARGET_ALBUMS = 12;
|
||||
const PAGE_SONGS_PER_FETCH = 100;
|
||||
const PAGE_MAX_FETCHES_PER_LOAD = 2;
|
||||
/** Local index page size — SQLite is cheap; larger pages than the network walk. */
|
||||
const LOCAL_PAGE_SIZE = 30;
|
||||
|
||||
/** Per-loadMore budget for the Navidrome bit_depth song-stream fallback. */
|
||||
const NETWORK_TARGET_ALBUMS = 12;
|
||||
const NETWORK_SONGS_PER_FETCH = 100;
|
||||
const NETWORK_MAX_FETCHES_PER_LOAD = 2;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
||||
@@ -43,6 +44,7 @@ export default function LosslessAlbums() {
|
||||
const auth = useAuthStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
@@ -52,6 +54,8 @@ export default function LosslessAlbums() {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
/** `true` = local SQLite; `false` = Navidrome song-stream walk; `null` until first fetch picks. */
|
||||
const [useLocalIndex, setUseLocalIndex] = useState<boolean | null>(null);
|
||||
|
||||
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
@@ -59,17 +63,10 @@ export default function LosslessAlbums() {
|
||||
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
|
||||
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
|
||||
|
||||
/** Pagination cursor + dedupe set, kept across loadMore calls so each page
|
||||
* resumes the song-stream walk where the previous one left off. Reset to
|
||||
* a fresh pair whenever the active server changes. */
|
||||
/** Network pagination cursor — unused on the local path. */
|
||||
const songCursor = useRef(0);
|
||||
const seenIds = useRef<Set<string>>(new Set());
|
||||
/** Re-entrancy guard. The IntersectionObserver can fire repeatedly while a
|
||||
* previous loadMore is still in flight (fast scroll, sentinel re-entering
|
||||
* the rootMargin band) — without this guard, two concurrent calls would
|
||||
* read the same songCursor, fetch the same song page, and push duplicate
|
||||
* album entries because each captures its own snapshot of the seen-Set
|
||||
* reference. */
|
||||
const localOffset = useRef(0);
|
||||
const inFlight = useRef(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -85,62 +82,97 @@ export default function LosslessAlbums() {
|
||||
activeServerId,
|
||||
]);
|
||||
|
||||
const loadMoreNetwork = useCallback(async (onProgress?: (albums: SubsonicAlbum[]) => void) => {
|
||||
const page = await ndListLosslessAlbumsPage({
|
||||
startSongOffset: songCursor.current,
|
||||
seenAlbumIds: seenIds.current,
|
||||
targetNewAlbums: NETWORK_TARGET_ALBUMS,
|
||||
songsPerPage: NETWORK_SONGS_PER_FETCH,
|
||||
maxPagesPerCall: NETWORK_MAX_FETCHES_PER_LOAD,
|
||||
onProgress: onProgress
|
||||
? (entries) => { onProgress(entries.map(e => e.album)); }
|
||||
: undefined,
|
||||
});
|
||||
songCursor.current = page.nextSongOffset;
|
||||
return page;
|
||||
}, []);
|
||||
|
||||
const loadMoreLocal = useCallback(async () => {
|
||||
const page = await runLocalLosslessAlbums(serverId, LOCAL_PAGE_SIZE, localOffset.current);
|
||||
if (!page) return null;
|
||||
localOffset.current += page.albums.length;
|
||||
return page;
|
||||
}, [serverId]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
if (inFlight.current || useLocalIndex === null) return;
|
||||
inFlight.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const page = await ndListLosslessAlbumsPage({
|
||||
startSongOffset: songCursor.current,
|
||||
seenAlbumIds: seenIds.current,
|
||||
targetNewAlbums: PAGE_TARGET_ALBUMS,
|
||||
songsPerPage: PAGE_SONGS_PER_FETCH,
|
||||
maxPagesPerCall: PAGE_MAX_FETCHES_PER_LOAD,
|
||||
onProgress: (newEntries) => {
|
||||
setAlbums(prev => [...prev, ...newEntries.map(e => e.album)]);
|
||||
},
|
||||
});
|
||||
songCursor.current = page.nextSongOffset;
|
||||
setHasMore(!page.done);
|
||||
if (useLocalIndex) {
|
||||
const page = await loadMoreLocal();
|
||||
if (!page) {
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
setAlbums(prev => [...prev, ...page.albums]);
|
||||
setHasMore(page.hasMore);
|
||||
} else {
|
||||
const page = await loadMoreNetwork(albums => {
|
||||
setAlbums(prev => [...prev, ...albums]);
|
||||
});
|
||||
setHasMore(!page.done);
|
||||
}
|
||||
} catch {
|
||||
setUnsupported(true);
|
||||
if (!useLocalIndex) {
|
||||
setUnsupported(true);
|
||||
}
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [loadMoreLocal, loadMoreNetwork, useLocalIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
songCursor.current = 0;
|
||||
seenIds.current = new Set();
|
||||
localOffset.current = 0;
|
||||
inFlight.current = false;
|
||||
setAlbums([]);
|
||||
setHasMore(true);
|
||||
setUnsupported(false);
|
||||
setUseLocalIndex(null);
|
||||
setLoading(true);
|
||||
|
||||
(async () => {
|
||||
inFlight.current = true;
|
||||
try {
|
||||
const page = await ndListLosslessAlbumsPage({
|
||||
startSongOffset: 0,
|
||||
seenAlbumIds: seenIds.current,
|
||||
targetNewAlbums: PAGE_TARGET_ALBUMS,
|
||||
songsPerPage: PAGE_SONGS_PER_FETCH,
|
||||
maxPagesPerCall: PAGE_MAX_FETCHES_PER_LOAD,
|
||||
onProgress: (newEntries) => {
|
||||
if (cancelled) return;
|
||||
setAlbums(prev => [...prev, ...newEntries.map(e => e.album)]);
|
||||
},
|
||||
if (indexEnabled && serverId) {
|
||||
const local = await runLocalLosslessAlbums(serverId, LOCAL_PAGE_SIZE, 0);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
setUseLocalIndex(true);
|
||||
localOffset.current = local.albums.length;
|
||||
setAlbums(local.albums);
|
||||
setHasMore(local.hasMore);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
setUseLocalIndex(false);
|
||||
const page = await loadMoreNetwork(albums => {
|
||||
if (!cancelled) setAlbums(prev => [...prev, ...albums]);
|
||||
});
|
||||
if (cancelled) return;
|
||||
songCursor.current = page.nextSongOffset;
|
||||
setHasMore(!page.done);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setUseLocalIndex(false);
|
||||
setUnsupported(true);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
@@ -150,10 +182,10 @@ export default function LosslessAlbums() {
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [activeServerId]);
|
||||
}, [activeServerId, indexEnabled, loadMoreNetwork, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore) return;
|
||||
if (!hasMore || useLocalIndex === null) return;
|
||||
const node = observerTarget.current;
|
||||
if (!node) return;
|
||||
const root = scrollBodyRef.current;
|
||||
@@ -166,7 +198,7 @@ export default function LosslessAlbums() {
|
||||
);
|
||||
obs.observe(node);
|
||||
return () => obs.disconnect();
|
||||
}, [hasMore, loadMore, loading, albums.length, scrollBodyEl]);
|
||||
}, [hasMore, loadMore, loading, albums.length, scrollBodyEl, useLocalIndex]);
|
||||
|
||||
const handleEnqueueSelected = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
@@ -232,7 +264,7 @@ export default function LosslessAlbums() {
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('home.losslessAlbums')}
|
||||
</h1>
|
||||
{!(selectionMode && selectedIds.size > 0) && (
|
||||
{!(selectionMode && selectedIds.size > 0) && useLocalIndex === false && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: 1.3 }}>
|
||||
{t('losslessAlbums.slowFetchHint')}
|
||||
</p>
|
||||
@@ -282,6 +314,7 @@ export default function LosslessAlbums() {
|
||||
albums.length,
|
||||
hasMore,
|
||||
selectionMode,
|
||||
useLocalIndex,
|
||||
perfFlags.disableMainstageVirtualLists,
|
||||
perfFlags.disableMainstageStickyHeader,
|
||||
]}
|
||||
@@ -311,6 +344,7 @@ export default function LosslessAlbums() {
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
linkQuery={LOSSLESS_MODE_QUERY}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface SidebarItemConfig {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/** Kept in nav/routes but hidden from sidebar and visibility settings. */
|
||||
export const CONSERVED_SIDEBAR_NAV_IDS = new Set<string>(['losslessAlbums']);
|
||||
|
||||
// All configurable nav items in their default order.
|
||||
// Fixed items (nowPlaying, settings, offline) are not listed here.
|
||||
export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
@@ -23,7 +26,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'favorites', visible: true },
|
||||
{ id: 'playlists', visible: true },
|
||||
{ id: 'mostPlayed', visible: true },
|
||||
{ id: 'losslessAlbums',visible: true },
|
||||
{ id: 'losslessAlbums',visible: false },
|
||||
{ id: 'radio', visible: true },
|
||||
{ id: 'folderBrowser', visible: false },
|
||||
{ id: 'deviceSync', visible: false },
|
||||
@@ -59,7 +62,10 @@ export const useSidebarStore = create<SidebarStore>()(
|
||||
const safe = (state.items ?? []).filter((i): i is SidebarItemConfig => i != null && typeof i.id === 'string');
|
||||
const known = new Set(safe.map(i => i.id));
|
||||
const missing = DEFAULT_SIDEBAR_ITEMS.filter(i => !known.has(i.id));
|
||||
state.items = missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
const merged = missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
state.items = merged.map(item =>
|
||||
CONSERVED_SIDEBAR_NAV_IDS.has(item.id) ? { ...item, visible: false } : item,
|
||||
);
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@import './main-content.css';
|
||||
@import './offline-banner.css';
|
||||
@import './offline-storage-full-banner.css';
|
||||
@import './lossless-mode-banner.css';
|
||||
@import './player-bar.css';
|
||||
@import './eq-popup.css';
|
||||
@import './queue-panel.css';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* ─── Lossless browse mode banner (artist / album drill-down) ─── */
|
||||
.lossless-mode-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 10px 16px;
|
||||
margin: 0 0 1rem 0;
|
||||
background: color-mix(in srgb, var(--accent, #7c4dff) 10%, var(--bg-card));
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #7c4dff) 35%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.lossless-mode-banner span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lossless-mode-banner__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent, #7c4dff);
|
||||
}
|
||||
|
||||
.lossless-mode-banner__exit {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||
import type { SidebarItemConfig } from '../../store/sidebarStore';
|
||||
import { CONSERVED_SIDEBAR_NAV_IDS, type SidebarItemConfig } from '../../store/sidebarStore';
|
||||
|
||||
export type SidebarNavSection = 'library' | 'system';
|
||||
|
||||
@@ -14,6 +14,7 @@ export function getLibraryItemsForReorder(
|
||||
randomNavMode: 'hub' | 'separate',
|
||||
): SidebarItemConfig[] {
|
||||
return items.filter(cfg => {
|
||||
if (CONSERVED_SIDEBAR_NAV_IDS.has(cfg.id)) return false;
|
||||
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
@@ -27,7 +28,7 @@ export function getSystemItemsForReorder(items: SidebarItemConfig[]): SidebarIte
|
||||
|
||||
/** Same entries as in Settings toggles — safe to hide via drag-out. */
|
||||
export function isSidebarNavItemUserHideable(id: string): boolean {
|
||||
return Boolean(ALL_NAV_ITEMS[id]);
|
||||
return Boolean(ALL_NAV_ITEMS[id]) && !CONSERVED_SIDEBAR_NAV_IDS.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ const opts = (over: Partial<Parameters<typeof runLocalAdvancedSearch>[1]> = {})
|
||||
bpmFrom: '',
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
losslessOnly: false,
|
||||
resultType: 'all' as const,
|
||||
...over,
|
||||
});
|
||||
@@ -61,6 +62,25 @@ describe('runLocalAdvancedSearch', () => {
|
||||
expect(captured).toMatchObject({ request: { libraryScope: 'lib7' } });
|
||||
});
|
||||
|
||||
it('passes lossless is_true filter to library_advanced_search', async () => {
|
||||
ready();
|
||||
let captured: unknown;
|
||||
onInvoke('library_advanced_search', (args) => {
|
||||
captured = args;
|
||||
return {
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [],
|
||||
totals: { artists: 0, albums: 0, tracks: 0 },
|
||||
source: 'local',
|
||||
};
|
||||
});
|
||||
await runLocalAdvancedSearch('s1', opts({ losslessOnly: true }), 100);
|
||||
expect(captured).toMatchObject({
|
||||
request: { filters: [{ field: 'lossless', op: 'is_true' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes bpm between filter to library_advanced_search', async () => {
|
||||
ready();
|
||||
let captured: unknown;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { search } from '../../api/subsonicSearch';
|
||||
import { libraryScopeForServer } from '../../api/subsonicClient';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
import { logLibrarySearch, timed } from './libraryDevLog';
|
||||
import { isLosslessSuffix } from './losslessFormats';
|
||||
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
|
||||
|
||||
export type AdvancedResultType = 'all' | 'artists' | 'albums' | 'songs';
|
||||
@@ -38,6 +39,7 @@ export interface LocalSearchOpts {
|
||||
bpmFrom: string;
|
||||
bpmTo: string;
|
||||
moodGroup: string;
|
||||
losslessOnly?: boolean;
|
||||
resultType: AdvancedResultType;
|
||||
}
|
||||
|
||||
@@ -89,6 +91,9 @@ function buildFilters(opts: LocalSearchOpts): LibraryFilterClause[] {
|
||||
if (OXIMEDIA_MOOD_SEARCH_ENABLED && opts.moodGroup) {
|
||||
filters.push({ field: 'mood_group', op: 'eq', value: opts.moodGroup });
|
||||
}
|
||||
if (opts.losslessOnly) {
|
||||
filters.push({ field: 'lossless', op: 'is_true' });
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
@@ -107,6 +112,7 @@ function applyClientSongFilters(
|
||||
if (to !== null) r = r.filter(s => !s.year || s.year <= to);
|
||||
if (bpmFrom !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm >= bpmFrom);
|
||||
if (bpmTo !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm <= bpmTo);
|
||||
if (opts.losslessOnly) r = r.filter(s => isLosslessSuffix(s.suffix));
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -223,6 +229,12 @@ export async function runNetworkAdvancedTextSearch(
|
||||
if (g) albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
|
||||
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
|
||||
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
||||
if (opts.losslessOnly) {
|
||||
const albumIds = new Set(songs.map(s => s.albumId).filter(Boolean));
|
||||
albums = albums.filter(a => albumIds.has(a.id));
|
||||
const artistIds = new Set(songs.map(s => s.artistId).filter(Boolean));
|
||||
artists = artists.filter(a => artistIds.has(a.id));
|
||||
}
|
||||
|
||||
return {
|
||||
artists: rt === 'albums' || rt === 'songs' ? [] : artists,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import { search, searchSongsPaged } from '../../api/subsonicSearch';
|
||||
import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { libraryAdvancedSearch } from '../../api/library';
|
||||
import { libraryAdvancedSearch, libraryGetArtistLosslessBrowse, libraryListLosslessAlbums } from '../../api/library';
|
||||
import { libraryScopeForServer } from '../../api/subsonicClient';
|
||||
import {
|
||||
LIVE_SEARCH_DEBOUNCE_NETWORK_MS,
|
||||
@@ -364,6 +364,52 @@ export async function runLocalRandomSongs(
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated lossless albums from the local index. Returns null when unavailable. */
|
||||
export async function runLocalLosslessAlbums(
|
||||
serverId: string | null | undefined,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<{ albums: SubsonicAlbum[]; hasMore: boolean } | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryListLosslessAlbums({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return {
|
||||
albums: resp.albums.map(albumToAlbum),
|
||||
hasMore: resp.hasMore,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lossless albums + tracks for one artist. Returns null when the index is unavailable. */
|
||||
export async function runLocalArtistLosslessBrowse(
|
||||
serverId: string | null | undefined,
|
||||
artistId: string,
|
||||
): Promise<{ albums: SubsonicAlbum[]; songs: SubsonicSong[] } | null> {
|
||||
if (!serverId || !artistId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryGetArtistLosslessBrowse({
|
||||
serverId,
|
||||
artistId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return {
|
||||
albums: resp.albums.map(albumToAlbum),
|
||||
songs: resp.tracks.map(trackToSong),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Random album sample from the local `album` table — SQLite `ORDER BY RANDOM() LIMIT N`.
|
||||
* Returns null when the index is unavailable (caller falls back to the network).
|
||||
@@ -397,6 +443,7 @@ export async function runLocalAlbumBrowsePage(
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
yearFilter?: { from: number; to: number },
|
||||
losslessOnly?: boolean,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
const filters: LibraryFilterClause[] = [];
|
||||
@@ -408,6 +455,9 @@ export async function runLocalAlbumBrowsePage(
|
||||
valueTo: yearFilter.to,
|
||||
});
|
||||
}
|
||||
if (losslessOnly) {
|
||||
filters.push({ field: 'lossless', op: 'is_true' });
|
||||
}
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
@@ -436,22 +486,25 @@ export async function runLocalAlbumsByGenres(
|
||||
genres: string[],
|
||||
sort: AlbumBrowseSort,
|
||||
limitPerGenre = GENRE_ALBUM_FETCH_LIMIT,
|
||||
losslessOnly?: boolean,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId)) || genres.length === 0) return null;
|
||||
try {
|
||||
const pages = await Promise.all(
|
||||
genres.map(genre =>
|
||||
libraryAdvancedSearch({
|
||||
genres.map(genre => {
|
||||
const filters: LibraryFilterClause[] = [{ field: 'genre', op: 'eq', value: genre }];
|
||||
if (losslessOnly) filters.push({ field: 'lossless', op: 'is_true' });
|
||||
return libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['album'],
|
||||
filters: [{ field: 'genre', op: 'eq', value: genre }],
|
||||
filters,
|
||||
sort: albumSortClauses(sort),
|
||||
limit: limitPerGenre,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (pages.some(p => p.source !== 'local')) return null;
|
||||
const merged = dedupeById(pages.flatMap(p => p.albums.map(albumToAlbum)));
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Containers that are *only* lossless — keep in sync with Rust `lossless_formats.rs`. */
|
||||
export const LOSSLESS_SUFFIXES = new Set([
|
||||
'flac', 'wav', 'wave', 'aiff', 'aif', 'dsf', 'dff', 'ape', 'wv', 'shn', 'tta',
|
||||
]);
|
||||
|
||||
export function isLosslessSuffix(suffix?: string | null): boolean {
|
||||
if (!suffix) return false;
|
||||
return LOSSLESS_SUFFIXES.has(suffix.toLowerCase());
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const LOSSLESS_MODE_QUERY = 'lossless=1';
|
||||
|
||||
export function isLosslessMode(searchParams: URLSearchParams): boolean {
|
||||
return searchParams.get('lossless') === '1';
|
||||
}
|
||||
Reference in New Issue
Block a user