diff --git a/CHANGELOG.md b/CHANGELOG.md index 3367f5ae..d6f9b048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index af97fc47..dacf1c2c 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -296,6 +296,11 @@ fn build_album( skip_totals: bool, applied: &mut BTreeSet, ) -> Result<(Vec, 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, + include_album_table_rows: bool, ) -> Result<(Vec, 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 Option { + let mut keys: Vec = 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(); diff --git a/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs b/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs new file mode 100644 index 00000000..2290f996 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs @@ -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 { + s.map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) +} + +pub fn get_artist_lossless_browse( + store: &LibraryStore, + req: &LibraryArtistLosslessBrowseRequest, +) -> Result { + 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 = 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::>>()?; + 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 = 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::>>()?; + 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 { + let raw: Option = 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); + } +} diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 39f05abe..7884784b 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -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::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::artist_lossless_browse::get_artist_lossless_browse(&runtime.store, &request) +} + #[tauri::command] pub async fn library_live_search( runtime: State<'_, LibraryRuntime>, diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index 4070cad2..42a95f90 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -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, + #[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, + 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, +} + +/// Lossless albums + tracks for one artist (local index). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryArtistLosslessBrowseResponse { + pub albums: Vec, + pub tracks: Vec, + pub source: String, +} + /// `library_search_cross_server` response (§5.5B / §5.9). #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/crates/psysonic-library/src/filter.rs b/src-tauri/crates/psysonic-library/src/filter.rs index a0749313..c4ff4d93 100644 --- a/src-tauri/crates/psysonic-library/src/filter.rs +++ b/src-tauri/crates/psysonic-library/src/filter.rs @@ -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], diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index e2546554..3561aad8 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -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; diff --git a/src-tauri/crates/psysonic-library/src/lossless_albums.rs b/src-tauri/crates/psysonic-library/src/lossless_albums.rs new file mode 100644 index 00000000..d177a9b6 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/lossless_albums.rs @@ -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 { + 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 { + 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 = 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::>>()?; + 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 { + let raw: Option = 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); + } +} diff --git a/src-tauri/crates/psysonic-library/src/lossless_formats.rs b/src-tauri/crates/psysonic-library/src/lossless_formats.rs new file mode 100644 index 00000000..70600122 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/lossless_formats.rs @@ -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::>() + .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 (")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 79f0f779..ad307728 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/api/library.ts b/src/api/library.ts index 2e4caa45..1e637b51 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -375,6 +375,73 @@ export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise
  • { + const indexKey = serverIndexKeyForId(request.serverId); + return invoke('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 { + const indexKey = serverIndexKeyForId(request.serverId); + return invoke('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; diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index a3ad75af..6d74e8d4 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -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 ( diff --git a/src/components/AlbumRow.tsx b/src/components/AlbumRow.tsx index d12c571b..dff552ff 100644 --- a/src/components/AlbumRow.tsx +++ b/src/components/AlbumRow.tsx @@ -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) diff --git a/src/components/ArtistCardLocal.tsx b/src/components/ArtistCardLocal.tsx index 33a3bc4e..7eebb82b 100644 --- a/src/components/ArtistCardLocal.tsx +++ b/src/components/ArtistCardLocal.tsx @@ -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 ( -
    navigate(`/artist/${artist.id}`)}> +
    navigate(href)}>
    {artist.coverArt || artist.id ? ( (null); const navigate = useNavigate(); const [showLeft, setShowLeft] = useState(false); @@ -54,7 +55,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText }: Props)
    - {artists.map(a => )} + {artists.map(a => )} {moreLink && (
    navigate(moreLink)}>
    diff --git a/src/components/LosslessAlbumsRail.tsx b/src/components/LosslessAlbumsRail.tsx index a196651b..1d90d0ba 100644 --- a/src/components/LosslessAlbumsRail.tsx +++ b/src/components/LosslessAlbumsRail.tsx @@ -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([]); 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} /> ); } diff --git a/src/components/LosslessFilterButton.tsx b/src/components/LosslessFilterButton.tsx new file mode 100644 index 00000000..68d66aab --- /dev/null +++ b/src/components/LosslessFilterButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/LosslessModeBanner.tsx b/src/components/LosslessModeBanner.tsx new file mode 100644 index 00000000..1fe442cf --- /dev/null +++ b/src/components/LosslessModeBanner.tsx @@ -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 ( +
    + + {t('losslessAlbums.modeBanner')} + +
    + ); +} diff --git a/src/components/artistDetail/ArtistDetailTopTracks.tsx b/src/components/artistDetail/ArtistDetailTopTracks.tsx index 6676fb16..de56d9af 100644 --- a/src/components/artistDetail/ArtistDetailTopTracks.tsx +++ b/src/components/artistDetail/ArtistDetailTopTracks.tsx @@ -13,9 +13,12 @@ interface Props { topSongs: SubsonicSong[]; marginTop: string; playTopSongWithContinuation: (startIndex: number) => Promise; + 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 (

    - {t('artistDetail.topTracks')} + {t(losslessOnly ? 'artistDetail.topTracksLossless' : 'artistDetail.topTracks')}

    diff --git a/src/components/settings/SidebarCustomizer.tsx b/src/components/settings/SidebarCustomizer.tsx index 36ce0df4..b0ae97bb 100644 --- a/src/components/settings/SidebarCustomizer.tsx +++ b/src/components/settings/SidebarCustomizer.tsx @@ -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; diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 862d2fa9..738d0d28 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/hooks/useArtistDetailData.ts b/src/hooks/useArtistDetailData.ts index af63a115..2e1abb9b 100644 --- a/src/hooks/useArtistDetailData.ts +++ b/src/hooks/useArtistDetailData.ts @@ -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>; + 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([]); const [featuredAlbums, setFeaturedAlbums] = useState([]); const [topSongs, setTopSongs] = useState([]); - // 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(); 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, }; } diff --git a/src/locales/de/albums.ts b/src/locales/de/albums.ts index da4b29f5..72f00eb7 100644 --- a/src/locales/de/albums.ts +++ b/src/locales/de/albums.ts @@ -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.', }; diff --git a/src/locales/de/losslessAlbums.ts b/src/locales/de/losslessAlbums.ts index 1a88e53d..cb089187 100644 --- a/src/locales/de/losslessAlbums.ts +++ b/src/locales/de/losslessAlbums.ts @@ -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', }; diff --git a/src/locales/de/search.ts b/src/locales/de/search.ts index 594ad240..8a19de03 100644 --- a/src/locales/de/search.ts +++ b/src/locales/de/search.ts @@ -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', diff --git a/src/locales/en/albums.ts b/src/locales/en/albums.ts index bcb9e003..c76704a5 100644 --- a/src/locales/en/albums.ts +++ b/src/locales/en/albums.ts @@ -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.', }; diff --git a/src/locales/en/artistDetail.ts b/src/locales/en/artistDetail.ts index 893cc8c5..0ce53b29 100644 --- a/src/locales/en/artistDetail.ts +++ b/src/locales/en/artistDetail.ts @@ -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', diff --git a/src/locales/en/losslessAlbums.ts b/src/locales/en/losslessAlbums.ts index ee9efff8..e0146b44 100644 --- a/src/locales/en/losslessAlbums.ts +++ b/src/locales/en/losslessAlbums.ts @@ -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', }; diff --git a/src/locales/en/search.ts b/src/locales/en/search.ts index f41f18c4..299f2f33 100644 --- a/src/locales/en/search.ts +++ b/src/locales/en/search.ts @@ -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', diff --git a/src/locales/es/albums.ts b/src/locales/es/albums.ts index a6765e46..7e68e154 100644 --- a/src/locales/es/albums.ts +++ b/src/locales/es/albums.ts @@ -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', }; diff --git a/src/locales/es/losslessAlbums.ts b/src/locales/es/losslessAlbums.ts index 210c6468..1ab05a60 100644 --- a/src/locales/es/losslessAlbums.ts +++ b/src/locales/es/losslessAlbums.ts @@ -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', }; diff --git a/src/locales/es/search.ts b/src/locales/es/search.ts index b7bdfaed..bec0eae2 100644 --- a/src/locales/es/search.ts +++ b/src/locales/es/search.ts @@ -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', diff --git a/src/locales/fr/albums.ts b/src/locales/fr/albums.ts index e843ea4b..76460499 100644 --- a/src/locales/fr/albums.ts +++ b/src/locales/fr/albums.ts @@ -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.', }; diff --git a/src/locales/fr/losslessAlbums.ts b/src/locales/fr/losslessAlbums.ts index d7a58a4c..d7e0ed41 100644 --- a/src/locales/fr/losslessAlbums.ts +++ b/src/locales/fr/losslessAlbums.ts @@ -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', }; diff --git a/src/locales/fr/search.ts b/src/locales/fr/search.ts index f308985e..620a4c22 100644 --- a/src/locales/fr/search.ts +++ b/src/locales/fr/search.ts @@ -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', diff --git a/src/locales/nb/albums.ts b/src/locales/nb/albums.ts index b00ae5a1..f6da95a2 100644 --- a/src/locales/nb/albums.ts +++ b/src/locales/nb/albums.ts @@ -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.', }; diff --git a/src/locales/nb/losslessAlbums.ts b/src/locales/nb/losslessAlbums.ts index 6342c12d..6b36210e 100644 --- a/src/locales/nb/losslessAlbums.ts +++ b/src/locales/nb/losslessAlbums.ts @@ -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', }; diff --git a/src/locales/nb/search.ts b/src/locales/nb/search.ts index 5af2a5da..39dc835e 100644 --- a/src/locales/nb/search.ts +++ b/src/locales/nb/search.ts @@ -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', diff --git a/src/locales/nl/albums.ts b/src/locales/nl/albums.ts index 6ae11e7e..a723a905 100644 --- a/src/locales/nl/albums.ts +++ b/src/locales/nl/albums.ts @@ -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.', }; diff --git a/src/locales/nl/losslessAlbums.ts b/src/locales/nl/losslessAlbums.ts index 939cf555..4e843a26 100644 --- a/src/locales/nl/losslessAlbums.ts +++ b/src/locales/nl/losslessAlbums.ts @@ -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', }; diff --git a/src/locales/nl/search.ts b/src/locales/nl/search.ts index ad771498..b7f9d523 100644 --- a/src/locales/nl/search.ts +++ b/src/locales/nl/search.ts @@ -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', diff --git a/src/locales/ro/albums.ts b/src/locales/ro/albums.ts index fea58cc4..3aec4cb9 100644 --- a/src/locales/ro/albums.ts +++ b/src/locales/ro/albums.ts @@ -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.', }; diff --git a/src/locales/ro/losslessAlbums.ts b/src/locales/ro/losslessAlbums.ts index 5a106c47..2cf751ae 100644 --- a/src/locales/ro/losslessAlbums.ts +++ b/src/locales/ro/losslessAlbums.ts @@ -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', }; diff --git a/src/locales/ro/search.ts b/src/locales/ro/search.ts index c8559dea..23a216ea 100644 --- a/src/locales/ro/search.ts +++ b/src/locales/ro/search.ts @@ -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', diff --git a/src/locales/ru/albums.ts b/src/locales/ru/albums.ts index e50b0342..1531ba27 100644 --- a/src/locales/ru/albums.ts +++ b/src/locales/ru/albums.ts @@ -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: 'Нет альбомов с текущими фильтрами.', }; diff --git a/src/locales/ru/artistDetail.ts b/src/locales/ru/artistDetail.ts index 03e7441b..3ffc8d70 100644 --- a/src/locales/ru/artistDetail.ts +++ b/src/locales/ru/artistDetail.ts @@ -10,7 +10,9 @@ export const artistDetail = { noRadio: 'Похожие треки для этого исполнителя не найдены.', notFound: 'Исполнитель не найден.', albumsBy: 'Альбомы — {{name}}', + albumsByLossless: 'Lossless-альбомы — {{name}}', topTracks: 'Популярные треки', + topTracksLossless: 'Lossless-треки', noAlbums: 'Альбомов нет.', trackTitle: 'Название', trackAlbum: 'Альбом', diff --git a/src/locales/ru/losslessAlbums.ts b/src/locales/ru/losslessAlbums.ts index 297acb3f..996246a4 100644 --- a/src/locales/ru/losslessAlbums.ts +++ b/src/locales/ru/losslessAlbums.ts @@ -2,4 +2,6 @@ export const losslessAlbums = { empty: 'В этой библиотеке пока нет lossless-альбомов.', unsupported: 'Этот сервер не предоставляет метаданные, необходимые для поиска lossless-альбомов.', slowFetchHint: 'Загружается медленнее других страниц альбомов — Psysonic проходит весь каталог песен по качеству.', + modeBanner: 'Режим lossless — показаны только FLAC, WAV и другие форматы без потерь.', + modeBannerExit: 'Показать все форматы', }; diff --git a/src/locales/ru/search.ts b/src/locales/ru/search.ts index 5cab6987..7923c18f 100644 --- a/src/locales/ru/search.ts +++ b/src/locales/ru/search.ts @@ -23,8 +23,9 @@ export const search = { advancedYearFrom: 'от', advancedYearTo: 'до', advancedBpm: 'BPM', - advancedBpmLocalNote: 'Тег BPM и измеренный анализ — при включённом локальном индексе; анализ приоритетнее тега', advancedBpmClear: 'Сбросить BPM', + advancedLossless: 'Формат', + advancedLosslessOnly: 'Только lossless', bpmSourceTag: 'BPM из тега файла', bpmSourceAnalysis: 'BPM из анализа аудио', advancedMoodGroup: 'Настроение', diff --git a/src/locales/zh/albums.ts b/src/locales/zh/albums.ts index 7248ec6e..624905c7 100644 --- a/src/locales/zh/albums.ts +++ b/src/locales/zh/albums.ts @@ -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: '没有符合当前筛选条件的专辑。', }; diff --git a/src/locales/zh/losslessAlbums.ts b/src/locales/zh/losslessAlbums.ts index 82590963..22bd670c 100644 --- a/src/locales/zh/losslessAlbums.ts +++ b/src/locales/zh/losslessAlbums.ts @@ -2,4 +2,6 @@ export const losslessAlbums = { empty: '此媒体库中还没有无损专辑。', unsupported: '此服务器未公开查找无损专辑所需的元数据。', slowFetchHint: '加载比其他专辑页面慢 — Psysonic 按音质遍历整个歌曲目录。', + modeBanner: '无损模式 — 仅显示 FLAC、WAV 等无损格式。', + modeBannerExit: '显示所有格式', }; diff --git a/src/locales/zh/search.ts b/src/locales/zh/search.ts index 3fd3e20f..9c71a7a6 100644 --- a/src/locales/zh/search.ts +++ b/src/locales/zh/search.ts @@ -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', diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 04ce990b..f6d99ee8 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -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('all'); const [starredOnly, setStarredOnly] = useState(false); const [genres, setGenres] = useState([]); @@ -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() { />
    - {/* Row 3: BPM (tag + measured enrichment via local index) */} + {/* Row 3: BPM (tag + measured enrichment) */} {indexEnabled && (
    @@ -519,9 +543,32 @@ export default function AdvancedSearch() { {t('search.advancedBpmClear')} )} - - {t('search.advancedBpmLocalNote')} +
    + )} + + {/* Lossless — suffix allowlist (FLAC, WAV, …) */} + {indexEnabled && ( +
    + + {t('search.advancedLossless')} +
    )} @@ -594,6 +641,7 @@ export default function AdvancedSearch() { )} @@ -601,6 +649,7 @@ export default function AdvancedSearch() { )} diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index d1500e1e..f886a27a 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -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
    ; if (!album) return
    {t('albumDetail.notFound')}
    ; - 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 && } {offlineStorageFull && (
    {t('albumDetail.offlineStorageFull', { mb: auth.maxCacheMb })} diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index ee04059a..315b53d6 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -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('all'); const [starredOnly, setStarredOnly] = useState(false); + const [losslessOnly, setLosslessOnly] = useState(false); const observerTarget = useRef(null); const gridMeasureRef = useRef(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() { + {indexEnabled && ( + + )} +