Compare commits

..

19 Commits

Author SHA1 Message Date
Maxim Isaev 0feb54ac33 fix(browse): album text search union, scope, and local-first index
Merge album/FTS/track sources for title search (any-word LIKE + prefix FTS),
dedupe cluster text hits by album id instead of album_key, route scoped
cluster browse through per-server advanced search, and prefer the local
index over network race when the library index is enabled.
2026-06-05 22:20:41 +03:00
Maxim Isaev e640d14a0e fix(cluster): dedup merged album lists by album_key at album grain
Roll up track candidates to one row per (server_id, album_id) before
partitioning by album_key so the same album on multiple servers (or mixed
keyed/unkeyed tracks) appears once with priority winner per spec §4.
2026-06-05 21:31:25 +03:00
Maxim Isaev 38ec62a639 fix(browse): cluster multi-library album search without network race
When two or more sidebar libraries are selected in cluster mode, All Albums
text search uses the merged local index only so an empty search3 response from
one member cannot win the local-vs-network race.
2026-06-05 21:26:14 +03:00
Maxim Isaev 6c64c5aa41 perf(browse): album-table search and single-library scoped list
Route plain album title search through the synced album table (LIKE)
instead of FTS over all tracks. Use album+EXISTS for single-library
scope; keep track GROUP BY only for multi-library union.
2026-06-05 21:06:47 +03:00
Maxim Isaev 478c049d0a perf(browse): fast local All Albums path and scoped multi-library union
Add library_list_albums (album table when unscoped, track GROUP BY when
scoped), cluster single-member bypass, ready-member caching, and UI load
deferrals. Remove REST scope allowlists from local hot paths; fix multi-
library scope union and invalidate cluster scope cache on picker toggles.
2026-06-05 21:04:43 +03:00
Maxim Isaev 26716e2fb0 fix(browse): cluster and lossless library scope allowlists
Apply cached getAlbumList2 allowlists per server in SQL and post-filter
so narrowed sidebar scope does not leak albums from other libraries or
cluster members. Wire restrictAlbumScopes into cluster advanced search
and dedicated lossless browse; detect cluster scope in All Albums paging.
2026-06-05 20:29:57 +03:00
Maxim Isaev 496b39a4da fix(browse): layered album filters and multi-library scope
Introduce albumBrowseExecution with explicit filter layers (library scope,
AND attributes, OR genre union, finalize) so multi-genre and cluster paths
cannot leak albums from other libraries. Wire multi-select sidebar picker
scope through local index, cluster merge reads, and REST allowlist fallback.
2026-06-05 19:54:23 +03:00
Maxim Isaev 558f42dba6 feat(cluster): per-member library scope in sidebar picker and merge reads
Cluster mode lists music folders from every member (cached for instant
picker), applies per-server musicLibraryFilter scopes in merged browse,
and passes libraryScopes through Rust list/advanced-search SQL filters.
Dropdown width sizes to content; single-server picker unchanged.
2026-06-05 19:11:40 +03:00
Maxim Isaev 0a09d29b06 fix(preview): Symphonia format sniff and cluster member stream URLs
Resolve preview container hints from HTTP headers, Subsonic suffix, and
magic-byte sniff after Symphonia 0.6. Route preview streams through
clusterBrowseServerId like main playback; guard CoverArtImage when the
preview cover ref is still loading.
2026-06-05 18:48:13 +03:00
Maxim Isaev 1d65a8d339 fix(cluster): artist Play All/top tracks and member-scoped browse UX
Load artist album tracks via getAlbumForServer when clusterSeedServerId is set
so Play All, shuffle, and top-track clicks resolve on the correct member server.
Also improve connection indicator tooltips, Song Info server labels, artist browse
covers/detail fallbacks, and faster merged artist list SQL.
2026-06-05 18:38:10 +03:00
Maxim Isaev 884951fbc2 feat(cluster): playback, browse, discovery, Tier 2/3, and infra
Enqueue-time cluster resolve with mixed-server queue refs; play_session
and scrobble target resolved server; mid-session cascade monitor.
Queue share filters to representative with export count. Excluded-member
banner and cluster settings diagnostics. Cluster advanced search, random
tracks, native favorite albums/artists, extended player stats, sync
rebuild hook. Discovery merge helper, filtered Tier 1, Tier 3 by-source
UI, i18n, and tauri test mocks.
2026-06-05 17:36:53 +03:00
Maxim Isaev 3447cf9fdd feat(cluster): virtual aggregate album/artist detail pages
Add Rust library_cluster_album_detail and library_cluster_artist_detail
with cluster_key dedup, owner metadata fallback, and merged tracklists.
Wire cluster-mode hooks and navigation seedServerId so detail routes
never fall back to single-server getAlbum/getArtist.
2026-06-05 17:02:25 +03:00
Maxim Isaev e601b5888a feat(cluster): playback resolve, writes fan-out, Orbit gating, random browse
Resolve tracks to cluster candidates at play time with cascade fallback;
fan-out scrobble/star/rating to all members; block Orbit host in cluster
mode; warn on mixed-server queue share; serve random picks from merged index.
2026-06-05 16:40:37 +03:00
Maxim Isaev 2ee601683c feat(cluster): Tier 2 favorites and player statistics aggregation
Add merged favorites listing (starred on any member) and cluster-scoped
play_session stats with cluster_key dedup; wire Favorites page and player
statistics panel to cluster APIs when a cluster is active.
2026-06-05 16:36:18 +03:00
Maxim Isaev 4f37fb8752 feat(cluster): Tier 1 browse — merged albums, artists, tracks, search
Add Rust list_merged_albums/artists commands and wire cluster-mode reads
into tracks, albums, artists catalog, and text search browse paths via
clusterBrowse helpers.
2026-06-05 16:32:30 +03:00
Maxim Isaev a7effbc271 feat(cluster): server cluster builder UI and scope switcher
Add cluster management in Settings (create, reorder, rename, sync toggle),
connection indicator dropdown for servers vs clusters, delete guard when a
server is in a cluster, and i18n across all locales.
2026-06-05 16:27:52 +03:00
Maxim Isaev 9813c79c91 feat(cluster): auth store cluster model and library API wrappers (step 3)
Add persisted clusters/activeClusterId, cluster CRUD actions, scope helpers,
representative recompute hook, library_cluster_* API wrappers, and vitest
coverage for cluster store rules and delete guard.
2026-06-05 16:23:38 +03:00
Maxim Isaev f3d4a06726 feat(library): cluster merge, resolve, and search commands (server cluster step 2)
Add library_cluster_list_tracks, library_cluster_resolve_candidates, and
library_search_cluster with priority-ordered dedup by cluster_key and
duration guard. Register Tauri commands; no frontend changes in this step.
2026-06-05 16:22:10 +03:00
Maxim Isaev dbccbea6cc feat(library): attached cluster identity DB for server cluster v1 (PR-1)
Add library-cluster.db (separate from library.sqlite) with track_cluster_key
normalization, batch rebuild, and ATTACH on both library store connections.
No Tauri commands or frontend changes in this PR.
2026-06-05 16:11:59 +03:00
747 changed files with 33134 additions and 21646 deletions
-84
View File
@@ -21,63 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fullscreen player — rebuilt for much lower CPU/RAM
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1001](https://github.com/Psychotoxical/psysonic/pull/1001)**
* The previous fullscreen player was a heavy CPU and memory consumer — constant repaints from animated/blurred backgrounds and effects kept the GPU and a CPU core busy the whole time it was open. It has been **completely replaced** by a static, low-overhead screen: only the seekbar, elapsed time, and clock update live; everything else stays still.
* Features: sharp high-res background, large album cover, true waveform seekbar, up-next queue popover, scrolling synced lyrics, clickable rating stars, and an on-screen clock.
* The artist photo now always shows as the background (album cover as fallback); the old **Appearance → "Fullscreen player"** settings were removed.
### Queue — Timeline display mode
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1004](https://github.com/Psychotoxical/psysonic/pull/1004), suggested by [@Legislate3030](https://github.com/Legislate3030)**
* New third queue display mode (cycle the header button, or pick it in **Settings → Personalisation → Queue display**). Timeline keeps the current track centered with played history above and upcoming tracks below — both visible at once — so it's easy to follow playback and jump back to earlier songs.
* The up-next order respects shuffle, and a "History" / "Up next" divider marks the boundary.
### Offline — unified local playback, library index join, and favorites sync
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)**
* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket.
* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution.
* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header.
* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind.
* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically.
* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist.
* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count.
### Themes — community Theme Store
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020)**
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
### Offline — local-bytes browse when the server is down
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1017](https://github.com/Psychotoxical/psysonic/pull/1017)**
* When the active server is unreachable, browse and detail pages read from **local playback bytes** and the **library index** instead of Subsonic — albums, artists, tracks, cached playlists, and cross-server favorites.
* Single integration contract: `offlineBrowseContext`, `offlineActionPolicy`, and `resolveAlbum` / `resolveArtist` / `resolvePlaylist` resolvers; context menus and detail toolbars block server mutations offline.
* Disconnect navigation forks by offline capability (stay on page, stay-reload, or redirect); Home reuses the last cached feed snapshot; DEV offline toggle simulates full disconnect for testing.
* PlayerBar hides star rating and favorite controls while offline browse is active.
## Changed
### Dependencies — npm and Rust refresh
@@ -98,15 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playback — Preload Next Track setting removed
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1007](https://github.com/Psychotoxical/psysonic/pull/1007)**
* The **Preload Next Track** toggle and timing modes under **Settings → Storage → Buffering** are gone — ranged streaming now starts playback without that extra RAM prefetch.
* Gapless and crossfade still prefetch the next track internally when Hot Cache is off; Hot Cache is unchanged.
## Fixed
### Servers — complete border on the active server card
@@ -126,24 +60,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Track preview — Symphonia 0.6 format hints and fast stream start
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
* Preview resolves container format from HTTP headers, Subsonic `suffix`, and magic-byte sniff so Symphonia 0.6 no longer fails with `.unknown` demuxer errors.
* Preview opens via ranged HTTP when the server supports byte ranges — audio starts after ~384 KiB buffered instead of waiting for a full-file download; buffered fallback uses the same probe seek-gate as main playback.
* Player bar cover guard while preview metadata loads; progress ring leaves the loading spinner once the engine emits `audio:preview-start`.
### Mainstage — hero backdrop stays in sync when skipping albums quickly
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1021](https://github.com/Psychotoxical/psysonic/pull/1021)**
* Rapid prev/next clicks on the Mainstage hero no longer leave the blurred cover-art background on the previous album while the foreground cover and metadata already show the next one.
## [1.47.0]
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
+3
View File
@@ -32,6 +32,9 @@ More translations are added over time.
---
> [!WARNING]
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
## What is Psysonic?
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
+20 -1
View File
@@ -4252,6 +4252,8 @@ dependencies = [
"serde_json",
"tauri",
"tokio",
"twox-hash",
"unicode-normalization",
"wiremock",
]
@@ -4266,7 +4268,6 @@ dependencies = [
"psysonic-analysis",
"psysonic-audio",
"psysonic-core",
"psysonic-library",
"reqwest",
"serde",
"serde_json",
@@ -6602,6 +6603,15 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
dependencies = [
"rand 0.9.4",
]
[[package]]
name = "typed-path"
version = "0.12.3"
@@ -6684,6 +6694,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
+1 -2
View File
@@ -6,7 +6,6 @@ resolver = "2"
version = "1.48.0-dev"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
[workspace.dependencies]
tempfile = "3"
@@ -19,7 +18,7 @@ name = "psysonic"
version.workspace = true
description = "Psysonic Desktop Music Player"
authors = []
license.workspace = true
license = ""
repository = ""
default-run = "psysonic"
edition.workspace = true
+1 -2
View File
@@ -21,8 +21,6 @@ accepted = [
"OpenSSL",
"BSL-1.0",
"CDLA-Permissive-2.0",
"GPL-3.0-or-later",
"bzip2-1.0.6",
]
# Skip the build host's own platform-pinning; we want a list across all targets
@@ -40,4 +38,5 @@ targets = [
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
filter-noassertion = false
workarounds = ["ring"]
@@ -3,7 +3,6 @@ name = "psysonic-analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -291,7 +291,7 @@ impl AnalysisBackfillQueueState {
}
}
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
#[derive(Default)]
pub struct PlaybackPriorityHints {
middle_track_ids: Mutex<HashSet<String>>,
@@ -516,145 +516,6 @@ pub async fn enqueue_track_analysis_from_file(
enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await
}
/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key,
/// plan enrichment under the library UUID, and skip work when both scopes are complete.
pub async fn enqueue_offline_library_analysis_from_file(
app: &tauri::AppHandle,
server_index_key: &str,
library_server_id: &str,
track_id: &str,
file_path: &std::path::Path,
explicit_priority: Option<AnalysisBackfillPriority>,
) -> Result<(), String> {
use tokio::io::AsyncReadExt;
use crate::track_analysis_plan::plan_track_analysis_offline_library;
let mut file = tokio::fs::File::open(file_path)
.await
.map_err(|e| e.to_string())?;
let mut prefix = vec![0u8; 16384];
let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?;
prefix.truncate(n);
if prefix.is_empty() {
return Ok(());
}
let content_hash = analysis_cache::md5_first_16kb(&prefix);
let plan = plan_track_analysis_offline_library(
app,
&[server_index_key, library_server_id],
library_server_id,
track_id,
&content_hash,
);
if !plan.any() {
crate::app_deprintln!(
"[analysis] offline library seed skip (complete) track_id={} index={} library={}",
track_id,
server_index_key,
library_server_id,
);
return Ok(());
}
let bytes = tokio::fs::read(file_path)
.await
.map_err(|e| e.to_string())?;
let format_hint = file_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.filter(|e| !e.is_empty());
let priority = explicit_priority.unwrap_or_else(|| {
analysis_backfill_resolve_priority(app, server_index_key, track_id, None)
});
enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue {
app,
cache_server_id: server_index_key,
enrichment_server_id: library_server_id,
track_id,
bytes: &bytes,
format_hint: format_hint.as_deref(),
priority,
plan,
fetch_ms: 0,
})
.await?;
Ok(())
}
struct OfflineLibraryAnalysisEnqueue<'a> {
app: &'a tauri::AppHandle,
cache_server_id: &'a str,
enrichment_server_id: &'a str,
track_id: &'a str,
bytes: &'a [u8],
format_hint: Option<&'a str>,
priority: AnalysisBackfillPriority,
plan: psysonic_core::track_analysis::TrackAnalysisPlan,
fetch_ms: u64,
}
async fn enqueue_track_analysis_offline_library_with_plan(
args: OfflineLibraryAnalysisEnqueue<'_>,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
if args.bytes.is_empty() || !args.plan.any() {
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
let content_hash = analysis_cache::md5_first_16kb(args.bytes);
if args.plan.needs_full_cpu_seed() {
crate::app_deprintln!(
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
args.track_id,
content_hash,
args.plan.need_waveform,
args.plan.need_loudness,
args.plan.enrichment.any()
);
submit_analysis_cpu_seed(
args.app.clone(),
args.cache_server_id.to_string(),
args.track_id.to_string(),
args.bytes.to_vec(),
args.format_hint.map(str::to_string),
args.priority,
args.fetch_ms,
)
.await?;
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
}
if args.plan.needs_enrichment_only() {
crate::app_deprintln!(
"[analysis] enrichment-only track_id={} hash={}",
args.track_id,
content_hash
);
let bpm_started = std::time::Instant::now();
let outcome = run_track_enrichment_from_bytes(
args.app,
args.enrichment_server_id,
args.track_id,
args.bytes,
analysis_emits_ui_events(args.priority),
)
.await;
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
if let Some(cache) = args.app.try_state::<analysis_cache::AnalysisCache>() {
let key = analysis_cache::TrackKey {
server_id: args.cache_server_id.to_string(),
track_id: args.track_id.to_string(),
md5_16kb: content_hash.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
return Err("track enrichment failed".to_string());
}
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms);
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
}
Ok(EnqueueTrackAnalysisOutcome::Complete)
}
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
pub async fn enqueue_analysis_seed(
app: &tauri::AppHandle,
@@ -15,21 +15,8 @@ pub fn plan_track_analysis(
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
}
/// Offline/library download: waveform cache and enrichment facts may live under the
/// playback index key while library rows use the UUID — try every scope before seeding.
pub fn plan_track_analysis_offline_library(
app: &AppHandle,
cache_server_ids: &[&str],
_enrichment_server_id: &str,
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) =
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
TrackAnalysisPlan {
need_waveform,
need_loudness,
@@ -116,32 +103,6 @@ fn cache_gaps(
)
}
fn cache_gaps_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
let mut need_waveform = true;
let mut need_loudness = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
if !nw {
need_waveform = false;
}
if !nl {
need_loudness = false;
}
if !need_waveform && !need_loudness {
break;
}
}
(need_waveform, need_loudness)
}
fn enrichment_plan(
app: &AppHandle,
server_id: &str,
@@ -156,45 +117,6 @@ fn enrichment_plan(
.unwrap_or_default()
}
fn enrichment_plan_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
let mut need_bpm = true;
let mut need_valence = true;
let mut need_arousal = true;
let mut need_moods = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let plan = enrichment_plan(app, server_id, track_id, content_hash);
if !plan.need_bpm {
need_bpm = false;
}
if !plan.need_valence {
need_valence = false;
}
if !plan.need_arousal {
need_arousal = false;
}
if !plan.need_moods {
need_moods = false;
}
if !need_bpm && !need_valence && !need_arousal && !need_moods {
break;
}
}
psysonic_core::track_enrichment::TrackEnrichmentPlan {
need_bpm,
need_valence,
need_arousal,
need_moods,
}
}
fn cache_gaps_for_content(
cache: Option<&AnalysisCache>,
server_id: &str,
@@ -272,14 +194,4 @@ mod tests {
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
}
#[test]
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
}
}
@@ -3,7 +3,6 @@ name = "psysonic-audio"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
+4 -17
View File
@@ -166,24 +166,15 @@ impl SizedDecoder {
inner: Cursor::new(data),
len: data_len,
};
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
// seekability during probe (same as `new_streaming`) so preview does not
// read the entire in-memory file before the first sample.
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate {
inner: Box::new(source),
seekable: gate.clone(),
}),
None => Box::new(source),
};
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
// and compete with the playback thread at track start.
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>,
MediaSourceStreamOptions { buffer_len: buf_len },
);
let mut hint = Hint::new();
if let Some(ext) = format_hint {
@@ -209,10 +200,6 @@ impl SizedDecoder {
}
})?;
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format
.tracks()
.iter()
+57 -215
View File
@@ -1,6 +1,6 @@
//! Short preview playback on a secondary sink (same output stream).
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rodio::Player;
@@ -10,16 +10,11 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
content_type_to_hint, format_hint_from_content_disposition, resolve_playback_format_hint,
MASTER_HEADROOM,
};
use super::play_input::url_format_hint;
use super::sources::PriorityBoostSource;
use super::stream::{
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
RangedHttpSource, RangedMp4ProbeGate,
};
// ────────────────────────────────────────────────────────────────────────────
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
@@ -137,201 +132,7 @@ pub(crate) fn resolve_preview_format_hint(
)
}
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(state))
}
/// Open a preview decoder — ranged HTTP when the server supports it (starts
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
async fn open_preview_decoder(
url: &str,
format_suffix: Option<&str>,
gen: u64,
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?;
let mut stream_hint = content_type_to_hint(
response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
)
.or_else(|| {
response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(format_hint_from_content_disposition)
})
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
.or_else(|| preview_format_hint_from_url(url))
.or_else(|| url_format_hint(url));
let supports_range = response
.headers()
.get(reqwest::header::ACCEPT_RANGES)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
if stream_hint.is_none() && supports_range {
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
{
let stat = pr.status();
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|| stat == reqwest::StatusCode::OK;
if ok {
if let Ok(bytes) = pr.bytes().await {
if !bytes.is_empty() {
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
}
}
}
}
}
}
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let total_usize = total as usize;
crate::app_deprintln!(
"[preview] ranged open — total={} KB, hint={:?}",
total_usize / 1024,
stream_hint
);
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
let downloaded_to = Arc::new(AtomicUsize::new(0));
let done = Arc::new(AtomicBool::new(false));
let playback_armed = Arc::new(AtomicBool::new(false));
let tail_ready = Arc::new(AtomicBool::new(false));
let tail_filled_from = Arc::new(AtomicU64::new(0));
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
tail_ready: tail_ready.clone(),
buf: buf.clone(),
downloaded_to: downloaded_to.clone(),
gen_arc: state.preview_gen.clone(),
gen,
format_hint: stream_hint.clone(),
});
tokio::spawn(ranged_download_task(
gen,
state.preview_gen.clone(),
preview_http,
app.clone(),
0.0,
url.to_string(),
response,
buf.clone(),
downloaded_to.clone(),
done.clone(),
state.stream_completed_cache.clone(),
state.stream_completed_spill.clone(),
state.normalization_engine.clone(),
state.normalization_target_lufs.clone(),
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
None,
playback_armed,
stream_hint.clone(),
tail_ready.clone(),
tail_filled_from.clone(),
));
if let Some(ref gate) = mp4_probe_gate {
wait_for_ranged_mp4_probe_ready(gate).await?;
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
}
let reader = RangedHttpSource {
buf,
downloaded_to,
tail_ready,
tail_filled_from,
total_size: total,
pos: 0,
done,
gen_arc: state.preview_gen.clone(),
gen,
};
let hint = stream_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
return Ok(Some(decoder));
}
crate::app_deprintln!(
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
supports_range,
total_size,
stream_hint
);
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let hint = resolve_preview_format_hint(
url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix,
&bytes,
);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
Ok(Some(decoder))
}
#[tauri::command]
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
pub async fn audio_preview_play(
id: String,
url: String,
@@ -363,24 +164,65 @@ pub async fn audio_preview_play(
preview_pause_main(&state);
}
// ── Open decoder (ranged stream when possible) ───────────────────────────
let decoder = match open_preview_decoder(
&url,
format_suffix.as_deref(),
gen,
&state,
&app,
)
.await?
{
Some(d) => d,
None => return Ok(()),
};
// ── Download ─────────────────────────────────────────────────────────────
// Dedicated client with a generous timeout. The shared `audio_http_client`
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
// ~60120 s on a typical home LAN. The watchdog (30 s wall-clock) still
// bounds how long the preview plays once the bytes are in memory, so a
// long download just means a longer "loading" spinner before audio starts.
let preview_http = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(&state));
let response = preview_http
.get(&url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?;
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
if state.preview_gen.load(Ordering::SeqCst) != gen {
// A newer preview started while we were downloading — bail.
return Ok(());
}
// ── Decode ───────────────────────────────────────────────────────────────
let hint = resolve_preview_format_hint(
&url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix.as_deref(),
&bytes,
);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
// ── Build source pipeline ────────────────────────────────────────────────
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
// before the seek made take_duration's wall-clock counter tick from
@@ -3,7 +3,6 @@ name = "psysonic-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -29,38 +29,17 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|| id.starts_with("ra-")
}
/// Windows reserved device names (case-insensitive) — invalid as path components.
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
/// Also used for media layout artist/album/title segments from server metadata.
pub fn sanitize_path_segment(segment: &str) -> String {
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
let trimmed = segment.trim();
if trimmed.is_empty() {
return "_".to_string();
}
let cleaned: String = trimmed
trimmed
.chars()
.map(|c| {
if c.is_control() || FORBIDDEN.contains(&c) {
'_'
} else {
c
}
})
.collect();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
return "_".to_string();
}
let upper = cleaned.to_ascii_uppercase();
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
return format!("_{cleaned}");
}
cleaned
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
.collect()
}
/// Relative path under `{root}/{server_segment}/` — change format here only.
@@ -277,13 +256,6 @@ mod tests {
base
}
#[test]
fn sanitize_rejects_dot_dot_and_reserved_names() {
assert_eq!(sanitize_path_segment(".."), "_");
assert_eq!(sanitize_path_segment("CON"), "_CON");
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
}
#[test]
fn segment_disk_usage_counts_canonical_only() {
let server = test_server_dir("usage");
@@ -5,7 +5,6 @@
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod cover_cache_layout;
pub mod media_layout;
pub mod logging;
pub mod ports;
pub mod track_analysis;
@@ -1,364 +0,0 @@
//! Local playback disk layout — artist/album/track paths from library-index fields.
//!
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
//! index key; artist/album/filename segments are derived from track metadata only.
use std::path::{Component, Path, PathBuf};
use crate::cover_cache_layout::sanitize_path_segment;
/// Max length for a single path component after sanitization (Windows budget).
pub const MAX_SEGMENT_LEN: usize = 120;
/// Inputs required to build hierarchical media paths (library index row projection).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackPathInput {
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: String,
pub title: String,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub suffix: Option<String>,
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
pub raw_json: Option<String>,
}
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalTier {
Ephemeral,
Library,
/// Auto-synced starred favorites — separate from user-pinned `library/`.
Favorites,
}
impl LocalTier {
pub fn subdir(self) -> &'static str {
match self {
Self::Ephemeral => "cache",
Self::Library => "library",
Self::Favorites => "favorites",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"ephemeral" | "cache" => Some(Self::Ephemeral),
"library" => Some(Self::Library),
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
_ => None,
}
}
}
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
let artist_seg = artist_folder_segment(input);
let album_seg = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let suffix = input
.suffix
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
let track_n = input.track_number.unwrap_or(0);
let disc_n = input.disc_number.unwrap_or(0);
format!(
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
input
.album_artist
.as_deref()
.map(str::trim)
.unwrap_or(""),
input.title.trim(),
)
}
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
pub fn relative_path_for_track(
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
let server_segment = sanitize_path_segment(server_index_key);
let artist = artist_folder_segment(input);
let album = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let ext = suffix.trim().trim_start_matches('.');
let filename = if ext.is_empty() {
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
} else {
format!(
"{}.{}",
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
sanitize_path_segment(ext)
)
};
PathBuf::from(server_segment)
.join(artist)
.join(album)
.join(filename)
}
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
pub fn absolute_track_path(
media_root: &Path,
tier: LocalTier,
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
media_root
.join(tier.subdir())
.join(relative_path_for_track(server_index_key, input, suffix))
}
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
pub fn ensure_track_path_within_tier(
media_root: &Path,
tier: LocalTier,
absolute: &Path,
) -> Result<(), String> {
let tier_root = media_root.join(tier.subdir());
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
return Err(format!(
"path `{}` escapes tier root `{}`",
absolute.display(),
tier_root.display()
));
};
for comp in rel.components() {
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
return Err(format!(
"path `{}` contains forbidden component `{comp:?}`",
absolute.display()
));
}
}
Ok(())
}
fn artist_folder_segment(input: &TrackPathInput) -> String {
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
let chosen = if artist.is_empty() || track_is_compilation(input) {
if !album_artist.is_empty() {
album_artist
} else {
"Various Artists"
}
} else {
artist
};
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
}
fn album_folder_segment(album: &str) -> String {
let trimmed = album.trim();
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
}
fn track_filename_stem(input: &TrackPathInput) -> String {
let title = input.title.trim();
let title = if title.is_empty() { "Unknown Title" } else { title };
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
if disc_n > 1 {
format!("{disc_n:02}-{track_n:02} - {title}")
} else {
format!("{track_n:02} - {title}")
}
}
fn track_is_compilation(input: &TrackPathInput) -> bool {
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
return true;
}
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
raw_json_marks_compilation(raw)
}
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
fn raw_json_marks_compilation(raw: &str) -> bool {
let lower = raw.to_ascii_lowercase();
lower.contains("\"iscompilation\":true")
|| lower.contains("\"iscompilation\": true")
|| lower.contains("\"compilation\":true")
|| lower.contains("\"compilation\": true")
|| lower.contains("\"compilation\":1")
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
}
fn various_artists_label(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
lower.contains("various artists")
}
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
let sanitized = sanitize_path_segment(segment);
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
if sanitized.chars().count() <= max_len {
return sanitized;
}
let hash = short_hash(segment);
let keep = max_len.saturating_sub(1 + hash.len());
let mut out = sanitized.chars().take(keep).collect::<String>();
out.push('_');
out.push_str(&hash);
out
}
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
fn short_hash(s: &str) -> String {
let mut h: u32 = 0;
for unit in s.encode_utf16() {
h = h.wrapping_mul(31).wrapping_add(unit as u32);
}
format!("{:08x}", h)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_input() -> TrackPathInput {
TrackPathInput {
artist: Some("Radiohead".to_string()),
album_artist: None,
album: "OK Computer".to_string(),
title: "Paranoid Android".to_string(),
track_number: Some(6),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
}
}
#[test]
fn relative_path_uses_library_segments() {
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
assert_eq!(
rel,
PathBuf::from("host_4533")
.join("Radiohead")
.join("OK Computer")
.join("06 - Paranoid Android.mp3")
);
}
#[test]
fn multi_disc_adds_disc_prefix() {
let mut input = sample_input();
input.disc_number = Some(2);
let rel = relative_path_for_track("srv", &input, "flac");
assert!(rel
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
}
#[test]
fn compilation_uses_album_artist_folder() {
let input = TrackPathInput {
artist: Some("Various Artists".to_string()),
album_artist: Some("Original Soundtrack".to_string()),
album: "Film Score".to_string(),
title: "Main Theme".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
}
#[test]
fn empty_artist_falls_back_to_various_artists() {
let input = TrackPathInput {
artist: None,
album_artist: None,
album: "Comp".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
}
#[test]
fn layout_fingerprint_is_stable() {
let a = layout_fingerprint(&sample_input());
let b = layout_fingerprint(&sample_input());
assert_eq!(a, b);
assert!(a.contains("Radiohead"));
assert!(a.contains("OK Computer"));
}
#[test]
fn tier_subdirs_are_fixed() {
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
assert_eq!(LocalTier::Library.subdir(), "library");
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
}
#[test]
fn absolute_path_includes_tier() {
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
assert!(path.starts_with(root.join("library")));
}
#[test]
fn dot_dot_metadata_does_not_escape_tier_root() {
let input = TrackPathInput {
artist: Some("..".to_string()),
album_artist: None,
album: "..".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
assert!(path.starts_with(root.join("library")));
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
}
#[test]
fn short_hash_matches_ts_imul31_utf16() {
// "Radiohead" — same as mediaLayout.test parity anchor.
assert_eq!(short_hash("Radiohead"), "3da68c3b");
}
#[test]
fn sanitize_and_truncate_uses_code_point_threshold() {
let cyrillic_a = '\u{0430}';
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
assert!(hundred.len() > MAX_SEGMENT_LEN);
assert_eq!(hundred.chars().count(), 100);
assert_eq!(
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
hundred
);
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
assert!(truncated.ends_with("_eef20600"));
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
}
}
@@ -3,7 +3,6 @@ name = "psysonic-integration"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
+2 -1
View File
@@ -3,7 +3,6 @@ name = "psysonic-library"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -16,6 +15,8 @@ serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "brotli"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] }
twox-hash = "2"
unicode-normalization = "0.1"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "test-util"] }
@@ -0,0 +1,10 @@
-- Plain All Albums browse: read from `album` with ORDER BY name (not track GROUP BY).
CREATE INDEX IF NOT EXISTS idx_album_server_name_browse
ON album(server_id, name COLLATE NOCASE);
-- Scoped album EXISTS probes: (server, album, library) on live tracks.
CREATE INDEX IF NOT EXISTS idx_track_server_album_library_browse
ON track(server_id, album_id, library_id)
WHERE deleted = 0
AND album_id IS NOT NULL
AND album_id != '';
@@ -22,11 +22,68 @@ use crate::filter::{self, EntityKind, FilterOp, SqlFragment};
use crate::repos;
use crate::search::{
aliased_track_columns, aliased_track_columns_resolved_bpm, bpm_resolved_expr,
fts_album_prefix_match_query, fts_album_title_prefix_match_query, fts_column_prefix_query, fts_query_meets_min_len,
fts_track_prefix_match_query, library_scope_equals_sql, like_contains, PAGE_LIMIT_MAX,
fts_album_prefix_any_token_match_query, fts_album_title_prefix_any_token_match_query,
fts_column_prefix_query, fts_query_meets_min_len, fts_track_prefix_match_query,
library_scope_filter_sql, like_any_token_contains_clause, like_contains, like_name_tokens,
PAGE_LIMIT_MAX,
};
use crate::store::LibraryStore;
fn effective_library_scope_ids(req: &LibraryAdvancedSearchRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s.to_string()])
.unwrap_or_default()
}
fn push_library_scope_where(w: &mut WhereBuilder, table: &str, scope_ids: &[String]) {
if scope_ids.is_empty() {
return;
}
let (sql, params) = library_scope_filter_sql(table, scope_ids);
if let Some(clause) = sql {
if params.len() == 1 {
w.push_param(&clause, params[0].clone());
} else {
w.push_params(&clause, params);
}
}
}
/// `album` rows have no `library_id`; scope is enforced via matching tracks.
fn push_album_table_library_scope(
w: &mut WhereBuilder,
album_alias: &str,
scope_ids: &[String],
) {
if scope_ids.is_empty() {
return;
}
let (clause, params) = library_scope_filter_sql("t_scope", scope_ids);
let Some(scope_clause) = clause else {
return;
};
w.push_params(
&format!(
"EXISTS (SELECT 1 FROM track t_scope \
WHERE t_scope.server_id = {album_alias}.server_id \
AND t_scope.album_id = {album_alias}.id \
AND t_scope.deleted = 0 \
AND {scope_clause})"
),
params,
);
}
/// `bpm` dual-storage resolution (§5.13.4): prefer analysis `track_fact(bpm)`,
/// then hot `track.bpm` tag, then other fact sources.
fn bpm_resolved_sql() -> String {
@@ -61,8 +118,8 @@ fn fts_candidate_pool_size(limit: u32, offset: u32) -> i64 {
need.saturating_mul(20).clamp(256, 10_000)
}
/// FTS rowid pick scoped to the active server (and optional library folder).
fn scoped_fts_rowid_subquery_sql(pool: i64, library_scope: Option<&str>) -> String {
/// FTS rowid pick scoped to the active server (and optional library folders).
fn scoped_fts_rowid_subquery_sql(pool: i64, scope_ids: &[String]) -> String {
let alias = "t_fts";
let mut sql = format!(
"SELECT f.rowid FROM track_fts f \
@@ -71,20 +128,21 @@ fn scoped_fts_rowid_subquery_sql(pool: i64, library_scope: Option<&str>) -> Stri
AND {alias}.server_id = ? \
AND {alias}.deleted = 0"
);
if library_scope.is_some() {
if let (Some(clause), _) = library_scope_filter_sql(alias, scope_ids) {
sql.push_str(" AND ");
sql.push_str(&library_scope_equals_sql(alias));
sql.push_str(&clause);
}
sql.push_str(&format!(" ORDER BY bm25(track_fts) LIMIT {pool}"));
sql
}
fn scoped_fts_pick_join_sql(pool: i64, library_scope: Option<&str>) -> String {
fn scoped_fts_pick_join_sql(pool: i64, scope_ids: &[String]) -> String {
let alias = "t_fts";
let mut scope_sql = String::new();
if library_scope.is_some() {
scope_sql = format!(" AND {}", library_scope_equals_sql(alias));
}
let scope_sql = if let (Some(clause), _) = library_scope_filter_sql(alias, scope_ids) {
format!(" AND {clause}")
} else {
String::new()
};
format!(
"track t INNER JOIN (\
SELECT f.rowid, bm25(track_fts) AS fts_rank \
@@ -99,14 +157,10 @@ fn scoped_fts_pick_join_sql(pool: i64, library_scope: Option<&str>) -> String {
)
}
fn scoped_fts_subquery_bind(
server_id: &str,
library_scope: Option<&str>,
) -> Vec<SqlValue> {
fn scoped_fts_subquery_bind(server_id: &str, scope_ids: &[String]) -> Vec<SqlValue> {
let mut params = vec![SqlValue::Text(server_id.to_string())];
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
params.push(SqlValue::Text(scope.to_string()));
}
let (_, scope_params) = library_scope_filter_sql("t_fts", scope_ids);
params.extend(scope_params);
params
}
@@ -217,10 +271,7 @@ fn build_track(
let mut w = WhereBuilder::new();
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
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));
}
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
@@ -246,8 +297,8 @@ fn build_track(
if let Some(q) = text.and_then(fts_track_prefix_match_query) {
applied.insert("text".to_string());
let pool = fts_candidate_pool_size(limit, offset);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let from = scoped_fts_pick_join_sql(pool, scope.as_deref());
let scope_ids = effective_library_scope_ids(req);
let from = scoped_fts_pick_join_sql(pool, &scope_ids);
let order = order_clause(&req.sort, EntityKind::Track)
.unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string());
return query_rows_fts(
@@ -255,7 +306,7 @@ fn build_track(
&cols,
&from,
&q,
&scoped_fts_subquery_bind(&req.server_id, scope.as_deref()),
&scoped_fts_subquery_bind(&req.server_id, &scope_ids),
&w,
&order,
limit,
@@ -307,12 +358,208 @@ fn server_has_indexed_tracks(store: &LibraryStore, server_id: &str) -> Result<bo
fn fts_album_text_match_query(req: &LibraryAdvancedSearchRequest, text: &str) -> Option<String> {
if req.query_album_title_only == Some(true) {
fts_album_title_prefix_match_query(text)
fts_album_title_prefix_any_token_match_query(text)
} else {
fts_album_prefix_match_query(text)
fts_album_prefix_any_token_match_query(text)
}
}
fn push_album_name_like_any_token(
w: &mut WhereBuilder,
column: &str,
text: &str,
applied: &mut BTreeSet<String>,
) {
let Some((sql, params)) = like_any_token_contains_clause(column, text) else {
return;
};
w.push_params(
&sql,
params.into_iter().map(SqlValue::Text).collect(),
);
applied.insert("text".to_string());
}
/// `album` row or any child track tag may carry the searchable title.
fn push_album_table_text_match(
w: &mut WhereBuilder,
text: &str,
applied: &mut BTreeSet<String>,
) {
let tokens = like_name_tokens(text);
if tokens.is_empty() {
return;
}
let mut parts = Vec::new();
let mut params = Vec::new();
for token in tokens {
let pat = like_contains(&token);
parts.push(
"(a.name COLLATE NOCASE LIKE ? ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM track t_mt \
WHERE t_mt.server_id = a.server_id AND t_mt.album_id = a.id \
AND t_mt.deleted = 0 \
AND t_mt.album COLLATE NOCASE LIKE ? ESCAPE '\\'))".to_string(),
);
params.push(SqlValue::Text(pat.clone()));
params.push(SqlValue::Text(pat));
}
w.push_params(&format!("({})", parts.join(" OR ")), params);
applied.insert("text".to_string());
}
/// Track group match: hot `t.album` tag or synced `album.name`.
fn push_track_group_text_match(
w: &mut WhereBuilder,
text: &str,
applied: &mut BTreeSet<String>,
) {
let tokens = like_name_tokens(text);
if tokens.is_empty() {
return;
}
let mut parts = Vec::new();
let mut params = Vec::new();
for token in tokens {
let pat = like_contains(&token);
parts.push(
"(t.album COLLATE NOCASE LIKE ? ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM album a_mt \
WHERE a_mt.server_id = t.server_id AND a_mt.id = t.album_id \
AND a_mt.name COLLATE NOCASE LIKE ? ESCAPE '\\'))".to_string(),
);
params.push(SqlValue::Text(pat.clone()));
params.push(SqlValue::Text(pat));
}
w.push_params(&format!("({})", parts.join(" OR ")), params);
applied.insert("text".to_string());
}
/// Synced `album` rows + scope filters — plain browse only (no free-text query).
fn try_build_album_from_table(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<Option<(Vec<LibraryAlbumDto>, u32)>, String> {
if scalar_requires_lossless_track_grouping(scalar)
|| scalar_requires_track_derived_entities(scalar)
{
return Ok(None);
}
if !crate::album_browse::album_table_usable(store, &req.server_id)? {
return Ok(None);
}
let table = build_album_from_table(store, req, None, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 {
return Ok(Some(table));
}
Ok(None)
}
fn album_text_hit_key(a: &LibraryAlbumDto) -> (String, String) {
(a.server_id.clone(), a.id.clone())
}
/// Prefer synced `album` rows; fill gaps from FTS / track-derived groups.
fn merge_album_text_hits(
table: Vec<LibraryAlbumDto>,
fts: Vec<LibraryAlbumDto>,
tracks: Vec<LibraryAlbumDto>,
) -> Vec<LibraryAlbumDto> {
let mut by_key: std::collections::HashMap<(String, String), LibraryAlbumDto> =
std::collections::HashMap::new();
for a in tracks {
by_key.entry(album_text_hit_key(&a)).or_insert(a);
}
for a in fts {
by_key.entry(album_text_hit_key(&a)).or_insert(a);
}
for a in table {
by_key.insert(album_text_hit_key(&a), a);
}
let mut out: Vec<LibraryAlbumDto> = by_key.into_values().collect();
out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
out
}
/// All Albums text search — union `album` LIKE, track FTS, and track GROUP BY
/// (substring + prefix). The `album`-table fast path alone misses titles that
/// only appear on track rows (Navidrome parity / Live Search).
#[allow(clippy::too_many_arguments)]
fn build_album_text_search(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: &str,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
applied.insert("text".to_string());
let fetch = limit.saturating_add(offset).clamp(1, PAGE_LIMIT_MAX);
let scope_ids = effective_library_scope_ids(req);
let mut scratch = BTreeSet::new();
let mut table = Vec::new();
// Match `list_albums`: multi-folder scope uses track GROUP BY, not album+EXISTS.
if scope_ids.len() <= 1
&& !scalar_requires_lossless_track_grouping(scalar)
&& !scalar_requires_track_derived_entities(scalar)
&& crate::album_browse::album_table_usable(store, &req.server_id)?
{
table = build_album_from_table(
store,
req,
Some(text),
scalar,
fetch,
0,
true,
&mut scratch,
)?
.0;
}
let mut fts = Vec::new();
let mut tracks = Vec::new();
if server_has_indexed_tracks(store, &req.server_id)? {
if fts_query_meets_min_len(text) {
if let Some(q) = fts_album_text_match_query(req, text) {
fts = build_album_from_fts(
store, req, &q, scalar, fetch, 0, true, &mut scratch,
)?
.0;
}
}
tracks = build_album_from_tracks(
store,
req,
Some(text),
scalar,
fetch,
0,
true,
&mut scratch,
true,
)?
.0;
}
let merged = merge_album_text_hits(table, fts, tracks);
let total = merged.len() as u32;
let page = merged
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok((page, if skip_totals { 0 } else { total }))
}
#[allow(clippy::too_many_arguments)]
fn build_album(
store: &LibraryStore,
@@ -334,12 +581,19 @@ fn build_album(
if req.starred_only == Some(true) {
return build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied);
}
if let Some(t) = text {
return build_album_text_search(
store, req, t, scalar, limit, offset, skip_totals, applied,
);
}
if let Some(table) = try_build_album_from_table(
store, req, scalar, limit, offset, skip_totals, applied,
)? {
return Ok(table);
}
if server_has_indexed_tracks(store, &req.server_id)? {
if let Some(q) = text.and_then(|t| fts_album_text_match_query(req, t)) {
return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
}
return build_album_from_tracks(
store, req, text, scalar, limit, offset, skip_totals, applied, false,
store, req, None, scalar, limit, offset, skip_totals, applied, false,
);
}
if !scalar_requires_track_derived_entities(scalar) {
@@ -367,13 +621,12 @@ fn build_album_from_table(
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
// `album` has no `library_id` / `deleted` columns, so `libraryScope` is
// a track-only filter (P20) and does not narrow album-table results.
// `album` has no `library_id`; scope is enforced via EXISTS on `track`.
let mut w = WhereBuilder::new();
w.push_param("a.server_id = ?", SqlValue::Text(req.server_id.clone()));
push_album_table_library_scope(&mut w, "a", &effective_library_scope_ids(req));
if let Some(t) = text {
w.push_param("a.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
push_album_table_text_match(&mut w, t, applied);
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Album)? {
@@ -434,13 +687,9 @@ fn build_album_from_tracks(
AND a.id = t.album_id AND a.song_count IS NOT NULL)",
);
}
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));
}
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
if let Some(t) = text {
w.push_param("t.album LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
push_track_group_text_match(&mut w, t, applied);
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
@@ -459,7 +708,9 @@ fn build_album_from_tracks(
applied,
);
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
let select = "t.server_id, t.album_id, \
MAX(COALESCE((SELECT a2.name FROM album a2 WHERE a2.server_id = t.server_id AND a2.id = t.album_id), 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 = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
@@ -558,10 +809,7 @@ fn build_artist_from_tracks(
w.push_raw(
"NOT EXISTS (SELECT 1 FROM artist ar WHERE ar.server_id = t.server_id AND ar.id = t.artist_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));
}
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
if let Some(t) = text {
w.push_param("t.artist LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
@@ -605,110 +853,94 @@ fn build_album_from_fts(
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
applied.insert("text".to_string());
let need = limit.saturating_add(offset) as i64;
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let fetch = limit.saturating_add(offset).clamp(1, PAGE_LIMIT_MAX);
let pool = fts_candidate_pool_size(fetch, 0);
let scope_ids = effective_library_scope_ids(req);
let mut w = WhereBuilder::new();
w.push_params(
&format!(
"t.rowid IN ({})",
scoped_fts_rowid_subquery_sql(pool, scope.as_deref())
),
{
let mut p = vec![SqlValue::Text(fts.to_string())];
p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref()));
p
},
);
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 != ''");
if let Some(scope) = scope {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
let mut extra = WhereBuilder::new();
extra.push_raw("t.deleted = 0");
extra.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
extra.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
push_library_scope_where(&mut extra, "t", &scope_ids);
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
w.push(frag);
extra.push(frag);
}
}
if req.starred_only == Some(true) {
w.push_raw("t.starred_at IS NOT NULL");
extra.push_raw("t.starred_at IS NOT NULL");
applied.insert("starred".to_string());
}
push_album_id_allowlist(
&mut w,
&mut extra,
"t.album_id",
req.restrict_album_ids.as_deref(),
applied,
);
let extra_sql = extra.where_sql();
let where_sql = w.where_sql();
let scope_tail = if let (Some(clause), _) = library_scope_filter_sql("t_fts", &scope_ids) {
format!(" AND {clause}")
} else {
String::new()
};
store.with_read_conn(|conn| {
let sql = format!(
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
"SELECT t.server_id, t.album_id, \
COALESCE(a.name, t.album), t.artist, t.artist_id, t.year, \
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
FROM track t \
WHERE {where_sql}"
FROM (\
SELECT f.rowid, bm25(track_fts) AS fts_rank \
FROM track_fts f \
JOIN track t_fts ON t_fts.rowid = f.rowid \
WHERE track_fts MATCH ? \
AND t_fts.server_id = ? \
AND t_fts.deleted = 0{scope_tail} \
ORDER BY fts_rank \
LIMIT {pool}\
) fts_pick \
JOIN track t ON t.rowid = fts_pick.rowid \
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id \
WHERE {extra_sql} \
GROUP BY t.album_id \
ORDER BY MIN(fts_pick.fts_rank) \
LIMIT ? OFFSET ?",
);
let params = w.params.clone();
let mut params: Vec<SqlValue> = Vec::new();
params.push(SqlValue::Text(fts.to_string()));
params.extend(scoped_fts_subquery_bind(&req.server_id, &scope_ids));
params.extend(extra.params);
params.push(SqlValue::Integer(fetch as i64));
params.push(SqlValue::Integer(offset as i64));
let mut stmt = conn.prepare(&sql)?;
let rows: Vec<AlbumBrowseTrackRow> =
stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
r.get(8)?,
r.get(9)?,
))
let albums: Vec<LibraryAlbumDto> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
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: None,
duration_sec: None,
year: r.get(5)?,
genre: r.get(6)?,
cover_art_id: r.get(7)?,
starred_at: r.get(8)?,
synced_at: r.get(9)?,
raw_json: Value::Null,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut seen = HashSet::new();
let mut deduped: Vec<LibraryAlbumDto> = Vec::new();
for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows {
if !seen.insert(album_id.clone()) {
continue;
}
deduped.push(LibraryAlbumDto {
server_id,
id: album_id,
name: album,
artist,
artist_id,
song_count: None,
duration_sec: None,
year,
genre,
cover_art_id,
starred_at,
synced_at,
raw_json: Value::Null,
});
if deduped.len() >= need as usize {
break;
}
}
let total = if skip_totals {
0
} else {
deduped.len() as u32
albums.len() as u32
};
let page = deduped
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok((page, total))
Ok((albums, total))
})
}
@@ -727,27 +959,24 @@ fn build_artist_from_fts(
applied.insert("text".to_string());
let need = limit.saturating_add(offset) as i64;
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let scope_ids = effective_library_scope_ids(req);
let mut w = WhereBuilder::new();
w.push_params(
&format!(
"t.rowid IN ({})",
scoped_fts_rowid_subquery_sql(pool, scope.as_deref())
scoped_fts_rowid_subquery_sql(pool, &scope_ids)
),
{
let mut p = vec![SqlValue::Text(fts.to_string())];
p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref()));
p.extend(scoped_fts_subquery_bind(&req.server_id, &scope_ids));
p
},
);
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''");
if let Some(scope) = scope {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
push_library_scope_where(&mut w, "t", &scope_ids);
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
@@ -1395,6 +1624,17 @@ mod tests {
.unwrap();
}
fn mark_album_catalog_row(store: &LibraryStore, server: &str, id: &str) {
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 1 WHERE server_id = ?1 AND id = ?2",
rusqlite::params![server, id],
)
})
.unwrap();
}
fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) {
store
.with_conn("misc", |c| {
@@ -1411,6 +1651,7 @@ mod tests {
LibraryAdvancedSearchRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
query: None,
entity_types: entities.to_vec(),
filters: Vec::new(),
@@ -1475,6 +1716,8 @@ mod tests {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al1", "Aurora Nights", None, None);
insert_album(&store, "s1", "al2", "Other", None, None);
mark_album_catalog_row(&store, "s1", "al1");
mark_album_catalog_row(&store, "s1", "al2");
insert_artist(&store, "s1", "ar1", "Aurora Quartet");
let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]);
r.query = Some("aurora".into());
@@ -1485,6 +1728,169 @@ mod tests {
assert_eq!(resp.artists[0].id, "ar1");
}
#[test]
fn album_title_search_matches_any_query_word_via_like() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_moon", "The Dark Side of the Moon", None, None);
insert_album(&store, "s1", "al_other", "Wish You Were Here", None, None);
mark_album_catalog_row(&store, "s1", "al_moon");
mark_album_catalog_row(&store, "s1", "al_other");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("moon side".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_moon");
}
#[test]
fn album_title_search_matches_later_word_only() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_moon", "The Dark Side of the Moon", None, None);
mark_album_catalog_row(&store, "s1", "al_moon");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("moon".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_moon");
}
#[test]
fn album_title_search_matches_synced_name_when_track_tag_differs() {
let store = LibraryStore::open_in_memory();
insert_album(
&store,
"s1",
"al_kerrang",
"Kerrang! Metallica Master of Puppets Revisited",
None,
None,
);
mark_album_catalog_row(&store, "s1", "al_kerrang");
let mut tr = track("s1", "t1", "A", "Various", "Master of Puppets Revisited");
tr.album_id = Some("al_kerrang".into());
TrackRepository::new(&store).upsert_batch(&[tr]).unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_kerrang");
}
fn track_with_lib(
server: &str,
id: &str,
album_id: &str,
album: &str,
library_id: Option<&str>,
) -> TrackRow {
let mut t = track(server, id, "A", "art-1", album);
t.album_id = Some(album_id.into());
t.library_id = library_id.map(str::to_string);
t
}
#[test]
fn album_title_text_search_respects_single_library_scope() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_lib("s1", "t1", "al-in", "Metallica", Some("lib-a")),
track_with_lib("s1", "t2", "al-out", "Metallica Covers", Some("lib-b")),
])
.unwrap();
insert_album(&store, "s1", "al-in", "Metallica", None, None);
insert_album(&store, "s1", "al-out", "Metallica Covers", None, None);
mark_album_catalog_row(&store, "s1", "al-in");
mark_album_catalog_row(&store, "s1", "al-out");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
r.library_scope_ids = Some(vec!["lib-a".into()]);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al-in");
}
#[test]
fn album_title_text_search_unions_multi_library_scope() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_lib("s1", "t1", "al-a", "Alpha Metallica", Some("lib-a")),
track_with_lib("s1", "t2", "al-b", "Beta Metallica", Some("lib-b")),
track_with_lib("s1", "t3", "al-c", "Gamma Other", Some("lib-c")),
])
.unwrap();
insert_album(&store, "s1", "al-a", "Alpha Metallica", None, None);
insert_album(&store, "s1", "al-b", "Beta Metallica", None, None);
mark_album_catalog_row(&store, "s1", "al-a");
mark_album_catalog_row(&store, "s1", "al-b");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
r.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 2, "expected {ids:?}");
assert!(ids.contains(&"al-a"));
assert!(ids.contains(&"al-b"));
}
#[test]
fn album_title_text_search_unions_sparse_table_and_track_catalog() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_self", "Metallica", None, None);
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 10 WHERE server_id = 's1' AND id = 'al_self'",
[],
)
})
.unwrap();
let mut plays = track("s1", "t1", "A", "Apocalyptica", "Plays Metallica Vol. 2");
plays.album_id = Some("al_plays".into());
let mut blacklist = track("s1", "t2", "B", "Various", "The Metallica Blacklist");
blacklist.album_id = Some("al_black".into());
let mut other = track("s1", "t3", "C", "Pink Floyd", "Wish You Were Here");
other.album_id = Some("al_wish".into());
TrackRepository::new(&store)
.upsert_batch(&[plays, blacklist, other])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 3);
assert!(ids.contains(&"al_self"));
assert!(ids.contains(&"al_plays"));
assert!(ids.contains(&"al_black"));
}
#[test]
fn album_title_search_fts_matches_any_word_on_track_catalog() {
let store = LibraryStore::open_in_memory();
let mut moon = track("s1", "t1", "Breathe", "Pink Floyd", "The Dark Side of the Moon");
moon.album_id = Some("al_moon".into());
let mut other = track("s1", "t2", "Shine", "Pink Floyd", "Wish You Were Here");
other.album_id = Some("al_wish".into());
TrackRepository::new(&store)
.upsert_batch(&[moon, other])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("wish moon".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"al_moon"));
assert!(ids.contains(&"al_wish"));
}
#[test]
fn text_query_derives_album_and_artist_from_tracks_when_tables_empty() {
let store = LibraryStore::open_in_memory();
@@ -0,0 +1,499 @@
//! Paginated All Albums browse from the local index (plain, no filters).
//!
//! Prefers the synced `album` table (≈5k rows) with LIMIT/OFFSET. Falls back to
//! track `GROUP BY` only when the album catalog is not populated (N1 ingest).
use crate::dto::{
LibraryAlbumBrowseRequest, LibraryAlbumBrowseResponse, LibraryAlbumDto, LibrarySortClause,
SortDir,
};
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
s.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
}
fn effective_scope_ids(req: &LibraryAlbumBrowseRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn album_table_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "a.name COLLATE NOCASE",
"artist" => "a.artist COLLATE NOCASE",
"year" => "a.year",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
keys.push("a.name COLLATE NOCASE ASC".to_string());
}
keys.push("a.id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
fn track_group_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
"year" => "COALESCE(a.year, la.year)",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
}
keys.push("la.album_id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
fn push_album_id_allowlist(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
column: &str,
ids: Option<&[String]>,
) {
let Some(ids) = ids else {
return;
};
if ids.is_empty() {
where_clauses.push("1 = 0".to_string());
return;
}
let placeholders = (0..ids.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
where_clauses.push(format!("{column} IN ({placeholders})"));
for id in ids {
params.push(SqlValue::Text(id.clone()));
}
}
/// `album` rows have no `library_id`; scope is enforced via matching tracks.
fn push_album_table_library_scope(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
scope_ids: &[String],
) {
if scope_ids.is_empty() {
return;
}
let (clause, scope_params) = library_scope_filter_sql("t_scope", scope_ids);
let Some(scope_clause) = clause else {
return;
};
where_clauses.push(format!(
"EXISTS (SELECT 1 FROM track t_scope \
WHERE t_scope.server_id = a.server_id \
AND t_scope.album_id = a.id \
AND t_scope.deleted = 0 \
AND {scope_clause})"
));
params.extend(scope_params);
}
fn map_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null),
})
}
pub(crate) fn album_table_usable(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
store
.with_read_conn(|c| {
c.query_row(
"SELECT EXISTS(
SELECT 1 FROM album
WHERE server_id = ?1 AND song_count IS NOT NULL
LIMIT 1
)",
rusqlite::params![server_id],
|r| r.get(0),
)
})
.map_err(|e| e.to_string())
}
fn list_albums_from_table(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let limit = req.limit.max(1);
let offset = req.offset;
let order_sql = album_table_order_sql(&req.sort);
let mut where_clauses = vec!["a.server_id = ?1".to_string()];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_scope_ids(req);
push_album_table_library_scope(&mut where_clauses, &mut params, &scope_ids);
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"a.id",
req.restrict_album_ids.as_deref(),
);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
a.server_id, \
a.id, \
a.name, \
a.artist, \
a.artist_id, \
a.song_count, \
a.duration_sec, \
a.year, \
a.genre, \
a.cover_art_id, \
a.starred_at, \
a.synced_at, \
a.raw_json \
FROM album a \
WHERE {where_sql} \
{order_sql} \
LIMIT ? OFFSET ?"
);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryAlbumBrowseResponse {
albums,
has_more,
source: "local".to_string(),
})
}
fn list_albums_from_tracks(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let limit = req.limit.max(1);
let offset = req.offset;
let order_sql = track_group_order_sql(&req.sort);
let mut where_clauses = vec![
"t.deleted = 0".to_string(),
"t.server_id = ?1".to_string(),
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_scope_ids(req);
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
params.extend(scope_params);
}
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"t.album_id",
req.restrict_album_ids.as_deref(),
);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
COALESCE(a.year, la.year), \
COALESCE(a.genre, la.genre), \
COALESCE(a.cover_art_id, la.cover_art_id), \
COALESCE(a.starred_at, la.starred_at), \
COALESCE(a.synced_at, la.synced_at), \
a.raw_json \
FROM ( \
SELECT \
t.server_id, \
t.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
MAX(t.cover_art_id) AS cover_art_id, \
MAX(t.starred_at) AS starred_at, \
MAX(t.synced_at) AS synced_at, \
COUNT(*) AS track_count, \
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \
FROM track t \
WHERE {where_sql} \
GROUP BY t.server_id, t.album_id \
) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
{order_sql} \
LIMIT ? OFFSET ?"
);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryAlbumBrowseResponse {
albums,
has_more,
source: "local".to_string(),
})
}
fn browse_is_scoped(req: &LibraryAlbumBrowseRequest) -> bool {
!effective_scope_ids(req).is_empty()
|| req
.restrict_album_ids
.as_ref()
.is_some_and(|ids| !ids.is_empty())
}
pub fn list_albums(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let scope_ids = effective_scope_ids(req);
// Unscoped, or a single library: `album` table + EXISTS (fast on ~5k rows).
// Multi-library union: filter tracks by `library_id IN (...)` then GROUP BY.
if album_table_usable(store, &req.server_id)?
&& (!browse_is_scoped(req) || scope_ids.len() == 1)
{
return list_albums_from_table(store, req);
}
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(LibraryAlbumBrowseResponse {
albums: Vec::new(),
has_more: false,
source: "local".to_string(),
});
}
list_albums_from_tracks(store, req)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
fn track(server: &str, id: &str, album_id: &str, album: &str, library_id: Option<&str>) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: format!("{album} track"),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some("art-1".into()),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: Some("mp3".into()),
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: library_id.map(String::from),
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
fn req(server: &str, limit: u32, offset: u32) -> LibraryAlbumBrowseRequest {
LibraryAlbumBrowseRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
sort: Vec::new(),
restrict_album_ids: None,
limit,
offset,
}
}
fn seed_album(
store: &LibraryStore,
server_id: &str,
id: &str,
name: &str,
song_count: i64,
) {
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, 'Band', ?4, 400, 1, '{}')",
rusqlite::params![server_id, id, name, song_count],
)
})
.unwrap();
}
#[test]
fn lists_albums_grouped_from_tracks() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", None),
track("s1", "t2", "al-2", "Beta", None),
])
.unwrap();
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 2);
assert!(!resp.has_more);
}
#[test]
fn prefers_album_table_when_synced_catalog_exists() {
let store = LibraryStore::open_in_memory();
seed_album(&store, "s1", "al-1", "Alpha", 10);
seed_album(&store, "s1", "al-2", "Beta", 8);
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].name, "Alpha");
assert_eq!(resp.albums[1].name, "Beta");
}
#[test]
fn library_scope_narrows_results() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "In", Some("lib-a")),
track("s1", "t2", "al-2", "Out", Some("lib-b")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "In", 1);
seed_album(&store, "s1", "al-2", "Out", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al-1");
}
#[test]
fn multi_library_scope_unions_albums() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
track("s1", "t2", "al-2", "Beta", Some("lib-b")),
track("s1", "t3", "al-3", "Gamma", Some("lib-c")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "Alpha", 1);
seed_album(&store, "s1", "al-2", "Beta", 1);
seed_album(&store, "s1", "al-3", "Gamma", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al-1");
assert_eq!(resp.albums[1].id, "al-2");
}
#[test]
fn multi_library_scope_includes_track_only_albums() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
track("s1", "t2", "al-2", "Zulu", Some("lib-b")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "Alpha", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al-1");
assert_eq!(resp.albums[1].id, "al-2");
}
}
@@ -6,7 +6,7 @@ use tauri::State;
use crate::dto::CatalogYearBoundsDto;
use crate::dto::GenreAlbumCountDto;
use crate::runtime::LibraryRuntime;
use crate::search::library_scope_equals_sql;
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
#[derive(Debug, Clone, serde::Deserialize)]
@@ -106,11 +106,34 @@ pub fn library_get_catalog_year_bounds(
catalog_year_bounds_for_server(&runtime.store, &server_id)
}
fn effective_genre_count_scope_ids(
library_scope: Option<&str>,
library_scope_ids: Option<&[String]>,
) -> Vec<String> {
if let Some(ids) = library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
library_scope
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| vec![s.to_string()])
.unwrap_or_default()
}
pub(crate) fn genre_album_counts_for_server(
store: &LibraryStore,
server_id: &str,
library_scope: Option<&str>,
library_scope_ids: Option<&[String]>,
) -> Result<Vec<GenreAlbumCountDto>, String> {
let scope_ids = effective_genre_count_scope_ids(library_scope, library_scope_ids);
store
.with_read_conn(|conn| {
let mut sql = String::from(
@@ -122,9 +145,9 @@ pub(crate) fn genre_album_counts_for_server(
);
let mut params: Vec<rusqlite::types::Value> =
vec![rusqlite::types::Value::Text(server_id.to_string())];
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
sql.push_str(&format!(" AND {}", library_scope_equals_sql("t")));
params.push(rusqlite::types::Value::Text(scope.to_string()));
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
sql.push_str(&format!(" AND {clause}"));
params.extend(scope_params);
}
sql.push_str(
" GROUP BY t.genre COLLATE NOCASE \
@@ -151,11 +174,13 @@ pub fn library_get_genre_album_counts(
runtime: State<'_, LibraryRuntime>,
server_id: String,
library_scope: Option<String>,
library_scope_ids: Option<Vec<String>>,
) -> Result<Vec<GenreAlbumCountDto>, String> {
genre_album_counts_for_server(
&runtime.store,
&server_id,
library_scope.as_deref(),
library_scope_ids.as_deref(),
)
}
@@ -307,7 +332,7 @@ mod tests {
.upsert_batch(&rock_one)
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None, None).unwrap();
assert_eq!(counts.len(), 2);
assert_eq!(counts[0].value, "Rock");
assert_eq!(counts[0].album_count, 2);
@@ -330,7 +355,7 @@ mod tests {
.upsert_batch(&[scoped, other])
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1"), None).unwrap();
assert_eq!(counts.len(), 1);
assert_eq!(counts[0].value, "Rock");
assert_eq!(counts[0].album_count, 1);
+292 -36
View File
@@ -20,10 +20,16 @@ use crate::cover_resolve::CoverEntryDto;
use crate::cross_server;
use crate::dto::{
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryClusterAdvancedSearchRequest, LibraryClusterListTracksRequest, LibraryClusterResolveRequest,
LibraryClusterResolveResponse, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse,
LibraryClusterScopeRequest, LibraryClusterPlayerStatsRequest, LibraryClusterPlayerStatsDayDetailRequest,
LibraryClusterEntityDetailRequest, LibraryClusterAlbumDetailResponse,
LibraryClusterArtistDetailResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
PlaySessionInputDto, PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto,
PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
TrackArtifactDto, TrackFactDto, TrackRefDto,
};
use crate::live_search;
@@ -396,40 +402,6 @@ pub async fn library_get_tracks_by_album(
Ok(rows.iter().map(LibraryTrackDto::from_row).collect())
}
/// Upsert Subsonic API song payloads into the library index so pin/download can
/// build `media/library/…` paths before a full sync has ingested the rows.
#[tauri::command]
pub fn library_upsert_songs_from_api(
runtime: State<'_, LibraryRuntime>,
server_id: String,
songs: Vec<serde_json::Value>,
) -> Result<u32, String> {
use crate::sync::subsonic_song_to_track_row;
use psysonic_integration::subsonic::Song;
if songs.is_empty() {
return Ok(0);
}
let synced_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs() as i64;
let repo = TrackRepository::new(&runtime.store);
let mut rows = Vec::with_capacity(songs.len());
for raw in songs {
let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
rows.push(subsonic_song_to_track_row(
&server_id,
&song,
&raw,
synced_at,
None,
));
}
repo.upsert_batch(&rows)?;
Ok(rows.len() as u32)
}
#[tauri::command]
pub async fn library_get_artifact(
runtime: State<'_, LibraryRuntime>,
@@ -507,6 +479,16 @@ pub async fn library_advanced_search(
library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await
}
#[tauri::command]
pub async fn library_cluster_advanced_search(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterAdvancedSearchRequest,
) -> Result<LibraryAdvancedSearchResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::server_cluster::run_cluster_advanced_search(&store, request))
.await
}
#[tauri::command]
pub async fn library_list_lossless_albums(
runtime: State<'_, LibraryRuntime>,
@@ -516,6 +498,15 @@ pub async fn library_list_lossless_albums(
library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await
}
#[tauri::command]
pub async fn library_list_albums(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryAlbumBrowseRequest,
) -> Result<crate::dto::LibraryAlbumBrowseResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::album_browse::list_albums(&store, &request)).await
}
#[tauri::command]
pub async fn library_list_albums_by_genre(
runtime: State<'_, LibraryRuntime>,
@@ -580,6 +571,265 @@ pub async fn library_search_cross_server(
cross_server::run_cross_server_search(&runtime.store, &query, limit, servers.as_deref())
}
#[tauri::command]
pub async fn library_cluster_list_tracks(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryTracksEnvelope, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_tracks(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_albums(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryClusterAlbumsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_albums(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_artists(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryClusterArtistsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_artists(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorites(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryTracksEnvelope, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_tracks(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorite_albums(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryClusterAlbumsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_albums(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorite_artists(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryClusterArtistsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_artists(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub fn library_cluster_player_stats_year_summary(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsRequest,
) -> Result<PlaySessionYearSummaryDto, String> {
crate::server_cluster::cluster_year_summary(
&runtime.store,
&request.servers_ordered,
request.year,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_heatmap(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsRequest,
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
crate::server_cluster::cluster_heatmap(
&runtime.store,
&request.servers_ordered,
request.year,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_day_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsDayDetailRequest,
) -> Result<PlaySessionDayDetailDto, String> {
crate::server_cluster::cluster_day_detail(
&runtime.store,
&request.servers_ordered,
&request.date_iso,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_recent_days(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
crate::server_cluster::cluster_recent_days(
&runtime.store,
&request.servers_ordered,
request.limit.unwrap_or(30),
)
}
#[tauri::command]
pub fn library_cluster_player_stats_most_played(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<Vec<PlaySessionMostPlayedDto>, String> {
crate::server_cluster::cluster_most_played(
&runtime.store,
&request.servers_ordered,
request.limit.unwrap_or(50),
)
}
#[tauri::command]
pub async fn library_cluster_resolve_candidates(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterResolveRequest,
) -> Result<LibraryClusterResolveResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || {
if let Some(key) = request.cluster_key.filter(|k| !k.is_empty()) {
let candidates = crate::server_cluster::resolve_candidates_by_cluster_key(
&store,
&request.servers_ordered,
&key,
)?;
return Ok(LibraryClusterResolveResponse {
candidates,
cluster_key: Some(key),
});
}
let server_id = request
.server_id
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| "cluster_key or (server_id, track_id) required".to_string())?;
let track_id = request
.track_id
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| "cluster_key or (server_id, track_id) required".to_string())?;
let cluster_key =
crate::server_cluster::cluster_key_for_track(&store, server_id, track_id)?;
let candidates = crate::server_cluster::resolve_candidates_for_track(
&store,
&request.servers_ordered,
server_id,
track_id,
)?;
Ok(LibraryClusterResolveResponse {
candidates,
cluster_key,
})
})
.await
}
#[tauri::command]
pub async fn library_cluster_album_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterEntityDetailRequest,
) -> Result<LibraryClusterAlbumDetailResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let server_id = request.server_id;
let entity_id = request.entity_id;
library_spawn_blocking(move || {
crate::server_cluster::cluster_album_detail(&store, &servers_ordered, &server_id, &entity_id)
})
.await
}
#[tauri::command]
pub async fn library_cluster_artist_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterEntityDetailRequest,
) -> Result<LibraryClusterArtistDetailResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let server_id = request.server_id;
let entity_id = request.entity_id;
library_spawn_blocking(move || {
crate::server_cluster::cluster_artist_detail(&store, &servers_ordered, &server_id, &entity_id)
})
.await
}
#[tauri::command]
pub async fn library_search_cluster(
runtime: State<'_, LibraryRuntime>,
query: String,
limit: Option<u32>,
offset: Option<u32>,
servers_ordered: Vec<String>,
) -> Result<LibraryCrossServerSearchResponse, String> {
let store = Arc::clone(&runtime.store);
let limit = limit.unwrap_or(100);
let offset = offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::run_cluster_search(&store, &query, limit, offset, &servers_ordered)
})
.await
}
// ── helpers ──────────────────────────────────────────────────────────
fn hydrate_refs(
@@ -947,6 +1197,12 @@ async fn library_sync_start_inner(
};
if let Some(runtime) = app_for_emit.try_state::<LibraryRuntime>() {
let _ = runtime.store.checkpoint_wal("sync.checkpoint");
if outcome.ok {
let _ = crate::server_cluster::rebuild_cluster_keys_for_server(
&runtime.store,
&server_id_for_emit,
);
}
}
let _ = app_for_emit.emit(LibrarySyncProgressPayload::IDLE_EVENT_NAME, &outcome);
@@ -4,6 +4,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use crate::filter::{EntityKind, FilterOp};
use crate::repos::TrackRow;
@@ -365,6 +366,14 @@ pub struct PlaySessionRecentDayDto {
pub partial_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionMostPlayedDto {
pub track: LibraryTrackDto,
pub track_play_count: u32,
pub total_listened_sec: f64,
}
/// Earliest/latest calendar years with at least one session (local TZ).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -390,6 +399,38 @@ pub struct GenreAlbumCountDto {
pub song_count: u32,
}
/// `library_list_albums` request — paginated plain All Albums browse (local index).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumBrowseRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default = "default_album_browse_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_album_browse_limit() -> u32 {
30
}
/// `library_list_albums` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumBrowseResponse {
pub albums: Vec<LibraryAlbumDto>,
pub has_more: bool,
pub source: String,
}
/// `library_list_albums_by_genre` request — paginated genre album browse (local index).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -399,6 +440,8 @@ pub struct LibraryGenreAlbumsRequest {
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
#[serde(default = "default_genre_album_limit")]
pub limit: u32,
@@ -520,6 +563,9 @@ pub struct LibraryAdvancedSearchRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Multiple music-folder ids (OR). Takes precedence over `library_scope` when non-empty.
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub query: Option<String>,
pub entity_types: Vec<EntityKind>,
@@ -605,6 +651,14 @@ pub struct LibraryLosslessAlbumsRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Multiple music-folder ids (OR). Preferred over `library_scope` when length > 1.
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
/// Navidrome-scoped album ids from getAlbumList2 (authoritative when track `library_id` is sparse).
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default = "default_lossless_limit")]
pub limit: u32,
#[serde(default)]
@@ -658,6 +712,158 @@ pub struct LibraryCrossServerSearchResponse {
pub servers_searched: Vec<String>,
}
/// Cluster candidate row for playback / write fan-out resolution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterCandidateDto {
pub server_id: String,
pub track_id: String,
pub duration_sec: i64,
pub priority_rank: u32,
pub is_winner: bool,
}
/// `library_cluster_list_tracks` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterListTracksRequest {
/// Ordered member server ids (index 0 = highest priority).
pub servers_ordered: Vec<String>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub offset: Option<u32>,
/// Per-member music-folder scopes (`server_id` → folder ids). Omitted members = all libraries.
#[serde(default)]
pub library_scopes: HashMap<String, Vec<String>>,
}
/// `library_cluster_advanced_search` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAdvancedSearchRequest {
/// Ordered member server ids (index 0 = highest priority).
pub servers_ordered: Vec<String>,
#[serde(default)]
pub query: Option<String>,
pub entity_types: Vec<EntityKind>,
#[serde(default)]
pub filters: Vec<LibraryFilterClause>,
#[serde(default)]
pub starred_only: Option<bool>,
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
/// Per-member album allowlists from getAlbumList2 (`server_id` → album ids).
#[serde(default)]
pub restrict_album_scopes: HashMap<String, Vec<String>>,
#[serde(default)]
pub query_album_title_only: Option<bool>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
pub limit: u32,
#[serde(default)]
pub offset: u32,
#[serde(default)]
pub skip_totals: bool,
/// Per-member music-folder scopes (`server_id` → folder ids). Omitted members = all libraries.
#[serde(default)]
pub library_scopes: HashMap<String, Vec<String>>,
}
/// Merged album browse response for cluster scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAlbumsResponse {
pub albums: Vec<LibraryAlbumDto>,
pub has_more: bool,
}
/// Merged artist browse response for cluster scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterArtistsResponse {
pub artists: Vec<LibraryArtistDto>,
pub has_more: bool,
}
/// Cluster player stats / favorites scope request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterScopeRequest {
pub servers_ordered: Vec<String>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterPlayerStatsRequest {
pub servers_ordered: Vec<String>,
pub year: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterPlayerStatsDayDetailRequest {
pub servers_ordered: Vec<String>,
pub date_iso: String,
}
/// `library_cluster_resolve_candidates` request — provide cluster_key OR seed track.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterResolveRequest {
pub servers_ordered: Vec<String>,
#[serde(default)]
pub cluster_key: Option<String>,
#[serde(default)]
pub server_id: Option<String>,
#[serde(default)]
pub track_id: Option<String>,
}
/// `library_cluster_resolve_candidates` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterResolveResponse {
pub candidates: Vec<LibraryClusterCandidateDto>,
#[serde(default)]
pub cluster_key: Option<String>,
}
/// `library_cluster_album_detail` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterEntityDetailRequest {
pub servers_ordered: Vec<String>,
pub server_id: String,
pub entity_id: String,
}
/// Virtual aggregate album detail (spec §4).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAlbumDetailResponse {
pub album: LibraryAlbumDto,
pub tracks: Vec<LibraryTrackDto>,
pub owner_server_id: String,
pub related_albums: Vec<LibraryAlbumDto>,
}
/// Virtual aggregate artist detail (spec §4).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterArtistDetailResponse {
pub artist: LibraryArtistDto,
pub albums: Vec<LibraryAlbumDto>,
pub top_tracks: Vec<LibraryTrackDto>,
pub owner_server_id: String,
#[serde(default)]
pub artist_key: Option<String>,
}
/// Read `MAX(server_updated_at)` for non-deleted tracks on this server
/// — used by `SyncStateDto` so callers can show "tracks watermark" in
/// Settings without a separate column.
@@ -7,7 +7,7 @@ use crate::dto::{
LibraryAlbumDto, LibraryGenreAlbumsRequest, LibraryGenreAlbumsResponse, LibrarySortClause,
SortDir,
};
use crate::search::library_scope_equals_sql;
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
@@ -18,6 +18,22 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
.map(String::from)
}
fn effective_genre_scope_ids(req: &LibraryGenreAlbumsRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
@@ -117,9 +133,10 @@ pub fn list_albums_by_genre(
SqlValue::Text(genre.to_string()),
];
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
where_clauses.push(library_scope_equals_sql("t"));
params.push(SqlValue::Text(scope));
let scope_ids = effective_genre_scope_ids(req);
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
params.extend(scope_params);
}
let where_sql = where_clauses.join(" AND ");
@@ -253,6 +270,7 @@ mod tests {
server_id: "s1".into(),
genre: "Rock".into(),
library_scope: Some("lib1".into()),
library_scope_ids: None,
sort: vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
@@ -272,6 +290,7 @@ mod tests {
server_id: "s1".into(),
genre: "Rock".into(),
library_scope: None,
library_scope_ids: None,
sort: vec![],
limit: 1,
offset: 0,
@@ -9,6 +9,7 @@
pub(crate) mod bulk_ingest;
pub mod advanced_search;
pub mod album_browse;
pub mod album_compilation_filter;
pub mod browse_support;
mod advanced_search_mood;
@@ -33,6 +34,7 @@ pub mod payload;
pub mod repos;
pub mod runtime;
pub mod search;
pub mod server_cluster;
pub mod store;
pub mod sync;
pub(crate) mod track_fts;
@@ -2,9 +2,12 @@
//!
//! Mirrors the frontend allowlist in `src/utils/library/losslessFormats.ts`.
use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse};
use crate::dto::{
LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse, LibrarySortClause,
SortDir,
};
use crate::lossless_formats::track_is_lossless_sql;
use crate::search::library_scope_equals_sql;
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
@@ -15,6 +18,45 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
.map(String::from)
}
fn effective_lossless_scope_ids(req: &LibraryLosslessAlbumsRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn lossless_album_order(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
return "ORDER BY la.max_bit_depth DESC, \
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, la.album_id ASC"
.to_string();
}
keys.push("la.album_id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
/// 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(
@@ -37,12 +79,25 @@ pub fn list_lossless_albums(
];
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 scope_ids = effective_lossless_scope_ids(req);
if !scope_ids.is_empty() {
let match_expr = crate::search::library_scope_match_sql("t");
where_clauses.push(format!("({match_expr}) IS NOT NULL AND TRIM({match_expr}) != ''"));
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
for p in scope_params {
params.push(p);
}
}
}
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"t.album_id",
req.restrict_album_ids.as_deref(),
);
let order_sql = lossless_album_order(&req.sort);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
@@ -81,9 +136,7 @@ pub fn list_lossless_albums(
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 \
{order_sql} \
LIMIT ? OFFSET ?"
);
@@ -106,6 +159,26 @@ pub fn list_lossless_albums(
})
}
fn push_album_id_allowlist(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
column: &str,
ids: Option<&[String]>,
) {
let Some(ids) = ids else {
return;
};
if ids.is_empty() {
where_clauses.push("1 = 0".to_string());
return;
}
let placeholders = (0..ids.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
where_clauses.push(format!("{column} IN ({placeholders})"));
for id in ids {
params.push(SqlValue::Text(id.clone()));
}
}
fn empty_response() -> LibraryLosslessAlbumsResponse {
LibraryLosslessAlbumsResponse {
albums: Vec::new(),
@@ -203,6 +276,9 @@ mod tests {
LibraryLosslessAlbumsRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
sort: Vec::new(),
restrict_album_ids: None,
limit,
offset,
}
@@ -271,6 +347,79 @@ mod tests {
assert_eq!(resp.albums[0].id, "al1");
}
#[test]
fn library_scope_ids_union_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());
let mut c = track_with_suffix("s1", "t3", "al3", "C", "flac", 16);
c.library_id = Some("lib3".into());
TrackRepository::new(&store)
.upsert_batch(&[a, b, c])
.unwrap();
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib1".into(), "lib3".into()]);
let resp = list_lossless_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al1");
assert_eq!(resp.albums[1].id, "al3");
}
#[test]
fn name_sort_overrides_bit_depth_default() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_suffix("s1", "t1", "al_z", "Zulu", "flac", 24),
track_with_suffix("s1", "t2", "al_a", "Alpha", "flac", 16),
])
.unwrap();
let mut sorted = req("s1", 50, 0);
sorted.sort = vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
}];
let resp = list_lossless_albums(&store, &sorted).unwrap();
assert_eq!(resp.albums[0].id, "al_a");
assert_eq!(resp.albums[1].id, "al_z");
}
#[test]
fn matches_suffix_from_raw_json_when_column_null() {
let store = LibraryStore::open_in_memory();
let mut row = track_with_suffix("s1", "t1", "al_json", "Json", "mp3", 0);
row.suffix = None;
row.raw_json = r#"{"suffix":"flac","bitDepth":24}"#.into();
TrackRepository::new(&store)
.upsert_batch(&[row])
.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_json");
}
#[test]
fn restrict_album_ids_narrows_lossless_results() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_suffix("s1", "t1", "al_keep", "Keep", "flac", 24),
track_with_suffix("s1", "t2", "al_drop", "Drop", "flac", 24),
])
.unwrap();
let mut restricted = req("s1", 50, 0);
restricted.restrict_album_ids = Some(vec!["al_keep".into()]);
let resp = list_lossless_albums(&store, &restricted).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_keep");
}
#[test]
fn pagination_sets_has_more() {
let store = LibraryStore::open_in_memory();
@@ -7,14 +7,22 @@ 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.
/// Effective suffix — hot `track.suffix`, then Navidrome `raw_json.suffix`.
pub fn track_suffix_expr(table_alias: &str) -> String {
format!(
"LOWER(COALESCE(NULLIF({table_alias}.suffix, ''), \
CAST(json_extract({table_alias}.raw_json, '$.suffix') AS TEXT), ''))"
)
}
/// `track_suffix_expr 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})")
format!("{} IN ({list})", track_suffix_expr(table_alias))
}
/// Album has at least one indexed lossless track (same allowlist as browse).
@@ -50,6 +58,6 @@ mod tests {
let sql = track_is_lossless_sql("t");
assert!(sql.contains("'flac'"));
assert!(sql.contains("'tta'"));
assert!(sql.starts_with("LOWER(t.suffix) IN ("));
assert!(sql.contains("json_extract(t.raw_json, '$.suffix')"));
}
}
@@ -228,18 +228,6 @@ impl<'a> TrackRepository<'a> {
})
}
/// All live rows for a Subsonic track id (any server). Used when legacy offline
/// folders name the server by URL index key rather than profile UUID.
pub fn find_live_by_id(&self, track_id: &str) -> Result<Vec<TrackRow>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?;
let rows = stmt
.query_map(params![track_id], row_to_track_row)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
})
}
/// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs
/// preserve their order in the result; unknown / deleted refs
/// are silently dropped (frontend reads `tracks.length` against
@@ -304,26 +292,6 @@ impl<'a> TrackRepository<'a> {
})
}
/// Legacy offline rows keyed by library `server_id` (index key scope).
pub fn list_offline_local_paths(
&self,
server_id: &str,
) -> Result<Vec<(String, String, Option<String>)>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1",
)?;
let rows = stmt.query_map(params![server_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
}
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
pub fn list_analysis_hash_bpm_ids_after(
@@ -615,13 +583,6 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE id = ?1 AND deleted = 0";
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
@@ -171,6 +171,11 @@ pub(crate) fn fts_album_title_prefix_match_query(raw: &str) -> Option<String> {
fts_prefix_token_expr(raw).map(|tokens| format!("album : {tokens}"))
}
/// All Albums title search — any query word may prefix-match the album column.
pub(crate) fn fts_album_title_prefix_any_token_match_query(raw: &str) -> Option<String> {
fts_prefix_token_or_expr(raw).map(|tokens| format!("album : ({tokens})"))
}
/// Live Search album match — any query word may hit album or album_artist (Navidrome parity).
pub(crate) fn fts_album_prefix_any_token_match_query(raw: &str) -> Option<String> {
fts_prefix_token_or_expr(raw).map(|tokens| {
@@ -226,6 +231,35 @@ pub(crate) fn library_scope_equals_sql(table_alias: &str) -> String {
format!("{} = ?", library_scope_match_sql(table_alias))
}
/// `library_id` filter for one or more Navidrome music-folder scopes.
pub(crate) fn library_scope_filter_sql(
table_alias: &str,
scope_ids: &[String],
) -> (Option<String>, Vec<rusqlite::types::Value>) {
use rusqlite::types::Value as SqlValue;
if scope_ids.is_empty() {
return (None, Vec::new());
}
if scope_ids.len() == 1 {
return (
Some(library_scope_equals_sql(table_alias)),
vec![SqlValue::Text(scope_ids[0].clone())],
);
}
let match_sql = library_scope_match_sql(table_alias);
let placeholders = (0..scope_ids.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(", ");
(
Some(format!("{match_sql} IN ({placeholders})")),
scope_ids
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect(),
)
}
pub(crate) fn aliased_track_columns(alias: &str) -> String {
crate::repos::track_columns()
.split(',')
@@ -320,6 +354,36 @@ pub(crate) fn like_contains(raw: &str) -> String {
format!("%{escaped}%")
}
/// Whitespace-split tokens for substring LIKE (any non-empty segment).
pub(crate) fn like_name_tokens(raw: &str) -> Vec<String> {
raw.split_whitespace()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
/// `(col LIKE ? OR …)` — any query word may match as a case-insensitive substring.
pub(crate) fn like_any_token_contains_clause(column: &str, raw: &str) -> Option<(String, Vec<String>)> {
let tokens = like_name_tokens(raw);
if tokens.is_empty() {
return None;
}
let col = format!("{column} COLLATE NOCASE");
if tokens.len() == 1 {
return Some((
format!("{col} LIKE ? ESCAPE '\\'"),
vec![like_contains(&tokens[0])],
));
}
let parts: Vec<String> = tokens
.iter()
.map(|_| format!("{col} LIKE ? ESCAPE '\\'"))
.collect();
let params: Vec<String> = tokens.iter().map(|t| like_contains(t)).collect();
Some((format!("({})", parts.join(" OR ")), params))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -462,6 +526,23 @@ mod tests {
);
}
#[test]
fn fts_album_title_prefix_any_token_match_query_or_words() {
assert_eq!(
fts_album_title_prefix_any_token_match_query("dark side").as_deref(),
Some("album : (\"dark\"* OR \"side\"*)")
);
}
#[test]
fn like_any_token_contains_clause_ors_words() {
let (sql, params) = like_any_token_contains_clause("a.name", "dark side").unwrap();
assert!(sql.contains(" OR "));
assert_eq!(params.len(), 2);
assert_eq!(params[0], "%dark%");
assert_eq!(params[1], "%side%");
}
#[test]
fn fts_track_match_query_or_across_display_columns() {
let q = fts_track_match_query("manowar").unwrap();
@@ -0,0 +1,595 @@
//! Cluster-scope advanced search: run per-server advanced search, then merge
//! winners by cluster identity keys with server-priority precedence.
use std::collections::{BTreeSet, HashMap, HashSet};
use rusqlite::types::Value as SqlValue;
use crate::advanced_search::run_advanced_search;
use crate::dto::{
LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto,
LibraryClusterAdvancedSearchRequest, LibrarySearchTotals, LibraryTrackDto,
};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
pub fn run_cluster_advanced_search(
store: &LibraryStore,
req: LibraryClusterAdvancedSearchRequest,
) -> Result<LibraryAdvancedSearchResponse, String> {
if req.servers_ordered.is_empty() {
return Ok(empty_response(req.skip_totals));
}
let page_limit = req.limit.clamp(1, PAGE_LIMIT_MAX);
let page_offset = req.offset as usize;
let per_server_limit = req
.limit
.saturating_add(req.offset)
.clamp(1, PAGE_LIMIT_MAX);
let mut all_tracks: Vec<LibraryTrackDto> = Vec::new();
let mut all_albums: Vec<LibraryAlbumDto> = Vec::new();
let mut all_artists: Vec<LibraryArtistDto> = Vec::new();
let mut applied_filters = BTreeSet::new();
for server_id in &req.servers_ordered {
let server_req = LibraryAdvancedSearchRequest {
server_id: server_id.clone(),
library_scope: None,
library_scope_ids: req
.library_scopes
.get(server_id)
.filter(|ids| !ids.is_empty())
.cloned(),
query: req.query.clone(),
entity_types: req.entity_types.clone(),
filters: req.filters.clone(),
starred_only: req.starred_only,
restrict_album_ids: req
.restrict_album_scopes
.get(server_id)
.filter(|ids| !ids.is_empty())
.cloned()
.or_else(|| req.restrict_album_ids.clone()),
query_album_title_only: req.query_album_title_only,
sort: req.sort.clone(),
limit: per_server_limit,
offset: 0,
skip_totals: true,
};
let resp = run_advanced_search(store, &server_req)?;
all_tracks.extend(resp.tracks);
all_albums.extend(resp.albums);
all_artists.extend(resp.artists);
applied_filters.extend(resp.applied_filters);
}
let merged_tracks = merge_tracks_by_cluster_key(store, all_tracks)?;
let merged_albums = if req
.query
.as_ref()
.is_some_and(|q| !q.trim().is_empty())
{
dedupe_album_search_hits(all_albums)
} else {
merge_albums_by_album_key(store, all_albums)?
};
let merged_artists = merge_artists_by_artist_key(store, all_artists)?;
let totals = if req.skip_totals {
LibrarySearchTotals::default()
} else {
LibrarySearchTotals {
artists: merged_artists.len() as u32,
albums: merged_albums.len() as u32,
tracks: merged_tracks.len() as u32,
}
};
Ok(LibraryAdvancedSearchResponse {
artists: merged_artists
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
albums: merged_albums
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
tracks: merged_tracks
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
totals,
applied_filters: applied_filters.into_iter().collect(),
source: "local".to_string(),
})
}
fn empty_response(skip_totals: bool) -> LibraryAdvancedSearchResponse {
LibraryAdvancedSearchResponse {
artists: Vec::new(),
albums: Vec::new(),
tracks: Vec::new(),
totals: if skip_totals {
LibrarySearchTotals::default()
} else {
LibrarySearchTotals {
artists: 0,
albums: 0,
tracks: 0,
}
},
applied_filters: Vec::new(),
source: "local".to_string(),
}
}
fn merge_tracks_by_cluster_key(
store: &LibraryStore,
tracks: Vec<LibraryTrackDto>,
) -> Result<Vec<LibraryTrackDto>, String> {
let refs: Vec<(String, String)> = tracks
.iter()
.map(|t| (t.server_id.clone(), t.id.clone()))
.collect();
let key_map = lookup_track_cluster_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for track in tracks {
let key = key_map
.get(&(track.server_id.clone(), track.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", track.server_id, track.id));
if seen.insert(key) {
out.push(track);
}
}
Ok(out)
}
/// Text search — keep distinct `(server_id, album_id)` rows; do not collapse
/// same-server albums that share an `album_key` (tribute / variant titles).
fn dedupe_album_search_hits(albums: Vec<LibraryAlbumDto>) -> Vec<LibraryAlbumDto> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for album in albums {
if seen.insert((album.server_id.clone(), album.id.clone())) {
out.push(album);
}
}
out
}
fn merge_albums_by_album_key(
store: &LibraryStore,
albums: Vec<LibraryAlbumDto>,
) -> Result<Vec<LibraryAlbumDto>, String> {
let refs: Vec<(String, String)> = albums
.iter()
.map(|a| (a.server_id.clone(), a.id.clone()))
.collect();
let key_map = lookup_album_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for album in albums {
let key = key_map
.get(&(album.server_id.clone(), album.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", album.server_id, album.id));
if seen.insert(key) {
out.push(album);
}
}
Ok(out)
}
fn merge_artists_by_artist_key(
store: &LibraryStore,
artists: Vec<LibraryArtistDto>,
) -> Result<Vec<LibraryArtistDto>, String> {
let refs: Vec<(String, String)> = artists
.iter()
.map(|a| (a.server_id.clone(), a.id.clone()))
.collect();
let key_map = lookup_artist_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for artist in artists {
let key = key_map
.get(&(artist.server_id.clone(), artist.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", artist.server_id, artist.id));
if seen.insert(key) {
out.push(artist);
}
}
Ok(out)
}
fn lookup_track_cluster_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, k.cluster_key
FROM wanted w
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = w.server_id AND k.track_id = w.entity_id"
),
)
}
fn lookup_album_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, MIN(k.album_key)
FROM wanted w
LEFT JOIN track t
ON t.server_id = w.server_id AND t.album_id = w.entity_id AND t.deleted = 0
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
GROUP BY w.server_id, w.entity_id"
),
)
}
fn lookup_artist_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, MIN(k.artist_key)
FROM wanted w
LEFT JOIN track t
ON t.server_id = w.server_id
AND COALESCE(NULLIF(t.artist_id, ''), t.artist) = w.entity_id
AND t.deleted = 0
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
GROUP BY w.server_id, w.entity_id"
),
)
}
fn lookup_keys_with_values(
store: &LibraryStore,
refs: &[(String, String)],
query_sql: &str,
) -> Result<HashMap<(String, String), Option<String>>, String> {
if refs.is_empty() {
return Ok(HashMap::new());
}
let values_sql = std::iter::repeat_n("(?, ?)", refs.len()).collect::<Vec<_>>().join(", ");
let sql = format!("WITH wanted(server_id, entity_id) AS (VALUES {values_sql}) {query_sql}");
let mut bind: Vec<SqlValue> = Vec::with_capacity(refs.len() * 2);
for (server_id, entity_id) in refs {
bind.push(SqlValue::Text(server_id.clone()));
bind.push(SqlValue::Text(entity_id.clone()));
}
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(rusqlite::params_from_iter(bind.iter()))?;
let mut out = HashMap::new();
while let Some(row) = rows.next()? {
let server_id: String = row.get(0)?;
let entity_id: String = row.get(1)?;
let key: Option<String> = row.get(2)?;
out.insert((server_id, entity_id), key);
}
Ok(out)
})
}
#[cfg(test)]
mod tests {
use crate::filter::EntityKind;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
use super::*;
fn track(server: &str, id: &str, artist: &str, artist_id: &str, album: &str, album_id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".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(2024),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: 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: "{}".into(),
}
}
#[test]
fn merges_tracks_by_cluster_key_with_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "LP", "alb-1"),
track("s2", "t2", "Band", "art-2", "LP", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Track],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].server_id, "s1");
assert_eq!(resp.totals.tracks, 1);
assert_eq!(resp.totals.albums, 0);
assert_eq!(resp.totals.artists, 0);
}
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, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 1, '{}')",
rusqlite::params![server, id, name],
)
})
.unwrap();
}
#[test]
fn cluster_text_search_respects_per_member_library_scope() {
let store = LibraryStore::open_in_memory();
let mut in_scope = track("s1", "t1", "Band", "art-1", "Metallica", "alb-in");
in_scope.library_id = Some("lib-a".into());
let mut out_scope = track("s1", "t2", "Band", "art-2", "Metallica Tribute", "alb-out");
out_scope.library_id = Some("lib-b".into());
TrackRepository::new(&store)
.upsert_batch(&[in_scope, out_scope])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into()]);
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: scopes,
},
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "alb-in");
}
#[test]
fn cluster_metallica_text_search_unions_table_and_track_catalog() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_self", "Metallica");
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 10 WHERE server_id = 's1' AND id = 'al_self'",
[],
)
})
.unwrap();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Apocalyptica", "art-1", "Plays Metallica Vol. 2", "al_plays"),
track("s1", "t2", "Various", "art-2", "The Metallica Blacklist", "al_black"),
track("s1", "t3", "Pink Floyd", "art-3", "Wish You Were Here", "al_wish"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 3, "expected {ids:?}");
assert!(ids.contains(&"al_self"));
assert!(ids.contains(&"al_plays"));
assert!(ids.contains(&"al_black"));
}
#[test]
fn text_search_keeps_distinct_same_server_albums_with_shared_album_key() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "Metallica", "alb-1"),
track("s1", "t2", "Band", "art-2", "Plays Metallica Vol. 2", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.albums.len(), 2);
}
#[test]
fn merges_albums_by_album_key_with_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "LP", "alb-1"),
track("s2", "t2", "Band", "art-2", "LP", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn applies_offset_after_merge() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band A", "art-a1", "LP A", "alb-a1"),
track("s2", "t2", "Band A", "art-a2", "LP A", "alb-a2"),
track("s1", "t3", "Band B", "art-b1", "LP B", "alb-b1"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Track],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 1,
offset: 1,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.totals.tracks, 2);
}
}
@@ -0,0 +1,171 @@
//! Attached `library-cluster.db` schema and metadata.
use std::path::{Path, PathBuf};
use rusqlite::{params, Connection, OptionalExtension};
pub const CLUSTER_DB_FILENAME: &str = "library-cluster.db";
pub const ATTACH_ALIAS: &str = "cluster";
pub const NORM_VERSION: &str = "1";
pub fn cluster_db_path(library_db_path: &Path) -> PathBuf {
library_db_path.with_file_name(CLUSTER_DB_FILENAME)
}
fn escape_sqlite_path(path: &str) -> String {
path.replace('\'', "''")
}
fn init_cluster_on_attached(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {ATTACH_ALIAS}.track_cluster_key (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
cluster_key TEXT NOT NULL,
album_key TEXT,
artist_key TEXT,
duration_sec INTEGER,
PRIMARY KEY (server_id, track_id)
)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_cluster_key \
ON track_cluster_key(cluster_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_album_key \
ON track_cluster_key(album_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_artist_key \
ON track_cluster_key(artist_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {ATTACH_ALIAS}.cluster_meta (
key TEXT PRIMARY KEY,
value TEXT
)"
),
[],
)?;
init_cluster_meta(conn)?;
Ok(())
}
fn init_cluster_file(cluster_path: &Path) -> rusqlite::Result<()> {
let cluster_conn = Connection::open(cluster_path)?;
cluster_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS track_cluster_key (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
cluster_key TEXT NOT NULL,
album_key TEXT,
artist_key TEXT,
duration_sec INTEGER,
PRIMARY KEY (server_id, track_id)
);
CREATE INDEX IF NOT EXISTS idx_cluster_key ON track_cluster_key(cluster_key);
CREATE INDEX IF NOT EXISTS idx_album_key ON track_cluster_key(album_key);
CREATE INDEX IF NOT EXISTS idx_artist_key ON track_cluster_key(artist_key);
CREATE TABLE IF NOT EXISTS cluster_meta (
key TEXT PRIMARY KEY,
value TEXT
);",
)?;
cluster_conn.execute(
"INSERT OR IGNORE INTO cluster_meta (key, value) VALUES ('norm_version', ?1)",
params![NORM_VERSION],
)?;
Ok(())
}
/// Create or migrate the cluster file, then attach it to `conn`.
pub fn attach_cluster_database(conn: &Connection, cluster_path: &Path) -> rusqlite::Result<()> {
if let Some(parent) = cluster_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
Some(e.to_string()),
)
})?;
}
init_cluster_file(cluster_path)?;
let path_str = escape_sqlite_path(&cluster_path.to_string_lossy());
conn.execute_batch(&format!(
"ATTACH DATABASE '{path_str}' AS {ATTACH_ALIAS}"
))?;
Ok(())
}
/// In-memory cluster DB for tests — attach then init schema on the alias.
pub fn attach_cluster_database_uri(conn: &Connection, uri: &str) -> rusqlite::Result<()> {
let path_str = escape_sqlite_path(uri);
conn.execute_batch(&format!(
"ATTACH DATABASE '{path_str}' AS {ATTACH_ALIAS}"
))?;
init_cluster_on_attached(conn)
}
pub fn ensure_cluster_schema(conn: &Connection) -> rusqlite::Result<()> {
init_cluster_on_attached(conn)
}
pub fn init_cluster_meta(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
&format!(
"INSERT OR IGNORE INTO {ATTACH_ALIAS}.cluster_meta (key, value) VALUES ('norm_version', ?1)"
),
params![NORM_VERSION],
)?;
Ok(())
}
pub fn stored_norm_version(conn: &Connection) -> rusqlite::Result<Option<String>> {
conn.query_row(
&format!(
"SELECT value FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|row| row.get(0),
)
.optional()
}
pub fn needs_norm_rebuild(conn: &Connection) -> rusqlite::Result<bool> {
Ok(stored_norm_version(conn)?.as_deref() != Some(NORM_VERSION))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_creates_cluster_tables_on_attach() {
let conn = Connection::open_in_memory().unwrap();
attach_cluster_database_uri(&conn, "file:cluster_mem?mode=memory&cache=shared").unwrap();
let count: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.sqlite_master \
WHERE type='table' AND name='track_cluster_key'"
),
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
//! Derive `cluster_key`, `album_key`, and `artist_key` from track metadata.
use std::hash::{Hash, Hasher};
use twox_hash::XxHash64;
use super::norm::norm_field;
const FIELD_SEP: u8 = 0x1f;
/// Precomputed keys for one track. `None` when any required field normalizes empty.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackClusterKeys {
pub cluster_key: String,
pub album_key: String,
pub artist_key: String,
}
fn effective_artist<'a>(artist: Option<&'a str>, album_artist: Option<&'a str>) -> Option<&'a str> {
artist
.filter(|s| !s.is_empty())
.or_else(|| album_artist.filter(|s| !s.is_empty()))
}
fn effective_album_artist<'a>(
album_artist: Option<&'a str>,
artist: Option<&'a str>,
) -> Option<&'a str> {
album_artist
.filter(|s| !s.is_empty())
.or_else(|| artist.filter(|s| !s.is_empty()))
}
fn hash_parts(parts: &[&str], sep: Option<u8>) -> String {
let mut hasher = XxHash64::with_seed(0);
for (i, part) in parts.iter().enumerate() {
if i > 0 {
if let Some(sep) = sep {
sep.hash(&mut hasher);
}
}
part.hash(&mut hasher);
}
format!("{:016x}", hasher.finish())
}
/// Compute cluster identity keys from raw track fields (spec §2.12.5).
pub fn compute_track_cluster_keys(
artist: Option<&str>,
album_artist: Option<&str>,
title: &str,
album: &str,
) -> Option<TrackClusterKeys> {
let artist_src = effective_artist(artist, album_artist)?;
let norm_artist = norm_field(artist_src);
let norm_title = norm_field(title);
let norm_album = norm_field(album);
if norm_artist.is_empty() || norm_title.is_empty() || norm_album.is_empty() {
return None;
}
let cluster_key = hash_parts(&[&norm_artist, &norm_title, &norm_album], Some(FIELD_SEP));
let album_artist_src = effective_album_artist(album_artist, artist).unwrap_or(artist_src);
let norm_album_artist = norm_field(album_artist_src);
let album_key = hash_parts(&[&norm_album_artist, &norm_album], None);
let artist_key = hash_parts(&[&norm_artist], None);
Some(TrackClusterKeys {
cluster_key,
album_key,
artist_key,
})
}
/// Stable cross-server artist merge key from display name alone (spec §2.5).
pub fn artist_key_from_display_name(name: &str) -> Option<String> {
let norm = norm_field(name);
if norm.is_empty() {
return None;
}
Some(hash_parts(&[&norm], None))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_metadata_yields_same_keys() {
let a = compute_track_cluster_keys(
Some("Artist"),
None,
"Title",
"Album",
)
.unwrap();
let b = compute_track_cluster_keys(
Some("artist"),
None,
"title",
"album",
)
.unwrap();
assert_eq!(a, b);
}
#[test]
fn artist_falls_back_to_album_artist_for_cluster_key() {
let with = compute_track_cluster_keys(None, Some("Band"), "Song", "LP").unwrap();
let direct = compute_track_cluster_keys(Some("Band"), None, "Song", "LP").unwrap();
assert_eq!(with.cluster_key, direct.cluster_key);
}
#[test]
fn empty_title_or_album_yields_none() {
assert!(compute_track_cluster_keys(Some("A"), None, "", "Album").is_none());
assert!(compute_track_cluster_keys(Some("A"), None, "Title", "").is_none());
assert!(compute_track_cluster_keys(None, None, "Title", "Album").is_none());
}
#[test]
fn artist_key_from_display_name_matches_track_derived_key() {
let from_track = compute_track_cluster_keys(Some("Pink Floyd"), None, "x", "y").unwrap();
let from_name = artist_key_from_display_name("Pink Floyd").unwrap();
assert_eq!(from_track.artist_key, from_name);
}
#[test]
fn punctuation_insensitive_cluster_key() {
let a = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time", "Dark Side").unwrap();
let b = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time!", "Dark Side.").unwrap();
assert_eq!(a.cluster_key, b.cluster_key);
}
}
@@ -0,0 +1,89 @@
//! Per-member music-folder (`library_scope`) filters for merged cluster queries.
use std::collections::HashMap;
use rusqlite::types::Value as SqlValue;
use crate::search::{library_scope_equals_sql, library_scope_match_sql};
/// `(sql_suffix, bind_params)` — AND ( (server + optional scope) OR … ).
pub(crate) fn scope_filter_sql_and_params(
table_alias: &str,
servers_ordered: &[String],
scopes: &HashMap<String, Vec<String>>,
) -> (String, Vec<SqlValue>) {
if scopes.is_empty() {
return (String::new(), Vec::new());
}
let mut parts = Vec::with_capacity(servers_ordered.len());
let mut params = Vec::new();
for sid in servers_ordered {
if let Some(scope_ids) = scopes.get(sid).filter(|v| !v.is_empty()) {
if scope_ids.len() == 1 {
let eq = library_scope_equals_sql(table_alias);
parts.push(format!("({table_alias}.server_id = ? AND {eq})"));
params.push(SqlValue::Text(sid.clone()));
params.push(SqlValue::Text(scope_ids[0].clone()));
} else {
let match_sql = library_scope_match_sql(table_alias);
let ph = (0..scope_ids.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(", ");
parts.push(format!(
"({table_alias}.server_id = ? AND {match_sql} IN ({ph}))"
));
params.push(SqlValue::Text(sid.clone()));
for id in scope_ids {
params.push(SqlValue::Text(id.clone()));
}
}
} else {
parts.push(format!("({table_alias}.server_id = ?)"));
params.push(SqlValue::Text(sid.clone()));
}
}
(format!(" AND ({})", parts.join(" OR ")), params)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::search::library_scope_filter_sql;
#[test]
fn scope_filter_empty_when_no_scopes() {
let (sql, params) = scope_filter_sql_and_params("t", &["s1".into()], &HashMap::new());
assert!(sql.is_empty());
assert!(params.is_empty());
}
#[test]
fn scope_filter_binds_scoped_and_unscoped_members() {
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into()]);
let (sql, params) = scope_filter_sql_and_params(
"t",
&["s1".into(), "s2".into()],
&scopes,
);
assert!(sql.contains("t.server_id = ?"));
assert_eq!(params.len(), 3);
}
#[test]
fn scope_filter_multi_folder_on_one_member() {
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into(), "lib-b".into()]);
let (sql, params) = scope_filter_sql_and_params("t", &["s1".into()], &scopes);
assert!(sql.contains(" IN ("));
assert_eq!(params.len(), 3);
}
#[test]
fn single_scope_uses_equals() {
let (sql, params) = library_scope_filter_sql("t", &["lib-a".into()]);
assert!(sql.unwrap().contains("= ?"));
assert_eq!(params.len(), 1);
}
}
@@ -0,0 +1,204 @@
//! Merged track listing for cluster scope (spec §4 Tier 1).
use rusqlite::types::Value as SqlValue;
use crate::dto::{LibraryTrackDto, LibraryTracksEnvelope};
use crate::repos;
use crate::search::{aliased_track_columns, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::{in_list_sql, priority_case_sql};
/// List merged tracks — one row per `cluster_key` (priority winner), solo rows
/// for empty-key tracks and duration outliers.
pub fn list_merged_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryTracksEnvelope, String> {
if servers_ordered.is_empty() {
return Ok(LibraryTracksEnvelope {
tracks: vec![],
total: 0,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (scope_sql, mut scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders}){scope_sql}
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY t.title COLLATE NOCASE, t.server_id, t.id
LIMIT ? OFFSET ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.append(&mut scope_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let tracks: Vec<LibraryTrackDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryTracksEnvelope {
total: tracks.len() as u32,
tracks,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, title: &str, artist: &str, album: &str, dur: i64) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: None,
album: album.into(),
album_id: None,
album_artist: Some(artist.into()),
duration_sec: dur,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: 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: "{}".into(),
}
}
#[test]
fn merge_collapses_same_cluster_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP", 200),
track("s2", "t2", "Song", "Band", "LP", 201),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0, &HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s1");
}
#[test]
fn unavailable_priority_falls_through() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP", 200),
track("s2", "t2", "Song", "Band", "LP", 201),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s2");
}
#[test]
fn empty_key_tracks_never_merge() {
let store = LibraryStore::open_in_memory();
store
.with_conn_mut("misc", |c| {
c.execute(
"INSERT INTO track (server_id, id, title, artist, album, duration_sec, synced_at, raw_json) \
VALUES ('s1', 't1', 'A', '', 'X', 1, 1, '{}'), \
('s2', 't2', 'A', '', 'X', 1, 1, '{}')",
[],
)
})
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0, &HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 2);
}
}
@@ -0,0 +1,285 @@
//! Merged album listing for cluster scope (spec §4 Tier 1 — dedup by `album_key`).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{LibraryAlbumDto, LibraryClusterAlbumsResponse};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::merge::ALBUM_ROLLUP_AND_PARTITION_CTE;
use super::priority::{in_list_sql, priority_case_sql};
pub fn list_merged_albums(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryClusterAlbumsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterAlbumsResponse {
albums: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (scope_sql, mut scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.album_id,
k.album_key,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND t.album_id IS NOT NULL AND t.album_id != ''{scope_sql}
),
{ALBUM_ROLLUP_AND_PARTITION_CTE}
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT
t.server_id,
t.album_id,
COALESCE(a.name, t.album),
COALESCE(a.artist, t.artist),
COALESCE(a.artist_id, t.artist_id),
COALESCE(a.song_count, (
SELECT COUNT(*) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.duration_sec, (
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
)),
COALESCE(a.year, t.year),
COALESCE(a.genre, t.genre),
COALESCE(a.cover_art_id, t.cover_art_id),
COALESCE(a.starred_at, t.starred_at),
COALESCE(a.synced_at, t.synced_at),
a.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
WHERE w.rn = 1
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE, t.server_id, t.album_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.append(&mut scope_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_album_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryClusterAlbumsResponse { albums, has_more })
}
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};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(
server: &str,
id: &str,
title: &str,
artist: &str,
album: &str,
album_id: &str,
) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
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: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: 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: "{}".into(),
}
}
#[test]
fn merge_collapses_same_album_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track("s2", "t2", "B", "Band", "LP", "alb2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into(), "s2".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn rollup_collapses_multiple_tracks_per_server_album() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track("s1", "t2", "B", "Band", "LP", "alb1"),
track("s1", "t3", "C", "Band", "LP", "alb1"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "alb1");
}
fn track_no_key(server: &str, id: &str, album_id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some(format!("art-{server}")),
album: "LP".into(),
album_id: Some(album_id.into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: 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: "{}".into(),
}
}
#[test]
fn rollup_uses_album_key_when_some_tracks_lack_keys() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track_no_key("s1", "t2", "alb1"),
track("s2", "t3", "B", "Band", "LP", "alb2"),
track_no_key("s2", "t4", "alb2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into(), "s2".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
}
@@ -0,0 +1,230 @@
//! Merged artist listing for cluster scope (spec §4 Tier 1 — dedup by `artist_key`).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{LibraryArtistDto, LibraryClusterArtistsResponse};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::priority::{in_list_sql, priority_case_sql};
pub fn list_merged_artists(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryClusterArtistsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterArtistsResponse {
artists: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
let (priority_sql, priority_params) = priority_case_sql("c.server_id", servers_ordered);
let (scope_sql, scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
// Artist-first catalog: one row per artist (not per track), then merge by
// `artist_key`. The previous track-scan + window over every row was O(tracks)
// with correlated album counts and blocked the Artists browse page on large libs.
let sql = format!(
"WITH artist_keys AS (
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
MIN(k.artist_key) AS artist_key
FROM track t
INNER JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders}){scope_sql}
AND k.artist_key IS NOT NULL
GROUP BY t.server_id, artist_ref
),
track_artists AS (
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS id,
MAX(t.artist) AS name,
COUNT(DISTINCT CASE
WHEN t.album_id IS NOT NULL AND t.album_id != '' THEN t.album_id
END) AS album_count,
MAX(t.synced_at) AS synced_at,
CAST(NULL AS TEXT) AS raw_json
FROM track t
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders}){scope_sql}
AND COALESCE(t.artist, '') != ''
AND NOT EXISTS (
SELECT 1 FROM artist ar
WHERE ar.server_id = t.server_id
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
)
GROUP BY t.server_id, COALESCE(NULLIF(t.artist_id, ''), t.artist)
),
catalog AS (
SELECT ar.server_id, ar.id, ar.name, ar.album_count, ar.synced_at, ar.raw_json
FROM artist ar
WHERE ar.server_id IN ({in_placeholders})
UNION ALL
SELECT server_id, id, name, album_count, synced_at, raw_json
FROM track_artists
),
candidates AS (
SELECT
c.server_id,
c.id,
c.name,
c.album_count,
c.synced_at,
c.raw_json,
({priority_sql}) AS priority_rank,
CASE
WHEN ak.artist_key IS NOT NULL THEN ak.artist_key
ELSE 'solo:' || c.server_id || ':' || c.id
END AS merge_key
FROM catalog c
LEFT JOIN artist_keys ak
ON ak.server_id = c.server_id AND ak.artist_ref = c.id
),
winners AS (
SELECT
server_id,
id,
name,
album_count,
synced_at,
raw_json,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM candidates
)
SELECT server_id, id, name, album_count, synced_at, raw_json
FROM winners
WHERE rn = 1
ORDER BY name COLLATE NOCASE, server_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.extend(in_params.iter().cloned());
params.extend(scope_params.iter().cloned());
params.extend(in_params.iter().cloned());
params.extend(scope_params.iter().cloned());
params.extend(in_params.iter().cloned());
params.extend(priority_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let artists: Vec<LibraryArtistDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_artist_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
let has_more = artists.len() as u32 == limit;
Ok(LibraryClusterArtistsResponse { artists, has_more })
}
fn map_artist_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let raw: Option<String> = r.get(5)?;
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: r.get(3)?,
synced_at: r.get(4)?,
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};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, artist: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: "LP".into(),
album_id: Some("alb1".into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: 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: "{}".into(),
}
}
#[test]
fn merge_collapses_same_artist_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
}
#[test]
fn prefers_artist_table_album_count_when_present() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn("test", |conn| {
conn.execute(
"INSERT INTO artist (server_id, id, name, album_count, synced_at, raw_json) \
VALUES ('s1', 'art-s1', 'Band', 3, 1, '{}'), \
('s2', 'art-s2', 'Band', 2, 1, '{}')",
[],
)
})
.unwrap();
let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
assert_eq!(resp.artists[0].album_count, Some(3));
}
}
@@ -0,0 +1,452 @@
//! Merged favorites — starred on any member counts (spec §4 Tier 2).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{
LibraryAlbumDto, LibraryArtistDto, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse, LibraryTrackDto,
LibraryTracksEnvelope,
};
use crate::repos;
use crate::search::{aliased_track_columns, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::{in_list_sql, priority_case_sql};
/// Merged starred tracks — one row per merge group when **any** member is starred.
pub fn list_merged_favorite_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryTracksEnvelope, String> {
if servers_ordered.is_empty() {
return Ok(LibraryTracksEnvelope {
tracks: vec![],
total: 0,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
t.starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
starred_merge AS (
SELECT DISTINCT p.merge_key
FROM partitioned p
JOIN candidates c ON c.tid = p.tid
WHERE c.starred_at IS NOT NULL
),
winners AS (
SELECT p.tid, p.merge_key,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY t.title COLLATE NOCASE, t.server_id, t.id
LIMIT ? OFFSET ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let tracks: Vec<LibraryTrackDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryTracksEnvelope {
total: tracks.len() as u32,
tracks,
})
}
/// Merged favorite albums — one row per album merge group when any member is starred.
pub fn list_merged_favorite_albums(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryClusterAlbumsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterAlbumsResponse {
albums: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.album_id,
k.album_key,
COALESCE(a.starred_at, t.starred_at) AS starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
LEFT JOIN album a
ON a.server_id = t.server_id AND a.id = t.album_id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND t.album_id IS NOT NULL AND t.album_id != ''
),
album_rollup AS (
SELECT
c.server_id,
c.album_id,
MIN(c.tid) AS tid,
MIN(c.priority_rank) AS priority_rank,
MAX(c.album_key) AS album_key,
MAX(c.starred_at) AS starred_at
FROM candidates c
GROUP BY c.server_id, c.album_id
),
partitioned AS (
SELECT
r.tid,
CASE
WHEN r.album_key IS NOT NULL THEN r.album_key
ELSE 'solo:' || r.server_id || ':' || r.album_id
END AS merge_key,
r.priority_rank,
r.starred_at
FROM album_rollup r
),
starred_merge AS (
SELECT DISTINCT merge_key
FROM partitioned
WHERE starred_at IS NOT NULL
),
winners AS (
SELECT p.tid,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT
t.server_id,
t.album_id,
COALESCE(a.name, t.album),
COALESCE(a.artist, t.artist),
COALESCE(a.artist_id, t.artist_id),
COALESCE(a.song_count, (
SELECT COUNT(*) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.duration_sec, (
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
)),
COALESCE(a.year, t.year),
COALESCE(a.genre, t.genre),
COALESCE(a.cover_art_id, t.cover_art_id),
COALESCE(a.starred_at, t.starred_at),
COALESCE(a.synced_at, t.synced_at),
a.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
WHERE w.rn = 1
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE, t.server_id, t.album_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_album_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryClusterAlbumsResponse {
has_more: albums.len() as u32 == limit,
albums,
})
}
/// Merged favorite artists — one row per artist merge group when any member track is starred.
pub fn list_merged_favorite_artists(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryClusterArtistsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterArtistsResponse {
artists: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
k.artist_key,
t.starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND COALESCE(t.artist, '') != ''
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.artist_key IS NULL THEN 'solo:' || c.server_id || ':' || c.artist_ref
ELSE c.artist_key
END AS merge_key,
c.priority_rank,
c.starred_at
FROM candidates c
),
starred_merge AS (
SELECT DISTINCT merge_key
FROM partitioned
WHERE starred_at IS NOT NULL
),
winners AS (
SELECT p.tid,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist),
COALESCE(ar.name, t.artist),
COALESCE(ar.album_count, (
SELECT COUNT(DISTINCT c.album_id) FROM track c
WHERE c.server_id = t.server_id
AND c.deleted = 0
AND c.album_id IS NOT NULL
AND (c.artist_id = t.artist_id OR c.artist = t.artist)
)),
COALESCE(ar.synced_at, t.synced_at),
ar.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN artist ar ON ar.server_id = t.server_id
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
WHERE w.rn = 1
ORDER BY COALESCE(ar.name, t.artist) COLLATE NOCASE, t.server_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let artists: Vec<LibraryArtistDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_artist_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryClusterArtistsResponse {
has_more: artists.len() as u32 == limit,
artists,
})
}
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),
})
}
fn map_artist_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let raw: Option<String> = r.get(5)?;
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: r.get(3)?,
synced_at: r.get(4)?,
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};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, starred: bool) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some("a1".into()),
album: "LP".into(),
album_id: Some("alb1".into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: if starred { Some(1) } else { 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: "{}".into(),
}
}
#[test]
fn starred_on_lower_priority_still_surfaces_merged_row() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_favorite_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s1");
}
#[test]
fn unstarred_merge_group_excluded() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", false)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_favorite_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert!(env.tracks.is_empty());
}
#[test]
fn favorite_albums_merge_when_any_member_starred() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_favorite_albums(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn favorite_artists_merge_when_any_member_starred() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp =
list_merged_favorite_artists(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
}
}
@@ -0,0 +1,89 @@
//! Duration guard and partition keys for cluster merge (spec §2.3).
pub const DURATION_TOLERANCE_SEC: i64 = 5;
/// Roll up per-track candidates to one row per `(server_id, album_id)` before
/// partitioning by `album_key` (spec §4 — album lists dedup by `album_key`).
pub const ALBUM_ROLLUP_AND_PARTITION_CTE: &str = "
album_rollup AS (
SELECT
c.server_id,
c.album_id,
MIN(c.tid) AS tid,
MIN(c.priority_rank) AS priority_rank,
MAX(c.album_key) AS album_key
FROM candidates c
GROUP BY c.server_id, c.album_id
),
partitioned AS (
SELECT
r.tid,
CASE
WHEN r.album_key IS NOT NULL THEN r.album_key
ELSE 'solo:' || r.server_id || ':' || r.album_id
END AS merge_key,
r.priority_rank
FROM album_rollup r
),
";
/// Synthetic partition for tracks without a `cluster_key` row (never merged).
pub fn solo_partition_key(server_id: &str, track_id: &str) -> String {
format!("solo:{server_id}:{track_id}")
}
/// Within one `cluster_key` group, split rows that fall outside ± tolerance of
/// the reference (priority-1 available candidate duration). Returns partition
/// keys: merged survivors share `cluster_key`; outliers get solo keys.
pub fn duration_partitions(
cluster_key: &str,
rows: &[(String, String, i64, u32)],
) -> Vec<(String, String, String)> {
// (server_id, track_id, duration_sec, priority_rank)
if rows.is_empty() {
return Vec::new();
}
let mut sorted = rows.to_vec();
sorted.sort_by_key(|(_, _, _, rank)| *rank);
let reference_duration = sorted[0].2;
let mut merged: Vec<&(String, String, i64, u32)> = Vec::new();
let mut outliers: Vec<&(String, String, i64, u32)> = Vec::new();
for row in &sorted {
if (row.2 - reference_duration).abs() <= DURATION_TOLERANCE_SEC {
merged.push(row);
} else {
outliers.push(row);
}
}
let mut out = Vec::new();
if !merged.is_empty() {
merged.sort_by_key(|(_, _, _, rank)| *rank);
let (sid, tid, _, _) = merged[0];
out.push((cluster_key.to_string(), sid.clone(), tid.clone()));
}
for (sid, tid, _, _) in outliers {
out.push((solo_partition_key(sid, tid), sid.clone(), tid.clone()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outlier_splits_to_solo_partition() {
let rows = vec![
("s1".into(), "t1".into(), 180, 0),
("s2".into(), "t2".into(), 182, 1),
("s3".into(), "t3".into(), 240, 2),
];
let parts = duration_partitions("ck1", &rows);
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].0, "ck1");
assert_eq!(parts[0].1, "s1");
assert_eq!(parts[1].0, "solo:s3:t3");
}
}
@@ -0,0 +1,44 @@
//! Server cluster identity — derived `cluster_key` / `album_key` / `artist_key`
//! in a separate attached SQLite DB (`library-cluster.db`). Distinct from
//! `repos/play_session/cluster.rs` (listening-session time-gap grouping).
mod detail;
mod advanced_search;
mod db;
mod keys;
mod library_scope;
mod list;
mod list_albums;
mod list_artists;
mod list_favorites;
mod merge;
mod norm;
mod play_stats;
mod priority;
mod rebuild;
mod resolve;
mod search;
pub use detail::{cluster_album_detail, cluster_artist_detail};
pub use advanced_search::run_cluster_advanced_search;
pub use db::{
attach_cluster_database, attach_cluster_database_uri, cluster_db_path, ensure_cluster_schema,
init_cluster_meta, needs_norm_rebuild, ATTACH_ALIAS, CLUSTER_DB_FILENAME, NORM_VERSION,
};
pub use keys::{compute_track_cluster_keys, TrackClusterKeys};
pub use list::list_merged_tracks;
pub use list_albums::list_merged_albums;
pub use list_artists::list_merged_artists;
pub use list_favorites::list_merged_favorite_tracks;
pub use list_favorites::{list_merged_favorite_albums, list_merged_favorite_artists};
pub use merge::DURATION_TOLERANCE_SEC;
pub use play_stats::{
cluster_day_detail, cluster_heatmap, cluster_most_played, cluster_recent_days, cluster_year_summary,
};
pub use rebuild::{
rebuild_all_cluster_keys, rebuild_cluster_keys_for_server, rebuild_if_norm_version_stale,
};
pub use resolve::{
cluster_key_for_track, resolve_candidates_by_cluster_key, resolve_candidates_for_track,
};
pub use search::{run_cluster_random_tracks, run_cluster_search};
@@ -0,0 +1,43 @@
//! Cheap Unicode normalization for cluster identity keys (spec §2.2).
use unicode_normalization::UnicodeNormalization;
/// NFD → drop combining marks → lowercase → letters/digits only.
pub fn norm_field(raw: &str) -> String {
raw.nfd()
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
.flat_map(|c| c.to_lowercase())
.filter(|c| c.is_alphanumeric())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_diacritics_and_punctuation() {
assert_eq!(norm_field("Café"), "cafe");
assert_eq!(norm_field("Mötley Crüe"), "motleycrue");
assert_eq!(norm_field("Hello, World!"), "helloworld");
}
#[test]
fn lowercases_and_removes_whitespace() {
assert_eq!(norm_field(" Pink FLOYD "), "pinkfloyd");
assert_eq!(norm_field("The\tBeatles\n"), "thebeatles");
}
#[test]
fn preserves_unicode_letters_and_digits() {
assert_eq!(norm_field("Sigur Rós"), "sigurros");
assert_eq!(norm_field("Track 99"), "track99");
}
#[test]
fn empty_or_non_alnum_only_becomes_empty() {
assert_eq!(norm_field(""), "");
assert_eq!(norm_field(" "), "");
assert_eq!(norm_field("---"), "");
}
}
@@ -0,0 +1,595 @@
//! Cluster-scoped player statistics — aggregate `play_session` across members (spec §4 Tier 2).
use std::collections::HashMap;
use rusqlite::types::Value as SqlValue;
use crate::dto::{
PlaySessionDayDetailDto, PlaySessionDayTotalsDto, PlaySessionDayTrackDto, PlaySessionHeatmapDayDto,
PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearSummaryDto,
};
use crate::repos;
use crate::search::aliased_track_columns;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::in_list_sql;
use super::priority::priority_case_sql;
const RECENT_DAYS_LIMIT_MAX: u32 = 90;
const MOST_PLAYED_LIMIT_MAX: u32 = 200;
#[derive(Default)]
struct DayAgg {
total_listened_sec: f64,
track_play_count: u32,
full_count: u32,
partial_count: u32,
plays: Vec<(i64, f64)>,
}
fn server_filter_sql(servers_ordered: &[String]) -> Result<(String, Vec<SqlValue>), String> {
if servers_ordered.is_empty() {
return Err("servers_ordered required".into());
}
let (placeholders, params) = in_list_sql(servers_ordered);
Ok((format!("ps.server_id IN ({placeholders})"), params))
}
fn unique_track_expr() -> &'static str {
"COALESCE(k.cluster_key, ps.server_id || ':' || ps.track_id)"
}
fn count_listening_sessions(plays: &[(i64, f64)]) -> u32 {
const GAP_MS: i64 = 30 * 60 * 1000;
if plays.is_empty() {
return 0;
}
let mut sorted = plays.to_vec();
sorted.sort_by_key(|p| p.0);
let mut sessions = 1u32;
let mut prev_end = sorted[0].0 + (sorted[0].1 * 1000.0) as i64;
for (started, listened) in sorted.iter().skip(1) {
if *started - prev_end > GAP_MS {
sessions += 1;
}
let end = *started + (*listened * 1000.0) as i64;
prev_end = prev_end.max(end);
}
sessions
}
fn validate_date_iso(date_iso: &str) -> Result<(), String> {
if date_iso.len() != 10 || date_iso.as_bytes()[4] != b'-' || date_iso.as_bytes()[7] != b'-' {
return Err("dateIso must be YYYY-MM-DD".into());
}
let year: i32 = date_iso[0..4]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
let month: u32 = date_iso[5..7]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
let day: u32 = date_iso[8..10]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
if year < 1970 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err("dateIso must be YYYY-MM-DD".into());
}
Ok(())
}
pub fn cluster_year_summary(
store: &LibraryStore,
servers_ordered: &[String],
year: i32,
) -> Result<PlaySessionYearSummaryDto, String> {
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
let year_str = year.to_string();
let unique = unique_track_expr();
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT \
COALESCE(SUM(ps.listened_sec), 0.0), \
COUNT(*), \
COUNT(DISTINCT {unique}), \
COUNT(DISTINCT date(ps.started_at_ms / 1000, 'unixepoch', 'localtime')), \
COALESCE(SUM(CASE WHEN ps.completion = 'full' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN ps.completion = 'partial' THEN 1 ELSE 0 END), 0) \
FROM play_session ps \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = ps.server_id AND k.track_id = ps.track_id \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?",
);
params.push(SqlValue::Text(year_str.clone()));
let totals = conn.query_row(
&sql,
rusqlite::params_from_iter(params.iter()),
|row| {
Ok((
row.get::<_, f64>(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
row.get::<_, i64>(4)? as u32,
row.get::<_, i64>(5)? as u32,
))
},
)?;
let plays_sql = format!(
"SELECT ps.started_at_ms, ps.listened_sec \
FROM play_session ps \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ? \
ORDER BY ps.started_at_ms ASC",
);
let mut play_params = params[..params.len() - 1].to_vec();
play_params.push(SqlValue::Text(year_str));
let mut stmt = conn.prepare(&plays_sql)?;
let plays = stmt
.query_map(rusqlite::params_from_iter(play_params.iter()), |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let (
total_listened_sec,
track_play_count,
unique_track_count,
listening_day_count,
full_count,
partial_count,
) = totals;
Ok(PlaySessionYearSummaryDto {
total_listened_sec,
session_count: count_listening_sessions(&plays),
track_play_count,
unique_track_count,
listening_day_count,
full_count,
partial_count,
})
})
.map_err(|e| e.to_string())
}
pub fn cluster_heatmap(
store: &LibraryStore,
servers_ordered: &[String],
year: i32,
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
params.push(SqlValue::Text(year.to_string()));
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT \
date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') AS d, \
COUNT(*) AS n \
FROM play_session ps \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ? \
GROUP BY d \
ORDER BY d ASC",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok(PlaySessionHeatmapDayDto {
date: row.get(0)?,
track_play_count: row.get::<_, i64>(1)? as u32,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())
}
pub fn cluster_day_detail(
store: &LibraryStore,
servers_ordered: &[String],
date_iso: &str,
) -> Result<PlaySessionDayDetailDto, String> {
validate_date_iso(date_iso)?;
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
params.push(SqlValue::Text(date_iso.to_string()));
store
.with_read_conn(|conn| {
let totals_sql = format!(
"SELECT \
COALESCE(SUM(ps.listened_sec), 0.0), \
COUNT(*), \
COALESCE(SUM(CASE WHEN ps.completion = 'full' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN ps.completion = 'partial' THEN 1 ELSE 0 END), 0) \
FROM play_session ps \
WHERE {server_sql} \
AND date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?",
);
let (total_listened_sec, track_play_count, full_count, partial_count) = conn.query_row(
&totals_sql,
rusqlite::params_from_iter(params.iter()),
|row| {
Ok((
row.get::<_, f64>(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
))
},
)?;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let rows_sql = format!(
"WITH sessions AS (
SELECT
ps.started_at_ms,
ps.listened_sec,
ps.completion,
COALESCE(k.cluster_key, 'solo:' || ps.server_id || ':' || ps.track_id) AS merge_key
FROM play_session ps
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = ps.server_id AND k.track_id = ps.track_id
WHERE {server_sql}
AND date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?
),
candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT merge_key, tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}, s.listened_sec, s.completion, s.started_at_ms
FROM sessions s
JOIN winners w ON w.merge_key = s.merge_key AND w.rn = 1
JOIN track t ON t.rowid = w.tid
ORDER BY s.started_at_ms DESC",
tol = DURATION_TOLERANCE_SEC,
);
let mut rows_params = params.clone();
rows_params.append(&mut priority_params);
rows_params.append(&mut in_params);
let track_col_count = repos::track_columns().split(',').count();
let mut stmt = conn.prepare(&rows_sql)?;
let tracks = stmt
.query_map(rusqlite::params_from_iter(rows_params.iter()), |row| {
let track = repos::row_to_track_row(row).map(|r| crate::dto::LibraryTrackDto::from_row(&r))?;
Ok(PlaySessionDayTrackDto {
server_id: track.server_id,
track_id: track.id,
title: track.title,
artist: track.artist,
listened_sec: row.get(track_col_count)?,
completion: row.get(track_col_count + 1)?,
started_at_ms: row.get(track_col_count + 2)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let plays: Vec<(i64, f64)> = tracks
.iter()
.map(|t| (t.started_at_ms, t.listened_sec))
.collect();
Ok(PlaySessionDayDetailDto {
totals: PlaySessionDayTotalsDto {
total_listened_sec,
session_count: count_listening_sessions(&plays),
track_play_count,
full_count,
partial_count,
},
tracks,
})
})
.map_err(|e| e.to_string())
}
pub fn cluster_recent_days(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
let limit = limit.clamp(1, RECENT_DAYS_LIMIT_MAX);
let (server_sql, params) = server_filter_sql(servers_ordered)?;
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT
date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') AS d,
ps.started_at_ms,
ps.listened_sec,
ps.completion
FROM play_session ps
WHERE {server_sql}
ORDER BY d DESC, ps.started_at_ms ASC",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, f64>(2)?,
row.get::<_, String>(3)?,
))
})?;
let mut by_day: HashMap<String, DayAgg> = HashMap::new();
for row in rows {
let (date, started_at_ms, listened_sec, completion) = row?;
let agg = by_day.entry(date).or_default();
agg.total_listened_sec += listened_sec;
agg.track_play_count += 1;
if completion == "full" {
agg.full_count += 1;
} else {
agg.partial_count += 1;
}
agg.plays.push((started_at_ms, listened_sec));
}
let mut out: Vec<PlaySessionRecentDayDto> = by_day
.into_iter()
.map(|(date, agg)| PlaySessionRecentDayDto {
date,
total_listened_sec: agg.total_listened_sec,
session_count: count_listening_sessions(&agg.plays),
track_play_count: agg.track_play_count,
full_count: agg.full_count,
partial_count: agg.partial_count,
})
.collect();
out.sort_by(|a, b| b.date.cmp(&a.date));
out.truncate(limit as usize);
Ok(out)
})
.map_err(|e| e.to_string())
}
pub fn cluster_most_played(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<PlaySessionMostPlayedDto>, String> {
let limit = limit.clamp(1, MOST_PLAYED_LIMIT_MAX);
let (server_sql, mut stats_params) = server_filter_sql(servers_ordered)?;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let col_count = repos::track_columns().split(',').count();
let sql = format!(
"WITH session_counts AS (
SELECT
COALESCE(k.cluster_key, 'solo:' || ps.server_id || ':' || ps.track_id) AS merge_key,
COUNT(*) AS track_play_count,
COALESCE(SUM(ps.listened_sec), 0.0) AS total_listened_sec
FROM play_session ps
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = ps.server_id AND k.track_id = ps.track_id
WHERE {server_sql}
GROUP BY merge_key
),
candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT merge_key, tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}, sc.track_play_count, sc.total_listened_sec
FROM session_counts sc
JOIN winners w ON w.merge_key = sc.merge_key AND w.rn = 1
JOIN track t ON t.rowid = w.tid
ORDER BY sc.track_play_count DESC, sc.total_listened_sec DESC, t.title COLLATE NOCASE ASC
LIMIT ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut stats_params);
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let track =
repos::row_to_track_row(row).map(|r| crate::dto::LibraryTrackDto::from_row(&r))?;
Ok(PlaySessionMostPlayedDto {
track,
track_play_count: row.get::<_, i64>(col_count)? as u32,
total_listened_sec: row.get(col_count + 1)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: album.into(),
album_id: Some(format!("alb-{server}")),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: 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: "{}".into(),
}
}
#[test]
fn day_detail_merges_track_identity_to_cluster_winner() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP"),
track("s2", "t2", "Song", "Band", "LP"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("test", |conn| {
conn.execute(
"INSERT INTO play_session (server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason)
VALUES (?1, ?2, ?3, 120.0, 120.0, 'full', 'ended')",
rusqlite::params!["s2", "t2", 1_000i64],
)?;
Ok(())
})
.unwrap();
let detail = cluster_day_detail(&store, &["s1".into(), "s2".into()], "1970-01-01").unwrap();
assert_eq!(detail.tracks.len(), 1);
assert_eq!(detail.tracks[0].server_id, "s1");
assert_eq!(detail.tracks[0].track_id, "t1");
assert_eq!(detail.totals.track_play_count, 1);
}
#[test]
fn most_played_aggregates_cluster_members() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP"),
track("s2", "t2", "Song", "Band", "LP"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("test", |conn| {
conn.execute(
"INSERT INTO play_session (server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason)
VALUES
('s1', 't1', 1700000000000, 60.0, 60.0, 'partial', 'ended'),
('s2', 't2', 1700000100000, 90.0, 90.0, 'full', 'ended')",
[],
)?;
Ok(())
})
.unwrap();
let rows = cluster_most_played(&store, &["s1".into(), "s2".into()], 10).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].track.server_id, "s1");
assert_eq!(rows[0].track_play_count, 2);
}
}
@@ -0,0 +1,44 @@
//! Priority rank SQL from an ordered server list (index 0 = highest).
use rusqlite::types::Value as SqlValue;
/// Build `CASE server_col WHEN ? THEN 0 … ELSE 9999 END` plus bind values.
pub fn priority_case_sql(server_col: &str, servers_ordered: &[String]) -> (String, Vec<SqlValue>) {
if servers_ordered.is_empty() {
return ("9999".to_string(), Vec::new());
}
let mut sql = format!("CASE {server_col}");
let mut params = Vec::with_capacity(servers_ordered.len());
for (rank, sid) in servers_ordered.iter().enumerate() {
sql.push_str(&format!(" WHEN ? THEN {rank}"));
params.push(SqlValue::Text(sid.clone()));
}
sql.push_str(" ELSE 9999 END");
(sql, params)
}
/// `server_id IN (?,?,…)` placeholders and bind values.
pub fn in_list_sql(servers: &[String]) -> (String, Vec<SqlValue>) {
if servers.is_empty() {
return ("0".to_string(), Vec::new());
}
let placeholders = vec!["?"; servers.len()].join(", ");
let params = servers
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect();
(placeholders, params)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn priority_case_orders_servers() {
let (sql, params) = priority_case_sql("t.server_id", &["a".into(), "b".into()]);
assert!(sql.contains("WHEN ? THEN 0"));
assert!(sql.contains("WHEN ? THEN 1"));
assert_eq!(params.len(), 2);
}
}
@@ -0,0 +1,362 @@
//! Batch rebuild of `track_cluster_key` — source of truth for cluster identity.
use rusqlite::{params, Transaction};
use crate::store::LibraryStore;
use super::db::{self, ATTACH_ALIAS, NORM_VERSION};
use super::keys::compute_track_cluster_keys;
const REBUILD_BATCH_SIZE: usize = 5_000;
struct TrackRow {
server_id: String,
track_id: String,
artist: Option<String>,
album_artist: Option<String>,
title: String,
album: String,
duration_sec: i64,
}
fn fetch_live_tracks(
tx: &Transaction<'_>,
server_id: Option<&str>,
) -> rusqlite::Result<Vec<TrackRow>> {
let (sql, bind): (&str, Option<&str>) = match server_id {
Some(_) => (
"SELECT server_id, id, artist, album_artist, title, album, duration_sec \
FROM track WHERE deleted = 0 AND server_id = ?1",
server_id,
),
None => (
"SELECT server_id, id, artist, album_artist, title, album, duration_sec \
FROM track WHERE deleted = 0",
None,
),
};
let mut stmt = tx.prepare(sql)?;
let rows = match bind {
Some(sid) => stmt.query_map(params![sid], map_track_row)?.collect(),
None => stmt.query_map([], map_track_row)?.collect(),
};
rows
}
fn map_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TrackRow> {
Ok(TrackRow {
server_id: row.get(0)?,
track_id: row.get(1)?,
artist: row.get(2)?,
album_artist: row.get(3)?,
title: row.get(4)?,
album: row.get(5)?,
duration_sec: row.get(6)?,
})
}
fn upsert_batch(tx: &Transaction<'_>, batch: &[(TrackRow, super::keys::TrackClusterKeys)]) -> rusqlite::Result<()> {
let mut stmt = tx.prepare(&format!(
"INSERT INTO {ATTACH_ALIAS}.track_cluster_key \
(server_id, track_id, cluster_key, album_key, artist_key, duration_sec) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
ON CONFLICT(server_id, track_id) DO UPDATE SET \
cluster_key = excluded.cluster_key, \
album_key = excluded.album_key, \
artist_key = excluded.artist_key, \
duration_sec = excluded.duration_sec"
))?;
for (row, keys) in batch {
stmt.execute(params![
row.server_id,
row.track_id,
keys.cluster_key,
keys.album_key,
keys.artist_key,
row.duration_sec,
])?;
}
Ok(())
}
fn rebuild_in_tx(
tx: &Transaction<'_>,
server_id: Option<&str>,
clear_scope: bool,
) -> rusqlite::Result<u32> {
if clear_scope {
match server_id {
Some(sid) => {
tx.execute(
&format!("DELETE FROM {ATTACH_ALIAS}.track_cluster_key WHERE server_id = ?1"),
params![sid],
)?;
}
None => {
tx.execute(
&format!("DELETE FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
)?;
}
}
}
let tracks = fetch_live_tracks(tx, server_id)?;
let mut written = 0u32;
let mut batch: Vec<(TrackRow, super::keys::TrackClusterKeys)> = Vec::new();
for row in tracks {
let Some(keys) = compute_track_cluster_keys(
row.artist.as_deref(),
row.album_artist.as_deref(),
&row.title,
&row.album,
) else {
continue;
};
batch.push((row, keys));
if batch.len() >= REBUILD_BATCH_SIZE {
upsert_batch(tx, &batch)?;
written = written.saturating_add(batch.len() as u32);
batch.clear();
}
}
if !batch.is_empty() {
upsert_batch(tx, &batch)?;
written = written.saturating_add(batch.len() as u32);
}
tx.execute(
&format!(
"INSERT INTO {ATTACH_ALIAS}.cluster_meta (key, value) VALUES ('norm_version', ?1) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
),
params![NORM_VERSION],
)?;
Ok(written)
}
/// Rebuild cluster keys for every live track in the library index.
pub fn rebuild_all_cluster_keys(store: &LibraryStore) -> Result<u32, String> {
store.with_conn_mut("cluster_rebuild_all", |conn| {
let tx = conn.transaction()?;
let count = rebuild_in_tx(&tx, None, true)?;
tx.commit()?;
Ok(count)
})
}
/// Rebuild cluster keys for one server's live tracks (deletes stale rows first).
pub fn rebuild_cluster_keys_for_server(
store: &LibraryStore,
server_id: &str,
) -> Result<u32, String> {
store.with_conn_mut("cluster_rebuild_server", |conn| {
let tx = conn.transaction()?;
let count = rebuild_in_tx(&tx, Some(server_id), true)?;
tx.commit()?;
Ok(count)
})
}
/// Rebuild when `cluster_meta.norm_version` lags the compiled rules.
pub fn rebuild_if_norm_version_stale(store: &LibraryStore) -> Result<Option<u32>, String> {
let stale = store.with_conn("cluster_norm_check", db::needs_norm_rebuild)?;
if !stale {
return Ok(None);
}
rebuild_all_cluster_keys(store).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::LibraryStore;
use rusqlite::params;
fn seed_track(
store: &LibraryStore,
server: &str,
id: &str,
artist: &str,
title: &str,
album: &str,
duration: i64,
) {
store
.with_conn_mut("misc", |conn| {
conn.execute(
"INSERT INTO track (server_id, id, title, artist, album, duration_sec, synced_at, raw_json) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, '{}')",
params![server, id, title, artist, album, duration],
)
})
.unwrap();
}
#[test]
fn rebuild_is_idempotent() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Artist", "Song", "Album", 200);
seed_track(&store, "s2", "t2", "Artist", "Song", "Album", 205);
let first = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(first, 2);
let count_after: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count_after, 2);
let second = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(second, 2);
let count_again: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count_again, 2);
}
#[test]
fn tracks_with_empty_fields_get_no_row() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "", "Song", "Album", 100);
seed_track(&store, "s1", "t2", "Artist", "Song", "Album", 100);
let written = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(written, 1);
let count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn same_cluster_key_across_servers_after_rebuild() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Band", "Hit", "LP", 180);
seed_track(&store, "s2", "t9", "Band", "Hit", "LP", 182);
rebuild_all_cluster_keys(&store).unwrap();
let keys: Vec<String> = store
.with_conn("misc", |c| {
let mut stmt = c.prepare(&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key ORDER BY server_id"
))?;
let rows: rusqlite::Result<Vec<String>> =
stmt.query_map([], |r| r.get(0))?.collect();
rows
})
.unwrap();
assert_eq!(keys.len(), 2);
assert_eq!(keys[0], keys[1]);
}
#[test]
fn per_server_rebuild_replaces_stale_rows() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "A", "One", "X", 1);
seed_track(&store, "s2", "t1", "B", "Two", "Y", 2);
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("misc", |conn| {
conn.execute(
"UPDATE track SET title = 'Updated' WHERE server_id = 's1' AND id = 't1'",
[],
)
})
.unwrap();
rebuild_cluster_keys_for_server(&store, "s1").unwrap();
let title_norm_row: Option<String> = store
.with_conn("misc", |c| {
use rusqlite::OptionalExtension;
c.query_row(
&format!(
"SELECT k.cluster_key FROM {ATTACH_ALIAS}.track_cluster_key k \
JOIN track t ON t.server_id = k.server_id AND t.id = k.track_id \
WHERE k.server_id = 's1' AND k.track_id = 't1'"
),
[],
|r| r.get(0),
)
.optional()
})
.unwrap();
assert!(title_norm_row.is_some());
let s2_count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key WHERE server_id = 's2'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(s2_count, 1);
}
#[test]
fn norm_version_bump_triggers_full_rebuild() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Artist", "Old", "Album", 1);
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("misc", |conn| {
conn.execute(
&format!(
"UPDATE {ATTACH_ALIAS}.cluster_meta SET value = '0' WHERE key = 'norm_version'"
),
[],
)
})
.unwrap();
assert!(
store
.with_conn("misc", db::needs_norm_rebuild)
.unwrap()
);
let rebuilt = rebuild_if_norm_version_stale(&store).unwrap();
assert_eq!(rebuilt, Some(1));
let version: String = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT value FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(version, NORM_VERSION);
}
}
@@ -0,0 +1,198 @@
//! Resolve cluster candidates for playback / writes (spec §56).
use rusqlite::types::Value as SqlValue;
use rusqlite::OptionalExtension;
use crate::dto::LibraryClusterCandidateDto;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::duration_partitions;
use super::priority::priority_case_sql;
/// All `(server_id, track_id)` rows sharing a `cluster_key`, ordered by priority.
pub fn resolve_candidates_by_cluster_key(
store: &LibraryStore,
servers_ordered: &[String],
cluster_key: &str,
) -> Result<Vec<LibraryClusterCandidateDto>, String> {
if servers_ordered.is_empty() || cluster_key.is_empty() {
return Ok(Vec::new());
}
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let in_placeholders = vec!["?"; servers_ordered.len()].join(", ");
let mut in_params: Vec<SqlValue> = servers_ordered
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect();
let sql = format!(
"SELECT t.server_id, t.id, COALESCE(k.duration_sec, t.duration_sec), ({priority_sql})
FROM {ATTACH_ALIAS}.track_cluster_key k
JOIN track t ON t.server_id = k.server_id AND t.id = k.track_id
WHERE k.cluster_key = ? AND t.deleted = 0 AND t.server_id IN ({in_placeholders})
ORDER BY 4, t.server_id, t.id"
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.push(SqlValue::Text(cluster_key.to_string()));
params.append(&mut in_params);
let rows: Vec<(String, String, i64, u32)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get::<_, i64>(3)? as u32))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
let with_rank = rows;
let partitions = duration_partitions(cluster_key, &with_rank);
let winner = partitions.first();
Ok(with_rank
.into_iter()
.map(|(server_id, track_id, duration_sec, priority_rank)| {
let is_winner = winner
.map(|(_, ws, wt)| ws == &server_id && wt == &track_id)
.unwrap_or(false);
LibraryClusterCandidateDto {
server_id,
track_id,
duration_sec,
priority_rank,
is_winner,
}
})
.collect())
}
/// Resolve `cluster_key` from a seed track, then return candidates.
pub fn resolve_candidates_for_track(
store: &LibraryStore,
servers_ordered: &[String],
server_id: &str,
track_id: &str,
) -> Result<Vec<LibraryClusterCandidateDto>, String> {
let cluster_key: Option<String> = store.with_read_conn(|conn| {
conn.query_row(
&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key \
WHERE server_id = ?1 AND track_id = ?2"
),
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
.optional()
})?;
let Some(cluster_key) = cluster_key else {
return Ok(vec![LibraryClusterCandidateDto {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
duration_sec: track_duration(store, server_id, track_id)?,
priority_rank: servers_ordered
.iter()
.position(|s| s == server_id)
.unwrap_or(9999) as u32,
is_winner: true,
}]);
};
resolve_candidates_by_cluster_key(store, servers_ordered, &cluster_key)
}
fn track_duration(store: &LibraryStore, server_id: &str, track_id: &str) -> Result<i64, String> {
store
.with_read_conn(|conn| {
conn.query_row(
"SELECT duration_sec FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0",
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
})
.map_err(|e| e.to_string())
}
/// Lookup cluster key for a track (for search/seed mapping).
pub fn cluster_key_for_track(
store: &LibraryStore,
server_id: &str,
track_id: &str,
) -> Result<Option<String>, String> {
store
.with_read_conn(|conn| {
conn.query_row(
&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key \
WHERE server_id = ?1 AND track_id = ?2"
),
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
.optional()
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn tr(server: &str, id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: None,
album: "LP".into(),
album_id: None,
album_artist: Some("Band".into()),
duration_sec: 180,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: 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: "{}".into(),
}
}
#[test]
fn resolve_orders_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[tr("s1", "t1"), tr("s2", "t2")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let key = cluster_key_for_track(&store, "s1", "t1").unwrap().unwrap();
let cands = resolve_candidates_by_cluster_key(&store, &["s1".into(), "s2".into()], &key).unwrap();
assert_eq!(cands.len(), 2);
assert!(cands[0].is_winner);
assert_eq!(cands[0].server_id, "s1");
}
}
@@ -0,0 +1,243 @@
//! Cluster-mode cross-server search — dedup by `cluster_key` + priority (spec §5).
use std::collections::HashSet;
use rusqlite::types::Value as SqlValue;
use crate::dto::{LibraryCrossServerSearchResponse, LibraryTrackDto};
use crate::repos;
use crate::search::{aliased_track_columns, fts_query, like_contains, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::solo_partition_key;
use super::priority::{in_list_sql, priority_case_sql};
const FUZZY_PER_SERVER_CAP: usize = 20;
/// FTS union over ordered servers; dedup by `cluster_key` (not canonical id).
pub fn run_cluster_search(
store: &LibraryStore,
query: &str,
limit: u32,
offset: u32,
servers_ordered: &[String],
) -> Result<LibraryCrossServerSearchResponse, String> {
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset as usize;
if servers_ordered.is_empty() {
return Ok(LibraryCrossServerSearchResponse::default());
}
let Some(fts) = fts_query(query) else {
return Ok(LibraryCrossServerSearchResponse::default());
};
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let canonical_idx = repos::track_columns().split(',').count();
let sql = format!(
"SELECT {cols}, k.cluster_key, ({priority_sql}) AS priority_rank \
FROM track_fts f \
JOIN track t ON t.rowid = f.rowid \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = t.server_id AND k.track_id = t.id \
WHERE track_fts MATCH ? AND t.deleted = 0 AND t.server_id IN ({in_placeholders}) \
ORDER BY bm25(track_fts) LIMIT ?"
);
let mut params: Vec<SqlValue> = Vec::new();
params.push(SqlValue::Text(fts));
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer((limit as i64).saturating_mul(4)));
let rows: Vec<(LibraryTrackDto, Option<String>, u32)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?;
let cluster_key: Option<String> = r.get(canonical_idx)?;
let rank: i64 = r.get(canonical_idx + 1)?;
Ok((track, cluster_key, rank as u32))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
let mut best_by_key: std::collections::HashMap<String, (LibraryTrackDto, u32)> =
std::collections::HashMap::new();
for (track, cluster_key, priority_rank) in rows {
let dedup_key = cluster_key
.clone()
.unwrap_or_else(|| solo_partition_key(&track.server_id, &track.id));
match best_by_key.get(&dedup_key) {
Some((_, best_rank)) if *best_rank <= priority_rank => {}
_ => {
best_by_key.insert(dedup_key, (track, priority_rank));
}
}
}
let hits: Vec<LibraryTrackDto> = best_by_key
.into_values()
.map(|(t, _)| t)
.skip(offset)
.take(limit as usize)
.collect();
let hit_keys: HashSet<(String, String)> = hits
.iter()
.map(|t| (t.server_id.clone(), t.id.clone()))
.collect();
let fuzzy = fuzzy_cluster_matches(
store,
servers_ordered,
query.trim(),
&hit_keys,
limit as usize,
)?;
Ok(LibraryCrossServerSearchResponse {
hits,
fuzzy,
servers_searched: servers_ordered.to_vec(),
})
}
/// Random merged track sample across cluster scope.
pub fn run_cluster_random_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<LibraryTrackDto>, String> {
if servers_ordered.is_empty() {
return Ok(Vec::new());
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY RANDOM()
LIMIT ?",
tol = super::merge::DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
fn fuzzy_cluster_matches(
store: &LibraryStore,
targets: &[String],
query: &str,
hit_keys: &HashSet<(String, String)>,
overall_cap: usize,
) -> Result<Vec<LibraryTrackDto>, String> {
let like = like_contains(query);
let cols = aliased_track_columns("t");
let key_idx = repos::track_columns().split(',').count();
let sql = format!(
"SELECT {cols}, k.cluster_key \
FROM track t \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = t.server_id AND k.track_id = t.id \
WHERE t.server_id = ? AND t.deleted = 0 AND t.title LIKE ? ESCAPE '\\' \
ORDER BY t.title COLLATE NOCASE ASC LIMIT ?"
);
let mut out: Vec<LibraryTrackDto> = Vec::new();
let mut seen_keys: HashSet<String> = HashSet::new();
for server in targets {
if out.len() >= overall_cap {
break;
}
let bound = [
SqlValue::Text(server.clone()),
SqlValue::Text(like.clone()),
SqlValue::Integer(FUZZY_PER_SERVER_CAP as i64),
];
let rows: Vec<(LibraryTrackDto, Option<String>)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(bound.iter()), |r| {
let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?;
let cluster_key: Option<String> = r.get(key_idx)?;
Ok((track, cluster_key))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
for (track, cluster_key) in rows {
if out.len() >= overall_cap {
break;
}
if hit_keys.contains(&(track.server_id.clone(), track.id.clone())) {
continue;
}
let dedup_key = cluster_key
.clone()
.unwrap_or_else(|| solo_partition_key(&track.server_id, &track.id));
if !seen_keys.insert(dedup_key) {
continue;
}
out.push(track);
}
}
Ok(out)
}
+66 -5
View File
@@ -7,9 +7,11 @@ use std::time::Duration;
use rusqlite::{params, Connection, OpenFlags};
use tauri::Manager;
use crate::server_cluster::{attach_cluster_database, attach_cluster_database_uri, cluster_db_path};
/// Current head of the embedded migrations. Bump each time a new
/// `migrations/NNN_*.sql` is added.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 2;
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
@@ -22,10 +24,15 @@ pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql");
const MIGRATION_002_ALBUM_BROWSE_INDEX: &str =
include_str!("../migrations/002_album_browse_index.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL)];
const MIGRATIONS: &[(i64, &str)] = &[
(1, INITIAL_SQL),
(2, MIGRATION_002_ALBUM_BROWSE_INDEX),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationOutcome {
@@ -40,9 +47,17 @@ pub(crate) enum MigrationOutcome {
/// In-memory tests share one DB across the read/write pair in a single store.
static IN_MEMORY_DB_COUNTER: AtomicU64 = AtomicU64::new(0);
fn in_memory_uri() -> String {
fn in_memory_uri(prefix: &str) -> String {
let n = IN_MEMORY_DB_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("file:psysonic_library_mem_{n}?mode=memory&cache=shared")
format!("file:psysonic_{prefix}_mem_{n}?mode=memory&cache=shared")
}
fn in_memory_library_uri() -> String {
in_memory_uri("library")
}
fn in_memory_cluster_uri() -> String {
in_memory_uri("cluster")
}
pub struct LibraryStore {
@@ -67,10 +82,12 @@ impl LibraryStore {
let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?;
configure_write_connection(&write_conn).map_err(|e| e.to_string())?;
run_migrations(&write_conn).map_err(|e| e.to_string())?;
attach_cluster_file(&write_conn, db_path).map_err(|e| e.to_string())?;
checkpoint_wal_conn(&write_conn, "open").map_err(|e| e.to_string())?;
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&read_conn).map_err(|e| e.to_string())?;
attach_cluster_file(&read_conn, db_path).map_err(|e| e.to_string())?;
Ok(Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
@@ -80,12 +97,15 @@ impl LibraryStore {
/// Build an in-memory DB with the production schema applied.
pub fn open_in_memory() -> Self {
let uri = in_memory_uri();
let uri = in_memory_library_uri();
let cluster_uri = in_memory_cluster_uri();
let write_conn = Connection::open(&uri).expect("in-memory write connection");
configure_write_connection(&write_conn).expect("write pragmas");
run_migrations(&write_conn).expect("schema migration");
attach_cluster_database_uri(&write_conn, &cluster_uri).expect("cluster attach write");
let read_conn = Connection::open(&uri).expect("in-memory read connection");
configure_read_connection(&read_conn).expect("read pragmas");
attach_cluster_database_uri(&read_conn, &cluster_uri).expect("cluster attach read");
Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
@@ -217,9 +237,11 @@ impl LibraryStore {
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_write, active_path).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_read, active_path).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(Some(backup))
@@ -253,9 +275,11 @@ impl LibraryStore {
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_write, active_path).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_read, active_path).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(())
@@ -285,6 +309,10 @@ fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) {
}
}
fn attach_cluster_file(conn: &Connection, library_db_path: &Path) -> rusqlite::Result<()> {
attach_cluster_database(conn, &cluster_db_path(library_db_path))
}
fn library_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
let db_dir = base.join("databases").join("library");
@@ -478,6 +506,39 @@ fn handle_breaking_schema_bump(
mod tests {
use super::*;
#[test]
fn cluster_db_attached_on_open_in_memory() {
use crate::server_cluster::ATTACH_ALIAS;
let store = LibraryStore::open_in_memory();
let count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.sqlite_master \
WHERE type='table' AND name='track_cluster_key'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count, 1);
let read_count: i64 = store
.with_read_conn(|c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(read_count, 1);
}
#[test]
fn read_conn_sees_committed_writes_from_write_conn() {
let store = LibraryStore::open_in_memory();
@@ -3,12 +3,10 @@ name = "psysonic-syncfs"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
psysonic-library = { path = "../psysonic-library" }
psysonic-analysis = { path = "../psysonic-analysis" }
psysonic-audio = { path = "../psysonic-audio" }
+2 -100
View File
@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::Path;
/// Recursively sums the size of all files under `root`.
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
@@ -25,47 +25,6 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
total
}
/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list.
pub fn collect_regular_files_under(root: &Path) -> Vec<std::path::PathBuf> {
if !root.is_dir() {
return Vec::new();
}
let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.is_file() {
files.push(path);
}
}
}
files
}
fn normalize_path_for_prefix(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path.
pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
let mut current = path.parent()?;
loop {
match current.file_name().and_then(|s| s.to_str()) {
Some("cache") | Some("library") | Some("favorites") => {
return Some(current.to_path_buf());
}
_ => current = current.parent()?,
}
}
}
/// Walks upward from `start_dir`, removing each empty directory using `remove_dir`
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
/// boundary is reached, or removal fails.
@@ -73,11 +32,9 @@ pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
/// not under `boundary`, the function is a no-op.
pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
let boundary_norm = normalize_path_for_prefix(boundary);
let mut current = Some(start_dir.to_path_buf());
while let Some(dir) = current {
let dir_norm = normalize_path_for_prefix(&dir);
if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) {
if dir == boundary || !dir.starts_with(boundary) {
break;
}
match std::fs::read_dir(&dir) {
@@ -95,29 +52,6 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
}
}
/// Post-order sweep: removes empty child directories under `root` (never `root` itself).
pub fn prune_empty_subdirs_under(root: &Path) {
if !root.is_dir() {
return;
}
let children: Vec<PathBuf> = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
for child in children {
prune_empty_subdirs_under(&child);
let is_empty = std::fs::read_dir(&child)
.map(|mut rd| rd.next().is_none())
.unwrap_or(false);
if is_empty {
let _ = std::fs::remove_dir(&child);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -153,17 +87,6 @@ mod tests {
assert!(path.exists(), "boundary dir must never be removed");
}
#[test]
fn collect_regular_files_under_lists_nested_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.mp3"), b"x").unwrap();
let sub = dir.path().join("Artist/Album");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("b.flac"), b"yy").unwrap();
let files = collect_regular_files_under(dir.path());
assert_eq!(files.len(), 2);
}
#[test]
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
let root = tempfile::tempdir().unwrap();
@@ -175,25 +98,4 @@ mod tests {
assert!(!child.exists(), "empty leaf should be pruned");
assert!(parent.exists(), "non-empty parent must stay");
}
#[test]
fn local_tier_boundary_from_path_finds_cache_root() {
let root = tempfile::tempdir().unwrap();
let track = root
.path()
.join("vol/media/cache/my.server/Artist/Album/track.flac");
std::fs::create_dir_all(track.parent().unwrap()).unwrap();
let boundary = local_tier_boundary_from_path(&track).unwrap();
assert_eq!(boundary, root.path().join("vol/media/cache"));
}
#[test]
fn prune_empty_subdirs_under_removes_nested_empty_tree() {
let root = tempfile::tempdir().unwrap();
let cache = root.path().join("cache").join("srv").join("Artist").join("Album");
std::fs::create_dir_all(&cache).unwrap();
prune_empty_subdirs_under(&root.path().join("cache"));
assert!(!cache.exists());
assert!(root.path().join("cache").exists(), "tier root preserved");
}
}
File diff suppressed because it is too large Load Diff
-1
View File
@@ -2,4 +2,3 @@ mod fs_utils;
pub mod offline;
pub mod downloads;
pub mod hot;
pub mod local;
+17 -50
View File
@@ -5,8 +5,6 @@ pub mod cli;
mod cover_cache;
mod library_analysis_backfill;
mod lib_commands;
mod theme_import;
pub mod theme_animation;
pub use psysonic_integration::discord;
@@ -116,38 +114,6 @@ pub fn run() {
let _ = window.set_title("Psysonic (Dev)");
}
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
// Poll a local theme.css and push it into the running app on save,
// so theme authors get a live loop without re-importing a zip. The
// frontend (dev only) installs it under the id in its
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
#[cfg(debug_assertions)]
{
let args: Vec<String> = std::env::args().collect();
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
match args.get(i + 1).cloned() {
Some(path) => {
eprintln!("[theme-watch] watching {path}");
let handle = app.handle().clone();
std::thread::spawn(move || {
let p = std::path::PathBuf::from(&path);
let mut last_css = String::new();
loop {
if let Ok(css) = std::fs::read_to_string(&p) {
if css != last_css {
last_css = css.clone();
let _ = handle.emit("theme-watch:css", css);
}
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
});
}
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
}
}
}
// ── Analysis cache (SQLite) ───────────────────────────────────
{
let cache = analysis_cache::AnalysisCache::init(app.handle())
@@ -627,7 +593,6 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
greet,
theme_import::import_theme_zip,
backup_export_library_db,
backup_import_library_db,
backup_export_full,
@@ -654,7 +619,6 @@ pub fn run() {
performance_cpu_snapshot,
set_subsonic_wire_user_agent,
no_compositing_mode,
theme_animation_risk,
linux_xdg_session_type,
is_tiling_wm_cmd,
open_mini_player,
@@ -744,14 +708,30 @@ pub fn run() {
psysonic_library::commands::library_search,
psysonic_library::commands::library_live_search,
psysonic_library::commands::library_advanced_search,
psysonic_library::commands::library_cluster_advanced_search,
psysonic_library::commands::library_list_lossless_albums,
psysonic_library::commands::library_list_albums,
psysonic_library::commands::library_list_albums_by_genre,
psysonic_library::commands::library_get_artist_lossless_browse,
psysonic_library::commands::library_search_cross_server,
psysonic_library::commands::library_cluster_list_tracks,
psysonic_library::commands::library_cluster_list_albums,
psysonic_library::commands::library_cluster_list_artists,
psysonic_library::commands::library_cluster_list_favorites,
psysonic_library::commands::library_cluster_list_favorite_albums,
psysonic_library::commands::library_cluster_list_favorite_artists,
psysonic_library::commands::library_cluster_player_stats_year_summary,
psysonic_library::commands::library_cluster_player_stats_heatmap,
psysonic_library::commands::library_cluster_player_stats_day_detail,
psysonic_library::commands::library_cluster_player_stats_recent_days,
psysonic_library::commands::library_cluster_player_stats_most_played,
psysonic_library::commands::library_cluster_resolve_candidates,
psysonic_library::commands::library_cluster_album_detail,
psysonic_library::commands::library_cluster_artist_detail,
psysonic_library::commands::library_search_cluster,
psysonic_library::commands::library_get_track,
psysonic_library::commands::library_get_tracks_batch,
psysonic_library::commands::library_get_tracks_by_album,
psysonic_library::commands::library_upsert_songs_from_api,
psysonic_library::commands::library_get_artifact,
psysonic_library::commands::library_get_facts,
psysonic_library::commands::library_get_offline_path,
@@ -812,19 +792,6 @@ pub fn run() {
psysonic_syncfs::cache::offline::clear_offline_cancel,
psysonic_syncfs::cache::offline::delete_offline_track,
psysonic_syncfs::cache::offline::get_offline_cache_size,
psysonic_syncfs::cache::local::download_track_local,
psysonic_syncfs::cache::local::probe_library_track_local,
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
psysonic_syncfs::cache::local::probe_media_files,
psysonic_syncfs::cache::local::get_media_tier_size,
psysonic_syncfs::cache::local::purge_media_tier,
psysonic_syncfs::cache::local::delete_media_file,
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
psysonic_syncfs::cache::hot::download_track_hot_cache,
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
psysonic_syncfs::cache::hot::get_hot_cache_size,
@@ -23,7 +23,6 @@ pub(crate) use perf::performance_cpu_snapshot;
pub(crate) use platform::{
linux_wayland_gpu_font_tuning_active, linux_wayland_text_render_settings_available,
set_linux_wayland_text_render_profile, set_linux_webkit_smooth_scrolling, set_window_decorations,
theme_animation_risk,
};
#[cfg(target_os = "linux")]
pub(crate) use platform::{
@@ -137,29 +137,6 @@ pub(crate) fn linux_webkit_apply_wayland_gpu_font_tuning(win: &tauri::WebviewWin
}
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
/// Tauri command: true when theme animations may be costly on this setup —
/// Linux with the Nvidia WebKit quirk active (recorded once at startup) or
/// compositing forced off. The frontend warns on animated themes when true.
/// Always false off Linux.
#[tauri::command]
pub(crate) fn theme_animation_risk() -> bool {
#[cfg(target_os = "linux")]
{
// Compositing forced off → GPU-accelerated effects/animation are costly.
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
.map(|v| v == "1")
.unwrap_or(false)
{
return true;
}
crate::theme_animation::nvidia_quirk_active()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
#[tauri::command]
pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
if let Some(win) = app_handle.get_webview_window("main") {
+1 -6
View File
@@ -17,16 +17,11 @@ fn apply_linux_webkit_nvidia_quirk() {
// may still be `XDG_SESSION_TYPE=wayland`. The quirk maps that to `__NV_DISABLE_EXPLICIT_SYNC`,
// which mismatches a real X11 EGL stack and can leave the webview gray — mirror the native-X11
// branch (`WEBKIT_DISABLE_DMABUF_RENDERER` only) whenever GDK is pinned to x11 first in the list.
// Detect once and record it for the UI's animated-theme CPU-load warning,
// so the theme_animation_risk command never has to re-probe the GPU.
let kind = needs_workaround();
psysonic_lib::theme_animation::set_nvidia_quirk_active(!matches!(kind, WorkaroundKind::None));
let forced_x11_gdk = std::env::var("GDK_BACKEND").ok().is_some_and(|s| {
matches!(s.split(',').next().map(str::trim), Some("x11"))
});
if forced_x11_gdk {
match kind {
match needs_workaround() {
WorkaroundKind::None => {}
WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => {
set_webkit_disable_dmabuf_renderer();
-22
View File
@@ -1,22 +0,0 @@
//! Startup-recorded display hint for the theme system.
//!
//! The Nvidia/WebKit GPU quirk is detected once at process start (in `main()`,
//! before the webview/GTK init). We record whether it was needed so the UI can
//! warn that animated themes may raise CPU load on this setup — without
//! re-probing the GPU later. Read via the `theme_animation_risk` command
//! (`lib_commands::app_api::platform`).
use std::sync::OnceLock;
static NVIDIA_QUIRK_ACTIVE: OnceLock<bool> = OnceLock::new();
/// Record the startup Nvidia-WebKit-quirk detection. Called once from `main()`.
pub fn set_nvidia_quirk_active(active: bool) {
let _ = NVIDIA_QUIRK_ACTIVE.set(active);
}
/// Whether the Nvidia WebKit quirk was needed at startup. False when unrecorded
/// (non-Linux, or GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`).
pub(crate) fn nvidia_quirk_active() -> bool {
NVIDIA_QUIRK_ACTIVE.get().copied().unwrap_or(false)
}
-99
View File
@@ -1,99 +0,0 @@
//! Local theme-package import.
//!
//! Reads a user-picked `.zip` and returns its `manifest.json` + `theme.css`
//! to the frontend, which runs the full theme-store contract validation
//! (`src/utils/themes/validateThemePackage.ts`) before installing.
//!
//! Only those two entries are pulled out — the thumbnail is not needed (the UI
//! derives a swatch from the CSS). Parsing the untrusted archive happens here
//! in Rust, outside the webview, and every read is size-capped so a malformed
//! or hostile archive (lying header, zip-bomb, path traversal) cannot exhaust
//! memory or escape the archive.
use std::io::Read;
use serde::Serialize;
/// On-disk archive cap. A real token-only theme zip is a few KB.
const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;
/// Per-entry uncompressed caps — mirror the frontend/CI limits
/// (`validateThemeCss` caps CSS at 64 KB; the manifest is tiny).
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
const MAX_CSS_BYTES: usize = 256 * 1024;
#[derive(Serialize)]
pub struct ImportedThemeFiles {
pub manifest: String,
pub css: String,
}
#[tauri::command]
pub fn import_theme_zip(path: String) -> Result<ImportedThemeFiles, String> {
let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?;
let len = file
.metadata()
.map_err(|e| format!("cannot read file info: {e}"))?
.len();
if len > MAX_ARCHIVE_BYTES {
return Err(format!(
"archive is too large (> {} KB)",
MAX_ARCHIVE_BYTES / 1024
));
}
let mut archive =
zip::ZipArchive::new(file).map_err(|_| "not a valid .zip archive".to_string())?;
let manifest = read_capped_entry(&mut archive, "manifest.json", MAX_MANIFEST_BYTES)?
.ok_or_else(|| "manifest.json was not found in the archive".to_string())?;
let css = read_capped_entry(&mut archive, "theme.css", MAX_CSS_BYTES)?
.ok_or_else(|| "theme.css was not found in the archive".to_string())?;
Ok(ImportedThemeFiles { manifest, css })
}
/// Find the first non-directory entry whose file name equals `wanted` (at the
/// archive root or under a single wrapping folder), reject path traversal, and
/// read it as UTF-8 text under `cap` bytes. Both the declared size and the
/// actual read are bounded, so a lying header cannot allocate past the cap.
fn read_capped_entry<R: Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
wanted: &str,
cap: usize,
) -> Result<Option<String>, String> {
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| format!("corrupt archive entry: {e}"))?;
if entry.is_dir() {
continue;
}
// `enclosed_name()` is `None` for absolute paths or `..` traversal.
let base = match entry.enclosed_name() {
Some(p) => match p.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
},
None => return Err("archive contains an unsafe path".to_string()),
};
if base != wanted {
continue;
}
if entry.size() > cap as u64 {
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
}
let mut buf = Vec::new();
entry
.by_ref()
.take(cap as u64 + 1)
.read_to_end(&mut buf)
.map_err(|e| format!("cannot read {wanted}: {e}"))?;
if buf.len() > cap {
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
}
return String::from_utf8(buf)
.map(Some)
.map_err(|_| format!("{wanted} is not valid UTF-8"));
}
Ok(None)
}
-56
View File
@@ -1,10 +1,6 @@
import { useEffect } from 'react';
import { useAuthStore } from './store/authStore';
import { usePlayerStore } from './store/playerStore';
import { useLyricsStore } from './store/lyricsStore';
import { useThemeStore } from './store/themeStore';
import { useInstalledThemesStore } from './store/installedThemesStore';
import { syncInjectedThemes } from './utils/themes/themeInjection';
import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore';
import { getWindowKind } from './app/windowKind';
@@ -17,66 +13,14 @@ export default function App() {
useThemeStore(s => s.theme);
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const installedThemes = useInstalledThemesStore(s => s.themes);
// Document-attribute hooks are shared between both window kinds — each
// webview has its own `document`, and theme / font / track-preview tokens
// are read by CSS in both trees.
// Installed community themes have no build-time CSS — inject their
// `[data-theme='<id>']` blocks into <head> from the persisted (localStorage)
// store. Runs before the data-theme effect below so the matching style exists
// when the attribute is applied. The store hydrates synchronously, so an
// active community theme is painted without a network round-trip.
useEffect(() => {
syncInjectedThemes(installedThemes);
}, [installedThemes]);
// Dev only: `--theme-watch <theme.css>` (debug builds) pushes a local theme's
// CSS in on every save. Install it under the id in its `[data-theme='<id>']`
// selector and apply it — the syncInjectedThemes effect above re-injects, so
// authoring is live without re-importing a zip. Never wired in production.
useEffect(() => {
if (!import.meta.env.DEV) return;
let unlisten: (() => void) | undefined;
void import('@tauri-apps/api/event').then(({ listen }) => {
const sub = listen<string>('theme-watch:css', ({ payload }) => {
const id = payload.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
if (!id) return;
useInstalledThemesStore.getState().install({
id, name: id, author: 'dev', version: '0.0.0', description: '', mode: 'dark', css: payload, installedAt: Date.now(),
});
useThemeStore.getState().setTheme(id);
});
// Guard the mocked-in-tests case where listen() isn't a promise.
if (sub && typeof sub.then === 'function') sub.then(u => { unlisten = u; });
}).catch(() => {});
return () => unlisten?.();
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', effectiveTheme);
}, [effectiveTheme]);
// Expose app state on the theme root so themes can react to it with a
// same-element compound selector, e.g. `[data-theme='x'][data-playing='true']`.
// The set of allowed state attributes is the contract's `stateSelectors`.
// (Sidebar-collapsed is set in AppShell, where that state lives.)
const isPlaying = usePlayerStore(s => s.isPlaying);
useEffect(() => {
document.documentElement.setAttribute('data-playing', isPlaying ? 'true' : 'false');
}, [isPlaying]);
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
useEffect(() => {
document.documentElement.setAttribute('data-fullscreen', isFullscreenOpen ? 'true' : 'false');
}, [isFullscreenOpen]);
const lyricsOpen = useLyricsStore(s => s.activeTab === 'lyrics');
useEffect(() => {
document.documentElement.setAttribute('data-lyrics-open', lyricsOpen ? 'true' : 'false');
}, [lyricsOpen]);
useEffect(() => {
document.documentElement.setAttribute('data-font', font);
}, [font]);
+427 -24
View File
@@ -202,6 +202,8 @@ export interface LibrarySortClause {
export interface LibraryAdvancedSearchRequest {
serverId: string;
libraryScope?: string | null;
/** Multiple music-folder ids (OR). Preferred over `libraryScope` when length > 1. */
libraryScopeIds?: string[] | null;
query?: string | null; // shorthand → fts clause on text fields
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
@@ -379,9 +381,52 @@ export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<Li
}));
}
export interface LibraryAlbumBrowseRequest {
serverId: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
}
export interface LibraryAlbumBrowseResponse {
albums: LibraryAlbumDto[];
hasMore: boolean;
source: 'local';
}
/** Paginated plain All Albums from the local track index. */
export function libraryListAlbums(
request: LibraryAlbumBrowseRequest,
): Promise<LibraryAlbumBrowseResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryAlbumBrowseResponse>('library_list_albums', {
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
sort: request.sort,
limit: request.limit,
offset: request.offset,
},
}).then(response => ({
...response,
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
}));
}
export interface LibraryLosslessAlbumsRequest {
serverId: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
}
@@ -401,6 +446,9 @@ export function libraryListLosslessAlbums(
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
sort: request.sort,
limit: request.limit,
offset: request.offset,
},
@@ -464,6 +512,327 @@ export function librarySearchCrossServer(args: {
}));
}
export interface LibraryClusterCandidateDto {
serverId: string;
trackId: string;
durationSec: number;
priorityRank: number;
isWinner: boolean;
}
export interface LibraryClusterResolveResponse {
candidates: LibraryClusterCandidateDto[];
clusterKey?: string | null;
}
function mapServersOrderedToIndexKeys(serverIds: string[]): string[] {
return serverIds.map(serverIndexKeyForId);
}
function mapClusterLibraryScopesToIndexKeys(
scopes: Record<string, string[]> | undefined,
): Record<string, string[]> | undefined {
if (!scopes) return undefined;
const out: Record<string, string[]> = {};
for (const [sid, scopeIds] of Object.entries(scopes)) {
if (scopeIds.length > 0) out[serverIndexKeyForId(sid)] = scopeIds;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/** Merged track list for cluster scope (ordered members = priority). */
export function libraryClusterListTracks(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_cluster_list_tracks', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(env => ({
...env,
tracks: mapTracksServerId(env.tracks),
}));
}
export interface LibraryClusterAlbumsResponse {
albums: LibraryAlbumDto[];
hasMore: boolean;
}
export interface LibraryClusterArtistsResponse {
artists: LibraryArtistDto[];
hasMore: boolean;
}
export function libraryClusterListAlbums(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_albums', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(resp => ({
...resp,
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListArtists(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_artists', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(resp => ({
...resp,
artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListFavorites(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_cluster_list_favorites', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(env => ({
...env,
tracks: mapTracksServerId(env.tracks),
}));
}
export function libraryClusterPlayerStatsYearSummary(args: {
serversOrdered: string[];
year: number;
}): Promise<PlaySessionYearSummary> {
return invoke<PlaySessionYearSummary>('library_cluster_player_stats_year_summary', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
year: args.year,
},
});
}
export function libraryClusterPlayerStatsHeatmap(args: {
serversOrdered: string[];
year: number;
}): Promise<PlaySessionHeatmapDay[]> {
return invoke<PlaySessionHeatmapDay[]>('library_cluster_player_stats_heatmap', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
year: args.year,
},
});
}
export function libraryClusterResolveCandidates(args: {
serversOrdered: string[];
clusterKey?: string;
serverId?: string;
trackId?: string;
}): Promise<LibraryClusterResolveResponse> {
return invoke<LibraryClusterResolveResponse>('library_cluster_resolve_candidates', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
clusterKey: args.clusterKey,
serverId: args.serverId ? serverIndexKeyForId(args.serverId) : undefined,
trackId: args.trackId,
},
}).then(response => ({
...response,
candidates: response.candidates.map(c => ({
...c,
serverId: mapServerIdFromIndexKey(c.serverId),
})),
}));
}
export interface LibraryClusterAlbumDetailResponse {
album: LibraryAlbumDto;
tracks: LibraryTrackDto[];
ownerServerId: string;
relatedAlbums: LibraryAlbumDto[];
}
export interface LibraryClusterArtistDetailResponse {
artist: LibraryArtistDto;
albums: LibraryAlbumDto[];
topTracks: LibraryTrackDto[];
ownerServerId: string;
artistKey?: string | null;
}
export function libraryClusterAlbumDetail(args: {
serversOrdered: string[];
serverId: string;
entityId: string;
}): Promise<LibraryClusterAlbumDetailResponse> {
return invoke<LibraryClusterAlbumDetailResponse>('library_cluster_album_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
serverId: serverIndexKeyForId(args.serverId),
entityId: args.entityId,
},
}).then(resp => ({
...resp,
album: { ...resp.album, serverId: mapServerIdFromIndexKey(resp.album.serverId) },
tracks: mapTracksServerId(resp.tracks),
ownerServerId: mapServerIdFromIndexKey(resp.ownerServerId),
relatedAlbums: resp.relatedAlbums.map(a => ({
...a,
serverId: mapServerIdFromIndexKey(a.serverId),
})),
}));
}
export function libraryClusterArtistDetail(args: {
serversOrdered: string[];
serverId: string;
entityId: string;
}): Promise<LibraryClusterArtistDetailResponse> {
return invoke<LibraryClusterArtistDetailResponse>('library_cluster_artist_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
serverId: serverIndexKeyForId(args.serverId),
entityId: args.entityId,
},
}).then(resp => ({
...resp,
artist: { ...resp.artist, serverId: mapServerIdFromIndexKey(resp.artist.serverId) },
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
topTracks: mapTracksServerId(resp.topTracks),
ownerServerId: mapServerIdFromIndexKey(resp.ownerServerId),
}));
}
/** Cluster-mode search — dedup by cluster_key + priority. */
export function librarySearchCluster(args: {
query: string;
limit?: number;
offset?: number;
serversOrdered: string[];
}): Promise<LibraryCrossServerSearchResponse> {
return invoke<LibraryCrossServerSearchResponse>('library_search_cluster', {
query: args.query,
limit: args.limit,
offset: args.offset,
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
}).then(response => ({
...response,
hits: mapTracksServerId(response.hits),
fuzzy: mapTracksServerId(response.fuzzy),
serversSearched: response.serversSearched.map(id => mapServerIdFromIndexKey(id)),
}));
}
export interface LibraryClusterAdvancedSearchRequest {
serversOrdered: string[];
query?: string | null;
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
starredOnly?: boolean | null;
restrictAlbumIds?: string[] | null;
/** Per-member getAlbumList2 allowlists (`serverId` → album ids). */
restrictAlbumScopes?: Record<string, string[]>;
queryAlbumTitleOnly?: boolean | null;
sort?: LibrarySortClause[];
limit: number;
offset?: number;
skipTotals?: boolean;
libraryScopes?: Record<string, string[]>;
}
export function libraryClusterAdvancedSearch(
request: LibraryClusterAdvancedSearchRequest,
): Promise<LibraryAdvancedSearchResponse> {
return invoke<LibraryAdvancedSearchResponse>('library_cluster_advanced_search', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(request.serversOrdered),
query: request.query ?? undefined,
entityTypes: request.entityTypes,
filters: request.filters ?? [],
starredOnly: request.starredOnly ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
restrictAlbumScopes: mapClusterLibraryScopesToIndexKeys(request.restrictAlbumScopes) ?? {},
queryAlbumTitleOnly: request.queryAlbumTitleOnly ?? undefined,
sort: request.sort ?? [],
limit: request.limit,
offset: request.offset ?? 0,
skipTotals: request.skipTotals ?? false,
libraryScopes: mapClusterLibraryScopesToIndexKeys(request.libraryScopes) ?? {},
},
}).then(response => ({
...response,
artists: response.artists.map(artist => ({
...artist,
serverId: mapServerIdFromIndexKey(artist.serverId),
})),
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId),
})),
tracks: mapTracksServerId(response.tracks),
}));
}
export function libraryClusterListFavoriteAlbums(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_favorite_albums', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(resp => ({
...resp,
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListFavoriteArtists(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_favorite_artists', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(resp => ({
...resp,
artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryGetTrack(
serverId: string,
trackId: string,
@@ -473,18 +842,6 @@ export function libraryGetTrack(
.then(track => (track ? { ...track, serverId } : track));
}
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
export function libraryUpsertSongsFromApi(
serverId: string,
songs: unknown[],
): Promise<number> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
}
/** `library_get_tracks_batch` cap (spec §7.1). */
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
const indexKeyMap = new Map<string, string>();
const remapped = refs.map(ref => {
@@ -499,18 +856,6 @@ export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrack
})));
}
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
if (refs.length === 0) return [];
const out: LibraryTrackDto[] = [];
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
out.push(...batch);
}
return out;
}
export function libraryGetTracksByAlbum(
serverId: string,
albumId: string,
@@ -748,11 +1093,13 @@ export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise
export function libraryGetGenreAlbumCounts(args: {
serverId: string;
libraryScope?: string;
libraryScopeIds?: string[];
}): Promise<GenreAlbumCountRow[]> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<GenreAlbumCountRow[]>('library_get_genre_album_counts', {
serverId: indexKey,
libraryScope: args.libraryScope,
libraryScopeIds: args.libraryScopeIds,
});
}
@@ -760,6 +1107,7 @@ export type LibraryGenreAlbumsRequest = {
serverId: string;
genre: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
@@ -783,6 +1131,7 @@ export function libraryListAlbumsByGenre(
serverId: indexKey,
genre: request.genre,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
sort: request.sort ?? [],
limit: request.limit ?? 50,
offset: request.offset ?? 0,
@@ -838,6 +1187,60 @@ export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySession
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
}
export interface PlaySessionMostPlayed {
track: LibraryTrackDto;
trackPlayCount: number;
totalListenedSec: number;
}
export function libraryClusterPlayerStatsDayDetail(args: {
serversOrdered: string[];
dateIso: string;
}): Promise<PlaySessionDayDetail> {
return invoke<PlaySessionDayDetail>('library_cluster_player_stats_day_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
dateIso: args.dateIso,
},
}).then(detail => ({
...detail,
tracks: detail.tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId),
})),
}));
}
export function libraryClusterPlayerStatsRecentDays(args: {
serversOrdered: string[];
limit?: number;
}): Promise<PlaySessionRecentDay[]> {
return invoke<PlaySessionRecentDay[]>('library_cluster_player_stats_recent_days', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
},
});
}
export function libraryClusterPlayerStatsMostPlayed(args: {
serversOrdered: string[];
limit?: number;
}): Promise<PlaySessionMostPlayed[]> {
return invoke<PlaySessionMostPlayed[]>('library_cluster_player_stats_most_played', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
},
}).then(rows => rows.map(row => ({
...row,
track: {
...row.track,
serverId: mapServerIdFromIndexKey(row.track.serverId),
},
})));
}
// ── Event subscriptions ───────────────────────────────────────────────
export interface LibrarySyncProgressPayload {
+3 -3
View File
@@ -1,6 +1,7 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { libraryScopeForServer } from './subsonicClient';
import { ndLogin } from './navidromeAdmin';
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
let cachedToken: { serverUrl: string; token: string } | null = null;
@@ -24,10 +25,9 @@ function asString(v: unknown, fallback = ''): string {
* Mirrors the Subsonic `musicFolderId` we pipe through `libraryFilterParams()` Navidrome
* uses the same id space, so the same value is valid for the native API's `library_id` filter. */
function currentLibraryId(): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return null;
const f = musicLibraryFilterByServer[activeServerId];
return !f || f === 'all' ? null : f;
return libraryScopeForServer(activeServerId) ?? null;
}
function asNumber(v: unknown): number | undefined {
-5
View File
@@ -20,11 +20,6 @@ vi.mock('axios', () => ({
default: { get: vi.fn() },
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForActiveServer: () => true,
shouldAttemptSubsonicForServer: () => true,
}));
import axios from 'axios';
import { pingWithCredentials, ping } from './subsonic';
import { getAlbumInfo2 } from './subsonicAlbumInfo';
+2 -2
View File
@@ -72,7 +72,7 @@ describe('libraryFilterParams', () => {
it('returns { musicFolderId } when the active server has a specific filter', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
musicLibraryFilterByServer: { [serverId]: ['mf-7'] },
});
expect(libraryFilterParams()).toEqual({ musicFolderId: 'mf-7' });
});
@@ -91,7 +91,7 @@ describe('libraryScopeForServer', () => {
it('returns the folder id when scoped', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
musicLibraryFilterByServer: { [serverId]: ['mf-7'] },
});
expect(libraryScopeForServer(serverId)).toBe('mf-7');
});
+109 -2
View File
@@ -8,6 +8,11 @@ import type {
SubsonicArtistInfo,
SubsonicSong,
} from './subsonicTypes';
import { isAllLibrariesFilter, normalizeMusicLibraryFilter } from '../utils/musicLibraryFilter';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { resolveClusterBrowseMembers } from '../utils/serverCluster/clusterBrowse';
import { libraryClusterResolveCandidates } from './library';
import { mergeClusterTracks, resolveClusterSeedIds } from '../utils/serverCluster/clusterDiscoveryMerge';
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: any } }>('getArtists.view', {
@@ -60,6 +65,9 @@ export async function getArtistInfoForServer(
}
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
if (isClusterMode()) {
return getTopSongsCluster(artist);
}
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return [];
return getTopSongsForServer(activeServerId, artist);
@@ -68,7 +76,7 @@ export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
export async function getTopSongsForServer(serverId: string, artist: string): Promise<SubsonicSong[]> {
try {
const { musicLibraryFilterByServer } = useAuthStore.getState();
const scoped = musicLibraryFilterByServer[serverId] && musicLibraryFilterByServer[serverId] !== 'all';
const scoped = !isAllLibrariesFilter(normalizeMusicLibraryFilter(musicLibraryFilterByServer[serverId]));
const topCount = scoped ? 20 : 5;
const data = await apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
@@ -83,7 +91,59 @@ export async function getTopSongsForServer(serverId: string, artist: string): Pr
}
}
async function getTopSongsCluster(artist: string): Promise<SubsonicSong[]> {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const settled = await Promise.allSettled(
members.map(serverId =>
apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
'getTopSongs.view',
{ artist, count: 20, ...libraryFilterParamsForServer(serverId) },
).then(data => ({ serverId, songs: data.topSongs?.song ?? [] })),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.slice(0, 5);
}
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
if (isClusterMode()) {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const requestCount = similarSongsRequestCount(count);
const settled = await Promise.allSettled(
members.map(serverId =>
apiForServer<{ similarSongs2: { song: SubsonicSong[] } }>(
serverId,
'getSimilarSongs2.view',
{ id, count: requestCount, ...libraryFilterParamsForServer(serverId) },
).then(data => ({ serverId, songs: data.similarSongs2?.song ?? [] })),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.filter(s => s.id !== id).slice(0, count);
}
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
@@ -96,7 +156,54 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
}
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
export async function getSimilarSongs(
id: string,
count = 50,
browseServerId?: string,
): Promise<SubsonicSong[]> {
if (isClusterMode()) {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const activeServerId = browseServerId ?? useAuthStore.getState().activeServerId ?? members[0] ?? '';
const requestCount = similarSongsRequestCount(count);
const seedCandidates = await libraryClusterResolveCandidates({
serversOrdered: members,
serverId: activeServerId,
trackId: id,
}).catch(() => null);
const seeds = resolveClusterSeedIds(
Object.fromEntries((seedCandidates?.candidates ?? []).map(c => [c.serverId, c.trackId])),
members,
);
const resolvedSeeds = seeds.length > 0
? seeds
: members.map(serverId => ({ serverId, seedId: id }));
const settled = await Promise.allSettled(
resolvedSeeds.map(({ serverId, seedId }) =>
apiForServer<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverId,
'getSimilarSongs.view',
{ id: seedId, count: requestCount, ...libraryFilterParamsForServer(serverId) },
).then(data => {
const raw = data.similarSongs?.song;
const songs = !raw ? [] : Array.isArray(raw) ? raw : [raw];
return { serverId, songs };
}),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.filter(s => s.id !== id).slice(0, count);
}
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
+8 -8
View File
@@ -4,6 +4,7 @@ import { version } from '../../package.json';
import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes';
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
import { libraryScopeForServer as scopeForServer } from '../utils/musicLibraryFilter';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
export const SUBSONIC_CLIENT = `psysonic/${version}`;
@@ -106,17 +107,16 @@ export function libraryFilterParams(): Record<string, string | number> {
return activeServerId ? libraryFilterParamsForServer(activeServerId) : {};
}
/** Navidrome/Subsonic music folder id for the local library index, or undefined for all libraries. */
export function libraryScopeForServer(serverId: string): string | undefined {
const resolved = resolveServerIdForIndexKey(serverId);
const f = useAuthStore.getState().musicLibraryFilterByServer[resolved];
if (f === undefined || f === 'all') return undefined;
return f;
}
export {
libraryScopeForServer,
libraryScopeIdsForServer,
libraryScopeInvokeArgs,
musicLibraryFilterForServer,
} from '../utils/musicLibraryFilter';
/** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */
export function libraryFilterParamsForServer(serverId: string): Record<string, string | number> {
const scope = libraryScopeForServer(serverId);
const scope = scopeForServer(serverId);
if (!scope) return {};
return { musicFolderId: scope };
}
+44 -45
View File
@@ -1,8 +1,9 @@
import { useAuthStore } from '../store/authStore';
import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '../utils/network/subsonicNetworkGuard';
isAllLibrariesFilter,
musicLibraryFilterForServer,
musicLibraryFilterStorageKey,
} from '../utils/musicLibraryFilter';
import { useAuthStore } from '../store/authStore';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import type {
RandomSongsFilters,
@@ -61,7 +62,6 @@ export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
}
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForActiveServer()) return [];
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'random',
size,
@@ -76,7 +76,6 @@ export async function getAlbumList(
offset = 0,
extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForActiveServer()) return [];
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type,
size,
@@ -93,46 +92,55 @@ export async function getAlbumList(
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
*/
let scopedLibraryAlbumIdCache: {
serverId: string;
folderId: string;
filterVersion: number;
ids: Set<string>;
} | null = null;
const scopedLibraryAlbumIdCaches = new Map<
string,
{ folderId: string; filterVersion: number; ids: Set<string> }
>();
async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
const { musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
/**
* Union of album ids across selected music folders **network fallback only**
* (Subsonic `getAlbumList2`). Local index browse filters via SQL `library_id`.
*/
export async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
const { musicLibraryFilterVersion } = useAuthStore.getState();
if (!serverId) return null;
const folder = musicLibraryFilterByServer[serverId];
if (folder === undefined || folder === 'all') {
scopedLibraryAlbumIdCache = null;
const filter = musicLibraryFilterForServer(serverId);
if (filter === 'all') {
scopedLibraryAlbumIdCaches.delete(serverId);
return null;
}
const hit = scopedLibraryAlbumIdCache;
const cacheKey = musicLibraryFilterStorageKey(serverId);
const hit = scopedLibraryAlbumIdCaches.get(serverId);
if (
hit &&
hit.serverId === serverId &&
hit.folderId === folder &&
hit.filterVersion === musicLibraryFilterVersion
hit
&& hit.folderId === cacheKey
&& hit.filterVersion === musicLibraryFilterVersion
) {
return hit.ids;
}
const ids = new Set<string>();
const pageSize = 500;
let offset = 0;
for (;;) {
const albums = await getAlbumListForServer(serverId, 'alphabeticalByName', pageSize, offset);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
for (const folderId of filter) {
let offset = 0;
for (;;) {
const albums = await getAlbumListForServer(
serverId,
'alphabeticalByName',
pageSize,
offset,
{ musicFolderId: folderId },
);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
}
}
scopedLibraryAlbumIdCache = {
serverId,
folderId: folder,
scopedLibraryAlbumIdCaches.set(serverId, {
folderId: cacheKey,
filterVersion: musicLibraryFilterVersion,
ids,
};
});
return ids;
}
@@ -181,9 +189,9 @@ export async function filterAlbumsToActiveLibrary(albums: SubsonicAlbum[]): Prom
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
export function similarSongsRequestCount(desired: number): number {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
if (f === undefined || f === 'all') return desired;
const { activeServerId } = useAuthStore.getState();
const f = activeServerId ? musicLibraryFilterForServer(activeServerId) : 'all';
if (isAllLibrariesFilter(f)) return desired;
return Math.min(300, Math.max(desired, desired * 4));
}
@@ -218,7 +226,6 @@ export async function getAlbumListForServer(
offset = 0,
extra: Record<string, unknown> = {},
): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForServer(serverId)) return [];
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
type,
size,
@@ -231,7 +238,6 @@ export async function getAlbumListForServer(
}
export async function getSong(id: string): Promise<SubsonicSong | null> {
if (!shouldAttemptSubsonicForActiveServer()) return null;
try {
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
return data.song ?? null;
@@ -241,7 +247,6 @@ export async function getSong(id: string): Promise<SubsonicSong | null> {
}
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return null;
try {
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
return data.song ?? null;
@@ -251,9 +256,6 @@ export async function getSongForServer(serverId: string, id: string): Promise<Su
}
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForActiveServer()) {
throw new Error('Subsonic unavailable');
}
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
@@ -263,9 +265,6 @@ export async function getAlbumForServer(
serverId: string,
id: string,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
+1 -21
View File
@@ -1,7 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
import { api, apiForServer } from './subsonicClient';
import { api } from './subsonicClient';
import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes';
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
@@ -20,22 +19,6 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
return { playlist, songs: entry ?? [] };
}
export async function getPlaylistForServer(
serverId: string,
id: string,
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>(
serverId,
'getPlaylist.view',
{ id },
);
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
@@ -57,9 +40,6 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
void import('../utils/offline/pinnedOfflineSync')
.then(m => m.schedulePinnedPlaylistSync(id))
.catch(() => {});
}
export async function updatePlaylistMeta(
-3
View File
@@ -2,9 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./subsonicArtists', () => ({ getArtist: vi.fn() }));
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForActiveServer: vi.fn(() => true),
}));
import { getArtist } from './subsonicArtists';
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
-3
View File
@@ -1,6 +1,5 @@
import { getArtist } from './subsonicArtists';
import { getAlbum } from './subsonicLibrary';
import { shouldAttemptSubsonicForActiveServer } from '../utils/network/subsonicNetworkGuard';
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
@@ -56,7 +55,6 @@ export async function prefetchArtistUserRatings(
else uncached.push(id);
}
if (!uncached.length) return out;
if (!shouldAttemptSubsonicForActiveServer()) return out;
let next = 0;
async function worker() {
for (;;) {
@@ -95,7 +93,6 @@ export async function prefetchAlbumUserRatings(
else uncached.push(id);
}
if (!uncached.length) return out;
if (!shouldAttemptSubsonicForActiveServer()) return out;
let next = 0;
async function worker() {
for (;;) {
-3
View File
@@ -11,9 +11,6 @@ vi.mock('./subsonicClient', () => ({
api: vi.fn(),
apiForServer: apiForServerMock,
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: () => true,
}));
describe('subsonicScrobble', () => {
beforeEach(() => {
+14 -3
View File
@@ -1,7 +1,9 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicNowPlaying } from './subsonicTypes';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { clusterFanOutScrobbleSubmission } from '../utils/serverCluster/clusterWriteFanout';
import { useAuthStore } from '../store/authStore';
async function scrobbleOnServer(
serverId: string,
@@ -9,14 +11,23 @@ async function scrobbleOnServer(
submission: boolean,
time?: number,
): Promise<void> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return;
const params: Record<string, unknown> = { id, submission };
if (time !== undefined) params.time = time;
await apiForServer(serverId, 'scrobble.view', params);
}
export async function scrobbleSong(id: string, time: number, serverId: string): Promise<void> {
export async function scrobbleSong(
id: string,
time: number,
serverId: string,
resolvedServerId?: string,
): Promise<void> {
if (!serverId) return;
if (isClusterMode()) {
const browseId = useAuthStore.getState().activeServerId ?? serverId;
await clusterFanOutScrobbleSubmission(browseId, id, time, resolvedServerId ?? serverId);
return;
}
try {
await scrobbleOnServer(serverId, id, true, time);
// Patch-on-use (§6.5 / F3): reflect the play in the local index so the
+28 -55
View File
@@ -1,4 +1,4 @@
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import { api, libraryFilterParams } from './subsonicClient';
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
import { useAuthStore } from '../store/authStore';
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
@@ -7,6 +7,11 @@ import {
invalidateStarredAlbumBrowse,
refreshStarredAlbumIndexFromServer,
} from '../utils/library/starredAlbumIndexSync';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import {
clusterFanOutRating,
clusterFanOutStar,
} from '../utils/serverCluster/clusterWriteFanout';
import type {
EntityRatingSupportLevel,
StarredResults,
@@ -15,17 +20,6 @@ import type {
SubsonicSong,
} from './subsonicTypes';
function parseStarred2Response(data: {
starred2?: {
artist?: SubsonicArtist[];
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}): StarredResults {
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
export async function getStarred(): Promise<StarredResults> {
const data = await api<{
starred2: {
@@ -34,50 +28,25 @@ export async function getStarred(): Promise<StarredResults> {
song?: SubsonicSong[];
}
}>('getStarred2.view', { ...libraryFilterParams() });
return parseStarred2Response(data);
}
/** Starred entities for an explicit saved server (not necessarily the active one). */
export async function getStarredForServer(serverId: string): Promise<StarredResults> {
const data = await apiForServer<{
starred2: {
artist?: SubsonicArtist[];
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}>(serverId, 'getStarred2.view', { ...libraryFilterParamsForServer(serverId) });
return parseStarred2Response(data);
}
function resolveStarServerId(meta?: StarPatchMeta): string | null {
return meta?.serverId ?? useAuthStore.getState().activeServerId;
}
async function starApi(
serverId: string | null | undefined,
endpoint: string,
params: Record<string, string>,
): Promise<void> {
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!sid) throw new Error('No server for star API');
if (sid === useAuthStore.getState().activeServerId) {
await api(endpoint, params);
} else {
await apiForServer(sid, endpoint, params);
}
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
export async function star(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
meta?: StarPatchMeta,
_meta?: StarPatchMeta,
): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song' && isClusterMode() && serverId) {
await clusterFanOutStar(serverId, id, true);
return;
}
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
const serverId = resolveStarServerId(meta);
await starApi(serverId, 'star.view', params);
await api('star.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
} else if (type === 'album' && serverId) {
@@ -85,22 +54,23 @@ export async function star(
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
}
void import('../utils/offline/favoritesOfflineSync')
.then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined))
.catch(() => {});
}
export async function unstar(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
meta?: StarPatchMeta,
_meta?: StarPatchMeta,
): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song' && isClusterMode() && serverId) {
await clusterFanOutStar(serverId, id, false);
return;
}
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
const serverId = resolveStarServerId(meta);
await starApi(serverId, 'unstar.view', params);
await api('unstar.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
} else if (type === 'album' && serverId) {
@@ -108,12 +78,15 @@ export async function unstar(
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
}
void import('../utils/offline/favoritesOfflineSync')
.then(m => m.onFavoritesOfflineStarChange(id, type, false, serverId ?? undefined))
.catch(() => {});
}
export async function setRating(id: string, rating: number): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (isClusterMode() && serverId) {
await clusterFanOutRating(serverId, id, rating);
invalidateEntityUserRatingCaches(id);
return;
}
await api('setRating.view', { id, rating });
// No-op in Rust when `id` is an album/artist (no track row matches).
patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { userRating: rating });
+6 -6
View File
@@ -26,8 +26,8 @@ export interface SubsonicAlbum {
displayArtist?: string;
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
discTitles?: SubsonicDiscTitle[];
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
/** Psysonic cluster: originating server for merged browse rows (client-only). */
clusterSeedServerId?: string;
}
export interface SubsonicDiscTitle {
@@ -92,8 +92,6 @@ export interface SubsonicSong {
trackPeak?: number;
albumPeak?: number;
};
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
/** OpenSubsonic: structured composer credit (string for back-compat). */
displayComposer?: string;
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
@@ -102,6 +100,8 @@ export interface SubsonicSong {
subRole?: string;
artist: { id?: string; name: string };
}>;
/** Psysonic cluster: browse-origin server for fan-out resolution (client-only). */
clusterBrowseServerId?: string;
}
export interface InternetRadioStation {
@@ -148,8 +148,8 @@ export interface SubsonicArtist {
starred?: string;
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
userRating?: number;
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
/** Psysonic cluster: originating server for merged browse rows (client-only). */
clusterSeedServerId?: string;
}
export interface SubsonicGenre {
+9 -24
View File
@@ -11,19 +11,16 @@ import PlayerBar from '../components/PlayerBar';
import BottomNav from '../components/BottomNav';
import { useIsMobile } from '../hooks/useIsMobile';
import LiveSearch from '../components/LiveSearch';
import DevNetworkModeToggle from '../components/DevNetworkModeToggle';
import NowPlayingDropdown from '../components/NowPlayingDropdown';
import QueuePanel from '../components/QueuePanel';
import AppRoutes from './AppRoutes';
import FullscreenPlayer from '../components/fullscreenPlayer/FullscreenPlayerStatic';
import FullscreenPlayer from '../components/FullscreenPlayer';
import ContextMenu from '../components/ContextMenu';
import SongInfoModal from '../components/SongInfoModal';
import DownloadFolderModal from '../components/DownloadFolderModal';
import GlobalConfirmModal from '../components/GlobalConfirmModal';
import ThemeMigrationNotice from '../components/ThemeMigrationNotice';
import OrbitAccountPicker from '../components/OrbitAccountPicker';
import OrbitHelpModal from '../components/OrbitHelpModal';
import OrbitReconnectModal from '../components/OrbitReconnectModal';
import TooltipPortal from '../components/TooltipPortal';
import OverlayScrollArea from '../components/OverlayScrollArea';
import {
@@ -41,8 +38,7 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
import { useOrbitGuest } from '../hooks/useOrbitGuest';
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
@@ -52,14 +48,15 @@ import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelect
import { useAppActivityTracking } from '../hooks/useAppActivityTracking';
import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator';
import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority';
import { useClusterPlaybackMonitor } from '../hooks/useClusterPlaybackMonitor';
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilterSuspend';
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
import { IS_LINUX } from '../utils/platform';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { usePlayerStore } from '../store/playerStore';
import '../store/previewPlayerVolumeSync';
import '../store/queueResolverBridge';
@@ -106,13 +103,12 @@ export function AppShell() {
const location = useLocation();
const prevPathnameRef = useRef(location.pathname);
useCoverNavigationPriority();
useClusterPlaybackMonitor();
useLiveSearchRouteScope();
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const hasOfflineContent = offlineCtx.hasBrowsingContent;
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const perfFlags = usePerfProbeFlags();
@@ -140,8 +136,7 @@ export function AppShell() {
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
}, [location.pathname, location.state]);
useOfflineAutoNav(connStatus, offlineNav, location, navigate);
useOfflineLibraryFilterSuspend();
useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate);
useEffect(() => {
initializeFromServerQueue();
@@ -180,13 +175,6 @@ export function AppShell() {
return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar);
}, [isSidebarCollapsed, setSidebarCollapsed]);
// Expose sidebar state on the theme root so themes can react with a
// `[data-theme='x'][data-sidebar-collapsed='true']` compound (contract
// `stateSelectors`). Other state attributes are set in App.tsx.
useEffect(() => {
document.documentElement.setAttribute('data-sidebar-collapsed', isSidebarCollapsed ? 'true' : 'false');
}, [isSidebarCollapsed]);
// Workaround for WebKitGTK 2.50.x text-input repaint hang on
// Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in,
// nudge WebKit awake on every input/textarea focus via a sync
@@ -255,7 +243,6 @@ export function AppShell() {
<div className="main-content-zoom">
<header className="content-header">
<LiveSearch />
{import.meta.env.DEV && <DevNetworkModeToggle />}
<div className="spacer" />
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
@@ -274,7 +261,7 @@ export function AppShell() {
</header>
<OrbitSessionBar />
{connStatus === 'disconnected' && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
)}
<div className="content-body app-shell-route-host">
<OverlayScrollArea
@@ -320,10 +307,8 @@ export function AppShell() {
<SongInfoModal />
<DownloadFolderModal />
<GlobalConfirmModal />
<ThemeMigrationNotice />
<OrbitAccountPicker />
<OrbitHelpModal />
<OrbitReconnectModal />
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
<AppUpdater />
</div>
-28
View File
@@ -13,12 +13,6 @@ import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation';
import { initFavoritesOfflineSync } from '../utils/offline/favoritesOfflineSync';
import { initPinnedOfflineSync } from '../utils/offline/pinnedOfflineSync';
import { initResumeIncompleteOfflinePins, scheduleResumeIncompleteOfflinePins } from '../utils/offline/resumeIncompleteOfflinePins';
import { runLegacyOfflineFileMigration } from '../utils/migrations/legacyOfflineFileMigration';
import { reconcileLibraryTierForServer } from '../utils/offline/libraryTierReconcile';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
@@ -104,28 +98,6 @@ export default function MainApp() {
return initHotCachePrefetch();
}, [migrationReady]);
useEffect(() => {
if (!migrationReady) return undefined;
void (async () => {
await runLegacyOfflineFileMigration();
const servers = useAuthStore.getState().servers;
for (const server of servers) {
await reconcileLibraryTierForServer(server.id);
}
scheduleResumeIncompleteOfflinePins();
})();
const stopInvalidation = initLocalPlaybackInvalidation();
const stopFavoritesSync = initFavoritesOfflineSync();
const stopPinnedOfflineSync = initPinnedOfflineSync();
const stopOfflineResume = initResumeIncompleteOfflinePins();
return () => {
stopInvalidation();
stopFavoritesSync();
stopPinnedOfflineSync();
stopOfflineResume();
};
}, [migrationReady, serverIdsKey]);
useEffect(() => {
if (!migrationReady) return;
useGlobalShortcutsStore.getState().registerAll();
-59
View File
@@ -24,14 +24,10 @@ vi.mock('./windowKind', () => ({
import { invoke } from '@tauri-apps/api/core';
import { getWindowKind } from './windowKind';
import {
applyThemeAtStartup,
installCrossWindowThemeSync,
pushLoggingModeToBackend,
pushUserAgentToBackend,
runPreReactBootstrap,
} from './bootstrap';
import { useThemeStore } from '../store/themeStore';
import { useInstalledThemesStore } from '../store/installedThemesStore';
const ORIGINAL_USER_AGENT = window.navigator.userAgent;
@@ -52,8 +48,6 @@ beforeEach(() => {
afterEach(() => {
setUserAgent(ORIGINAL_USER_AGENT);
document.documentElement.removeAttribute('data-theme');
document.head.querySelectorAll('style[data-installed-theme]').forEach((el) => el.remove());
});
describe('pushUserAgentToBackend', () => {
@@ -149,56 +143,3 @@ describe('runPreReactBootstrap', () => {
// the bootstrap doesn't second-guess that decision.
});
});
describe('applyThemeAtStartup', () => {
const setPersistedTheme = (state: Record<string, unknown>) =>
localStorage.setItem('psysonic_theme', JSON.stringify({ state, version: 1 }));
it('does nothing when there is no persisted theme', () => {
applyThemeAtStartup();
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
});
it('sets data-theme to the active theme (scheduler off)', () => {
setPersistedTheme({ theme: 'kanagawa-wave', enableThemeScheduler: false });
applyThemeAtStartup();
expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave');
});
it('injects installed community themes up front', () => {
setPersistedTheme({ theme: 'dracula', enableThemeScheduler: false });
localStorage.setItem('psysonic_installed_themes', JSON.stringify({
state: { themes: [{ id: 'dracula', name: 'Dracula', author: 'a', version: '1.0.0', description: '', mode: 'dark', css: "[data-theme='dracula']{--accent:#bd93f9;}", installedAt: 0 }] },
version: 1,
}));
applyThemeAtStartup();
expect(document.head.querySelector('style[data-installed-theme="dracula"]')).not.toBeNull();
expect(document.documentElement.getAttribute('data-theme')).toBe('dracula');
});
it('does not throw on malformed storage', () => {
localStorage.setItem('psysonic_theme', '{not json');
expect(() => applyThemeAtStartup()).not.toThrow();
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
});
});
describe('installCrossWindowThemeSync', () => {
it('rehydrates the matching store on a cross-window storage event', () => {
const themeRehydrate = vi.spyOn(useThemeStore.persist, 'rehydrate').mockResolvedValue(undefined);
const installedRehydrate = vi.spyOn(useInstalledThemesStore.persist, 'rehydrate').mockResolvedValue(undefined);
installCrossWindowThemeSync();
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_theme' }));
expect(themeRehydrate).toHaveBeenCalled();
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_installed_themes' }));
expect(installedRehydrate).toHaveBeenCalled();
themeRehydrate.mockClear();
installedRehydrate.mockClear();
window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated-key' }));
expect(themeRehydrate).not.toHaveBeenCalled();
expect(installedRehydrate).not.toHaveBeenCalled();
});
});
-67
View File
@@ -1,10 +1,6 @@
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
import { invoke } from '@tauri-apps/api/core';
import { getWindowKind } from './windowKind';
import { migrateThemeSelection } from '../utils/themes/themeMigration';
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
import { syncInjectedThemes } from '../utils/themes/themeInjection';
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
/** Sync backend HTTP User-Agent from the main webview once at startup. */
export function pushUserAgentToBackend(): void {
@@ -39,63 +35,6 @@ export function pushLoggingModeToBackend(): void {
}
}
function readInstalledThemes(): InstalledTheme[] {
try {
const raw = localStorage.getItem('psysonic_installed_themes');
if (!raw) return [];
const parsed = JSON.parse(raw) as { state?: { themes?: InstalledTheme[] } };
return Array.isArray(parsed.state?.themes) ? (parsed.state!.themes as InstalledTheme[]) : [];
} catch {
return [];
}
}
/**
* Apply the active theme synchronously, before React mounts, so the first paint
* is already correct. Zustand rehydrate + the `data-theme` effect run after the
* first paint, so without this a non-Mocha active theme (every light theme and
* every installed community theme) flashes the `:root` Mocha default for a
* frame. We set `data-theme` to the effective (scheduler-resolved) theme and
* inject installed community themes' CSS up front. Runs after the migration so
* the persisted ids are already resolved.
*/
export function applyThemeAtStartup(): void {
try {
const raw = localStorage.getItem('psysonic_theme');
if (!raw) return; // fresh profile — the :root default is correct
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
const s = parsed.state;
if (!s) return;
syncInjectedThemes(readInstalledThemes());
const effective = getScheduledTheme({
enableThemeScheduler: !!s.enableThemeScheduler,
theme: String(s.theme ?? 'mocha'),
themeDay: String(s.themeDay ?? 'latte'),
themeNight: String(s.themeNight ?? 'mocha'),
timeDayStart: String(s.timeDayStart ?? '07:00'),
timeNightStart: String(s.timeNightStart ?? '19:00'),
});
if (effective) document.documentElement.setAttribute('data-theme', effective);
} catch {
// Non-fatal — App's effects apply the theme after mount.
}
}
/**
* Keep theme state in sync across webviews (main mini player). Zustand
* persist does not sync across windows on its own; the `storage` event fires in
* *other* windows when localStorage changes, so rehydrate the relevant store
* there. Installing/applying/uninstalling in one window then live-updates the
* other (App's effects re-run and re-apply `data-theme` / injected styles).
*/
export function installCrossWindowThemeSync(): void {
if (typeof window === 'undefined') return;
window.addEventListener('storage', (e) => {
if (e.key === 'psysonic_theme') void useThemeStore.persist?.rehydrate?.();
else if (e.key === 'psysonic_installed_themes') void useInstalledThemesStore.persist?.rehydrate?.();
});
}
/** Mark the document in Vite dev so CSS can show dev-only chrome. */
export function markDevBuildDocument(): void {
if (import.meta.env.DEV) {
@@ -107,12 +46,6 @@ export function markDevBuildDocument(): void {
export function runPreReactBootstrap(): void {
// Pre-warm the window-kind cache so subsequent reads are sync + safe.
getWindowKind();
// Reset any persisted theme that is no longer bundled and not installed, so
// the store hydrates onto a paintable theme (no unstyled-:root flash).
migrateThemeSelection();
// Paint the correct theme on the very first frame (no Mocha flash).
applyThemeAtStartup();
installCrossWindowThemeSync();
markDevBuildDocument();
pushUserAgentToBackend();
pushLoggingModeToBackend();
+20 -28
View File
@@ -1,13 +1,14 @@
import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { coverStorageKeyFromRef } from '../cover/storageKeys';
@@ -16,14 +17,12 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { resolveCoverDisplayTier } from '../cover/tiers';
import { acquireUrl } from '../utils/imageCache/urlPool';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { useDragDrop } from '../contexts/DragDropContext';
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -64,28 +63,24 @@ function AlbumCard({
}: AlbumCardProps) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
const offlineServerId = album.serverId ?? activeServerId;
const localEntries = useLocalPlaybackStore(s => s.entries);
const isOffline = isOfflinePinComplete(album.id, offlineServerId);
const albumLinkQuery = useMemo(
() => appendServerQuery(linkQuery, album.serverId),
[linkQuery, album.serverId],
);
void localEntries;
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isOffline = useOfflineStore(s => {
const meta = s.albums[`${serverId}:${album.id}`];
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const psyDrag = useDragDrop();
const coverServerScope = useMemo(
() => coverServerScopeForServerId(album.serverId),
[album.serverId],
);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve });
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
libraryResolve,
clusterSeedServerId: album.clusterSeedServerId,
});
const dragCoverKey = useMemo(() => {
if (!coverRef) return '';
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
@@ -96,7 +91,7 @@ function AlbumCard({
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigateToAlbum(album.id, { search: albumLinkQuery });
navigateToAlbum(album.id, { search: linkQuery, seedServerId: album.clusterSeedServerId });
};
return (
@@ -190,13 +185,10 @@ function AlbumCard({
onClick={async e => {
e.stopPropagation();
try {
const tracks = await fetchAlbumTracks(
album.id,
offlineServerId || undefined,
);
if (tracks.length > 0) enqueue(tracks);
const data = await getAlbum(album.id);
enqueue(data.songs.map(songToTrack));
} catch {
// Unavailable offline or network failure — silent on hover action
// Network failure — silent (toast would be too noisy for a hover action)
}
}}
aria-label={t('contextMenu.enqueueAlbum')}
+99 -130
View File
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../cover/types';
import { useCoverLightboxSrc } from '../cover/lightbox';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
@@ -19,7 +20,6 @@ import { formatMb } from '../utils/format/formatBytes';
import { sanitizeHtml } from '../utils/sanitizeHtml';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { tooltipAttrs } from './tooltipAttrs';
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
/** True when the album artist label means "no single artist" `getArtistInfo`
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
@@ -72,10 +72,12 @@ interface AlbumHeaderProps {
headerArtistRefs: SubsonicOpenArtistRef[];
songs: SubsonicSong[];
coverArtId?: string;
/** Cluster / multi-server album detail — fetch cover from the seed member, not representative. */
coverServerScope?: CoverServerScope;
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
offlineStatus: 'none' | 'downloading' | 'cached';
offlineProgress: { done: number; total: number } | null;
bio: string | null;
bioOpen: boolean;
@@ -92,8 +94,6 @@ interface AlbumHeaderProps {
onEntityRatingChange: (rating: number) => void;
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
actionPolicy?: OfflineActionPolicy;
}
export default function AlbumHeader({
@@ -101,6 +101,7 @@ export default function AlbumHeader({
headerArtistRefs,
songs,
coverArtId,
coverServerScope = COVER_SCOPE_ACTIVE,
resolvedCoverUrl,
isStarred,
downloadProgress,
@@ -120,16 +121,14 @@ export default function AlbumHeader({
entityRatingValue,
onEntityRatingChange,
entityRatingSupport,
actionPolicy,
}: AlbumHeaderProps) {
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
const { t } = useTranslation();
const navigate = useNavigate();
const goBack = useAlbumDetailBack();
const isMobile = useIsMobile();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true });
const coverRef = useAlbumCoverRef(info.id, coverArtId, coverServerScope, { libraryResolve: true });
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
alt: `${info.name} Cover`,
});
@@ -227,7 +226,7 @@ export default function AlbumHeader({
<StarRating
value={entityRatingValue}
onChange={onEntityRatingChange}
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
disabled={entityRatingSupport === 'track_only'}
labelKey="entityRating.albumAriaLabel"
/>
</div>
@@ -255,16 +254,14 @@ export default function AlbumHeader({
{/* Row 2 — Secondary actions */}
<div className="album-actions-row album-actions-row--secondary">
{policy.canFavorite && (
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
<button
className="album-icon-btn album-icon-btn--sm"
@@ -276,7 +273,7 @@ export default function AlbumHeader({
<Share2 size={16} />
</button>
{showBioButton && policy.canShowBio && (
{showBioButton && (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onBio}
@@ -287,57 +284,44 @@ export default function AlbumHeader({
</button>
)}
{policy.canDownload && (
downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
)
{downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
)}
{policy.canPinOffline && (
offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'queued' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onCacheOffline}
aria-label={t('albumDetail.offlineQueued')}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
</button>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
)
{offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
)}
</div>
</div>
@@ -368,15 +352,13 @@ export default function AlbumHeader({
>
<ListPlus size={16} />
</button>
{policy.canFavorite && (
<button
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
<button
type="button"
className="btn btn-surface"
@@ -388,7 +370,7 @@ export default function AlbumHeader({
</button>
</div>
{showBioButton && policy.canShowBio && (
{showBioButton && (
<button
className="btn btn-surface"
id="album-bio-btn"
@@ -399,60 +381,47 @@ export default function AlbumHeader({
</button>
)}
{policy.canDownload && (
downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
) : (
<button
className="btn btn-surface"
id="album-download-btn"
onClick={onDownload}
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
</button>
)
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="btn btn-surface"
id="album-download-btn"
onClick={onDownload}
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
</button>
)}
{policy.canPinOffline && (
offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'queued' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineQueued')}
</button>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-surface offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
)
{offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-surface offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
)}
</div>
)}
+1 -1
View File
@@ -251,7 +251,7 @@ export default function AlbumRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueAlbums.map((a, idx) => (
<AlbumCard
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
key={a.id}
album={a}
showRating={showRating}
linkQuery={albumLinkQuery}
+3 -12
View File
@@ -1,6 +1,6 @@
import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import React, { useMemo, useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { useTracklistColumns } from '../utils/useTracklistColumns';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -15,7 +15,6 @@ import { TrackRow } from './albumTrackList/TrackRow';
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
@@ -39,7 +38,6 @@ interface AlbumTrackListProps {
sortKey?: SortKey;
sortDir?: 'asc' | 'desc';
onSort?: (key: SortKey) => void;
actionPolicy?: OfflineActionPolicy;
}
// ── AlbumTrackList ────────────────────────────────────────────────────────────
@@ -62,9 +60,7 @@ export default function AlbumTrackList({
sortKey,
sortDir,
onSort,
actionPolicy,
}: AlbumTrackListProps) {
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
const { t } = useTranslation();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
@@ -102,10 +98,6 @@ export default function AlbumTrackList({
);
const currentTrackId = currentTrack?.id ?? null;
const displayCols = useMemo(
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
[policy.canFavorite, visibleCols],
);
if (isMobile) {
return (
@@ -147,7 +139,7 @@ export default function AlbumTrackList({
>
<TracklistHeaderRow
visibleCols={displayCols}
visibleCols={visibleCols}
gridStyle={gridStyle}
sortKey={sortKey}
sortDir={sortDir}
@@ -178,7 +170,7 @@ export default function AlbumTrackList({
key={song.id}
song={song}
globalIdx={globalIdx}
visibleCols={displayCols}
visibleCols={visibleCols}
gridStyle={gridStyle}
currentTrackId={currentTrackId}
isPlaying={isPlaying}
@@ -194,7 +186,6 @@ export default function AlbumTrackList({
onToggleSelect={onToggleSelect}
onDragStart={onDragStart}
setContextMenuSongId={setContextMenuSongId}
actionPolicy={policy}
/>
);
})}
+9 -10
View File
@@ -1,13 +1,11 @@
import type { SubsonicArtist } from '../api/subsonicTypes';
import React, { useMemo } from 'react';
import React from 'react';
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
interface Props {
artist: SubsonicArtist;
@@ -20,17 +18,18 @@ interface Props {
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
const { t } = useTranslation();
const navigateToArtist = useNavigateToArtist();
const coverServerScope = useMemo(
() => coverServerScopeForServerId(artist.serverId),
[artist.serverId],
);
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, coverServerScope, { libraryResolve });
const artistLinkQuery = appendServerQuery(linkQuery, artist.serverId);
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, {
libraryResolve,
clusterSeedServerId: artist.clusterSeedServerId,
});
return (
<div
className="artist-card"
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
onClick={() => navigateToArtist(artist.id, {
search: linkQuery,
seedServerId: artist.clusterSeedServerId,
})}
>
<div className="artist-card-avatar">
{coverRef ? (
+1 -1
View File
@@ -117,7 +117,7 @@ export default function ArtistRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => (
<ArtistCardLocal
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
key={a.id}
artist={a}
linkQuery={artistLinkQuery}
libraryResolve={libraryResolve}
-64
View File
@@ -1,64 +0,0 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ArrowUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
interface BackToTopButtonProps {
/** Id of the scroll viewport to watch/scroll. Defaults to the main route scroller. */
viewportId?: string;
/** Show the button once the viewport is scrolled past this many pixels. */
threshold?: number;
}
/**
* A floating "back to top" affordance for long pages. Watches a scroll viewport
* (the overlay-scroll element wrapping the routes by default) and, once it is
* scrolled past `threshold`, shows a button that smooth-scrolls it back to the
* top reusing the same `getElementById(...).scrollTo` pattern AppShell uses
* for its route-change scroll reset.
*
* The button is portalled into `.app-shell-route-host` and positioned
* `absolute` against it: the scroll viewport itself sets `contain: paint`, which
* would otherwise make a `position: fixed` child resolve against the *scrolling*
* box (so it would drift with the content). The route host is a non-contained,
* `position: relative` ancestor that spans exactly the content area (between the
* sidebar and queue, above the player bar), so the button stays pinned to the
* visible viewport corner regardless of scroll.
*/
export default function BackToTopButton({
viewportId = APP_MAIN_SCROLL_VIEWPORT_ID,
threshold = 400,
}: BackToTopButtonProps) {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => {
setHost(document.querySelector<HTMLElement>('.app-shell-route-host'));
const el = document.getElementById(viewportId);
if (!el) return;
const onScroll = () => setVisible(el.scrollTop > threshold);
onScroll(); // sync immediately (e.g. switching back to an already-scrolled tab)
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [viewportId, threshold]);
if (!visible || !host) return null;
return createPortal(
<button
type="button"
className="back-to-top-btn"
onClick={() =>
document.getElementById(viewportId)?.scrollTo({ top: 0, behavior: 'smooth' })
}
aria-label={t('common.backToTop')}
data-tooltip={t('common.backToTop')}
data-tooltip-pos="left"
>
<ArrowUp size={18} aria-hidden="true" />
</button>,
host,
);
}
+9 -8
View File
@@ -1,11 +1,11 @@
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray';
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Music } from 'lucide-react';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
@@ -585,22 +585,23 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
libraryResolve: false,
clusterSeedServerId: album.clusterSeedServerId,
});
const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
const imgSrc = coverImgSrc(coverHandle.src);
const bgResolved = coverHandle.src;
const handleOpen = () => navigate(`/album/${album.id}`);
const handleOpen = () => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId });
const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const data = await resolveAlbum(serverId, album.id);
if (!data) return;
const data = await getAlbum(album.id);
enqueue(data.songs.map(songToTrack));
} catch {
/* silent — toast would be too noisy for a hover action */
+1 -1
View File
@@ -247,7 +247,7 @@ export default function CachedImage({
};
const fallbackStyle: React.CSSProperties = isFallback
? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' }
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
: {};
return (
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ServerCluster } from '../utils/serverCluster/types';
import {
formatExcludedMemberLabels,
getClusterMergeDiagnostics,
type ClusterMergeDiagnostics,
} from '../utils/serverCluster/clusterMergeStatus';
export default function ClusterMergeBanner({ cluster }: { cluster: ServerCluster }) {
const { t } = useTranslation();
const [diag, setDiag] = useState<ClusterMergeDiagnostics | null>(null);
useEffect(() => {
let cancelled = false;
void getClusterMergeDiagnostics(cluster).then(res => {
if (!cancelled) setDiag(res);
}).catch(() => {
if (!cancelled) setDiag(null);
});
return () => { cancelled = true; };
}, [cluster]);
if (!diag || diag.mergeCount >= diag.totalCount) return null;
return (
<div className="connection-indicator-cluster-banner">
{t('cluster.mergeBanner', {
excluded: formatExcludedMemberLabels(diag.members),
})}
</div>
);
}
+116 -38
View File
@@ -3,12 +3,14 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown } from 'lucide-react';
import { Check, ChevronDown, Layers } from 'lucide-react';
import { ConnectionStatus } from '../hooks/useConnectionStatus';
import { useClusterConnectionLed } from '../hooks/useClusterConnectionLed';
import { useAuthStore } from '../store/authStore';
import { switchActiveServer } from '../utils/server/switchActiveServer';
import { switchActiveCluster, switchActiveServer } from '../utils/server/switchActiveServer';
import { showToast } from '../utils/ui/toast';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
import type { ServerCluster } from '../utils/serverCluster/types';
interface Props {
status: ConnectionStatus;
@@ -20,14 +22,22 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const { t } = useTranslation();
const navigate = useNavigate();
const servers = useAuthStore(s => s.servers);
const clusters = useAuthStore(s => s.clusters);
const activeServerId = useAuthStore(s => s.activeServerId);
const activeClusterId = useAuthStore(s => s.activeClusterId);
const [menuOpen, setMenuOpen] = useState(false);
const [switchingId, setSwitchingId] = useState<string | null>(null);
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
const hostRef = useRef<HTMLDivElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const multi = servers.length > 1;
const multi = servers.length > 1 || clusters.length > 0;
const activeCluster = activeClusterId
? clusters.find(cluster => cluster.id === activeClusterId) ?? null
: null;
const clusterLed = useClusterConnectionLed(activeCluster);
const effectiveStatus =
activeCluster && clusterLed.ledStatus !== null ? clusterLed.ledStatus : status;
const updateMenuPosition = useCallback(() => {
const el = hostRef.current;
@@ -81,7 +91,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
};
const onPickServer = async (srv: ServerProfile) => {
if (srv.id === activeServerId) {
if (!activeClusterId && srv.id === activeServerId) {
setMenuOpen(false);
return;
}
@@ -96,14 +106,59 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
navigate('/');
};
const onPickCluster = async (cluster: ServerCluster) => {
if (cluster.id === activeClusterId) {
setMenuOpen(false);
return;
}
setSwitchingId(cluster.id);
const ok = await switchActiveCluster(cluster.id);
setSwitchingId(null);
setMenuOpen(false);
if (!ok) {
showToast(t('connection.switchFailed'), 5000, 'error');
return;
}
navigate('/');
};
const label = isLan ? 'LAN' : t('connection.extern');
const tooltip = multi
? t('connection.switchServerHint')
: status === 'connected'
const metaTooltip = multi ? t('connection.switchScopeHint') : undefined;
const statusTooltip =
effectiveStatus === 'connected'
? t('connection.connectedTo', { server: serverName })
: status === 'disconnected'
: effectiveStatus === 'disconnected'
? t('connection.disconnectedFrom', { server: serverName })
: t('connection.checking');
: effectiveStatus === 'degraded'
? t('connection.connectedTo', { server: serverName })
: t('connection.checking');
const clusterActive = Boolean(activeCluster);
const ledTooltip = clusterActive
? (clusterLed.ledTooltip ?? t('connection.checking'))
: statusTooltip;
const renderMenuItem = (id: string, labelText: string, active: boolean, onClick: () => void, icon?: React.ReactNode) => (
<button
key={id}
type="button"
role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={switchingId === id}
onClick={onClick}
>
<span className="nav-library-dropdown-item-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{icon}
{labelText}
</span>
{switchingId === id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
) : (
<span className="nav-library-dropdown-check-spacer" aria-hidden />
)}
</button>
);
return (
<div className="connection-indicator-host" ref={hostRef}>
@@ -111,14 +166,21 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={onTriggerClick}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
role={multi ? 'button' : undefined}
aria-haspopup={multi ? 'menu' : undefined}
aria-expanded={multi ? menuOpen : undefined}
>
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<div
className={`connection-led connection-led--${effectiveStatus}`}
data-tooltip={ledTooltip}
data-tooltip-pos="bottom"
{...(clusterActive ? { 'data-tooltip-wrap': true } : {})}
/>
<div
className="connection-meta"
data-tooltip={metaTooltip}
data-tooltip-pos="bottom"
>
<span className="connection-type">{label}</span>
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
@@ -136,7 +198,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
ref={menuPanelRef}
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
role="menu"
aria-label={t('connection.switchServerTitle')}
aria-label={t('connection.switchScopeTitle')}
style={{
position: 'fixed',
top: menuFixed.top,
@@ -146,6 +208,38 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
zIndex: 10050,
}}
>
{clusters.length > 0 && (
<>
<div
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--text-muted)',
padding: '6px 10px 4px',
}}
>
{t('connection.switchClusterTitle')}
</div>
{clusters.map(cluster =>
renderMenuItem(
cluster.id,
cluster.name,
activeClusterId === cluster.id,
() => void onPickCluster(cluster),
<Layers size={14} style={{ flexShrink: 0, opacity: 0.85 }} aria-hidden />,
),
)}
<div
style={{
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)',
marginTop: 2,
paddingTop: 2,
}}
/>
</>
)}
<div
style={{
fontSize: 10,
@@ -158,30 +252,14 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
>
{t('connection.switchServerTitle')}
</div>
{servers.map(srv => {
const active = srv.id === activeServerId;
const busy = switchingId === srv.id;
const labelText = serverListDisplayLabel(srv, servers);
return (
<button
key={srv.id}
type="button"
role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={busy}
onClick={() => onPickServer(srv)}
>
<span className="nav-library-dropdown-item-label">{labelText}</span>
{switchingId === srv.id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
) : (
<span className="nav-library-dropdown-check-spacer" aria-hidden />
)}
</button>
);
})}
{servers.map(srv =>
renderMenuItem(
srv.id,
serverListDisplayLabel(srv, servers),
!activeClusterId && srv.id === activeServerId,
() => void onPickServer(srv),
),
)}
<div
style={{
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)',
-17
View File
@@ -43,23 +43,6 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
orbitBulkGuard: vi.fn(async () => true),
}));
vi.mock('@/hooks/useOfflineBrowseContext', () => ({
useOfflineBrowseContext: () => ({
active: false,
serverId: 'srv-1',
capabilities: {
localLibrary: false,
favorites: false,
playlists: false,
manualPins: false,
playerStats: false,
},
hasBrowseCapability: false,
hasBrowsingContent: false,
connStatus: 'connected' as const,
}),
}));
import ContextMenu from './ContextMenu';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
-28
View File
@@ -17,29 +17,8 @@ import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
import { useContextMenuRating } from '../hooks/useContextMenuRating';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useNavigate } from 'react-router-dom';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import {
offlineActionPolicy,
type OfflineSurface,
} from '../utils/offline/offlineActionPolicy';
import ContextMenuItems from './contextMenu/ContextMenuItems';
function contextMenuSurfaceForType(type: string | null): OfflineSurface {
switch (type) {
case 'album':
case 'multi-album':
return 'contextMenuAlbum';
case 'artist':
case 'multi-artist':
return 'contextMenuArtist';
case 'playlist':
case 'multi-playlist':
return 'contextMenuPlaylist';
default:
return 'contextMenuSong';
}
}
export { AddToPlaylistSubmenu };
@@ -201,12 +180,6 @@ export default function ContextMenu() {
const downloadAlbum = downloadAlbumAction;
const { active: offlineBrowseActive } = useOfflineBrowseContext();
const offlinePolicy = offlineActionPolicy(
contextMenuSurfaceForType(type),
offlineBrowseActive,
);
if (!contextMenu.isOpen || !contextMenu.item) return null;
return (
@@ -260,7 +233,6 @@ export default function ContextMenu() {
isStarred={isStarred}
pinToPlaybackServer={pinToPlaybackServer}
navigateLibrary={navigateLibrary}
offlinePolicy={offlinePolicy}
/>
</div>
</>
-23
View File
@@ -1,23 +0,0 @@
import { Cloud, CloudOff } from 'lucide-react';
import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
/** DEV-only: simulate full offline (disconnect UI, block Subsonic, local playback only). */
export default function DevNetworkModeToggle() {
const forceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
const toggle = useDevOfflineBrowseStore(s => s.toggleForceOffline);
if (!import.meta.env.DEV) return null;
return (
<button
type="button"
className={`dev-network-mode-toggle${forceOffline ? ' dev-network-mode-toggle--offline' : ''}`}
onClick={toggle}
title={forceOffline ? 'DEV: forced offline (click for online)' : 'DEV: online (click to simulate offline)'}
aria-pressed={forceOffline}
>
{forceOffline ? <CloudOff size={16} aria-hidden /> : <Cloud size={16} aria-hidden />}
<span>{forceOffline ? 'Offline' : 'Online'}</span>
</button>
);
}
+188
View File
@@ -0,0 +1,188 @@
/**
* `FullscreenPlayer` characterization (Phase F5c).
*
* Includes the §4.5 regression test from the v2 plan the cover image
* must call `useCachedUrl(coverUrl, coverKey, false)`. The `false`
* third argument selects the fallback path that avoids a double
* crossfade (fetchUrl blobUrl). A refactor that "tidies up" the
* useCachedUrl call sites would silently regress the FS player.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
star: vi.fn(async () => undefined),
unstar: vi.fn(async () => undefined),
getLyricsBySongId: vi.fn(async () => null),
}));
vi.mock('@/api/lastfm', () => ({
lastfmScrobble: vi.fn(async () => undefined),
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
lastfmLoveTrack: vi.fn(async () => undefined),
lastfmUnloveTrack: vi.fn(async () => undefined),
lastfmGetTrackLoved: vi.fn(async () => false),
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
// `useCachedUrl` is the surface §4.5 needs to characterize. Mock the module
// so we can assert the third positional arg `false` is preserved.
vi.mock('./CachedImage', async () => {
const actual = await vi.importActual<typeof import('./CachedImage')>('./CachedImage');
return {
...actual,
useCachedUrl: vi.fn((url, _key, opt) => `mock://${url}?opt=${String(opt ?? 'default')}`),
};
});
import FullscreenPlayer from './FullscreenPlayer';
import { useCachedUrl } from './CachedImage';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react';
beforeEach(() => {
resetAllStores();
const id = useAuthStore.getState().addServer({
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(id);
vi.mocked(useCachedUrl).mockClear();
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined);
});
afterEach(() => {
vi.mocked(useCachedUrl).mockClear();
});
describe('FullscreenPlayer — render', () => {
it('renders the labelled Fullscreen Player dialog', () => {
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(getByLabelText('Fullscreen Player')).toBeInTheDocument();
});
it('exposes the Close Fullscreen button', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(getByLabelText('Close Fullscreen')).toBeInTheDocument();
});
});
describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
// The component calls `useCachedUrl` twice:
// - line 338: for the small art box (default behaviour, opt=true)
// - line 674: for the cover (opt=false, no fetchUrl fallback)
// The `false` arg is load-bearing — it avoids a double crossfade by
// routing through the cache-only path. Pin it.
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
usePlayerStore.setState({
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
});
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
const calls = vi.mocked(useCachedUrl).mock.calls;
const coverCall = calls.find(c => c[2] === false);
expect(coverCall).toBeDefined();
expect(typeof coverCall?.[1]).toBe('string');
expect(String(coverCall?.[1])).toContain('album-1');
});
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
usePlayerStore.setState({
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
});
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
const calls = vi.mocked(useCachedUrl).mock.calls;
const defaultOptCalls = calls.filter(c => c[2] !== false);
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
expect(defaultOptCalls.some(c => String(c[1]).includes('album-1'))).toBe(true);
});
});
describe('FullscreenPlayer — control wiring', () => {
it('clicking Close Fullscreen calls the onClose prop', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const onClose = vi.fn();
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={onClose} />,
);
fireEvent.click(getByLabelText('Close Fullscreen'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('clicking Stop calls stop()', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Stop'));
expect(stopSpy).toHaveBeenCalledTimes(1);
});
it('clicking Previous Track calls previous()', () => {
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
index: 1,
currentTrack: makeTrack({ id: 'b' }),
});
usePlayerStore.setState({ currentTime: 5 });
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Previous Track'));
expect(prevSpy).toHaveBeenCalledTimes(1);
});
it('clicking Next Track calls next()', () => {
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
index: 0,
currentTrack: makeTrack({ id: 'a' }),
});
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Next Track'));
expect(nextSpy).toHaveBeenCalledTimes(1);
});
it('clicking Repeat cycles via toggleRepeat', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(usePlayerStore.getState().repeatMode).toBe('off');
fireEvent.click(getByLabelText('Repeat'));
expect(usePlayerStore.getState().repeatMode).toBe('all');
});
});
+228
View File
@@ -0,0 +1,228 @@
import { queueSongStar } from '../store/pendingStarSync';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
import { playbackCoverArtForAlbum } from '../utils/playback/playbackServer';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import {
SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal,
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useCachedUrl } from './CachedImage';
import { getCachedBlob } from '../utils/imageCache';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { FsLyricsApple } from './fullscreenPlayer/FsLyricsApple';
import { FsLyricsRail } from './fullscreenPlayer/FsLyricsRail';
import { FsArt } from './fullscreenPlayer/FsArt';
import { FsPortrait } from './fullscreenPlayer/FsPortrait';
import { FsSeekbar } from './fullscreenPlayer/FsSeekbar';
import { FsLyricsMenu } from './fullscreenPlayer/FsLyricsMenu';
import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
import { useFsIdleFade } from '../hooks/useFsIdleFade';
import { useQueueTrackAt } from '../hooks/useQueueTracks';
interface FullscreenPlayerProps {
onClose: () => void;
}
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
// Derive isStarred inside the selector so we only re-render when the boolean
// actually flips — not when any unrelated track's star status changes.
const isStarred = usePlayerStore(s => {
const track = s.currentTrack;
if (!track) return false;
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
});
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
const artCover = usePlaybackCoverArt(playbackCoverRef, 300);
const artUrl = artCover.src;
const artKey = artCover.cacheKey;
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
const coverUrl = portraitCover.src;
const coverKey = portraitCover.cacheKey;
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
// Dynamic accent color extracted from the current album cover, applied as
// --dynamic-fs-accent on the root element. Cache hits return instantly so
// same-album tracks reuse the color without re-fetching.
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
// Artist image → portrait on right. Falls back to cover art.
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
const portraitUrl = artistBgUrl || resolvedCoverUrl;
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
// Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
// (thin-state): the next ref resolves from the cache (the prefetch window
// around the current index keeps it warm).
const queueIndex = usePlayerStore(s => s.queueIndex);
const nextTrack = useQueueTrackAt(queueIndex + 1);
const queueServerId = usePlayerStore(s => s.queueServerId);
const activeServerId = useAuthStore(s => s.activeServerId);
useEffect(() => {
if (!nextTrack?.albumId || !nextTrack.coverArt) return;
const { src: url, cacheKey: key } = playbackCoverArtForAlbum(
nextTrack.albumId,
nextTrack.coverArt,
300,
);
getCachedBlob(url, key).catch(() => {});
}, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]);
// Lyrics settings popover state
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
const fsControlsRef = useRef<HTMLDivElement>(null);
// Idle-fade system — hides controls after 3 s of inactivity; Esc closes.
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
const metaParts = useMemo(() => [
currentTrack?.album,
currentTrack?.year?.toString(),
currentTrack?.suffix?.toUpperCase(),
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
].filter(Boolean), [currentTrack]);
return (
<div
className="fs-player"
role="dialog"
aria-modal="true"
aria-label={t('player.fullscreen')}
data-idle={isIdle}
data-lyrics={isAppleMode || undefined}
onMouseMove={handleMouseMove}
style={{
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
'--fs-portrait-dim': String(fsPortraitDim / 100),
} as React.CSSProperties}
>
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
<div className="fs-mesh-bg" aria-hidden="true">
<div className="fs-mesh-blob fs-mesh-blob-a" />
<div className="fs-mesh-blob fs-mesh-blob-b" />
</div>
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
<div className="fs-scrim" aria-hidden="true" />
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
{/* Layer 3 — info cluster, bottom-left */}
<div className="fs-cluster">
{/* Album art */}
<div className="fs-art-wrap">
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
</div>
{/* Track title — massive statement */}
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
{/* Artist — secondary, below track */}
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
{/* Metadata row */}
{metaParts.length > 0 && (
<div className="fs-meta">
{metaParts.map((part, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="fs-meta-dot">·</span>}
<span>{part}</span>
</React.Fragment>
))}
</div>
)}
{/* Controls */}
<div className="fs-controls" ref={fsControlsRef}>
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
<Square size={13} fill="currentColor" />
</button>
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
</button>
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
</button>
<button
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
onClick={toggleRepeat}
aria-label={t('player.repeat')}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
{currentTrack && (
<button
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<div style={{ position: 'relative', zIndex: 9 }}>
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
<button
ref={lyricsMenuTriggerRef}
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
onClick={() => setLyricsMenuOpen(v => !v)}
aria-label={t('player.fsLyricsToggle')}
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
>
<MicVocal size={14} />
</button>
</div>
</div>
</div>
{/* Layer 4 — full-width seekbar, bottom edge */}
<FsSeekbar duration={duration} />
</div>
);
}
+19 -44
View File
@@ -1,5 +1,4 @@
import { getRandomAlbums } from '../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
@@ -34,25 +33,14 @@ function HeroBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const counter = useRef(url ? 1 : 0);
const latestUrlRef = useRef(url);
latestUrlRef.current = url;
const counter = useRef(1);
useEffect(() => {
if (!url) {
setLayers([]);
return;
}
if (!url) return;
const id = counter.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
}, 20);
const t2 = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.filter(l => l.id === id));
}, 900);
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 20);
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 900);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [url]);
@@ -271,13 +259,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
useEffect(() => {
if (!album || albumFormats[album.id] !== undefined) return;
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
resolveAlbum(serverId, album.id).then(data => {
if (!data) {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
return;
}
getAlbum(album.id).then(data => {
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
}).catch(() => {
@@ -285,26 +267,25 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
});
}, [album?.id]);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt, undefined, {
clusterSeedServerId: album?.clusterSeedServerId,
});
const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
// Per-album fallback so a cache miss on the current slide does not flash empty,
// but never reuse another album's art (that caused bg/foreground desync on fast nav).
const stableBgByAlbum = useRef<Record<string, string>>({});
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
// transition (which would cause the background to flash empty before fading in).
const stableBgUrl = useRef('');
const albumId = album?.id;
useEffect(() => {
if (bgHandle.src && albumId) {
stableBgByAlbum.current[albumId] = bgHandle.src;
}
}, [bgHandle.src, albumId]);
const heroBgUrl = bgHandle.src || (albumId ? stableBgByAlbum.current[albumId] ?? '' : '');
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
});
useEffect(() => {
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
}, [bgHandle.src, albumId]);
if (!album) return <div className="hero-placeholder" />;
@@ -314,10 +295,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
className="hero"
role="banner"
aria-label={t('hero.eyebrow')}
onClick={() => navigateToAlbum(album.id)}
onClick={() => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId })}
style={{ cursor: 'pointer' }}
>
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={heroBgUrl} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */}
@@ -360,10 +341,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={async e => {
e.stopPropagation();
try {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
const albumData = await getAlbum(album.id);
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
} catch (_) {}
}}
@@ -393,10 +371,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={async (e) => {
e.stopPropagation();
try {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(songToTrack);
usePlayerStore.getState().enqueue(tracks);
} catch (_) {}
+35 -17
View File
@@ -23,6 +23,7 @@ import {
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -34,7 +35,7 @@ import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
import { CoverArtImage } from '../cover/CoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '../cover/layoutSizes';
import { albumCoverRef } from '../cover/ref';
import { albumCoverRef, coverScopeForServerProfileId } from '../cover/ref';
import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults';
@@ -55,11 +56,20 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
type LiveSearchSource = 'local' | 'network';
function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
function LiveSearchAlbumThumb({
albumId,
coverArt,
clusterSeedServerId,
}: {
albumId: string;
coverArt: string;
clusterSeedServerId?: string;
}) {
return (
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
clusterSeedServerId={clusterSeedServerId}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
@@ -70,17 +80,19 @@ function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt
);
}
function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> }) {
// Search results carry the per-track `mf-…` coverArt id, which the cover
// pipeline fails to resolve and the thumbnail goes blank. The album-scoped
// `al-<albumId>_0` id is what actually loads (verified in the RC1 blank-thumb
// investigation), and a song's search thumbnail is its album cover anyway —
// so fetch the album cover from the albumId. Falls back to a music glyph when
// there is no album to key on.
function LiveSearchSongThumb({
song,
}: {
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'>;
}) {
const albumId = song.albumId?.trim();
const scope = React.useMemo(
() => coverScopeForServerProfileId(song.clusterBrowseServerId),
[song.clusterBrowseServerId],
);
const coverRef = React.useMemo(
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`) : undefined),
[albumId],
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`, scope) : undefined),
[albumId, scope],
);
if (!coverRef) return <div className="search-result-icon"><Music size={14} /></div>;
return (
@@ -95,7 +107,11 @@ function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumI
);
}
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
function LiveSearchArtistThumb({
artist,
}: {
artist: Pick<SubsonicArtist, 'id' | 'coverArt' | 'clusterSeedServerId'>;
}) {
const [failed, setFailed] = useState(false);
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
@@ -103,6 +119,7 @@ function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' |
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
clusterSeedServerId={artist.clusterSeedServerId}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
@@ -138,6 +155,7 @@ export default function LiveSearch() {
const liveSearchGenRef = useRef(0);
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const navigateToArtist = useNavigateToArtist();
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
@@ -527,8 +545,8 @@ export default function LiveSearch() {
},
},
] : results ? [
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id); setOpen(false); setQuery(''); } }))),
...(results.artists.map(a => ({ id: a.id, action: () => { navigateToArtist(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => {
const track = songToTrack(s);
enqueue([track]);
@@ -738,7 +756,7 @@ export default function LiveSearch() {
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
onClick={() => { navigateToArtist(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'artist');
@@ -762,14 +780,14 @@ export default function LiveSearch() {
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigateToAlbum(a.id); setOpen(false); setQuery(''); }}
onClick={() => { navigateToAlbum(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} clusterSeedServerId={a.clusterSeedServerId} />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
+1 -1
View File
@@ -10,7 +10,7 @@ interface Props {
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(--text-on-accent)' } : {};
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
return (
<button
+4 -16
View File
@@ -4,11 +4,10 @@ import { useTranslation } from 'react-i18next';
import { Settings, HardDriveDownload } from 'lucide-react';
import { useSidebarStore } from '../store/sidebarStore';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
@@ -16,10 +15,8 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
const { t } = useTranslation();
const sidebarItems = useSidebarStore(s => s.items);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const isServerOffline = offlineCtx.active;
const hasOfflineContent = offlineCtx.capabilities.manualPins;
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
@@ -32,15 +29,6 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
cfg.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
+1 -1
View File
@@ -238,7 +238,7 @@ export default function MobilePlayerView() {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
queueSongStar(currentTrack.id, !isStarred);
}, [currentTrack, isStarred]);
// Scrubber touch/mouse drag
+2 -2
View File
@@ -108,7 +108,7 @@ export default function NowPlayingDropdown() {
{visible.length > 0 && (
<span style={{
background: 'var(--accent)',
color: 'var(--text-on-accent)',
color: 'var(--ctp-crust)',
fontSize: '10px',
fontWeight: 'bold',
padding: '2px 6px',
@@ -161,7 +161,7 @@ export default function NowPlayingDropdown() {
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--card-placeholder-bg)' }}>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
{stream.albumId && stream.coverArt ? (
<TrackCoverArtImage
song={{
+7
View File
@@ -8,6 +8,7 @@ import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { usePlaybackServerId } from '../hooks/usePlaybackServerId';
import { useClusterMemberDisplayLabel } from '../hooks/useClusterMemberDisplayLabel';
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
import CachedImage from './CachedImage';
import OverlayScrollArea from './OverlayScrollArea';
@@ -88,6 +89,7 @@ export default function NowPlayingInfo() {
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
const subsonicServerId = usePlaybackServerId();
const subsonicReady = Boolean(subsonicServerId);
const clusterServerLabel = useClusterMemberDisplayLabel(subsonicServerId);
const primaryArtist = currentTrack ? primaryTrackArtistRef(currentTrack) : null;
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
@@ -230,6 +232,11 @@ export default function NowPlayingInfo() {
<div className="np-info-artist-body">
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
<div className="np-info-artist-name">{artistName || t('common.unknownArtist', 'Unknown artist')}</div>
{clusterServerLabel && (
<div className="np-info-cluster-server">
{t('nowPlayingInfo.clusterServer')}: {clusterServerLabel}
</div>
)}
{bioClean && (
<>
<p
-135
View File
@@ -1,135 +0,0 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { HardDriveDownload, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatBytes } from '../utils/format/formatBytes';
const OPEN_DELAY_MS = 450;
interface Props {
label: string;
totalBytes: number | null;
libraryBytes: number | null;
favoritesBytes: number | null;
}
export function OfflineLibraryDiskStat({
label,
totalBytes,
libraryBytes,
favoritesBytes,
}: Props) {
const { t } = useTranslation();
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
const openTimerRef = useRef<number | null>(null);
const breakdownReady = libraryBytes !== null && favoritesBytes !== null;
const clearOpenTimer = () => {
if (openTimerRef.current !== null) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
};
const scheduleOpen = () => {
if (!breakdownReady) return;
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => setOpen(true), OPEN_DELAY_MS);
};
const close = () => {
clearOpenTimer();
setOpen(false);
};
const breakdownAriaLabel = breakdownReady
? [
t('connection.offlineLibraryDiskTierLibrary', { size: formatBytes(libraryBytes) }),
t('connection.offlineLibraryDiskTierFavorites', { size: formatBytes(favoritesBytes) }),
].join('. ')
: undefined;
useLayoutEffect(() => {
if (!open || !anchorRef.current || !popoverRef.current) {
setStyle({ opacity: 0 });
return;
}
const anchor = anchorRef.current.getBoundingClientRect();
const box = popoverRef.current.getBoundingClientRect();
const GAP = 8;
const MARGIN = 8;
let top = anchor.bottom + GAP;
let left = anchor.left + anchor.width / 2 - box.width / 2;
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
setStyle({ opacity: 1, top, left });
}, [open, libraryBytes, favoritesBytes]);
useEffect(() => () => clearOpenTimer(), []);
return (
<>
<div
ref={anchorRef}
className={`offline-library-header-stat${breakdownReady ? ' offline-library-header-stat--interactive' : ''}`}
aria-live="polite"
aria-label={breakdownAriaLabel}
onMouseEnter={scheduleOpen}
onMouseLeave={close}
onFocus={scheduleOpen}
onBlur={close}
tabIndex={breakdownReady ? 0 : undefined}
>
<span className="offline-library-disk-label">{label}</span>
<span className="offline-library-disk-value">
{totalBytes !== null ? formatBytes(totalBytes) : '…'}
</span>
</div>
{open && breakdownReady && createPortal(
<div
ref={popoverRef}
className="offline-library-disk-breakdown"
style={{ position: 'fixed', pointerEvents: 'auto', ...style }}
role="tooltip"
onMouseEnter={() => {
clearOpenTimer();
setOpen(true);
}}
onMouseLeave={close}
>
<div className="offline-library-disk-breakdown-row">
<span
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--library"
aria-hidden
>
<HardDriveDownload size={15} strokeWidth={2} />
</span>
<span className="offline-library-disk-breakdown-size">
{formatBytes(libraryBytes)}
</span>
</div>
<div className="offline-library-disk-breakdown-divider" aria-hidden />
<div className="offline-library-disk-breakdown-row">
<span
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--favorites"
aria-hidden
>
<Heart size={15} strokeWidth={2} />
</span>
<span className="offline-library-disk-breakdown-size">
{formatBytes(favoritesBytes)}
</span>
</div>
</div>,
document.body,
)}
</>
);
}

Some files were not shown because too many files have changed in this diff Show More