mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-24 16:25:46 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 901d260bf1 | |||
| ebef8aa061 | |||
| 9de56bf638 | |||
| 842e041a6e | |||
| 0de488335e | |||
| 2c95a3499c | |||
| de191e45d5 | |||
| 49ad3618a8 | |||
| 4148e063dd | |||
| 88f7a7bc90 | |||
| daa6fbbfd7 | |||
| aabd342a64 | |||
| fc34a0ec59 | |||
| f4e1086131 | |||
| 23f032b274 | |||
| aad1a6c3f0 | |||
| 10fc489ce9 | |||
| b3573e7107 | |||
| 9fb0d5638b | |||
| f9df918c72 | |||
| 03a1ba9582 | |||
| 2d3c723a6e | |||
| 40db6b08d2 | |||
| a66d932afe | |||
| f706336e58 | |||
| 1c23305887 | |||
| 41157ccaca | |||
| dc4eef1a97 |
@@ -21,6 +21,63 @@ 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
|
||||
@@ -41,6 +98,15 @@ 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
|
||||
@@ -60,6 +126,24 @@ 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)
|
||||
|
||||
@@ -32,9 +32,6 @@ 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.
|
||||
|
||||
Generated
+1
-20
@@ -4252,8 +4252,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tokio",
|
||||
"twox-hash",
|
||||
"unicode-normalization",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
@@ -4268,6 +4266,7 @@ dependencies = [
|
||||
"psysonic-analysis",
|
||||
"psysonic-audio",
|
||||
"psysonic-core",
|
||||
"psysonic-library",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6603,15 +6602,6 @@ 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"
|
||||
@@ -6694,15 +6684,6 @@ 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"
|
||||
|
||||
@@ -6,6 +6,7 @@ resolver = "2"
|
||||
version = "1.48.0-dev"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[workspace.dependencies]
|
||||
tempfile = "3"
|
||||
@@ -18,7 +19,7 @@ name = "psysonic"
|
||||
version.workspace = true
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
license.workspace = true
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition.workspace = true
|
||||
|
||||
@@ -21,6 +21,8 @@ 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
|
||||
@@ -38,5 +40,4 @@ targets = [
|
||||
ignore-build-dependencies = false
|
||||
ignore-dev-dependencies = true
|
||||
ignore-transitive-dependencies = false
|
||||
filter-noassertion = false
|
||||
workarounds = ["ring"]
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 + preload next).
|
||||
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
|
||||
#[derive(Default)]
|
||||
pub struct PlaybackPriorityHints {
|
||||
middle_track_ids: Mutex<HashSet<String>>,
|
||||
@@ -516,6 +516,145 @@ 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,8 +15,21 @@ pub fn plan_track_analysis(
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
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);
|
||||
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);
|
||||
TrackAnalysisPlan {
|
||||
need_waveform,
|
||||
need_loudness,
|
||||
@@ -103,6 +116,32 @@ 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,
|
||||
@@ -117,6 +156,45 @@ 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,
|
||||
@@ -194,4 +272,14 @@ 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,6 +3,7 @@ name = "psysonic-audio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -166,15 +166,24 @@ 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(
|
||||
Box::new(source) as Box<dyn MediaSource>,
|
||||
MediaSourceStreamOptions { buffer_len: buf_len },
|
||||
);
|
||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = format_hint {
|
||||
@@ -200,6 +209,10 @@ impl SizedDecoder {
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Some(gate) = &probe_seek_gate {
|
||||
gate.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Short preview playback on a secondary sink (same output stream).
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
@@ -10,11 +10,16 @@ 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, resolve_playback_format_hint,
|
||||
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,
|
||||
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.
|
||||
@@ -132,7 +137,201 @@ 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,
|
||||
@@ -164,65 +363,24 @@ pub async fn audio_preview_play(
|
||||
preview_pause_main(&state);
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// ~60–120 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();
|
||||
// ── 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(()),
|
||||
};
|
||||
|
||||
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,6 +3,7 @@ name = "psysonic-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -29,17 +29,38 @@ 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();
|
||||
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
|
||||
if trimmed.is_empty() {
|
||||
return "_".to_string();
|
||||
}
|
||||
trimmed
|
||||
let cleaned: String = trimmed
|
||||
.chars()
|
||||
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
|
||||
.collect()
|
||||
.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
|
||||
}
|
||||
|
||||
/// Relative path under `{root}/{server_segment}/` — change format here only.
|
||||
@@ -256,6 +277,13 @@ 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,6 +5,7 @@
|
||||
//! 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;
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! 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,6 +3,7 @@ name = "psysonic-integration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-library"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
@@ -15,8 +16,6 @@ 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"] }
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
-- 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,68 +22,11 @@ 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_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,
|
||||
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,
|
||||
};
|
||||
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 {
|
||||
@@ -118,8 +61,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 folders).
|
||||
fn scoped_fts_rowid_subquery_sql(pool: i64, scope_ids: &[String]) -> String {
|
||||
/// 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 {
|
||||
let alias = "t_fts";
|
||||
let mut sql = format!(
|
||||
"SELECT f.rowid FROM track_fts f \
|
||||
@@ -128,21 +71,20 @@ fn scoped_fts_rowid_subquery_sql(pool: i64, scope_ids: &[String]) -> String {
|
||||
AND {alias}.server_id = ? \
|
||||
AND {alias}.deleted = 0"
|
||||
);
|
||||
if let (Some(clause), _) = library_scope_filter_sql(alias, scope_ids) {
|
||||
if library_scope.is_some() {
|
||||
sql.push_str(" AND ");
|
||||
sql.push_str(&clause);
|
||||
sql.push_str(&library_scope_equals_sql(alias));
|
||||
}
|
||||
sql.push_str(&format!(" ORDER BY bm25(track_fts) LIMIT {pool}"));
|
||||
sql
|
||||
}
|
||||
|
||||
fn scoped_fts_pick_join_sql(pool: i64, scope_ids: &[String]) -> String {
|
||||
fn scoped_fts_pick_join_sql(pool: i64, library_scope: Option<&str>) -> String {
|
||||
let alias = "t_fts";
|
||||
let scope_sql = if let (Some(clause), _) = library_scope_filter_sql(alias, scope_ids) {
|
||||
format!(" AND {clause}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut scope_sql = String::new();
|
||||
if library_scope.is_some() {
|
||||
scope_sql = format!(" AND {}", library_scope_equals_sql(alias));
|
||||
}
|
||||
format!(
|
||||
"track t INNER JOIN (\
|
||||
SELECT f.rowid, bm25(track_fts) AS fts_rank \
|
||||
@@ -157,10 +99,14 @@ fn scoped_fts_pick_join_sql(pool: i64, scope_ids: &[String]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn scoped_fts_subquery_bind(server_id: &str, scope_ids: &[String]) -> Vec<SqlValue> {
|
||||
fn scoped_fts_subquery_bind(
|
||||
server_id: &str,
|
||||
library_scope: Option<&str>,
|
||||
) -> Vec<SqlValue> {
|
||||
let mut params = vec![SqlValue::Text(server_id.to_string())];
|
||||
let (_, scope_params) = library_scope_filter_sql("t_fts", scope_ids);
|
||||
params.extend(scope_params);
|
||||
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
|
||||
params.push(SqlValue::Text(scope.to_string()));
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
@@ -271,7 +217,10 @@ 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()));
|
||||
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
|
||||
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));
|
||||
}
|
||||
for c in scalar {
|
||||
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
|
||||
applied.insert(c.field.clone());
|
||||
@@ -297,8 +246,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_ids = effective_library_scope_ids(req);
|
||||
let from = scoped_fts_pick_join_sql(pool, &scope_ids);
|
||||
let scope = trimmed_nonempty(req.library_scope.as_deref());
|
||||
let from = scoped_fts_pick_join_sql(pool, scope.as_deref());
|
||||
let order = order_clause(&req.sort, EntityKind::Track)
|
||||
.unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string());
|
||||
return query_rows_fts(
|
||||
@@ -306,7 +255,7 @@ fn build_track(
|
||||
&cols,
|
||||
&from,
|
||||
&q,
|
||||
&scoped_fts_subquery_bind(&req.server_id, &scope_ids),
|
||||
&scoped_fts_subquery_bind(&req.server_id, scope.as_deref()),
|
||||
&w,
|
||||
&order,
|
||||
limit,
|
||||
@@ -358,208 +307,12 @@ 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_any_token_match_query(text)
|
||||
fts_album_title_prefix_match_query(text)
|
||||
} else {
|
||||
fts_album_prefix_any_token_match_query(text)
|
||||
fts_album_prefix_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,
|
||||
@@ -581,19 +334,12 @@ 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, None, scalar, limit, offset, skip_totals, applied, false,
|
||||
store, req, text, scalar, limit, offset, skip_totals, applied, false,
|
||||
);
|
||||
}
|
||||
if !scalar_requires_track_derived_entities(scalar) {
|
||||
@@ -621,12 +367,13 @@ fn build_album_from_table(
|
||||
skip_totals: bool,
|
||||
applied: &mut BTreeSet<String>,
|
||||
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
|
||||
// `album` has no `library_id`; scope is enforced via EXISTS on `track`.
|
||||
// `album` has no `library_id` / `deleted` columns, so `libraryScope` is
|
||||
// a track-only filter (P20) and does not narrow album-table results.
|
||||
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 {
|
||||
push_album_table_text_match(&mut w, t, applied);
|
||||
w.push_param("a.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
|
||||
applied.insert("text".to_string());
|
||||
}
|
||||
for c in scalar {
|
||||
if let Some(frag) = resolve_clause(c, EntityKind::Album)? {
|
||||
@@ -687,9 +434,13 @@ fn build_album_from_tracks(
|
||||
AND a.id = t.album_id AND a.song_count IS NOT NULL)",
|
||||
);
|
||||
}
|
||||
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
|
||||
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));
|
||||
}
|
||||
if let Some(t) = text {
|
||||
push_track_group_text_match(&mut w, t, applied);
|
||||
w.push_param("t.album LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
|
||||
applied.insert("text".to_string());
|
||||
}
|
||||
for c in scalar {
|
||||
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
|
||||
@@ -708,9 +459,7 @@ fn build_album_from_tracks(
|
||||
applied,
|
||||
);
|
||||
|
||||
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), \
|
||||
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
|
||||
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
|
||||
MAX(t.starred_at), MAX(t.synced_at)";
|
||||
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
|
||||
@@ -809,7 +558,10 @@ 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)",
|
||||
);
|
||||
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
|
||||
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));
|
||||
}
|
||||
if let Some(t) = text {
|
||||
w.push_param("t.artist LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
|
||||
applied.insert("text".to_string());
|
||||
@@ -853,94 +605,110 @@ fn build_album_from_fts(
|
||||
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 pool = fts_candidate_pool_size(fetch, 0);
|
||||
let scope_ids = effective_library_scope_ids(req);
|
||||
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 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);
|
||||
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));
|
||||
}
|
||||
for c in scalar {
|
||||
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
|
||||
applied.insert(c.field.clone());
|
||||
extra.push(frag);
|
||||
w.push(frag);
|
||||
}
|
||||
}
|
||||
if req.starred_only == Some(true) {
|
||||
extra.push_raw("t.starred_at IS NOT NULL");
|
||||
w.push_raw("t.starred_at IS NOT NULL");
|
||||
applied.insert("starred".to_string());
|
||||
}
|
||||
push_album_id_allowlist(
|
||||
&mut extra,
|
||||
&mut w,
|
||||
"t.album_id",
|
||||
req.restrict_album_ids.as_deref(),
|
||||
applied,
|
||||
);
|
||||
let extra_sql = extra.where_sql();
|
||||
|
||||
let scope_tail = if let (Some(clause), _) = library_scope_filter_sql("t_fts", &scope_ids) {
|
||||
format!(" AND {clause}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let where_sql = w.where_sql();
|
||||
store.with_read_conn(|conn| {
|
||||
let sql = format!(
|
||||
"SELECT t.server_id, t.album_id, \
|
||||
COALESCE(a.name, t.album), t.artist, t.artist_id, t.year, \
|
||||
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
|
||||
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
|
||||
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 ?",
|
||||
FROM track t \
|
||||
WHERE {where_sql}"
|
||||
);
|
||||
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 params = w.params.clone();
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
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,
|
||||
})
|
||||
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)?,
|
||||
))
|
||||
})?
|
||||
.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 {
|
||||
albums.len() as u32
|
||||
deduped.len() as u32
|
||||
};
|
||||
Ok((albums, total))
|
||||
let page = deduped
|
||||
.into_iter()
|
||||
.skip(offset as usize)
|
||||
.take(limit as usize)
|
||||
.collect();
|
||||
Ok((page, total))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -959,24 +727,27 @@ 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_ids = effective_library_scope_ids(req);
|
||||
let scope = trimmed_nonempty(req.library_scope.as_deref());
|
||||
|
||||
let mut w = WhereBuilder::new();
|
||||
w.push_params(
|
||||
&format!(
|
||||
"t.rowid IN ({})",
|
||||
scoped_fts_rowid_subquery_sql(pool, &scope_ids)
|
||||
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_ids));
|
||||
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.artist_id IS NOT NULL AND t.artist_id != ''");
|
||||
push_library_scope_where(&mut w, "t", &scope_ids);
|
||||
if let Some(scope) = scope {
|
||||
let clause = library_scope_equals_sql("t");
|
||||
w.push_param(&clause, SqlValue::Text(scope));
|
||||
}
|
||||
for c in scalar {
|
||||
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
|
||||
applied.insert(c.field.clone());
|
||||
@@ -1624,17 +1395,6 @@ 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| {
|
||||
@@ -1651,7 +1411,6 @@ mod tests {
|
||||
LibraryAdvancedSearchRequest {
|
||||
server_id: server.into(),
|
||||
library_scope: None,
|
||||
library_scope_ids: None,
|
||||
query: None,
|
||||
entity_types: entities.to_vec(),
|
||||
filters: Vec::new(),
|
||||
@@ -1716,8 +1475,6 @@ 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());
|
||||
@@ -1728,169 +1485,6 @@ 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();
|
||||
|
||||
@@ -1,499 +0,0 @@
|
||||
//! 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_filter_sql;
|
||||
use crate::search::library_scope_equals_sql;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
@@ -106,34 +106,11 @@ 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(
|
||||
@@ -145,9 +122,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(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
|
||||
sql.push_str(&format!(" AND {clause}"));
|
||||
params.extend(scope_params);
|
||||
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()));
|
||||
}
|
||||
sql.push_str(
|
||||
" GROUP BY t.genre COLLATE NOCASE \
|
||||
@@ -174,13 +151,11 @@ 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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -332,7 +307,7 @@ mod tests {
|
||||
.upsert_batch(&rock_one)
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None, None).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
|
||||
assert_eq!(counts.len(), 2);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
assert_eq!(counts[0].album_count, 2);
|
||||
@@ -355,7 +330,7 @@ mod tests {
|
||||
.upsert_batch(&[scoped, other])
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1"), None).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
assert_eq!(counts[0].album_count, 1);
|
||||
|
||||
@@ -20,16 +20,10 @@ 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,
|
||||
LibraryClusterAdvancedSearchRequest, LibraryClusterListTracksRequest, LibraryClusterResolveRequest,
|
||||
LibraryClusterResolveResponse, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse,
|
||||
LibraryClusterScopeRequest, LibraryClusterPlayerStatsRequest, LibraryClusterPlayerStatsDayDetailRequest,
|
||||
LibraryClusterEntityDetailRequest, LibraryClusterAlbumDetailResponse,
|
||||
LibraryClusterArtistDetailResponse,
|
||||
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
|
||||
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
|
||||
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
|
||||
PlaySessionInputDto, PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto,
|
||||
PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
TrackArtifactDto, TrackFactDto, TrackRefDto,
|
||||
};
|
||||
use crate::live_search;
|
||||
@@ -402,6 +396,40 @@ 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>,
|
||||
@@ -479,16 +507,6 @@ 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>,
|
||||
@@ -498,15 +516,6 @@ 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>,
|
||||
@@ -571,265 +580,6 @@ 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(
|
||||
@@ -1197,12 +947,6 @@ 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,7 +4,6 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::filter::{EntityKind, FilterOp};
|
||||
use crate::repos::TrackRow;
|
||||
@@ -366,14 +365,6 @@ 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")]
|
||||
@@ -399,38 +390,6 @@ 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")]
|
||||
@@ -440,8 +399,6 @@ 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,
|
||||
@@ -563,9 +520,6 @@ 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>,
|
||||
@@ -651,14 +605,6 @@ 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)]
|
||||
@@ -712,158 +658,6 @@ 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_filter_sql;
|
||||
use crate::search::library_scope_equals_sql;
|
||||
use crate::store::LibraryStore;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
@@ -18,22 +18,6 @@ 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 {
|
||||
@@ -133,10 +117,9 @@ pub fn list_albums_by_genre(
|
||||
SqlValue::Text(genre.to_string()),
|
||||
];
|
||||
|
||||
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);
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
where_clauses.push(library_scope_equals_sql("t"));
|
||||
params.push(SqlValue::Text(scope));
|
||||
}
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
@@ -270,7 +253,6 @@ 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,
|
||||
@@ -290,7 +272,6 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
genre: "Rock".into(),
|
||||
library_scope: None,
|
||||
library_scope_ids: None,
|
||||
sort: vec![],
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
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;
|
||||
@@ -34,7 +33,6 @@ 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,12 +2,9 @@
|
||||
//!
|
||||
//! Mirrors the frontend allowlist in `src/utils/library/losslessFormats.ts`.
|
||||
|
||||
use crate::dto::{
|
||||
LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse, LibrarySortClause,
|
||||
SortDir,
|
||||
};
|
||||
use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse};
|
||||
use crate::lossless_formats::track_is_lossless_sql;
|
||||
use crate::search::library_scope_filter_sql;
|
||||
use crate::search::library_scope_equals_sql;
|
||||
use crate::store::LibraryStore;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
@@ -18,45 +15,6 @@ 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(
|
||||
@@ -79,25 +37,12 @@ pub fn list_lossless_albums(
|
||||
];
|
||||
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
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 \
|
||||
@@ -136,7 +81,9 @@ 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_sql} \
|
||||
ORDER BY la.max_bit_depth DESC, \
|
||||
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, \
|
||||
la.album_id ASC \
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
@@ -159,26 +106,6 @@ 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(),
|
||||
@@ -276,9 +203,6 @@ mod tests {
|
||||
LibraryLosslessAlbumsRequest {
|
||||
server_id: server.into(),
|
||||
library_scope: None,
|
||||
library_scope_ids: None,
|
||||
sort: Vec::new(),
|
||||
restrict_album_ids: None,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
@@ -347,79 +271,6 @@ 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,22 +7,14 @@ pub const LOSSLESS_SUFFIXES: &[&str] = &[
|
||||
"flac", "wav", "wave", "aiff", "aif", "dsf", "dff", "ape", "wv", "shn", "tta",
|
||||
];
|
||||
|
||||
/// 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.
|
||||
/// `LOWER(alias.suffix) IN ('flac', …)` for SQL WHERE clauses.
|
||||
pub fn track_is_lossless_sql(table_alias: &str) -> String {
|
||||
let list = LOSSLESS_SUFFIXES
|
||||
.iter()
|
||||
.map(|s| format!("'{s}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("{} IN ({list})", track_suffix_expr(table_alias))
|
||||
format!("LOWER({table_alias}.suffix) IN ({list})")
|
||||
}
|
||||
|
||||
/// Album has at least one indexed lossless track (same allowlist as browse).
|
||||
@@ -58,6 +50,6 @@ mod tests {
|
||||
let sql = track_is_lossless_sql("t");
|
||||
assert!(sql.contains("'flac'"));
|
||||
assert!(sql.contains("'tta'"));
|
||||
assert!(sql.contains("json_extract(t.raw_json, '$.suffix')"));
|
||||
assert!(sql.starts_with("LOWER(t.suffix) IN ("));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,18 @@ 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
|
||||
@@ -292,6 +304,26 @@ 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(
|
||||
@@ -583,6 +615,13 @@ 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,11 +171,6 @@ 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| {
|
||||
@@ -231,35 +226,6 @@ 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(',')
|
||||
@@ -354,36 +320,6 @@ 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::*;
|
||||
@@ -526,23 +462,6 @@ 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();
|
||||
|
||||
@@ -1,595 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//! 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
@@ -1,135 +0,0 @@
|
||||
//! 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.1–2.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);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//! 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};
|
||||
@@ -1,43 +0,0 @@
|
||||
//! 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("---"), "");
|
||||
}
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
//! Resolve cluster candidates for playback / writes (spec §5–6).
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -7,11 +7,9 @@ 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 = 2;
|
||||
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// Lowest applied schema version the current code can advance from purely
|
||||
/// additively. If a DB carries a version below this, the breaking-bump hook
|
||||
@@ -24,15 +22,10 @@ pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 2;
|
||||
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),
|
||||
(2, MIGRATION_002_ALBUM_BROWSE_INDEX),
|
||||
];
|
||||
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL)];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum MigrationOutcome {
|
||||
@@ -47,17 +40,9 @@ 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(prefix: &str) -> String {
|
||||
fn in_memory_uri() -> String {
|
||||
let n = IN_MEMORY_DB_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
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")
|
||||
format!("file:psysonic_library_mem_{n}?mode=memory&cache=shared")
|
||||
}
|
||||
|
||||
pub struct LibraryStore {
|
||||
@@ -82,12 +67,10 @@ 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),
|
||||
@@ -97,15 +80,12 @@ impl LibraryStore {
|
||||
|
||||
/// Build an in-memory DB with the production schema applied.
|
||||
pub fn open_in_memory() -> Self {
|
||||
let uri = in_memory_library_uri();
|
||||
let cluster_uri = in_memory_cluster_uri();
|
||||
let uri = in_memory_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),
|
||||
@@ -237,11 +217,9 @@ 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))
|
||||
@@ -275,11 +253,9 @@ 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(())
|
||||
@@ -309,10 +285,6 @@ 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");
|
||||
@@ -506,39 +478,6 @@ 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,10 +3,12 @@ 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" }
|
||||
|
||||
|
||||
+100
-2
@@ -1,4 +1,4 @@
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Recursively sums the size of all files under `root`.
|
||||
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
|
||||
@@ -25,6 +25,47 @@ 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.
|
||||
@@ -32,9 +73,11 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
|
||||
/// `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 {
|
||||
if dir == boundary || !dir.starts_with(boundary) {
|
||||
let dir_norm = normalize_path_for_prefix(&dir);
|
||||
if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) {
|
||||
break;
|
||||
}
|
||||
match std::fs::read_dir(&dir) {
|
||||
@@ -52,6 +95,29 @@ 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::*;
|
||||
@@ -87,6 +153,17 @@ 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();
|
||||
@@ -98,4 +175,25 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
+1422
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ mod fs_utils;
|
||||
pub mod offline;
|
||||
pub mod downloads;
|
||||
pub mod hot;
|
||||
pub mod local;
|
||||
|
||||
+50
-17
@@ -5,6 +5,8 @@ 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;
|
||||
|
||||
@@ -114,6 +116,38 @@ 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())
|
||||
@@ -593,6 +627,7 @@ 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,
|
||||
@@ -619,6 +654,7 @@ 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,
|
||||
@@ -708,30 +744,14 @@ 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,
|
||||
@@ -792,6 +812,19 @@ 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,6 +23,7 @@ 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,6 +137,29 @@ 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") {
|
||||
|
||||
@@ -17,11 +17,16 @@ 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 needs_workaround() {
|
||||
match kind {
|
||||
WorkaroundKind::None => {}
|
||||
WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => {
|
||||
set_webkit_disable_dmabuf_renderer();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! 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
@@ -1,6 +1,10 @@
|
||||
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';
|
||||
@@ -13,14 +17,66 @@ 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]);
|
||||
|
||||
+24
-427
@@ -202,8 +202,6 @@ 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[];
|
||||
@@ -381,52 +379,9 @@ 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;
|
||||
}
|
||||
@@ -446,9 +401,6 @@ 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,
|
||||
},
|
||||
@@ -512,327 +464,6 @@ 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,
|
||||
@@ -842,6 +473,18 @@ 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 => {
|
||||
@@ -856,6 +499,18 @@ 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,
|
||||
@@ -1093,13 +748,11 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1107,7 +760,6 @@ export type LibraryGenreAlbumsRequest = {
|
||||
serverId: string;
|
||||
genre: string;
|
||||
libraryScope?: string | null;
|
||||
libraryScopeIds?: string[] | null;
|
||||
sort?: LibrarySortClause[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
@@ -1131,7 +783,6 @@ 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,
|
||||
@@ -1187,60 +838,6 @@ 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 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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;
|
||||
@@ -25,9 +24,10 @@ 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 } = useAuthStore.getState();
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
if (!activeServerId) return null;
|
||||
return libraryScopeForServer(activeServerId) ?? null;
|
||||
const f = musicLibraryFilterByServer[activeServerId];
|
||||
return !f || f === 'all' ? null : f;
|
||||
}
|
||||
|
||||
function asNumber(v: unknown): number | undefined {
|
||||
|
||||
@@ -20,6 +20,11 @@ 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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
+2
-109
@@ -8,11 +8,6 @@ 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', {
|
||||
@@ -65,9 +60,6 @@ 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);
|
||||
@@ -76,7 +68,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 = !isAllLibrariesFilter(normalizeMusicLibraryFilter(musicLibraryFilterByServer[serverId]));
|
||||
const scoped = musicLibraryFilterByServer[serverId] && musicLibraryFilterByServer[serverId] !== 'all';
|
||||
const topCount = scoped ? 20 : 5;
|
||||
const data = await apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
|
||||
serverId,
|
||||
@@ -91,59 +83,7 @@ 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() });
|
||||
@@ -156,54 +96,7 @@ 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,
|
||||
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);
|
||||
}
|
||||
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||
|
||||
@@ -4,7 +4,6 @@ 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}`;
|
||||
@@ -107,16 +106,17 @@ export function libraryFilterParams(): Record<string, string | number> {
|
||||
return activeServerId ? libraryFilterParamsForServer(activeServerId) : {};
|
||||
}
|
||||
|
||||
export {
|
||||
libraryScopeForServer,
|
||||
libraryScopeIdsForServer,
|
||||
libraryScopeInvokeArgs,
|
||||
musicLibraryFilterForServer,
|
||||
} from '../utils/musicLibraryFilter';
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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 = scopeForServer(serverId);
|
||||
const scope = libraryScopeForServer(serverId);
|
||||
if (!scope) return {};
|
||||
return { musicFolderId: scope };
|
||||
}
|
||||
|
||||
+45
-44
@@ -1,9 +1,8 @@
|
||||
import {
|
||||
isAllLibrariesFilter,
|
||||
musicLibraryFilterForServer,
|
||||
musicLibraryFilterStorageKey,
|
||||
} from '../utils/musicLibraryFilter';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
shouldAttemptSubsonicForActiveServer,
|
||||
shouldAttemptSubsonicForServer,
|
||||
} from '../utils/network/subsonicNetworkGuard';
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||
import type {
|
||||
RandomSongsFilters,
|
||||
@@ -62,6 +61,7 @@ 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,6 +76,7 @@ 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,
|
||||
@@ -92,55 +93,46 @@ 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.
|
||||
*/
|
||||
const scopedLibraryAlbumIdCaches = new Map<
|
||||
string,
|
||||
{ folderId: string; filterVersion: number; ids: Set<string> }
|
||||
>();
|
||||
let scopedLibraryAlbumIdCache: {
|
||||
serverId: string;
|
||||
folderId: string;
|
||||
filterVersion: number;
|
||||
ids: Set<string>;
|
||||
} | null = null;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
|
||||
const { musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
|
||||
if (!serverId) return null;
|
||||
const filter = musicLibraryFilterForServer(serverId);
|
||||
if (filter === 'all') {
|
||||
scopedLibraryAlbumIdCaches.delete(serverId);
|
||||
const folder = musicLibraryFilterByServer[serverId];
|
||||
if (folder === undefined || folder === 'all') {
|
||||
scopedLibraryAlbumIdCache = null;
|
||||
return null;
|
||||
}
|
||||
const cacheKey = musicLibraryFilterStorageKey(serverId);
|
||||
const hit = scopedLibraryAlbumIdCaches.get(serverId);
|
||||
const hit = scopedLibraryAlbumIdCache;
|
||||
if (
|
||||
hit
|
||||
&& hit.folderId === cacheKey
|
||||
&& hit.filterVersion === musicLibraryFilterVersion
|
||||
hit &&
|
||||
hit.serverId === serverId &&
|
||||
hit.folderId === folder &&
|
||||
hit.filterVersion === musicLibraryFilterVersion
|
||||
) {
|
||||
return hit.ids;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const pageSize = 500;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
scopedLibraryAlbumIdCaches.set(serverId, {
|
||||
folderId: cacheKey,
|
||||
scopedLibraryAlbumIdCache = {
|
||||
serverId,
|
||||
folderId: folder,
|
||||
filterVersion: musicLibraryFilterVersion,
|
||||
ids,
|
||||
});
|
||||
};
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -189,9 +181,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 } = useAuthStore.getState();
|
||||
const f = activeServerId ? musicLibraryFilterForServer(activeServerId) : 'all';
|
||||
if (isAllLibrariesFilter(f)) return desired;
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
|
||||
if (f === undefined || f === 'all') return desired;
|
||||
return Math.min(300, Math.max(desired, desired * 4));
|
||||
}
|
||||
|
||||
@@ -226,6 +218,7 @@ 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,
|
||||
@@ -238,6 +231,7 @@ 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;
|
||||
@@ -247,6 +241,7 @@ 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;
|
||||
@@ -256,6 +251,9 @@ 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 ?? [] };
|
||||
@@ -265,6 +263,9 @@ 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,6 +1,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { api } from './subsonicClient';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
|
||||
@@ -19,6 +20,22 @@ 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) {
|
||||
@@ -40,6 +57,9 @@ 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(
|
||||
|
||||
@@ -2,6 +2,9 @@ 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';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -55,6 +56,7 @@ export async function prefetchArtistUserRatings(
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
@@ -93,6 +95,7 @@ export async function prefetchAlbumUserRatings(
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
|
||||
@@ -11,6 +11,9 @@ vi.mock('./subsonicClient', () => ({
|
||||
api: vi.fn(),
|
||||
apiForServer: apiForServerMock,
|
||||
}));
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForServer: () => true,
|
||||
}));
|
||||
|
||||
describe('subsonicScrobble', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import type { SubsonicNowPlaying } from './subsonicTypes';
|
||||
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
|
||||
import { isClusterMode } from '../utils/serverCluster/clusterScope';
|
||||
import { clusterFanOutScrobbleSubmission } from '../utils/serverCluster/clusterWriteFanout';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
async function scrobbleOnServer(
|
||||
serverId: string,
|
||||
@@ -11,23 +9,14 @@ 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,
|
||||
resolvedServerId?: string,
|
||||
): Promise<void> {
|
||||
export async function scrobbleSong(id: string, time: number, serverId: 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api, libraryFilterParams } from './subsonicClient';
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
|
||||
@@ -7,11 +7,6 @@ import {
|
||||
invalidateStarredAlbumBrowse,
|
||||
refreshStarredAlbumIndexFromServer,
|
||||
} from '../utils/library/starredAlbumIndexSync';
|
||||
import { isClusterMode } from '../utils/serverCluster/clusterScope';
|
||||
import {
|
||||
clusterFanOutRating,
|
||||
clusterFanOutStar,
|
||||
} from '../utils/serverCluster/clusterWriteFanout';
|
||||
import type {
|
||||
EntityRatingSupportLevel,
|
||||
StarredResults,
|
||||
@@ -20,6 +15,17 @@ 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: {
|
||||
@@ -28,25 +34,50 @@ export async function getStarred(): Promise<StarredResults> {
|
||||
song?: SubsonicSong[];
|
||||
}
|
||||
}>('getStarred2.view', { ...libraryFilterParams() });
|
||||
const r = data.starred2 ?? {};
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
await api('star.view', params);
|
||||
const serverId = resolveStarServerId(meta);
|
||||
await starApi(serverId, 'star.view', params);
|
||||
if (type === 'song') {
|
||||
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
|
||||
} else if (type === 'album' && serverId) {
|
||||
@@ -54,23 +85,22 @@ 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;
|
||||
await api('unstar.view', params);
|
||||
const serverId = resolveStarServerId(meta);
|
||||
await starApi(serverId, 'unstar.view', params);
|
||||
if (type === 'song') {
|
||||
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
|
||||
} else if (type === 'album' && serverId) {
|
||||
@@ -78,15 +108,12 @@ 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 });
|
||||
|
||||
@@ -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[];
|
||||
/** Psysonic cluster: originating server for merged browse rows (client-only). */
|
||||
clusterSeedServerId?: string;
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicDiscTitle {
|
||||
@@ -92,6 +92,8 @@ 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. */
|
||||
@@ -100,8 +102,6 @@ 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;
|
||||
/** Psysonic cluster: originating server for merged browse rows (client-only). */
|
||||
clusterSeedServerId?: string;
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicGenre {
|
||||
|
||||
+24
-9
@@ -11,16 +11,19 @@ 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';
|
||||
import FullscreenPlayer from '../components/fullscreenPlayer/FullscreenPlayerStatic';
|
||||
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 {
|
||||
@@ -38,7 +41,8 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
|
||||
import { useOrbitGuest } from '../hooks/useOrbitGuest';
|
||||
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
|
||||
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
|
||||
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
|
||||
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
|
||||
@@ -48,15 +52,14 @@ 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';
|
||||
@@ -103,12 +106,13 @@ export function AppShell() {
|
||||
const location = useLocation();
|
||||
const prevPathnameRef = useRef(location.pathname);
|
||||
useCoverNavigationPriority();
|
||||
useClusterPlaybackMonitor();
|
||||
useLiveSearchRouteScope();
|
||||
useNowPlayingPrewarm();
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const hasOfflineContent = offlineCtx.hasBrowsingContent;
|
||||
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
@@ -136,7 +140,8 @@ export function AppShell() {
|
||||
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
|
||||
}, [location.pathname, location.state]);
|
||||
|
||||
useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate);
|
||||
useOfflineAutoNav(connStatus, offlineNav, location, navigate);
|
||||
useOfflineLibraryFilterSuspend();
|
||||
|
||||
useEffect(() => {
|
||||
initializeFromServerQueue();
|
||||
@@ -175,6 +180,13 @@ 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
|
||||
@@ -243,6 +255,7 @@ 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 />
|
||||
@@ -261,7 +274,7 @@ export function AppShell() {
|
||||
</header>
|
||||
<OrbitSessionBar />
|
||||
{connStatus === 'disconnected' && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
|
||||
)}
|
||||
<div className="content-body app-shell-route-host">
|
||||
<OverlayScrollArea
|
||||
@@ -307,8 +320,10 @@ export function AppShell() {
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<GlobalConfirmModal />
|
||||
<ThemeMigrationNotice />
|
||||
<OrbitAccountPicker />
|
||||
<OrbitHelpModal />
|
||||
<OrbitReconnectModal />
|
||||
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
|
||||
<AppUpdater />
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,12 @@ 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';
|
||||
@@ -98,6 +104,28 @@ 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();
|
||||
|
||||
@@ -24,10 +24,14 @@ 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;
|
||||
|
||||
@@ -48,6 +52,8 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
setUserAgent(ORIGINAL_USER_AGENT);
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.head.querySelectorAll('style[data-installed-theme]').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
describe('pushUserAgentToBackend', () => {
|
||||
@@ -143,3 +149,56 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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 {
|
||||
@@ -35,6 +39,63 @@ 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) {
|
||||
@@ -46,6 +107,12 @@ 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();
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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';
|
||||
@@ -17,12 +16,14 @@ 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 { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { fetchAlbumTracks, 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;
|
||||
@@ -63,24 +64,28 @@ function AlbumCard({
|
||||
}: AlbumCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isHolding, pressBind } = useLongPressAction({
|
||||
onShortPress: () => playAlbum(album.id),
|
||||
onLongPress: () => playAlbumShuffled(album.id),
|
||||
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
|
||||
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
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 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 psyDrag = useDragDrop();
|
||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
|
||||
libraryResolve,
|
||||
clusterSeedServerId: album.clusterSeedServerId,
|
||||
});
|
||||
const coverServerScope = useMemo(
|
||||
() => coverServerScopeForServerId(album.serverId),
|
||||
[album.serverId],
|
||||
);
|
||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve });
|
||||
const dragCoverKey = useMemo(() => {
|
||||
if (!coverRef) return '';
|
||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
||||
@@ -91,7 +96,7 @@ function AlbumCard({
|
||||
|
||||
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
||||
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
||||
navigateToAlbum(album.id, { search: linkQuery, seedServerId: album.clusterSeedServerId });
|
||||
navigateToAlbum(album.id, { search: albumLinkQuery });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -185,10 +190,13 @@ function AlbumCard({
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const data = await getAlbum(album.id);
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
const tracks = await fetchAlbumTracks(
|
||||
album.id,
|
||||
offlineServerId || undefined,
|
||||
);
|
||||
if (tracks.length > 0) enqueue(tracks);
|
||||
} catch {
|
||||
// Network failure — silent (toast would be too noisy for a hover action)
|
||||
// Unavailable offline or network failure — silent on hover action
|
||||
}
|
||||
}}
|
||||
aria-label={t('contextMenu.enqueueAlbum')}
|
||||
|
||||
+130
-99
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -20,6 +19,7 @@ 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,12 +72,10 @@ 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' | 'downloading' | 'cached';
|
||||
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
|
||||
offlineProgress: { done: number; total: number } | null;
|
||||
bio: string | null;
|
||||
bioOpen: boolean;
|
||||
@@ -94,6 +92,8 @@ 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,7 +101,6 @@ export default function AlbumHeader({
|
||||
headerArtistRefs,
|
||||
songs,
|
||||
coverArtId,
|
||||
coverServerScope = COVER_SCOPE_ACTIVE,
|
||||
resolvedCoverUrl,
|
||||
isStarred,
|
||||
downloadProgress,
|
||||
@@ -121,14 +120,16 @@ 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, coverServerScope, { libraryResolve: true });
|
||||
const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true });
|
||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
|
||||
alt: `${info.name} Cover`,
|
||||
});
|
||||
@@ -226,7 +227,7 @@ export default function AlbumHeader({
|
||||
<StarRating
|
||||
value={entityRatingValue}
|
||||
onChange={onEntityRatingChange}
|
||||
disabled={entityRatingSupport === 'track_only'}
|
||||
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
@@ -254,14 +255,16 @@ export default function AlbumHeader({
|
||||
|
||||
{/* Row 2 — Secondary actions */}
|
||||
<div className="album-actions-row album-actions-row--secondary">
|
||||
<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>
|
||||
{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"
|
||||
@@ -273,7 +276,7 @@ export default function AlbumHeader({
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
{showBioButton && (
|
||||
{showBioButton && policy.canShowBio && (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onBio}
|
||||
@@ -284,44 +287,57 @@ export default function AlbumHeader({
|
||||
</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.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>
|
||||
)
|
||||
)}
|
||||
|
||||
{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>
|
||||
{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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,13 +368,15 @@ export default function AlbumHeader({
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</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>
|
||||
{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
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
@@ -370,7 +388,7 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBioButton && (
|
||||
{showBioButton && policy.canShowBio && (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-bio-btn"
|
||||
@@ -381,47 +399,60 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
{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>
|
||||
</div>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
)}
|
||||
{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>
|
||||
{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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -251,7 +251,7 @@ export default function AlbumRow({
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{uniqueAlbums.map((a, idx) => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||
album={a}
|
||||
showRating={showRating}
|
||||
linkQuery={albumLinkQuery}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useTracklistColumns } from '../utils/useTracklistColumns';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
|
||||
@@ -38,6 +39,7 @@ interface AlbumTrackListProps {
|
||||
sortKey?: SortKey;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
onSort?: (key: SortKey) => void;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
||||
@@ -60,7 +62,9 @@ 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);
|
||||
@@ -98,6 +102,10 @@ 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 (
|
||||
@@ -139,7 +147,7 @@ export default function AlbumTrackList({
|
||||
>
|
||||
|
||||
<TracklistHeaderRow
|
||||
visibleCols={visibleCols}
|
||||
visibleCols={displayCols}
|
||||
gridStyle={gridStyle}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
@@ -170,7 +178,7 @@ export default function AlbumTrackList({
|
||||
key={song.id}
|
||||
song={song}
|
||||
globalIdx={globalIdx}
|
||||
visibleCols={visibleCols}
|
||||
visibleCols={displayCols}
|
||||
gridStyle={gridStyle}
|
||||
currentTrackId={currentTrackId}
|
||||
isPlaying={isPlaying}
|
||||
@@ -186,6 +194,7 @@ export default function AlbumTrackList({
|
||||
onToggleSelect={onToggleSelect}
|
||||
onDragStart={onDragStart}
|
||||
setContextMenuSongId={setContextMenuSongId}
|
||||
actionPolicy={policy}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import React from 'react';
|
||||
import React, { useMemo } 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;
|
||||
@@ -18,18 +20,17 @@ interface Props {
|
||||
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, {
|
||||
libraryResolve,
|
||||
clusterSeedServerId: artist.clusterSeedServerId,
|
||||
});
|
||||
const coverServerScope = useMemo(
|
||||
() => coverServerScopeForServerId(artist.serverId),
|
||||
[artist.serverId],
|
||||
);
|
||||
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, coverServerScope, { libraryResolve });
|
||||
const artistLinkQuery = appendServerQuery(linkQuery, artist.serverId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigateToArtist(artist.id, {
|
||||
search: linkQuery,
|
||||
seedServerId: artist.clusterSeedServerId,
|
||||
})}
|
||||
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
|
||||
>
|
||||
<div className="artist-card-avatar">
|
||||
{coverRef ? (
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ArtistRow({
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{artists.map(a => (
|
||||
<ArtistCardLocal
|
||||
key={a.id}
|
||||
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||
artist={a}
|
||||
linkQuery={artistLinkQuery}
|
||||
libraryResolve={libraryResolve}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||
import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary';
|
||||
import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
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,23 +585,22 @@ 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,
|
||||
clusterSeedServerId: album.clusterSeedServerId,
|
||||
});
|
||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
|
||||
const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, {
|
||||
surface: 'dense',
|
||||
ensurePriority: 'high',
|
||||
});
|
||||
const imgSrc = coverImgSrc(coverHandle.src);
|
||||
const bgResolved = coverHandle.src;
|
||||
const handleOpen = () => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId });
|
||||
const handleOpen = () => navigate(`/album/${album.id}`);
|
||||
const handleEnqueue = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const data = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const data = await resolveAlbum(serverId, album.id);
|
||||
if (!data) return;
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
} catch {
|
||||
/* silent — toast would be too noisy for a hover action */
|
||||
|
||||
@@ -247,7 +247,7 @@ export default function CachedImage({
|
||||
};
|
||||
|
||||
const fallbackStyle: React.CSSProperties = isFallback
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' }
|
||||
: {};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,12 @@ 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, Layers } from 'lucide-react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useClusterConnectionLed } from '../hooks/useClusterConnectionLed';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { switchActiveCluster, switchActiveServer } from '../utils/server/switchActiveServer';
|
||||
import { 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;
|
||||
@@ -22,22 +20,14 @@ 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 || 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 multi = servers.length > 1;
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
const el = hostRef.current;
|
||||
@@ -91,7 +81,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
};
|
||||
|
||||
const onPickServer = async (srv: ServerProfile) => {
|
||||
if (!activeClusterId && srv.id === activeServerId) {
|
||||
if (srv.id === activeServerId) {
|
||||
setMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -106,59 +96,14 @@ 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 metaTooltip = multi ? t('connection.switchScopeHint') : undefined;
|
||||
const statusTooltip =
|
||||
effectiveStatus === 'connected'
|
||||
const tooltip = multi
|
||||
? t('connection.switchServerHint')
|
||||
: status === 'connected'
|
||||
? t('connection.connectedTo', { server: serverName })
|
||||
: effectiveStatus === 'disconnected'
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: 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>
|
||||
);
|
||||
: t('connection.checking');
|
||||
|
||||
return (
|
||||
<div className="connection-indicator-host" ref={hostRef}>
|
||||
@@ -166,21 +111,14 @@ 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--${effectiveStatus}`}
|
||||
data-tooltip={ledTooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
{...(clusterActive ? { 'data-tooltip-wrap': true } : {})}
|
||||
/>
|
||||
<div
|
||||
className="connection-meta"
|
||||
data-tooltip={metaTooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<div className={`connection-led connection-led--${status}`} />
|
||||
<div className="connection-meta">
|
||||
<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>
|
||||
@@ -198,7 +136,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.switchScopeTitle')}
|
||||
aria-label={t('connection.switchServerTitle')}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: menuFixed.top,
|
||||
@@ -208,38 +146,6 @@ 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,
|
||||
@@ -252,14 +158,30 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
>
|
||||
{t('connection.switchServerTitle')}
|
||||
</div>
|
||||
{servers.map(srv =>
|
||||
renderMenuItem(
|
||||
srv.id,
|
||||
serverListDisplayLabel(srv, servers),
|
||||
!activeClusterId && srv.id === activeServerId,
|
||||
() => void onPickServer(srv),
|
||||
),
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)',
|
||||
|
||||
@@ -43,6 +43,23 @@ 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';
|
||||
|
||||
@@ -17,8 +17,29 @@ 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 };
|
||||
|
||||
|
||||
@@ -180,6 +201,12 @@ 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 (
|
||||
@@ -233,6 +260,7 @@ export default function ContextMenu() {
|
||||
isStarred={isStarred}
|
||||
pinToPlaybackServer={pinToPlaybackServer}
|
||||
navigateLibrary={navigateLibrary}
|
||||
offlinePolicy={offlinePolicy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* `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');
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+44
-19
@@ -1,4 +1,5 @@
|
||||
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
|
||||
import { getRandomAlbums } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
@@ -33,14 +34,25 @@ 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(1);
|
||||
const counter = useRef(url ? 1 : 0);
|
||||
const latestUrlRef = useRef(url);
|
||||
latestUrlRef.current = url;
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
if (!url) {
|
||||
setLayers([]);
|
||||
return;
|
||||
}
|
||||
const id = counter.current++;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
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);
|
||||
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);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]);
|
||||
|
||||
@@ -259,7 +271,13 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (!album || albumFormats[album.id] !== undefined) return;
|
||||
getAlbum(album.id).then(data => {
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
resolveAlbum(serverId, album.id).then(data => {
|
||||
if (!data) {
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
|
||||
return;
|
||||
}
|
||||
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(() => {
|
||||
@@ -267,25 +285,26 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
});
|
||||
}, [album?.id]);
|
||||
|
||||
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt, undefined, {
|
||||
clusterSeedServerId: album?.clusterSeedServerId,
|
||||
});
|
||||
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
|
||||
const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, {
|
||||
surface: 'dense',
|
||||
ensurePriority: 'high',
|
||||
});
|
||||
|
||||
// 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('');
|
||||
// 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>>({});
|
||||
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" />;
|
||||
|
||||
@@ -295,10 +314,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
className="hero"
|
||||
role="banner"
|
||||
aria-label={t('hero.eyebrow')}
|
||||
onClick={() => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId })}
|
||||
onClick={() => navigateToAlbum(album.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={heroBgUrl} />}
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
@@ -341,7 +360,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
|
||||
} catch (_) {}
|
||||
}}
|
||||
@@ -371,7 +393,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) {}
|
||||
|
||||
@@ -23,7 +23,6 @@ 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';
|
||||
@@ -35,7 +34,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, coverScopeForServerProfileId } from '../cover/ref';
|
||||
import { albumCoverRef } from '../cover/ref';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useShareSearch } from '../hooks/useShareSearch';
|
||||
import ShareSearchResults from './search/ShareSearchResults';
|
||||
@@ -56,20 +55,11 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
|
||||
|
||||
type LiveSearchSource = 'local' | 'network';
|
||||
|
||||
function LiveSearchAlbumThumb({
|
||||
albumId,
|
||||
coverArt,
|
||||
clusterSeedServerId,
|
||||
}: {
|
||||
albumId: string;
|
||||
coverArt: string;
|
||||
clusterSeedServerId?: string;
|
||||
}) {
|
||||
function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
|
||||
return (
|
||||
<AlbumCoverArtImage
|
||||
albumId={albumId}
|
||||
coverArt={coverArt}
|
||||
clusterSeedServerId={clusterSeedServerId}
|
||||
libraryResolve={false}
|
||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||
surface="dense"
|
||||
@@ -80,19 +70,17 @@ function LiveSearchAlbumThumb({
|
||||
);
|
||||
}
|
||||
|
||||
function LiveSearchSongThumb({
|
||||
song,
|
||||
}: {
|
||||
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'>;
|
||||
}) {
|
||||
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.
|
||||
const albumId = song.albumId?.trim();
|
||||
const scope = React.useMemo(
|
||||
() => coverScopeForServerProfileId(song.clusterBrowseServerId),
|
||||
[song.clusterBrowseServerId],
|
||||
);
|
||||
const coverRef = React.useMemo(
|
||||
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`, scope) : undefined),
|
||||
[albumId, scope],
|
||||
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`) : undefined),
|
||||
[albumId],
|
||||
);
|
||||
if (!coverRef) return <div className="search-result-icon"><Music size={14} /></div>;
|
||||
return (
|
||||
@@ -107,11 +95,7 @@ function LiveSearchSongThumb({
|
||||
);
|
||||
}
|
||||
|
||||
function LiveSearchArtistThumb({
|
||||
artist,
|
||||
}: {
|
||||
artist: Pick<SubsonicArtist, 'id' | 'coverArt' | 'clusterSeedServerId'>;
|
||||
}) {
|
||||
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
|
||||
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
|
||||
@@ -119,7 +103,6 @@ function LiveSearchArtistThumb({
|
||||
<ArtistCoverArtImage
|
||||
artistId={artist.id}
|
||||
coverArt={artist.coverArt}
|
||||
clusterSeedServerId={artist.clusterSeedServerId}
|
||||
libraryResolve={false}
|
||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||
surface="dense"
|
||||
@@ -155,7 +138,6 @@ 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);
|
||||
@@ -545,8 +527,8 @@ export default function LiveSearch() {
|
||||
},
|
||||
},
|
||||
] : results ? [
|
||||
...(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.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.songs.map(s => ({ id: s.id, action: () => {
|
||||
const track = songToTrack(s);
|
||||
enqueue([track]);
|
||||
@@ -756,7 +738,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={() => { navigateToArtist(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); }}
|
||||
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, a, 'artist');
|
||||
@@ -780,14 +762,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, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); }}
|
||||
onClick={() => { navigateToAlbum(a.id); 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} clusterSeedServerId={a.clusterSeedServerId} />
|
||||
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
|
||||
) : (
|
||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||
)}
|
||||
|
||||
@@ -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(--ctp-crust)' } : {};
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -4,10 +4,11 @@ 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 { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
|
||||
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
||||
|
||||
@@ -15,8 +16,10 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
const { t } = useTranslation();
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const isServerOffline = offlineCtx.active;
|
||||
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||
const luckyMixBase = useLuckyMixAvailable();
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
|
||||
@@ -29,6 +32,15 @@ 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]);
|
||||
|
||||
@@ -238,7 +238,7 @@ export default function MobilePlayerView() {
|
||||
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
// Scrubber touch/mouse drag
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function NowPlayingDropdown() {
|
||||
{visible.length > 0 && (
|
||||
<span style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--ctp-crust)',
|
||||
color: 'var(--text-on-accent)',
|
||||
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(--bg-surface)' }}>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--card-placeholder-bg)' }}>
|
||||
{stream.albumId && stream.coverArt ? (
|
||||
<TrackCoverArtImage
|
||||
song={{
|
||||
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
@@ -89,7 +88,6 @@ 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 ?? '';
|
||||
@@ -232,11 +230,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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
Reference in New Issue
Block a user