mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
2 Commits
27624d4c69
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
| e822fff39f | |||
| dc7addae03 |
@@ -9,42 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
>
|
||||
|
||||
|
||||
## [1.51.0]
|
||||
|
||||
## Added
|
||||
|
||||
### Multi-server library — browse selected servers as one catalogue
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
|
||||
|
||||
* The library scope now combines selected libraries across configured servers for Home, albums, artists, composers, genres, favourites, playlists, search, statistics and detail pages, while de-duplicating shared music by scope priority.
|
||||
* Actions preserve the concrete owning server even when identical track, album or artist IDs exist elsewhere. Source pickers expose equivalent copies, and playback can offer another selected source when the first one fails.
|
||||
|
||||
## Changed
|
||||
|
||||
### Large libraries — incremental browse and identity maintenance
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
|
||||
|
||||
* Scoped browse projections, identity matching and sync recovery now update incrementally instead of repeatedly rebuilding the whole catalogue, keeping startup and foreground refreshes responsive on large multi-server libraries.
|
||||
* Home feeds, text search, detail reads, most-played results and statistics use indexed local scope queries where available, with network fallback retained for servers that are not indexed yet.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Multi-server ownership — mutations and playback stay on the correct server
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
|
||||
|
||||
* Queue restore and top-up, Orbit sessions, ratings, favourites, playlists, device sync, offline pins, covers, lyrics, radio, sharing and context-menu actions no longer drift to the active server when an item belongs to another selected server.
|
||||
* Sync interruptions and unavailable servers no longer leave stale scope state blocking browse refreshes; pending identity work resumes safely after restart.
|
||||
|
||||
### Startup — keep the loading splash until initial content is ready
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
|
||||
|
||||
* Cold starts no longer reveal a blank or partially committed app shell between the splash and the first route content; the handoff is atomic after the initial screen is ready.
|
||||
|
||||
|
||||
## [1.50.0]
|
||||
|
||||
## Added
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1783776592,
|
||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
||||
"lastModified": 1784356753,
|
||||
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
|
||||
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+7
-4
@@ -13,9 +13,6 @@
|
||||
min-height: 100%;
|
||||
background: var(--startup-splash-bg, #1e1e2e);
|
||||
}
|
||||
#root.app-root--startup-pending {
|
||||
visibility: hidden;
|
||||
}
|
||||
#app-startup-splash {
|
||||
--splash-bg: var(--startup-splash-bg, #1e1e2e);
|
||||
--splash-text: var(--startup-splash-text, #cdd6f4);
|
||||
@@ -35,6 +32,12 @@
|
||||
background: var(--splash-bg);
|
||||
color: var(--splash-text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
opacity: 1;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
#app-startup-splash.app-startup-splash--hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__logo {
|
||||
height: 72px;
|
||||
@@ -86,7 +89,7 @@
|
||||
</div>
|
||||
<span class="app-startup-splash__label">Loading</span>
|
||||
</div>
|
||||
<div id="root" class="app-root--startup-pending"></div>
|
||||
<div id="root"></div>
|
||||
<script src="/startup-splash-reveal.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-jyNFcFi8VrgcFe1TW4tuXDErYyUBwH8/bZK0DNTFGk8="
|
||||
"npmDepsHash": "sha256-xwXDgWdkPEd5W8TVUCP29fbJesI4shYjeCUzlG69/aE="
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.51.0-dev",
|
||||
"version": "1.50.0-rc.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.51.0-dev",
|
||||
"version": "1.50.0-rc.4",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.51.0-dev",
|
||||
"version": "1.50.0-rc.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.50.0
|
||||
pkgver=1.49.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+7
-7
@@ -4120,7 +4120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.51.0-dev"
|
||||
version = "1.50.0-rc.4"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4176,7 +4176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.51.0-dev"
|
||||
version = "1.50.0-rc.4"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4196,7 +4196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.51.0-dev"
|
||||
version = "1.50.0-rc.4"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4227,7 +4227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.51.0-dev"
|
||||
version = "1.50.0-rc.4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"reqwest",
|
||||
@@ -4239,7 +4239,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.51.0-dev"
|
||||
version = "1.50.0-rc.4"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4257,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.51.0-dev"
|
||||
version = "1.50.0-rc.4"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4273,7 +4273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.51.0-dev"
|
||||
version = "1.50.0-rc.4"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.51.0-dev"
|
||||
version = "1.50.0-rc.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -3,10 +3,10 @@ mod store;
|
||||
|
||||
pub use compute::{
|
||||
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_limited,
|
||||
decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target, seed_from_bytes_execute,
|
||||
seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome,
|
||||
decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target,
|
||||
seed_from_bytes_execute, seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome,
|
||||
};
|
||||
pub use store::{
|
||||
AnalysisCache, AnalysisDeleteServerReport, FailedTrackEntry, LoudnessEntry, TrackKey,
|
||||
WaveformEntry, ANALYSIS_DB_SCHEMA_VERSION,
|
||||
WaveformEntry,
|
||||
};
|
||||
|
||||
@@ -19,68 +19,10 @@ const MIGRATION_002_SERVER_ID: &str = include_str!("../../migrations/002_server_
|
||||
/// Embedded migrations, ascending by version. The runner sorts defensively and
|
||||
/// applies each missing one in its own transaction (schema change + version
|
||||
/// marker commit together — see [`run_migrations_with`]).
|
||||
const MIGRATIONS: &[(i64, &str)] = &[(1, MIGRATION_001_BASELINE), (2, MIGRATION_002_SERVER_ID)];
|
||||
|
||||
struct OperationalTable {
|
||||
name: &'static str,
|
||||
columns: &'static [&'static str],
|
||||
primary_key: &'static [&'static str],
|
||||
}
|
||||
|
||||
const OPERATIONAL_TABLES: [OperationalTable; 3] = [
|
||||
OperationalTable {
|
||||
name: "analysis_track",
|
||||
columns: &[
|
||||
"server_id",
|
||||
"track_id",
|
||||
"md5_16kb",
|
||||
"status",
|
||||
"waveform_algo_version",
|
||||
"loudness_algo_version",
|
||||
"updated_at",
|
||||
],
|
||||
primary_key: &["server_id", "track_id", "md5_16kb"],
|
||||
},
|
||||
OperationalTable {
|
||||
name: "waveform_cache",
|
||||
columns: &[
|
||||
"server_id",
|
||||
"track_id",
|
||||
"md5_16kb",
|
||||
"bins",
|
||||
"bin_count",
|
||||
"is_partial",
|
||||
"known_until_sec",
|
||||
"duration_sec",
|
||||
"updated_at",
|
||||
],
|
||||
primary_key: &["server_id", "track_id", "md5_16kb"],
|
||||
},
|
||||
OperationalTable {
|
||||
name: "loudness_cache",
|
||||
columns: &[
|
||||
"server_id",
|
||||
"track_id",
|
||||
"md5_16kb",
|
||||
"integrated_lufs",
|
||||
"true_peak",
|
||||
"recommended_gain_db",
|
||||
"target_lufs",
|
||||
"updated_at",
|
||||
],
|
||||
primary_key: &["server_id", "track_id", "md5_16kb", "target_lufs"],
|
||||
},
|
||||
const MIGRATIONS: &[(i64, &str)] = &[
|
||||
(1, MIGRATION_001_BASELINE),
|
||||
(2, MIGRATION_002_SERVER_ID),
|
||||
];
|
||||
const OPERATIONAL_INDEXES: [&str; 1] = ["idx_analysis_track_status"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SwapDatabaseStage {
|
||||
BackupActive,
|
||||
ActivateDestination,
|
||||
Open,
|
||||
Configure,
|
||||
Migrate,
|
||||
}
|
||||
|
||||
/// Bins in waveform BLOB: `2 * bin_count` bytes (peak u8, then mean-abs u8 per time bin).
|
||||
fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool {
|
||||
@@ -196,11 +138,8 @@ impl AnalysisCache {
|
||||
let mut conn = Connection::open(&db_path).map_err(|e| e.to_string())?;
|
||||
configure_connection(&conn).map_err(|e| e.to_string())?;
|
||||
run_migrations(&mut conn).map_err(|e| e.to_string())?;
|
||||
verify_operational_schema_conn(&conn)?;
|
||||
checkpoint_wal_conn(&conn, "open").map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
/// Builds an in-memory SQLite database with the production schema applied.
|
||||
@@ -214,24 +153,16 @@ impl AnalysisCache {
|
||||
/// use it.
|
||||
pub fn open_in_memory() -> Self {
|
||||
let mut conn = Connection::open_in_memory().expect("in-memory connection");
|
||||
conn.pragma_update(None, "foreign_keys", "ON")
|
||||
.expect("pragma foreign_keys");
|
||||
conn.pragma_update(None, "foreign_keys", "ON").expect("pragma foreign_keys");
|
||||
run_migrations(&mut conn).expect("schema migration");
|
||||
verify_operational_schema_conn(&conn).expect("operational schema");
|
||||
let _ = checkpoint_wal_conn(&conn, "open");
|
||||
Self {
|
||||
conn: Mutex::new(conn),
|
||||
}
|
||||
Self { conn: Mutex::new(conn) }
|
||||
}
|
||||
|
||||
/// Remove `loudness_cache` rows for this logical track (bare id and `stream:`
|
||||
/// variant) scoped to one server. A reseed on server A must not delete
|
||||
/// server B's analysis for the same bare `track_id`.
|
||||
pub fn delete_loudness_for_track_id(
|
||||
&self,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<u64, String> {
|
||||
pub fn delete_loudness_for_track_id(&self, server_id: &str, track_id: &str) -> Result<u64, String> {
|
||||
if track_id.trim().is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -255,11 +186,7 @@ impl AnalysisCache {
|
||||
/// Remove `waveform_cache` rows for this logical track (bare id and `stream:`
|
||||
/// variant) scoped to one server. See [`Self::delete_loudness_for_track_id`]
|
||||
/// for the scoping rationale.
|
||||
pub fn delete_waveform_for_track_id(
|
||||
&self,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<u64, String> {
|
||||
pub fn delete_waveform_for_track_id(&self, server_id: &str, track_id: &str) -> Result<u64, String> {
|
||||
if track_id.trim().is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -303,22 +230,13 @@ impl AnalysisCache {
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
let waveforms = tx
|
||||
.execute(
|
||||
"DELETE FROM waveform_cache WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
)
|
||||
.execute("DELETE FROM waveform_cache WHERE server_id = ?1", params![server_id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
let loudness = tx
|
||||
.execute(
|
||||
"DELETE FROM loudness_cache WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
)
|
||||
.execute("DELETE FROM loudness_cache WHERE server_id = ?1", params![server_id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
let analysis_tracks = tx
|
||||
.execute(
|
||||
"DELETE FROM analysis_track WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
)
|
||||
.execute("DELETE FROM analysis_track WHERE server_id = ?1", params![server_id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(AnalysisDeleteServerReport {
|
||||
@@ -336,30 +254,12 @@ impl AnalysisCache {
|
||||
checkpoint_wal_conn(&conn, op).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Verify the migration head and objects required by runtime reads/writes.
|
||||
pub fn verify_operational_schema(&self) -> Result<(), String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
verify_operational_schema_conn(&conn)
|
||||
}
|
||||
|
||||
/// Atomically switch analysis sqlite file while replacing the held
|
||||
/// connection so runtime writers cannot continue on the old inode.
|
||||
pub fn swap_database_file(
|
||||
&self,
|
||||
active_path: &Path,
|
||||
destination_path: &Path,
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
self.swap_database_file_with(active_path, destination_path, |_| Ok(()))
|
||||
}
|
||||
|
||||
fn swap_database_file_with(
|
||||
&self,
|
||||
active_path: &Path,
|
||||
destination_path: &Path,
|
||||
mut before_stage: impl FnMut(SwapDatabaseStage) -> Result<(), String>,
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
if !destination_path.exists() {
|
||||
return Ok(None);
|
||||
@@ -369,7 +269,6 @@ impl AnalysisCache {
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
checkpoint_wal_conn(&conn, "pre-swap").map_err(|e| e.to_string())?;
|
||||
let old_conn = std::mem::replace(&mut *conn, tmp);
|
||||
drop(old_conn);
|
||||
|
||||
@@ -380,62 +279,28 @@ impl AnalysisCache {
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("audio-analysis.sqlite")
|
||||
));
|
||||
let mut active_backed_up = false;
|
||||
let mut destination_activated = false;
|
||||
let swap_result = (|| {
|
||||
remove_db_with_sidecars(&backup)?;
|
||||
if active_path.exists() {
|
||||
before_stage(SwapDatabaseStage::BackupActive)?;
|
||||
fs::rename(active_path, &backup).map_err(|e| e.to_string())?;
|
||||
active_backed_up = true;
|
||||
move_sidecar(active_path, &backup, "-wal")?;
|
||||
move_sidecar(active_path, &backup, "-shm")?;
|
||||
}
|
||||
before_stage(SwapDatabaseStage::ActivateDestination)?;
|
||||
fs::rename(destination_path, active_path).map_err(|e| e.to_string())?;
|
||||
destination_activated = true;
|
||||
move_sidecar(destination_path, active_path, "-wal")?;
|
||||
move_sidecar(destination_path, active_path, "-shm")?;
|
||||
|
||||
before_stage(SwapDatabaseStage::Open)?;
|
||||
let mut reopened = Connection::open(active_path).map_err(|e| e.to_string())?;
|
||||
before_stage(SwapDatabaseStage::Configure)?;
|
||||
configure_connection(&reopened).map_err(|e| e.to_string())?;
|
||||
before_stage(SwapDatabaseStage::Migrate)?;
|
||||
run_migrations(&mut reopened).map_err(|e| e.to_string())?;
|
||||
verify_operational_schema_conn(&reopened)?;
|
||||
checkpoint_wal_conn(&reopened, "swap").map_err(|e| e.to_string())?;
|
||||
Ok(reopened)
|
||||
})();
|
||||
|
||||
match swap_result {
|
||||
Ok(reopened) => {
|
||||
*conn = reopened;
|
||||
Ok(Some(backup))
|
||||
}
|
||||
Err(error) => match recover_failed_swap(
|
||||
active_path,
|
||||
destination_path,
|
||||
&backup,
|
||||
active_backed_up,
|
||||
destination_activated,
|
||||
) {
|
||||
Ok(reopened) => {
|
||||
*conn = reopened;
|
||||
Err(error)
|
||||
}
|
||||
Err(rollback_error) => Err(format!(
|
||||
"analysis database swap failed: {error}; rollback failed: {rollback_error}"
|
||||
)),
|
||||
},
|
||||
remove_db_with_sidecars(&backup).ok();
|
||||
if active_path.exists() {
|
||||
fs::rename(active_path, &backup).map_err(|e| e.to_string())?;
|
||||
move_sidecar(active_path, &backup, "-wal")?;
|
||||
move_sidecar(active_path, &backup, "-shm")?;
|
||||
}
|
||||
if let Err(err) = fs::rename(destination_path, active_path) {
|
||||
if backup.exists() {
|
||||
let _ = fs::rename(&backup, active_path);
|
||||
let _ = move_sidecar(&backup, active_path, "-wal");
|
||||
let _ = move_sidecar(&backup, active_path, "-shm");
|
||||
}
|
||||
return Err(err.to_string());
|
||||
}
|
||||
let mut reopened = Connection::open(active_path).map_err(|e| e.to_string())?;
|
||||
configure_connection(&reopened).map_err(|e| e.to_string())?;
|
||||
run_migrations(&mut reopened).map_err(|e| e.to_string())?;
|
||||
*conn = reopened;
|
||||
Ok(Some(backup))
|
||||
}
|
||||
|
||||
pub fn restore_database_backup(
|
||||
&self,
|
||||
backup_path: &Path,
|
||||
active_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
pub fn restore_database_backup(&self, backup_path: &Path, active_path: &Path) -> Result<(), String> {
|
||||
let mut conn = self
|
||||
.conn
|
||||
.lock()
|
||||
@@ -455,13 +320,15 @@ impl AnalysisCache {
|
||||
let mut reopened = Connection::open(active_path).map_err(|e| e.to_string())?;
|
||||
configure_connection(&reopened).map_err(|e| e.to_string())?;
|
||||
run_migrations(&mut reopened).map_err(|e| e.to_string())?;
|
||||
verify_operational_schema_conn(&reopened)?;
|
||||
*conn = reopened;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop analysis rows written under legacy server ids (profile UUIDs).
|
||||
pub fn migrate_server_keys(&self, mappings: &[(String, String)]) -> Result<(), String> {
|
||||
pub fn migrate_server_keys(
|
||||
&self,
|
||||
mappings: &[(String, String)],
|
||||
) -> Result<(), String> {
|
||||
let mut conn = self
|
||||
.conn
|
||||
.lock()
|
||||
@@ -473,21 +340,12 @@ impl AnalysisCache {
|
||||
if legacy.is_empty() || key.is_empty() || legacy == key {
|
||||
continue;
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM waveform_cache WHERE server_id = ?1",
|
||||
params![legacy],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute(
|
||||
"DELETE FROM loudness_cache WHERE server_id = ?1",
|
||||
params![legacy],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute(
|
||||
"DELETE FROM analysis_track WHERE server_id = ?1",
|
||||
params![legacy],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM waveform_cache WHERE server_id = ?1", params![legacy])
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM loudness_cache WHERE server_id = ?1", params![legacy])
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM analysis_track WHERE server_id = ?1", params![legacy])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
@@ -495,10 +353,7 @@ impl AnalysisCache {
|
||||
|
||||
pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> {
|
||||
let now = now_unix_ts();
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO analysis_track (
|
||||
@@ -525,10 +380,7 @@ impl AnalysisCache {
|
||||
}
|
||||
|
||||
pub fn upsert_waveform(&self, key: &TrackKey, entry: &WaveformEntry) -> Result<(), String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO waveform_cache (
|
||||
@@ -559,10 +411,7 @@ impl AnalysisCache {
|
||||
}
|
||||
|
||||
pub fn upsert_loudness(&self, key: &TrackKey, entry: &LoudnessEntry) -> Result<(), String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO loudness_cache (
|
||||
@@ -590,10 +439,7 @@ impl AnalysisCache {
|
||||
}
|
||||
|
||||
pub fn get_waveform(&self, key: &TrackKey) -> Result<Option<WaveformEntry>, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let row = conn
|
||||
.query_row(
|
||||
r#"
|
||||
@@ -659,10 +505,7 @@ impl AnalysisCache {
|
||||
/// True when this exact `(track_id, md5_16kb)` has a loudness row for the current algo version.
|
||||
/// Used after `delete_loudness_for_track_id`: waveform may still be cached, but EBU data was removed.
|
||||
pub fn loudness_row_exists_for_key(&self, key: &TrackKey) -> Result<bool, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let exists: i64 = conn
|
||||
.query_row(
|
||||
r#"
|
||||
@@ -679,12 +522,7 @@ impl AnalysisCache {
|
||||
AND a.loudness_algo_version = ?4
|
||||
)
|
||||
"#,
|
||||
params![
|
||||
key.server_id,
|
||||
key.track_id,
|
||||
key.md5_16kb,
|
||||
LOUDNESS_ALGO_VERSION
|
||||
],
|
||||
params![key.server_id, key.track_id, key.md5_16kb, LOUDNESS_ALGO_VERSION],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -697,10 +535,7 @@ impl AnalysisCache {
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<Option<WaveformEntry>, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
query_latest_waveform_scoped(&conn, server_id, track_id)
|
||||
}
|
||||
|
||||
@@ -710,10 +545,7 @@ impl AnalysisCache {
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
query_latest_md5_16kb_scoped(&conn, server_id, track_id)
|
||||
}
|
||||
|
||||
@@ -725,18 +557,12 @@ impl AnalysisCache {
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<Option<(String, i64)>, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
query_latest_status_scoped(&conn, server_id, track_id)
|
||||
}
|
||||
|
||||
pub fn count_failed_tracks(&self, server_id: &str) -> Result<i64, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
query_failed_tracks_count_scoped(&conn, server_id)
|
||||
}
|
||||
|
||||
@@ -745,10 +571,7 @@ impl AnalysisCache {
|
||||
server_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<FailedTrackEntry>, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
query_failed_tracks_scoped(&conn, server_id, limit)
|
||||
}
|
||||
|
||||
@@ -757,10 +580,7 @@ impl AnalysisCache {
|
||||
server_id: &str,
|
||||
track_ids: &[String],
|
||||
) -> Result<u64, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
if track_ids.is_empty() {
|
||||
let deleted = conn
|
||||
.execute(
|
||||
@@ -792,17 +612,11 @@ impl AnalysisCache {
|
||||
/// Both waveform and loudness rows exist for this `(server_id, track_id)` —
|
||||
/// a CPU seed from bytes/file would only decode the file to immediately skip
|
||||
/// with `SkippedWaveformCacheHit`.
|
||||
pub fn cpu_seed_redundant_for_track(
|
||||
&self,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
Ok(self
|
||||
.get_latest_waveform_for_track(server_id, track_id)?
|
||||
.is_some()
|
||||
&& self
|
||||
.get_latest_loudness_for_track(server_id, track_id)?
|
||||
.is_some())
|
||||
pub fn cpu_seed_redundant_for_track(&self, server_id: &str, track_id: &str) -> Result<bool, String> {
|
||||
Ok(
|
||||
self.get_latest_waveform_for_track(server_id, track_id)?.is_some()
|
||||
&& self.get_latest_loudness_for_track(server_id, track_id)?.is_some(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Latest loudness for `(server_id, track_id)`.
|
||||
@@ -811,12 +625,10 @@ impl AnalysisCache {
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<Option<LoudnessSnapshot>, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
query_latest_loudness_scoped(&conn, server_id, track_id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Server-scoped variant of the "latest waveform for this track" lookup: filters
|
||||
@@ -918,7 +730,9 @@ fn query_latest_status_scoped(
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(candidate) = row {
|
||||
let take = latest.as_ref().is_none_or(|(_, ts)| candidate.1 > *ts);
|
||||
let take = latest
|
||||
.as_ref()
|
||||
.is_none_or(|(_, ts)| candidate.1 > *ts);
|
||||
if take {
|
||||
latest = Some(candidate);
|
||||
}
|
||||
@@ -1032,7 +846,10 @@ fn query_latest_loudness_scoped(
|
||||
}
|
||||
|
||||
fn analysis_db_path<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Result<PathBuf, String> {
|
||||
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let base = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let db_dir = base.join("databases").join("analysis");
|
||||
let db_path = db_dir.join("audio-analysis.sqlite");
|
||||
let legacy_data = base.join("audio-analysis.sqlite");
|
||||
@@ -1131,37 +948,6 @@ fn move_sidecar(from_base: &Path, to_base: &Path, suffix: &str) -> Result<(), St
|
||||
fs::rename(from, to).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn recover_failed_swap(
|
||||
active_path: &Path,
|
||||
destination_path: &Path,
|
||||
backup_path: &Path,
|
||||
active_backed_up: bool,
|
||||
destination_activated: bool,
|
||||
) -> Result<Connection, String> {
|
||||
if destination_activated && active_path.exists() {
|
||||
let _ = remove_db_with_sidecars(destination_path);
|
||||
if fs::rename(active_path, destination_path).is_ok() {
|
||||
let _ = move_sidecar(active_path, destination_path, "-wal");
|
||||
let _ = move_sidecar(active_path, destination_path, "-shm");
|
||||
} else {
|
||||
let _ = remove_db_with_sidecars(active_path);
|
||||
}
|
||||
}
|
||||
if active_backed_up {
|
||||
remove_db_with_sidecars(active_path)?;
|
||||
fs::rename(backup_path, active_path).map_err(|e| e.to_string())?;
|
||||
move_sidecar(backup_path, active_path, "-wal")?;
|
||||
move_sidecar(backup_path, active_path, "-shm")?;
|
||||
}
|
||||
|
||||
let mut reopened = Connection::open(active_path).map_err(|e| e.to_string())?;
|
||||
configure_connection(&reopened).map_err(|e| e.to_string())?;
|
||||
run_migrations(&mut reopened).map_err(|e| e.to_string())?;
|
||||
verify_operational_schema_conn(&reopened)?;
|
||||
checkpoint_wal_conn(&reopened, "swap-rollback").map_err(|e| e.to_string())?;
|
||||
Ok(reopened)
|
||||
}
|
||||
|
||||
fn remove_db_with_sidecars(path: &Path) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||
@@ -1183,95 +969,6 @@ fn configure_connection(conn: &Connection) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_operational_schema_conn(conn: &Connection) -> Result<(), String> {
|
||||
let migration_head = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
|
||||
row.get::<_, Option<i64>>(0)
|
||||
})
|
||||
.map_err(|error| format!("analysis migration history unavailable: {error}"))?;
|
||||
if migration_head != Some(ANALYSIS_DB_SCHEMA_VERSION) {
|
||||
return Err(format!(
|
||||
"analysis schema migration head mismatch: expected {}, found {}",
|
||||
ANALYSIS_DB_SCHEMA_VERSION,
|
||||
migration_head
|
||||
.map(|version| version.to_string())
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
let mut missing = Vec::new();
|
||||
for table in &OPERATIONAL_TABLES {
|
||||
let present = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
|
||||
[table.name],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !present {
|
||||
missing.push(format!("table {}", table.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut statement = conn
|
||||
.prepare("SELECT name, pk FROM pragma_table_info(?1)")
|
||||
.map_err(|error| error.to_string())?;
|
||||
let columns: Vec<(String, i64)> = statement
|
||||
.query_map([table.name], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.map_err(|error| error.to_string())?
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|error| error.to_string())?;
|
||||
for required in table.columns {
|
||||
if !columns.iter().any(|(name, _)| name == *required) {
|
||||
missing.push(format!("column {}.{required}", table.name));
|
||||
}
|
||||
}
|
||||
let mut actual_primary_key: Vec<(i64, String)> = columns
|
||||
.iter()
|
||||
.filter(|(_, ordinal)| *ordinal > 0)
|
||||
.map(|(name, ordinal)| (*ordinal, name.clone()))
|
||||
.collect();
|
||||
actual_primary_key.sort_by_key(|(ordinal, _)| *ordinal);
|
||||
let actual_primary_key: Vec<String> = actual_primary_key
|
||||
.into_iter()
|
||||
.map(|(_, name)| name)
|
||||
.collect();
|
||||
if actual_primary_key
|
||||
!= table
|
||||
.primary_key
|
||||
.iter()
|
||||
.map(|column| (*column).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
missing.push(format!(
|
||||
"primary key {} expected ({}) found ({})",
|
||||
table.name,
|
||||
table.primary_key.join(", "),
|
||||
actual_primary_key.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
for index in OPERATIONAL_INDEXES {
|
||||
let present = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?1)",
|
||||
[index],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !present {
|
||||
missing.push(format!("index {index}"));
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"analysis schema missing operational objects: {}",
|
||||
missing.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-shot safety net before the first table-rewriting migration (002
|
||||
/// `server_id`). Snapshots the existing DB via `VACUUM INTO` — a transactionally
|
||||
/// consistent copy even with WAL — to `<db>.pre-v<N>.bak`, so a catastrophic
|
||||
@@ -1459,49 +1156,6 @@ mod tests {
|
||||
"waveform_cache"
|
||||
]
|
||||
);
|
||||
drop(conn);
|
||||
cache.verify_operational_schema().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operational_schema_rejects_current_head_with_missing_table() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
{
|
||||
let conn = cache.conn.lock().unwrap();
|
||||
let head: i64 = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(head, ANALYSIS_DB_SCHEMA_VERSION);
|
||||
conn.execute_batch("DROP TABLE waveform_cache").unwrap();
|
||||
}
|
||||
|
||||
let error = cache.verify_operational_schema().unwrap_err();
|
||||
assert!(error.contains("table waveform_cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operational_schema_rejects_current_head_with_v1_table_shapes() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(MIGRATION_001_BASELINE).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
|
||||
INSERT INTO schema_migrations(version, applied_at) VALUES (1, 0), (2, 0);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = verify_operational_schema_conn(&conn).unwrap_err();
|
||||
for table in ["analysis_track", "waveform_cache", "loudness_cache"] {
|
||||
assert!(
|
||||
error.contains(&format!("column {table}.server_id")),
|
||||
"missing v2 column was not reported for {table}: {error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains(&format!("primary key {table}")),
|
||||
"v1 primary key was not rejected for {table}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── waveform roundtrip ────────────────────────────────────────────────────
|
||||
@@ -1619,13 +1273,8 @@ mod tests {
|
||||
cache.touch_track_status(&k, "ok").unwrap();
|
||||
cache.upsert_waveform(&k, &waveform(4, false)).unwrap();
|
||||
// Insert under stream:abc, look up with bare abc.
|
||||
let got = cache
|
||||
.get_latest_waveform_for_track("server-a", "abc")
|
||||
.unwrap();
|
||||
assert!(
|
||||
got.is_some(),
|
||||
"bare-id lookup must find stream-prefixed row"
|
||||
);
|
||||
let got = cache.get_latest_waveform_for_track("server-a", "abc").unwrap();
|
||||
assert!(got.is_some(), "bare-id lookup must find stream-prefixed row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1648,9 +1297,7 @@ mod tests {
|
||||
let k = key("abc");
|
||||
cache.touch_track_status(&k, "ok").unwrap();
|
||||
|
||||
assert!(!cache
|
||||
.cpu_seed_redundant_for_track("server-a", "abc")
|
||||
.unwrap());
|
||||
assert!(!cache.cpu_seed_redundant_for_track("server-a", "abc").unwrap());
|
||||
|
||||
cache.upsert_waveform(&k, &waveform(4, false)).unwrap();
|
||||
assert!(
|
||||
@@ -1681,10 +1328,7 @@ mod tests {
|
||||
let deleted = cache
|
||||
.delete_loudness_for_track_id("server-a", "abc")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
deleted, 2,
|
||||
"delete must remove both bare and stream:abc rows"
|
||||
);
|
||||
assert_eq!(deleted, 2, "delete must remove both bare and stream:abc rows");
|
||||
assert!(!cache.loudness_row_exists_for_key(&bare).unwrap());
|
||||
assert!(!cache.loudness_row_exists_for_key(&prefixed).unwrap());
|
||||
}
|
||||
@@ -1697,13 +1341,9 @@ mod tests {
|
||||
cache.touch_track_status(&bare, "ok").unwrap();
|
||||
cache.touch_track_status(&prefixed, "ok").unwrap();
|
||||
cache.upsert_waveform(&bare, &waveform(4, false)).unwrap();
|
||||
cache
|
||||
.upsert_waveform(&prefixed, &waveform(4, false))
|
||||
.unwrap();
|
||||
cache.upsert_waveform(&prefixed, &waveform(4, false)).unwrap();
|
||||
|
||||
let deleted = cache
|
||||
.delete_waveform_for_track_id("server-a", "abc")
|
||||
.unwrap();
|
||||
let deleted = cache.delete_waveform_for_track_id("server-a", "abc").unwrap();
|
||||
assert_eq!(deleted, 2);
|
||||
assert!(cache.get_waveform(&bare).unwrap().is_none());
|
||||
assert!(cache.get_waveform(&prefixed).unwrap().is_none());
|
||||
@@ -1791,10 +1431,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
versions,
|
||||
(1..=ANALYSIS_DB_SCHEMA_VERSION).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(versions, (1..=ANALYSIS_DB_SCHEMA_VERSION).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1816,11 +1453,7 @@ mod tests {
|
||||
// Same (track_id, md5_16kb) under two server ids are independent rows.
|
||||
let conn = cache.conn.lock().unwrap();
|
||||
let rows: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM analysis_track WHERE track_id='t'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.query_row("SELECT COUNT(*) FROM analysis_track WHERE track_id='t'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(rows, 2);
|
||||
}
|
||||
@@ -1841,9 +1474,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn backup_file(dir: &Path) -> PathBuf {
|
||||
dir.join(format!(
|
||||
"audio-analysis.sqlite.pre-v{ANALYSIS_DB_SCHEMA_VERSION}.bak"
|
||||
))
|
||||
dir.join(format!("audio-analysis.sqlite.pre-v{ANALYSIS_DB_SCHEMA_VERSION}.bak"))
|
||||
}
|
||||
|
||||
fn sqlite_sidecar(path: &Path, suffix: &str) -> PathBuf {
|
||||
@@ -1857,7 +1488,6 @@ mod tests {
|
||||
let mut conn = Connection::open(db_path).unwrap();
|
||||
configure_connection(&conn).unwrap();
|
||||
run_migrations(&mut conn).unwrap();
|
||||
verify_operational_schema_conn(&conn).unwrap();
|
||||
AnalysisCache {
|
||||
conn: Mutex::new(conn),
|
||||
}
|
||||
@@ -1913,10 +1543,7 @@ mod tests {
|
||||
let dir = unique_temp_dir("bkp-absent");
|
||||
let db_path = dir.join("audio-analysis.sqlite");
|
||||
backup_before_pending_migration(&db_path).unwrap();
|
||||
assert!(
|
||||
!backup_file(&dir).exists(),
|
||||
"no backup for a fresh (absent) DB"
|
||||
);
|
||||
assert!(!backup_file(&dir).exists(), "no backup for a fresh (absent) DB");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1942,9 +1569,7 @@ mod tests {
|
||||
let k = key("abc");
|
||||
cache.touch_track_status(&k, "queued").unwrap();
|
||||
|
||||
let none = cache
|
||||
.content_cache_coverage("server-a", "abc", "deadbeef")
|
||||
.unwrap();
|
||||
let none = cache.content_cache_coverage("server-a", "abc", "deadbeef").unwrap();
|
||||
assert!(!none.has_waveform);
|
||||
assert!(!none.has_loudness);
|
||||
assert!(!none.complete());
|
||||
@@ -1958,9 +1583,7 @@ mod tests {
|
||||
assert!(!only_waveform.complete());
|
||||
|
||||
cache.upsert_loudness(&k, &loudness(-14.0)).unwrap();
|
||||
let full = cache
|
||||
.content_cache_coverage("server-a", "abc", "deadbeef")
|
||||
.unwrap();
|
||||
let full = cache.content_cache_coverage("server-a", "abc", "deadbeef").unwrap();
|
||||
assert!(full.complete());
|
||||
}
|
||||
|
||||
@@ -1985,9 +1608,7 @@ mod tests {
|
||||
md5_16kb: "".to_string(),
|
||||
};
|
||||
cache.touch_track_status(&empty_md5, "ready").unwrap();
|
||||
cache
|
||||
.upsert_waveform(&empty_md5, &waveform(4, false))
|
||||
.unwrap();
|
||||
cache.upsert_waveform(&empty_md5, &waveform(4, false)).unwrap();
|
||||
assert!(
|
||||
cache
|
||||
.get_latest_md5_16kb_for_track("server-a", "t2")
|
||||
@@ -2055,9 +1676,7 @@ mod tests {
|
||||
let cache = open_file_cache(&active_path);
|
||||
let old_key = key_on("server-a", "old");
|
||||
cache.touch_track_status(&old_key, "ready").unwrap();
|
||||
cache
|
||||
.upsert_waveform(&old_key, &waveform(4, false))
|
||||
.unwrap();
|
||||
cache.upsert_waveform(&old_key, &waveform(4, false)).unwrap();
|
||||
|
||||
{
|
||||
let dst = open_file_cache(&destination_path);
|
||||
@@ -2079,14 +1698,8 @@ mod tests {
|
||||
.is_some(),
|
||||
"cache must reopen on swapped destination DB"
|
||||
);
|
||||
assert!(
|
||||
!destination_path.exists(),
|
||||
"destination DB must be moved into active path"
|
||||
);
|
||||
assert!(
|
||||
backup_path.exists(),
|
||||
"previous active DB must be moved to backup path"
|
||||
);
|
||||
assert!(!destination_path.exists(), "destination DB must be moved into active path");
|
||||
assert!(backup_path.exists(), "previous active DB must be moved to backup path");
|
||||
|
||||
cache
|
||||
.restore_database_backup(&backup_path, &active_path)
|
||||
@@ -2122,95 +1735,6 @@ mod tests {
|
||||
let _ = remove_db_with_sidecars(&active_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_database_file_restores_active_database_for_every_failure_stage() {
|
||||
for stage in [
|
||||
SwapDatabaseStage::BackupActive,
|
||||
SwapDatabaseStage::ActivateDestination,
|
||||
SwapDatabaseStage::Open,
|
||||
SwapDatabaseStage::Configure,
|
||||
SwapDatabaseStage::Migrate,
|
||||
] {
|
||||
let active_path = unique_temp_file("swap-fault-active");
|
||||
let destination_path = unique_temp_file("swap-fault-destination");
|
||||
let cache = open_file_cache(&active_path);
|
||||
let old_key = key_on("server-a", "old");
|
||||
cache.touch_track_status(&old_key, "ready").unwrap();
|
||||
cache.upsert_waveform(&old_key, &waveform(4, false)).unwrap();
|
||||
{
|
||||
let destination = open_file_cache(&destination_path);
|
||||
let new_key = key_on("server-a", "new");
|
||||
destination.touch_track_status(&new_key, "ready").unwrap();
|
||||
destination
|
||||
.upsert_waveform(&new_key, &waveform(4, false))
|
||||
.unwrap();
|
||||
destination.checkpoint_wal("fault-destination").unwrap();
|
||||
}
|
||||
|
||||
let error = cache
|
||||
.swap_database_file_with(&active_path, &destination_path, |current| {
|
||||
if current == stage {
|
||||
Err(format!("injected {stage:?} failure"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.contains("injected"));
|
||||
assert!(
|
||||
cache.get_waveform(&old_key).unwrap().is_some(),
|
||||
"live connection was not restored after {stage:?}"
|
||||
);
|
||||
assert!(
|
||||
cache
|
||||
.get_waveform(&key_on("server-a", "new"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"failed destination stayed live after {stage:?}"
|
||||
);
|
||||
let reopened = open_file_cache(&active_path);
|
||||
assert!(
|
||||
reopened.get_waveform(&old_key).unwrap().is_some(),
|
||||
"active file was not restored after {stage:?}"
|
||||
);
|
||||
|
||||
let _ = remove_db_with_sidecars(&active_path);
|
||||
let _ = remove_db_with_sidecars(&destination_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_database_file_rejects_current_head_v1_shape_and_restores_active() {
|
||||
let active_path = unique_temp_file("swap-invalid-active");
|
||||
let destination_path = unique_temp_file("swap-invalid-destination");
|
||||
let cache = open_file_cache(&active_path);
|
||||
let old_key = key_on("server-a", "old");
|
||||
cache.touch_track_status(&old_key, "ready").unwrap();
|
||||
cache.upsert_waveform(&old_key, &waveform(4, false)).unwrap();
|
||||
{
|
||||
let destination = Connection::open(&destination_path).unwrap();
|
||||
destination.execute_batch(MIGRATION_001_BASELINE).unwrap();
|
||||
destination
|
||||
.execute_batch(
|
||||
"CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
|
||||
INSERT INTO schema_migrations(version, applied_at) VALUES (2, 0);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let error = cache
|
||||
.swap_database_file(&active_path, &destination_path)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("column analysis_track.server_id"));
|
||||
assert!(cache.get_waveform(&old_key).unwrap().is_some());
|
||||
open_file_cache(&active_path)
|
||||
.verify_operational_schema()
|
||||
.unwrap();
|
||||
|
||||
let _ = remove_db_with_sidecars(&active_path);
|
||||
let _ = remove_db_with_sidecars(&destination_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_db_helpers_move_and_cleanup_sidecars() {
|
||||
let from = unique_temp_file("migrate-from");
|
||||
@@ -2246,9 +1770,18 @@ mod tests {
|
||||
fn run_migrations_with_applies_unsorted_versions_once() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let migrations = [
|
||||
(3, "CREATE TABLE IF NOT EXISTS m3 (id INTEGER PRIMARY KEY);"),
|
||||
(1, "CREATE TABLE IF NOT EXISTS m1 (id INTEGER PRIMARY KEY);"),
|
||||
(2, "CREATE TABLE IF NOT EXISTS m2 (id INTEGER PRIMARY KEY);"),
|
||||
(
|
||||
3,
|
||||
"CREATE TABLE IF NOT EXISTS m3 (id INTEGER PRIMARY KEY);",
|
||||
),
|
||||
(
|
||||
1,
|
||||
"CREATE TABLE IF NOT EXISTS m1 (id INTEGER PRIMARY KEY);",
|
||||
),
|
||||
(
|
||||
2,
|
||||
"CREATE TABLE IF NOT EXISTS m2 (id INTEGER PRIMARY KEY);",
|
||||
),
|
||||
];
|
||||
run_migrations_with(&mut conn, &migrations).unwrap();
|
||||
run_migrations_with(&mut conn, &migrations).unwrap();
|
||||
|
||||
@@ -17,7 +17,6 @@ use crate::analysis_perf::{emit_analysis_track_perf, AnalysisSeedTimings};
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WaveformUpdatedPayload {
|
||||
pub track_id: String,
|
||||
pub server_index_key: String,
|
||||
pub is_partial: bool,
|
||||
}
|
||||
|
||||
@@ -1421,7 +1420,6 @@ async fn spawn_cpu_seed_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisCpuSe
|
||||
let notify_ui = analysis_emits_ui_events(job.priority);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let sid = job.server_id.clone();
|
||||
let sid_for_event = sid.clone();
|
||||
let tid = job.track_id.clone();
|
||||
let bytes = job.bytes;
|
||||
let format_hint = job.format_hint;
|
||||
@@ -1483,7 +1481,6 @@ async fn spawn_cpu_seed_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisCpuSe
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload {
|
||||
track_id: tid_log.clone(),
|
||||
server_index_key: sid_for_event,
|
||||
is_partial: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ use crate::ipc::{
|
||||
pub(crate) fn emit_partial_loudness_from_bytes(
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
server_id: Option<&str>,
|
||||
bytes: &[u8],
|
||||
target_lufs: f32,
|
||||
pre_analysis_attenuation_db: f32,
|
||||
@@ -64,10 +63,6 @@ pub(crate) fn emit_partial_loudness_from_bytes(
|
||||
"analysis:loudness-partial",
|
||||
PartialLoudnessPayload {
|
||||
track_id: playback_identity(url),
|
||||
server_index_key: {
|
||||
let sid = crate::analysis_dispatch::resolve_server_id_for_app(app, server_id);
|
||||
(!sid.is_empty()).then_some(sid)
|
||||
},
|
||||
gain_db,
|
||||
target_lufs,
|
||||
is_partial: true,
|
||||
|
||||
@@ -7,7 +7,6 @@ use tauri::{AppHandle, Emitter};
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PartialLoudnessPayload {
|
||||
pub(crate) track_id: Option<String>,
|
||||
pub(crate) server_index_key: Option<String>,
|
||||
pub(crate) gain_db: f32,
|
||||
pub(crate) target_lufs: f32,
|
||||
pub(crate) is_partial: bool,
|
||||
|
||||
@@ -666,7 +666,6 @@ pub(crate) async fn ranged_download_task(
|
||||
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||
let url_for_emit = url.clone();
|
||||
let app_for_emit = app.clone();
|
||||
let server_id_for_emit = resolve_server_id_for_app(&app, server_id.as_deref());
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged dl start: total={} KiB (~{:.2} MiB)",
|
||||
@@ -706,7 +705,6 @@ pub(crate) async fn ranged_download_task(
|
||||
"analysis:loudness-partial",
|
||||
crate::ipc::PartialLoudnessPayload {
|
||||
track_id: crate::helpers::playback_identity(&url_for_emit),
|
||||
server_index_key: (!server_id_for_emit.is_empty()).then_some(server_id_for_emit.clone()),
|
||||
gain_db: provisional_db,
|
||||
target_lufs,
|
||||
is_partial: true,
|
||||
|
||||
@@ -150,14 +150,7 @@ pub(crate) async fn track_download_task(
|
||||
loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed),
|
||||
)
|
||||
.clamp(-24.0, 0.0);
|
||||
crate::helpers::emit_partial_loudness_from_bytes(
|
||||
&app,
|
||||
&url,
|
||||
server_id.as_deref(),
|
||||
&capture,
|
||||
target_lufs,
|
||||
pre_db,
|
||||
);
|
||||
crate::helpers::emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db);
|
||||
}
|
||||
}
|
||||
offset += pushed;
|
||||
|
||||
@@ -14,7 +14,7 @@ tauri = { version = "2" }
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.40", features = ["bundled", "functions"] }
|
||||
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"] }
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
-- Candidate-first New Releases: seek one selected library in descending
|
||||
-- server creation order, with album/track ids available for stable ties.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_library_created_album
|
||||
ON track(server_id, library_id, server_created_at DESC, album_id, id)
|
||||
WHERE deleted = 0
|
||||
AND server_created_at IS NOT NULL
|
||||
AND album_id IS NOT NULL
|
||||
AND album_id != '';
|
||||
|
||||
-- Owner-scoped ratings for tracks, albums, and artists. The composite primary
|
||||
-- key covers the batch cache lookups, so a secondary index is unnecessary.
|
||||
CREATE TABLE IF NOT EXISTS entity_user_rating (
|
||||
server_id TEXT NOT NULL,
|
||||
entity_kind TEXT NOT NULL CHECK (entity_kind IN ('track', 'album', 'artist')),
|
||||
entity_id TEXT NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, entity_kind, entity_id)
|
||||
);
|
||||
|
||||
-- Suffix-selective candidate index for local Lossless Albums browse.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_lossless_album_browse
|
||||
ON track(server_id, suffix COLLATE NOCASE, library_id, album_id)
|
||||
WHERE deleted = 0
|
||||
AND album_id IS NOT NULL
|
||||
AND album_id != ''
|
||||
AND suffix IS NOT NULL;
|
||||
@@ -1,33 +0,0 @@
|
||||
-- Materialized per-library album rows for candidate-first scoped browse. The
|
||||
-- track table remains authoritative; this table avoids GROUP BY track on every
|
||||
-- All Albums page request.
|
||||
CREATE TABLE IF NOT EXISTS album_browse_projection (
|
||||
server_id TEXT NOT NULL,
|
||||
library_id TEXT NOT NULL,
|
||||
album_id TEXT NOT NULL,
|
||||
identity_key TEXT,
|
||||
name TEXT NOT NULL,
|
||||
artist TEXT,
|
||||
artist_id TEXT,
|
||||
song_count INTEGER NOT NULL,
|
||||
duration_sec INTEGER NOT NULL,
|
||||
year INTEGER,
|
||||
genre TEXT,
|
||||
cover_art_id TEXT,
|
||||
starred_at INTEGER,
|
||||
synced_at INTEGER NOT NULL,
|
||||
representative_track_id TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, library_id, album_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_name
|
||||
ON album_browse_projection(server_id, library_id, name COLLATE NOCASE, album_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_artist
|
||||
ON album_browse_projection(server_id, library_id, artist COLLATE NOCASE, name COLLATE NOCASE, album_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_artist_year
|
||||
ON album_browse_projection(server_id, library_id, artist COLLATE NOCASE, year, name COLLATE NOCASE, album_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_identity
|
||||
ON album_browse_projection(server_id, library_id, identity_key);
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Ordinary Tracks browse is title-ordered per selected library. Keep the
|
||||
-- keyset cursor query on the partial live-track index rather than scanning
|
||||
-- the server-wide title index.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_library_title_id
|
||||
ON track(server_id, library_id, title COLLATE NOCASE, id)
|
||||
WHERE deleted = 0;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Unicode-lowercased display name for indexed album-credit matching.
|
||||
ALTER TABLE artist ADD COLUMN name_fold TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_name_fold ON artist(server_id, name_fold);
|
||||
@@ -1,3 +0,0 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_album_starred
|
||||
ON album(server_id, starred_at)
|
||||
WHERE starred_at IS NOT NULL;
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Materialized composer credits by album. Track raw_json remains authoritative;
|
||||
-- this compact table avoids JSON scans on every Composers browse/detail load.
|
||||
CREATE TABLE IF NOT EXISTS composer_album_projection (
|
||||
server_id TEXT NOT NULL,
|
||||
library_id TEXT NOT NULL,
|
||||
composer_id TEXT NOT NULL,
|
||||
composer_name TEXT NOT NULL,
|
||||
name_sort TEXT NOT NULL,
|
||||
identity_key TEXT NOT NULL,
|
||||
album_id TEXT NOT NULL,
|
||||
synced_at INTEGER NOT NULL,
|
||||
representative_track_id TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, library_id, composer_id, album_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_composer_album_projection_name
|
||||
ON composer_album_projection(server_id, library_id, name_sort, composer_id, album_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_composer_album_projection_identity
|
||||
ON composer_album_projection(server_id, library_id, identity_key, composer_id, album_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_composer_album_projection_album
|
||||
ON composer_album_projection(server_id, library_id, album_id);
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Durable invalidation journal for the derived cluster sidecar. Mutations write
|
||||
-- compact entity keys here in the same transaction as their authoritative rows;
|
||||
-- identity maintenance consumes them after sync or before a clustered read.
|
||||
CREATE TABLE IF NOT EXISTS identity_invalidation (
|
||||
server_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('server', 'track', 'album', 'artist')),
|
||||
entity_id TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (server_id, kind, entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_identity_invalidation_server
|
||||
ON identity_invalidation(server_id, kind);
|
||||
@@ -194,12 +194,12 @@ pub fn run_advanced_search(
|
||||
&req.server_id,
|
||||
req.library_scope.as_deref(),
|
||||
req.library_scopes.as_deref(),
|
||||
)?;
|
||||
);
|
||||
// Any >1-library scope dedups album/artist rows via cluster keys, including
|
||||
// the Layer-1 same-server path — build keys first so dedup works on a cold
|
||||
// index (idempotent; only rebuilds when needed).
|
||||
if multi_library_merge_enabled(&scope_pairs) {
|
||||
crate::scope_merge::ensure_cluster_keys_for_scopes(store, &scope_pairs)?;
|
||||
crate::identity::ensure_cluster_keys_built(store, &req.server_id)?;
|
||||
}
|
||||
if scoped_layer1_eligible(&scope_pairs) {
|
||||
return run_advanced_search_layer1_scope(
|
||||
@@ -229,7 +229,7 @@ pub fn run_advanced_search(
|
||||
let mut legacy = req.clone();
|
||||
if legacy.library_scope.is_none() {
|
||||
if let Some(pair) = scope_pairs.first() {
|
||||
legacy.library_scope = pair.library_id.clone();
|
||||
legacy.library_scope = Some(pair.library_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,38 +578,6 @@ fn build_multi_scope_artist(
|
||||
skip_totals: bool,
|
||||
applied: &mut BTreeSet<String>,
|
||||
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
|
||||
if album_artist_credit_mode(req) && !scalar_requires_track_derived_entities(scalar) {
|
||||
applied.insert("library_scope".to_string());
|
||||
applied.insert("artist_credit_mode".to_string());
|
||||
let mut filter = WhereBuilder::new();
|
||||
if let Some(bucket) = req.artist_letter_bucket.as_deref() {
|
||||
push_artist_letter_bucket(&mut filter, bucket, applied);
|
||||
}
|
||||
if let Some(query) = text {
|
||||
filter.push_param(
|
||||
"ar.name_fold LIKE ? ESCAPE '\\'",
|
||||
SqlValue::Text(like_contains_folded(query)),
|
||||
);
|
||||
applied.insert("text".to_string());
|
||||
}
|
||||
for clause in scalar {
|
||||
if let Some(fragment) = resolve_clause(clause, EntityKind::Artist)? {
|
||||
applied.insert(clause.field.clone());
|
||||
filter.push(fragment);
|
||||
}
|
||||
}
|
||||
let order = deduped_artist_order_sql(&req.sort);
|
||||
return scope_merge::list_index_artists_multi_scope_album_filtered(
|
||||
store,
|
||||
scopes,
|
||||
&filter.where_sql(),
|
||||
filter.params(),
|
||||
&order,
|
||||
limit,
|
||||
offset,
|
||||
skip_totals,
|
||||
);
|
||||
}
|
||||
let (extra_where, extra_params) = multi_scope_track_filter_sql(
|
||||
store,
|
||||
req,
|
||||
@@ -750,28 +718,10 @@ fn multi_scope_track_filter_sql(
|
||||
if let Some(t) = text {
|
||||
match text_entity {
|
||||
Some(EntityKind::Artist) => {
|
||||
if let Some(fts) = fts_column_prefix_query("artist", t) {
|
||||
let pool = fts_candidate_pool_size(req.limit, req.offset);
|
||||
let rowids = store.with_read_conn(|conn| {
|
||||
collect_scope_fts_rowids(conn, &fts, scopes, pool)
|
||||
})?;
|
||||
if rowids.is_empty() {
|
||||
w.push_raw("1 = 0");
|
||||
} else {
|
||||
let placeholders = std::iter::repeat_n("?", rowids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
w.push_params(
|
||||
&format!("t.rowid IN ({placeholders})"),
|
||||
rowids.into_iter().map(SqlValue::Integer).collect(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
w.push_param(
|
||||
"t.artist LIKE ? ESCAPE '\\'",
|
||||
SqlValue::Text(like_contains_folded(t)),
|
||||
);
|
||||
}
|
||||
w.push_param(
|
||||
"t.artist LIKE ? ESCAPE '\\'",
|
||||
SqlValue::Text(like_contains_folded(t)),
|
||||
);
|
||||
applied.insert("text".to_string());
|
||||
}
|
||||
Some(EntityKind::Album) | None => {
|
||||
@@ -1190,14 +1140,11 @@ fn push_artist_library_scope_pairs(
|
||||
pairs: &[LibraryScopePair],
|
||||
applied: &mut BTreeSet<String>,
|
||||
) {
|
||||
// A whole-server pair needs no additional predicate because this query is
|
||||
// already pinned to `ar.server_id`. Empty-string ids remain exact scopes.
|
||||
if pairs.iter().any(|pair| pair.library_id.is_none()) {
|
||||
return;
|
||||
}
|
||||
let scoped: Vec<&String> = pairs
|
||||
// Pairs may carry profile or index `server_id`; this query is already pinned to
|
||||
// one server via `ar.server_id = ?`, so only drop empty library ids.
|
||||
let scoped: Vec<&LibraryScopePair> = pairs
|
||||
.iter()
|
||||
.filter_map(|pair| pair.library_id.as_ref())
|
||||
.filter(|p| !p.library_id.trim().is_empty())
|
||||
.collect();
|
||||
if scoped.is_empty() {
|
||||
return;
|
||||
@@ -1208,7 +1155,7 @@ fn push_artist_library_scope_pairs(
|
||||
let clause = library_scope_sargable_equals_sql("t");
|
||||
w.push_params(
|
||||
&format!("{exists_prefix}{clause})"),
|
||||
vec![SqlValue::Text(scoped[0].to_string())],
|
||||
vec![SqlValue::Text(scoped[0].library_id.clone())],
|
||||
);
|
||||
} else {
|
||||
let in_clause = library_scope_in_sql("t", scoped.len());
|
||||
@@ -1216,25 +1163,20 @@ fn push_artist_library_scope_pairs(
|
||||
&format!("{exists_prefix}{in_clause})"),
|
||||
scoped
|
||||
.iter()
|
||||
.map(|library_id| SqlValue::Text((*library_id).clone()))
|
||||
.map(|p| SqlValue::Text(p.library_id.clone()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
applied.insert("library_scope".to_string());
|
||||
}
|
||||
|
||||
fn push_artist_library_scope(
|
||||
w: &mut WhereBuilder,
|
||||
req: &LibraryAdvancedSearchRequest,
|
||||
applied: &mut BTreeSet<String>,
|
||||
) -> Result<(), String> {
|
||||
fn push_artist_library_scope(w: &mut WhereBuilder, req: &LibraryAdvancedSearchRequest, applied: &mut BTreeSet<String>) {
|
||||
let pairs = ordered_library_scope_pairs(
|
||||
&req.server_id,
|
||||
req.library_scope.as_deref(),
|
||||
req.library_scopes.as_deref(),
|
||||
)?;
|
||||
);
|
||||
push_artist_library_scope_pairs(w, &req.server_id, &pairs, applied);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1391,7 +1333,7 @@ fn build_artist_from_table(
|
||||
}
|
||||
let mut w = WhereBuilder::new();
|
||||
w.push_param("ar.server_id = ?", SqlValue::Text(req.server_id.clone()));
|
||||
push_artist_library_scope(&mut w, req, applied)?;
|
||||
push_artist_library_scope(&mut w, req, applied);
|
||||
if album_artist_credit_mode(req) {
|
||||
w.push_raw("ar.album_count IS NOT NULL");
|
||||
applied.insert("artist_credit_mode".to_string());
|
||||
@@ -2029,7 +1971,7 @@ where
|
||||
F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
|
||||
{
|
||||
let where_sql = w.where_sql();
|
||||
store.with_scope_detail_read_conn(|conn| {
|
||||
store.with_read_conn(|conn| {
|
||||
let total = if skip_totals {
|
||||
0u32
|
||||
} else {
|
||||
@@ -2427,19 +2369,9 @@ mod tests {
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"INSERT INTO artist (server_id, id, name, name_sort, name_fold, album_count, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, '{}')",
|
||||
rusqlite::params![
|
||||
server,
|
||||
id,
|
||||
name,
|
||||
crate::artist_sort::sort_key_for_display_name(
|
||||
name,
|
||||
crate::artist_sort::DEFAULT_IGNORED_ARTICLES,
|
||||
),
|
||||
name.trim().to_lowercase(),
|
||||
album_count,
|
||||
],
|
||||
"INSERT INTO artist (server_id, id, name, album_count, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, ?3, ?4, 1, '{}')",
|
||||
rusqlite::params![server, id, name, album_count],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -3663,7 +3595,7 @@ mod tests {
|
||||
fn scope_pair(server: &str, lib: &str) -> crate::dto::LibraryScopePair {
|
||||
crate::dto::LibraryScopePair {
|
||||
server_id: server.into(),
|
||||
library_id: Some(lib.into()),
|
||||
library_id: lib.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3691,23 +3623,6 @@ mod tests {
|
||||
|
||||
fn seed_and_rebuild(store: &LibraryStore, rows: &[TrackRow]) {
|
||||
TrackRepository::new(store).upsert_batch(rows).unwrap();
|
||||
store
|
||||
.with_conn_mut("test.seed_scoped_artists", |conn| {
|
||||
for row in rows {
|
||||
let (Some(artist_id), Some(artist)) =
|
||||
(row.artist_id.as_deref(), row.artist.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO artist (server_id, id, name, synced_at) VALUES (?1, ?2, ?3, 1) \
|
||||
ON CONFLICT(server_id, id) DO NOTHING",
|
||||
rusqlite::params![&row.server_id, artist_id, artist],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
crate::identity::rebuild_cluster_keys(store, None).unwrap();
|
||||
}
|
||||
|
||||
@@ -3835,111 +3750,6 @@ mod tests {
|
||||
assert_eq!(resp.artists.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_scope_album_artist_search_matches_cyrillic_prefixes() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artist_with_album_count(&store, "s1", "ar_kino", "Кино", Some(1));
|
||||
insert_artist_with_album_count(&store, "s2", "ar_kinoproby", "Кинопробы", Some(1));
|
||||
let mut kino = scoped_track(
|
||||
"s1", "t-kino", "Song", "Кино", "Album", "alb-kino", "lib-a", None, None, None,
|
||||
);
|
||||
kino.artist_id = Some("ar_kino".into());
|
||||
let mut kinoproby = scoped_track(
|
||||
"s2", "t-kinoproby", "Song", "Кинопробы", "Album", "alb-kinoproby", "lib-b", None, None, None,
|
||||
);
|
||||
kinoproby.artist_id = Some("ar_kinoproby".into());
|
||||
TrackRepository::new(&store).upsert_batch(&[kino, kinoproby]).unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Artist]);
|
||||
r.library_scopes = Some(vec![scope_pair("s1", "lib-a"), scope_pair("s2", "lib-b")]);
|
||||
r.artist_credit_mode = Some(ArtistCreditMode::Album);
|
||||
r.query = Some("Кино".into());
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.artists.iter().map(|artist| artist.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["ar_kino", "ar_kinoproby"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_scope_track_artist_search_matches_cyrillic_prefixes() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut kino = scoped_track(
|
||||
"s1", "t-kino", "Song", "Кино", "Album", "alb-kino", "lib-a", None, None, None,
|
||||
);
|
||||
kino.artist_id = Some("ar_kino".into());
|
||||
let mut kinoproby = scoped_track(
|
||||
"s2", "t-kinoproby", "Song", "Кинопробы", "Album", "alb-kinoproby", "lib-b", None, None, None,
|
||||
);
|
||||
kinoproby.artist_id = Some("ar_kinoproby".into());
|
||||
TrackRepository::new(&store).upsert_batch(&[kino, kinoproby]).unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Artist]);
|
||||
r.library_scopes = Some(vec![scope_pair("s1", "lib-a"), scope_pair("s2", "lib-b")]);
|
||||
r.artist_credit_mode = Some(ArtistCreditMode::Track);
|
||||
r.query = Some("Кино".into());
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.artists.iter().map(|artist| artist.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["ar_kino", "ar_kinoproby"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer1_album_artist_search_matches_cyrillic_credit_case_variants() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artist_with_album_count(&store, "s1", "ar_kino", "Кино", Some(1));
|
||||
insert_artist_with_album_count(&store, "s1", "ar_kinoproby", "КИНО-пробы", Some(1));
|
||||
let mut kino = scoped_track(
|
||||
"s1", "t-kino", "Song", "Кино", "Album", "alb-kino", "lib-a", None, None, None,
|
||||
);
|
||||
kino.artist_id = Some("ar_kino".into());
|
||||
let mut kinoproby = scoped_track(
|
||||
"s1", "t-kinoproby", "Song", "Кино-пробы", "Album", "alb-kinoproby", "lib-b", None, None, None,
|
||||
);
|
||||
kinoproby.artist_id = Some("ar_kinoproby".into());
|
||||
TrackRepository::new(&store).upsert_batch(&[kino, kinoproby]).unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Artist]);
|
||||
r.library_scopes = Some(vec![scope_pair("s1", "lib-a"), scope_pair("s1", "lib-b")]);
|
||||
r.artist_credit_mode = Some(ArtistCreditMode::Album);
|
||||
r.query = Some("Кино".into());
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.artists.iter().map(|artist| artist.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["ar_kino", "ar_kinoproby"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_server_album_artist_search_uses_album_credit_not_track_performer() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artist_with_album_count(&store, "s1", "ar-kino", "КИНО-пробы", Some(1));
|
||||
insert_artist_with_album_count(&store, "s2", "ar-other", "Other", Some(1));
|
||||
let mut sampler_track = scoped_track(
|
||||
"s1", "t-sampler", "Song", "Guest Performer", "Tribute", "alb-tribute", "lib-a", None, None, None,
|
||||
);
|
||||
sampler_track.artist_id = Some("ar-guest".into());
|
||||
sampler_track.album_artist = Some("Кино-пробы".into());
|
||||
let mut other_track = scoped_track(
|
||||
"s2", "t-other", "Song", "Other", "Other Album", "alb-other", "lib-b", None, None, None,
|
||||
);
|
||||
other_track.artist_id = Some("ar-other".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[sampler_track, other_track])
|
||||
.unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Artist]);
|
||||
r.library_scopes = Some(vec![scope_pair("s1", "lib-a"), scope_pair("s2", "lib-b")]);
|
||||
r.artist_credit_mode = Some(ArtistCreditMode::Album);
|
||||
r.query = Some("Кино".into());
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.artists.iter().map(|artist| artist.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["ar-kino"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_scope_track_browse_without_cluster_keys_returns_scoped_tracks() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -1,819 +0,0 @@
|
||||
//! Materialized browse rows maintained alongside track ingest.
|
||||
//!
|
||||
//! The `track` catalog remains authoritative. These compact rows avoid grouping
|
||||
//! every track in a selected library before the first All Albums page can render.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Transaction};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::repos::TrackRow;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
pub(crate) type AlbumScope = (String, String, String);
|
||||
pub const MIGRATION_ID: &str = "scope_browse_album_projection_v1";
|
||||
const BACKFILL_BATCH_SIZE: i64 = 10_000;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScopeBrowseProjectionInspectDto {
|
||||
pub needed: bool,
|
||||
pub total_tracks: u64,
|
||||
pub done_tracks: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScopeBrowseProjectionProgressEvent {
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
fn add_scope(
|
||||
scopes: &mut HashSet<AlbumScope>,
|
||||
server_id: &str,
|
||||
library_id: Option<String>,
|
||||
album_id: Option<String>,
|
||||
) {
|
||||
let Some(album_id) = album_id.filter(|id| !id.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
scopes.insert((
|
||||
server_id.to_string(),
|
||||
library_id.unwrap_or_default(),
|
||||
album_id,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn collect_album_scopes_for_track_ids(
|
||||
tx: &Transaction<'_>,
|
||||
server_id: &str,
|
||||
track_ids: &[String],
|
||||
) -> rusqlite::Result<HashSet<AlbumScope>> {
|
||||
let mut scopes = HashSet::new();
|
||||
let mut statement = tx.prepare_cached(
|
||||
"SELECT library_id, album_id FROM track WHERE server_id = ?1 AND id = ?2",
|
||||
)?;
|
||||
for track_id in track_ids {
|
||||
if let Some((library_id, album_id)) = statement
|
||||
.query_row(params![server_id, track_id], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})
|
||||
.optional()?
|
||||
{
|
||||
add_scope(&mut scopes, server_id, library_id, album_id);
|
||||
}
|
||||
}
|
||||
Ok(scopes)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_library_tagged_albums(
|
||||
tx: &Transaction<'_>,
|
||||
server_id: &str,
|
||||
library_id: &str,
|
||||
album_ids: &[String],
|
||||
) -> rusqlite::Result<()> {
|
||||
let mut scopes = HashSet::new();
|
||||
for album_id in album_ids {
|
||||
add_scope(
|
||||
&mut scopes,
|
||||
server_id,
|
||||
Some(String::new()),
|
||||
Some(album_id.clone()),
|
||||
);
|
||||
add_scope(
|
||||
&mut scopes,
|
||||
server_id,
|
||||
Some(library_id.to_string()),
|
||||
Some(album_id.clone()),
|
||||
);
|
||||
}
|
||||
refresh_album_scopes(tx, scopes)
|
||||
}
|
||||
|
||||
/// Capture old and incoming album owners before a track batch changes them.
|
||||
pub(crate) fn collect_affected_album_scopes(
|
||||
tx: &Transaction<'_>,
|
||||
rows: &[TrackRow],
|
||||
) -> rusqlite::Result<HashSet<AlbumScope>> {
|
||||
let mut scopes = HashSet::new();
|
||||
let mut previous = tx.prepare_cached(
|
||||
"SELECT library_id, album_id FROM track WHERE server_id = ?1 AND id = ?2",
|
||||
)?;
|
||||
for row in rows {
|
||||
if let Some((library_id, album_id)) = previous
|
||||
.query_row(params![row.server_id, row.id], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.optional()?
|
||||
{
|
||||
add_scope(&mut scopes, &row.server_id, library_id, album_id);
|
||||
}
|
||||
add_scope(
|
||||
&mut scopes,
|
||||
&row.server_id,
|
||||
row.library_id.clone(),
|
||||
row.album_id.clone(),
|
||||
);
|
||||
}
|
||||
Ok(scopes)
|
||||
}
|
||||
|
||||
/// Recompute only albums affected by a single track ingest transaction.
|
||||
pub(crate) fn refresh_album_scopes(
|
||||
tx: &Transaction<'_>,
|
||||
scopes: HashSet<AlbumScope>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let mut delete = tx.prepare_cached(
|
||||
"DELETE FROM album_browse_projection \
|
||||
WHERE server_id = ?1 AND library_id = ?2 AND album_id = ?3",
|
||||
)?;
|
||||
let mut insert = tx.prepare_cached(
|
||||
"INSERT INTO album_browse_projection ( \
|
||||
server_id, library_id, album_id, name, artist, artist_id, song_count, \
|
||||
duration_sec, year, genre, cover_art_id, starred_at, synced_at, representative_track_id \
|
||||
) \
|
||||
SELECT t.server_id, COALESCE(t.library_id, ''), t.album_id, MAX(t.album), \
|
||||
MAX(COALESCE(NULLIF(TRIM(t.album_artist), ''), 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), MIN(t.id) \
|
||||
FROM track t \
|
||||
WHERE t.server_id = ?1 AND COALESCE(t.library_id, '') = ?2 AND t.album_id = ?3 \
|
||||
AND t.deleted = 0 \
|
||||
GROUP BY t.server_id, COALESCE(t.library_id, ''), t.album_id",
|
||||
)?;
|
||||
let mut update_identity = tx.prepare_cached(
|
||||
"UPDATE album_browse_projection SET identity_key = ?4 \
|
||||
WHERE server_id = ?1 AND library_id = ?2 AND album_id = ?3",
|
||||
)?;
|
||||
for (server_id, library_id, album_id) in &scopes {
|
||||
delete.execute(params![server_id, library_id, album_id])?;
|
||||
insert.execute(params![server_id, library_id, album_id])?;
|
||||
let identity_key = crate::identity::concrete_physical_album_key(server_id, album_id);
|
||||
update_identity.execute(params![server_id, library_id, album_id, identity_key])?;
|
||||
}
|
||||
crate::composer_projection::refresh_album_scopes(tx, &scopes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full resync can tombstone arbitrary old rows, so rebuild one server's compact
|
||||
/// projection after its orphan sweep instead of leaving deleted albums visible.
|
||||
pub(crate) fn rebuild_server(tx: &Transaction<'_>, server_id: &str) -> rusqlite::Result<()> {
|
||||
tx.execute(
|
||||
"DELETE FROM album_browse_projection WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO album_browse_projection ( \
|
||||
server_id, library_id, album_id, name, artist, artist_id, song_count, \
|
||||
duration_sec, year, genre, cover_art_id, starred_at, synced_at, representative_track_id \
|
||||
) \
|
||||
SELECT t.server_id, COALESCE(t.library_id, ''), t.album_id, MAX(t.album), \
|
||||
MAX(COALESCE(NULLIF(TRIM(t.album_artist), ''), 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), MIN(t.id) \
|
||||
FROM track t \
|
||||
WHERE t.server_id = ?1 AND t.deleted = 0 AND t.album_id IS NOT NULL AND t.album_id != '' \
|
||||
GROUP BY t.server_id, COALESCE(t.library_id, ''), t.album_id",
|
||||
params![server_id],
|
||||
)?;
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT library_id, album_id FROM album_browse_projection WHERE server_id = ?1",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![server_id], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
drop(stmt);
|
||||
let mut update = tx.prepare_cached(
|
||||
"UPDATE album_browse_projection SET identity_key = ?4 \
|
||||
WHERE server_id = ?1 AND library_id = ?2 AND album_id = ?3",
|
||||
)?;
|
||||
for (library_id, album_id) in rows {
|
||||
let identity_key = crate::identity::concrete_physical_album_key(server_id, &album_id);
|
||||
update.execute(params![server_id, library_id, album_id, identity_key])?;
|
||||
}
|
||||
crate::composer_projection::rebuild_scope(tx, server_id, "")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep materialized album browse partitions aligned with the cluster sidecar.
|
||||
/// Every physical album gets one unanimous cluster key or a server-qualified fallback.
|
||||
pub(crate) fn reconcile_identity_keys(
|
||||
tx: &Transaction<'_>,
|
||||
server_id: Option<&str>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let server_filter = if server_id.is_some() {
|
||||
" AND ap.server_id = ?1"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let sql = format!(
|
||||
"WITH resolved AS MATERIALIZED ( \
|
||||
SELECT ap.server_id, ap.library_id, ap.album_id, \
|
||||
COALESCE(( \
|
||||
SELECT CASE \
|
||||
WHEN COUNT(*) > 0 \
|
||||
AND COUNT(*) = COUNT(ck.album_key) \
|
||||
AND COUNT(DISTINCT ck.album_key) = 1 \
|
||||
THEN MAX(ck.album_key) \
|
||||
END \
|
||||
FROM track t \
|
||||
LEFT JOIN cluster.track_cluster_key ck \
|
||||
ON ck.server_id = t.server_id AND ck.track_id = t.id \
|
||||
WHERE t.server_id = ap.server_id \
|
||||
AND t.album_id = ap.album_id AND t.deleted = 0 \
|
||||
), 'physical:' || length(ap.server_id) || ':' || ap.server_id || ':' || ap.album_id) \
|
||||
AS identity_key \
|
||||
FROM album_browse_projection ap \
|
||||
WHERE EXISTS ( \
|
||||
SELECT 1 FROM track t \
|
||||
WHERE t.server_id = ap.server_id AND t.album_id = ap.album_id AND t.deleted = 0 \
|
||||
){server_filter} \
|
||||
) \
|
||||
UPDATE album_browse_projection AS ap \
|
||||
SET identity_key = resolved.identity_key \
|
||||
FROM resolved \
|
||||
WHERE ap.server_id = resolved.server_id \
|
||||
AND ap.library_id = resolved.library_id \
|
||||
AND ap.album_id = resolved.album_id \
|
||||
AND ap.identity_key IS NOT resolved.identity_key"
|
||||
);
|
||||
match server_id {
|
||||
Some(server_id) => tx.execute(&sql, params![server_id])?,
|
||||
None => tx.execute(&sql, [])?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh only physical albums named by the durable identity invalidation journal.
|
||||
/// Artist invalidations expand to every physical album that currently references
|
||||
/// that artist because canonical album identity depends on unanimous artist ids.
|
||||
pub(crate) fn reconcile_invalidated_identity_keys(
|
||||
tx: &Transaction<'_>,
|
||||
server_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
tx.execute(
|
||||
"WITH invalidated_artist AS MATERIALIZED ( \
|
||||
SELECT entity_id FROM identity_invalidation \
|
||||
WHERE server_id = ?1 AND kind = 'artist' \
|
||||
), \
|
||||
invalidated_album AS MATERIALIZED ( \
|
||||
SELECT entity_id FROM identity_invalidation \
|
||||
WHERE server_id = ?1 AND kind = 'album' \
|
||||
UNION \
|
||||
SELECT DISTINCT t.album_id FROM track t \
|
||||
JOIN invalidated_artist ia ON ia.entity_id = t.artist_id \
|
||||
WHERE t.server_id = ?1 AND t.deleted = 0 \
|
||||
AND t.album_id IS NOT NULL AND t.album_id != '' \
|
||||
), \
|
||||
resolved AS MATERIALIZED ( \
|
||||
SELECT ap.server_id, ap.library_id, ap.album_id, \
|
||||
COALESCE(( \
|
||||
SELECT CASE \
|
||||
WHEN COUNT(*) > 0 \
|
||||
AND COUNT(*) = COUNT(ck.album_key) \
|
||||
AND COUNT(DISTINCT ck.album_key) = 1 \
|
||||
THEN MAX(ck.album_key) \
|
||||
END \
|
||||
FROM track t \
|
||||
LEFT JOIN cluster.track_cluster_key ck \
|
||||
ON ck.server_id = t.server_id AND ck.track_id = t.id \
|
||||
WHERE t.server_id = ap.server_id \
|
||||
AND t.album_id = ap.album_id AND t.deleted = 0 \
|
||||
), 'physical:' || length(ap.server_id) || ':' || ap.server_id || ':' || ap.album_id) \
|
||||
AS identity_key \
|
||||
FROM album_browse_projection ap \
|
||||
JOIN invalidated_album ia ON ia.entity_id = ap.album_id \
|
||||
WHERE ap.server_id = ?1 \
|
||||
) \
|
||||
UPDATE album_browse_projection AS ap \
|
||||
SET identity_key = resolved.identity_key \
|
||||
FROM resolved \
|
||||
WHERE ap.server_id = resolved.server_id \
|
||||
AND ap.library_id = resolved.library_id \
|
||||
AND ap.album_id = resolved.album_id \
|
||||
AND ap.identity_key IS NOT resolved.identity_key",
|
||||
params![server_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the projection rows affected by an authoritative scope mutation.
|
||||
/// Empty scope means every library on the server; non-empty scope is exact.
|
||||
pub(crate) fn rebuild_scope(
|
||||
tx: &Transaction<'_>,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
if library_scope.is_empty() {
|
||||
return rebuild_server(tx, server_id);
|
||||
}
|
||||
let mut scopes = HashSet::new();
|
||||
for sql in [
|
||||
"SELECT album_id FROM album_browse_projection \
|
||||
WHERE server_id = ?1 AND library_id = ?2",
|
||||
"SELECT DISTINCT album_id FROM track \
|
||||
WHERE server_id = ?1 AND library_id = ?2 AND deleted = 0 \
|
||||
AND album_id IS NOT NULL AND album_id != ''",
|
||||
"SELECT DISTINCT album_id FROM composer_album_projection \
|
||||
WHERE server_id = ?1 AND library_id = ?2",
|
||||
] {
|
||||
let mut statement = tx.prepare(sql)?;
|
||||
let album_ids = statement
|
||||
.query_map(params![server_id, library_scope], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
for album_id in album_ids {
|
||||
add_scope(
|
||||
&mut scopes,
|
||||
server_id,
|
||||
Some(library_scope.to_string()),
|
||||
Some(album_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
refresh_album_scopes(tx, scopes)
|
||||
}
|
||||
|
||||
fn migration_completed(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let completed: Option<Option<i64>> = conn
|
||||
.query_row(
|
||||
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
|
||||
params![MIGRATION_ID],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(completed.flatten().is_some())
|
||||
}
|
||||
|
||||
fn cursor_rowid(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT cursor_rowid FROM library_data_migration WHERE id = ?1",
|
||||
params![MIGRATION_ID],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|cursor| cursor.unwrap_or(0))
|
||||
}
|
||||
|
||||
fn inspect_album(store: &LibraryStore) -> Result<ScopeBrowseProjectionInspectDto, String> {
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let total: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM track WHERE deleted = 0", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
if total == 0 || migration_completed(conn)? {
|
||||
return Ok(ScopeBrowseProjectionInspectDto {
|
||||
needed: false,
|
||||
total_tracks: total.max(0) as u64,
|
||||
done_tracks: total.max(0) as u64,
|
||||
});
|
||||
}
|
||||
let migration_started: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM library_data_migration WHERE id = ?1)",
|
||||
params![MIGRATION_ID],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let has_projection: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM album_browse_projection)",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !migration_started && has_projection {
|
||||
return Ok(ScopeBrowseProjectionInspectDto {
|
||||
needed: false,
|
||||
total_tracks: total.max(0) as u64,
|
||||
done_tracks: total.max(0) as u64,
|
||||
});
|
||||
}
|
||||
let cursor = cursor_rowid(conn)?;
|
||||
let done: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
|
||||
params![cursor],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(ScopeBrowseProjectionInspectDto {
|
||||
needed: true,
|
||||
total_tracks: total.max(0) as u64,
|
||||
done_tracks: done.max(0) as u64,
|
||||
})
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn inspect(store: &LibraryStore) -> Result<ScopeBrowseProjectionInspectDto, String> {
|
||||
let album = inspect_album(store)?;
|
||||
let composer = crate::composer_projection::inspect(store)?;
|
||||
let pending = [album.clone(), composer.clone()]
|
||||
.into_iter()
|
||||
.filter(|item| item.needed)
|
||||
.collect::<Vec<_>>();
|
||||
if pending.is_empty() {
|
||||
return Ok(ScopeBrowseProjectionInspectDto {
|
||||
needed: false,
|
||||
total_tracks: album.total_tracks.max(composer.total_tracks),
|
||||
done_tracks: album.done_tracks.max(composer.done_tracks),
|
||||
});
|
||||
}
|
||||
Ok(ScopeBrowseProjectionInspectDto {
|
||||
needed: true,
|
||||
total_tracks: pending.iter().map(|item| item.total_tracks).max().unwrap_or(0),
|
||||
done_tracks: pending.iter().map(|item| item.done_tracks).min().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_ready(store: &LibraryStore) -> Result<bool, String> {
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
if migration_completed(conn)? {
|
||||
return Ok(true);
|
||||
}
|
||||
// Fresh installs have no legacy catalog to backfill: sync maintains
|
||||
// projection rows incrementally before the first browse request.
|
||||
let migration_started: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM library_data_migration WHERE id = ?1)",
|
||||
params![MIGRATION_ID],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if migration_started {
|
||||
return Ok(false);
|
||||
}
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM album_browse_projection)",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn run_backfill(store: &LibraryStore, app: &AppHandle) -> Result<(), String> {
|
||||
run_backfill_impl(store, Some(app))?;
|
||||
crate::composer_projection::run_backfill(store, Some(app))
|
||||
}
|
||||
|
||||
fn run_backfill_impl(store: &LibraryStore, app: Option<&AppHandle>) -> Result<(), String> {
|
||||
let inspect_result = inspect_album(store)?;
|
||||
if !inspect_result.needed {
|
||||
return Ok(());
|
||||
}
|
||||
loop {
|
||||
let (done, finished) = store.with_conn_mut("browse_projection.backfill", |conn| {
|
||||
if migration_completed(conn)? {
|
||||
return Ok((inspect_result.total_tracks, true));
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO library_data_migration (id, cursor_rowid, started_at) \
|
||||
VALUES (?1, 0, strftime('%s','now')) \
|
||||
ON CONFLICT(id) DO NOTHING",
|
||||
params![MIGRATION_ID],
|
||||
)?;
|
||||
let cursor = cursor_rowid(conn)?;
|
||||
let tx = conn.transaction()?;
|
||||
let rows = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT rowid, server_id, COALESCE(library_id, ''), album_id \
|
||||
FROM track WHERE deleted = 0 AND rowid > ?1 \
|
||||
ORDER BY rowid LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![cursor, BACKFILL_BATCH_SIZE], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, Option<String>>(3)?,
|
||||
))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
if let Some(last_rowid) = rows.last().map(|row| row.0) {
|
||||
let mut scopes = HashSet::new();
|
||||
for (_, server_id, library_id, album_id) in rows {
|
||||
add_scope(&mut scopes, &server_id, Some(library_id), album_id);
|
||||
}
|
||||
refresh_album_scopes(&tx, scopes)?;
|
||||
tx.execute(
|
||||
"UPDATE library_data_migration SET cursor_rowid = ?2 WHERE id = ?1",
|
||||
params![MIGRATION_ID, last_rowid],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
let done: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
|
||||
params![last_rowid],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok((done.max(0) as u64, false))
|
||||
} else {
|
||||
tx.execute(
|
||||
"UPDATE library_data_migration SET completed_at = strftime('%s','now') WHERE id = ?1",
|
||||
params![MIGRATION_ID],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok((inspect_result.total_tracks, true))
|
||||
}
|
||||
})?;
|
||||
if let Some(app) = app {
|
||||
app.emit(
|
||||
"scope_browse_projection:progress",
|
||||
ScopeBrowseProjectionProgressEvent {
|
||||
done,
|
||||
total: inspect_result.total_tracks,
|
||||
},
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
if finished {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::{
|
||||
LibraryScopeBrowseEntity, LibraryScopeBrowseRequest, LibraryScopePair, LibrarySortClause,
|
||||
SortDir,
|
||||
};
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
|
||||
fn track(id: &str, album_id: &str, album: &str, library_id: &str) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: "s1".into(),
|
||||
id: id.into(),
|
||||
title: id.into(),
|
||||
title_sort: None,
|
||||
artist: Some("Artist".into()),
|
||||
artist_id: Some("artist".into()),
|
||||
album: album.into(),
|
||||
album_id: Some(album_id.into()),
|
||||
album_artist: Some("Artist".into()),
|
||||
duration_sec: 120,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
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: Some(library_id.into()),
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn album_track(
|
||||
server_id: &str,
|
||||
id: &str,
|
||||
artist: &str,
|
||||
artist_id: &str,
|
||||
album_id: &str,
|
||||
album: &str,
|
||||
album_artist: &str,
|
||||
library_id: &str,
|
||||
) -> TrackRow {
|
||||
let mut row = track(id, album_id, album, library_id);
|
||||
row.server_id = server_id.into();
|
||||
row.artist = Some(artist.into());
|
||||
row.artist_id = Some(artist_id.into());
|
||||
row.album_artist = Some(album_artist.into());
|
||||
row
|
||||
}
|
||||
|
||||
fn insert_artist(store: &LibraryStore, server_id: &str, artist_id: &str, name: &str) {
|
||||
store
|
||||
.with_conn_mut("test.browse_projection.artist", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO artist(server_id, id, name, synced_at) VALUES (?1, ?2, ?3, 1)",
|
||||
params![server_id, artist_id, name],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn browse_albums(
|
||||
store: &LibraryStore,
|
||||
scopes: Vec<LibraryScopePair>,
|
||||
) -> Vec<crate::dto::LibraryAlbumDto> {
|
||||
crate::scope_browse::browse(
|
||||
store,
|
||||
&LibraryScopeBrowseRequest {
|
||||
entity: LibraryScopeBrowseEntity::Album,
|
||||
scopes,
|
||||
sort: vec![LibrarySortClause {
|
||||
field: "name".into(),
|
||||
dir: SortDir::Asc,
|
||||
}],
|
||||
limit: 20,
|
||||
cursor: None,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.albums
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_refreshes_only_affected_album_projection() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track("t1", "a1", "Album One", "lib")])
|
||||
.unwrap();
|
||||
let name: String = store.with_read_conn(|conn| conn.query_row(
|
||||
"SELECT name FROM album_browse_projection WHERE server_id = 's1' AND library_id = 'lib' AND album_id = 'a1'",
|
||||
[], |row| row.get(0),
|
||||
)).unwrap();
|
||||
assert_eq!(name, "Album One");
|
||||
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track("t1", "a1", "Album Renamed", "lib")])
|
||||
.unwrap();
|
||||
let name: String = store.with_read_conn(|conn| conn.query_row(
|
||||
"SELECT name FROM album_browse_projection WHERE server_id = 's1' AND library_id = 'lib' AND album_id = 'a1'",
|
||||
[], |row| row.get(0),
|
||||
)).unwrap();
|
||||
assert_eq!(name, "Album Renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_processes_tracks_without_album_ids_before_advancing_cursor() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("no-album", "", "Ignored", "lib"),
|
||||
track("t1", "a1", "Album One", "lib"),
|
||||
])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn_mut("test.clear_projection_marker", |conn| {
|
||||
conn.execute("DELETE FROM album_browse_projection", [])?;
|
||||
conn.execute(
|
||||
"DELETE FROM library_data_migration WHERE id = ?1",
|
||||
params![MIGRATION_ID],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
run_backfill_impl(&store, None).unwrap();
|
||||
assert!(is_ready(&store).unwrap());
|
||||
let count: i64 = store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM album_browse_projection WHERE album_id = 'a1'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_browse_reconciles_partial_keys_to_one_canonical_album_partition() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artist(&store, "s1", "artist-1", "Metallica");
|
||||
insert_artist(&store, "s2", "artist-2", "Metallica");
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[album_track(
|
||||
"s1",
|
||||
"t1",
|
||||
"Metallica",
|
||||
"artist-1",
|
||||
"album-1",
|
||||
"S&M2",
|
||||
"Metallica & San Francisco Symphony",
|
||||
"lib-a",
|
||||
)])
|
||||
.unwrap();
|
||||
crate::identity::rebuild_cluster_keys(&store, None).unwrap();
|
||||
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
album_track(
|
||||
"s1", "t2", "Metallica", "artist-1", "album-1", "S&M2",
|
||||
"Metallica", "lib-b",
|
||||
),
|
||||
album_track(
|
||||
"s2", "t3", "Metallica", "artist-2", "album-2", "S&M2",
|
||||
"Metallica", "lib-c",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let albums = browse_albums(
|
||||
&store,
|
||||
vec![
|
||||
LibraryScopePair {
|
||||
server_id: "s1".into(),
|
||||
library_id: Some("lib-a".into()),
|
||||
},
|
||||
LibraryScopePair {
|
||||
server_id: "s1".into(),
|
||||
library_id: Some("lib-b".into()),
|
||||
},
|
||||
LibraryScopePair {
|
||||
server_id: "s2".into(),
|
||||
library_id: Some("lib-c".into()),
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(albums.len(), 1);
|
||||
assert_eq!(albums[0].server_id, "s1");
|
||||
assert_eq!(albums[0].id, "album-1");
|
||||
|
||||
let keys = store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT identity_key FROM album_browse_projection \
|
||||
WHERE album_id IN ('album-1', 'album-2')",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(keys.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_browse_keeps_ambiguous_physical_albums_separate() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
for (server, artist_id, name) in [
|
||||
("s1", "s1-a", "Artist A"),
|
||||
("s1", "s1-b", "Artist B"),
|
||||
("s2", "s2-a", "Artist A"),
|
||||
("s2", "s2-b", "Artist B"),
|
||||
] {
|
||||
insert_artist(&store, server, artist_id, name);
|
||||
}
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
album_track(
|
||||
"s1", "s1-t1", "Artist A", "s1-a", "s1-album", "Split",
|
||||
"Various Artists", "lib-a",
|
||||
),
|
||||
album_track(
|
||||
"s1", "s1-t2", "Artist B", "s1-b", "s1-album", "Split",
|
||||
"Various Artists", "lib-a",
|
||||
),
|
||||
album_track(
|
||||
"s2", "s2-t1", "Artist A", "s2-a", "s2-album", "Split",
|
||||
"Various Artists", "lib-b",
|
||||
),
|
||||
album_track(
|
||||
"s2", "s2-t2", "Artist B", "s2-b", "s2-album", "Split",
|
||||
"Various Artists", "lib-b",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let albums = browse_albums(
|
||||
&store,
|
||||
vec![
|
||||
LibraryScopePair {
|
||||
server_id: "s1".into(),
|
||||
library_id: Some("lib-a".into()),
|
||||
},
|
||||
LibraryScopePair {
|
||||
server_id: "s2".into(),
|
||||
library_id: Some("lib-b".into()),
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(albums.len(), 2);
|
||||
assert_eq!(
|
||||
albums
|
||||
.iter()
|
||||
.map(|album| album.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["s1-album", "s2-album"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,14 @@
|
||||
//! IS-3 bulk ingest tuning — drop write-heavy indexes, restore at end.
|
||||
//! IS-3 bulk ingest tuning — drop hot-path indexes, restore at end.
|
||||
//!
|
||||
//! Most secondary `track` indexes are unnecessary during the initial
|
||||
//! upsert-only pass but cost several index inserts per row. `idx_track_album`
|
||||
//! stays live because other servers can still browse while one server ingests.
|
||||
//! The suspended indexes are recreated once before FTS rebuild.
|
||||
//! Secondary `track` indexes (album/artist/remap/…) are useless during the
|
||||
//! initial upsert-only pass but cost several index inserts per row. Dropping
|
||||
//! them for IS-3 keeps batch writes flat into the tens-of-ms range on large
|
||||
//! libraries; they are recreated once before FTS rebuild.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
const TRACK_SECONDARY_INDEX_NAMES: [&str; 10] = [
|
||||
"idx_track_album",
|
||||
"idx_track_artist",
|
||||
"idx_track_updated",
|
||||
"idx_track_starred",
|
||||
"idx_track_library",
|
||||
"idx_track_bpm",
|
||||
"idx_track_isrc",
|
||||
"idx_track_remap_path",
|
||||
"idx_track_remap_hash",
|
||||
"idx_track_title",
|
||||
];
|
||||
|
||||
const DROP_TRACK_SECONDARY_INDEXES: &str = r#"
|
||||
DROP INDEX IF EXISTS idx_track_album;
|
||||
DROP INDEX IF EXISTS idx_track_artist;
|
||||
DROP INDEX IF EXISTS idx_track_updated;
|
||||
DROP INDEX IF EXISTS idx_track_starred;
|
||||
@@ -51,7 +39,7 @@ CREATE INDEX IF NOT EXISTS idx_track_title
|
||||
WHERE deleted = 0;
|
||||
"#;
|
||||
|
||||
/// Drop write-heavy secondary indexes on `track`; the album browse index stays.
|
||||
/// Drop secondary indexes on `track` so bulk upserts only touch the PK.
|
||||
pub fn suspend_track_secondary_indexes(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(DROP_TRACK_SECONDARY_INDEXES)
|
||||
}
|
||||
@@ -62,31 +50,6 @@ pub fn restore_track_secondary_indexes(conn: &Connection) -> rusqlite::Result<()
|
||||
conn.execute_batch(RESTORE_TRACK_SECONDARY_INDEXES)
|
||||
}
|
||||
|
||||
/// Idempotent open-time repair for a process that exited while bulk indexes
|
||||
/// were suspended.
|
||||
pub fn ensure_track_secondary_indexes(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
restore_track_secondary_indexes(&tx)?;
|
||||
tx.commit()
|
||||
}
|
||||
|
||||
pub(crate) fn missing_track_secondary_indexes(
|
||||
conn: &Connection,
|
||||
) -> rusqlite::Result<Vec<&'static str>> {
|
||||
let mut missing = Vec::new();
|
||||
for name in TRACK_SECONDARY_INDEX_NAMES {
|
||||
let present = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?1)",
|
||||
[name],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)?;
|
||||
if !present {
|
||||
missing.push(name);
|
||||
}
|
||||
}
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -98,16 +61,6 @@ mod tests {
|
||||
store
|
||||
.with_conn_mut("misc", |conn| {
|
||||
suspend_track_secondary_indexes(conn)?;
|
||||
let album_index_exists: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master \
|
||||
WHERE type = 'index' AND name = 'idx_track_album'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(
|
||||
album_index_exists, 1,
|
||||
"album reads stay indexed during ingest"
|
||||
);
|
||||
conn.execute(
|
||||
"INSERT INTO track (server_id, id, title, album, album_id, artist_id, \
|
||||
duration_sec, deleted, synced_at, raw_json) \
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,420 +0,0 @@
|
||||
//! Materialized composer credits maintained alongside album browse rows.
|
||||
|
||||
use std::collections::btree_map::Entry;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Transaction};
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::artist_sort::{sort_key_for_display_name, DEFAULT_IGNORED_ARTICLES};
|
||||
use crate::browse_projection::{AlbumScope, ScopeBrowseProjectionInspectDto, ScopeBrowseProjectionProgressEvent};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
pub const MIGRATION_ID: &str = "scope_browse_composer_projection_v1";
|
||||
const BACKFILL_BATCH_SIZE: i64 = 10_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ComposerCredit {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn trimmed_string(value: Option<&Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn extract_composer_credits(raw_json: &str) -> Vec<ComposerCredit> {
|
||||
let Ok(raw) = serde_json::from_str::<Value>(raw_json) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(contributors) = raw.get("contributors").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut credits = BTreeMap::<String, String>::new();
|
||||
for contributor in contributors {
|
||||
let Some(role) = trimmed_string(contributor.get("role")) else {
|
||||
continue;
|
||||
};
|
||||
if !role.eq_ignore_ascii_case("composer") {
|
||||
continue;
|
||||
}
|
||||
let artist = contributor.get("artist");
|
||||
let id = artist
|
||||
.and_then(|value| trimmed_string(value.get("id")))
|
||||
.or_else(|| trimmed_string(contributor.get("artistId")));
|
||||
let name = artist
|
||||
.and_then(|value| trimmed_string(value.get("name")))
|
||||
.or_else(|| trimmed_string(contributor.get("name")));
|
||||
if let (Some(id), Some(name)) = (id, name) {
|
||||
credits.entry(id).or_insert(name);
|
||||
}
|
||||
}
|
||||
credits
|
||||
.into_iter()
|
||||
.map(|(id, name)| ComposerCredit { id, name })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn identity_key(name: &str) -> String {
|
||||
crate::identity::norm_part(name).unwrap_or_else(|| name.trim().to_lowercase())
|
||||
}
|
||||
|
||||
fn insert_credit(
|
||||
insert: &mut rusqlite::CachedStatement<'_>,
|
||||
server_id: &str,
|
||||
library_id: &str,
|
||||
album_id: &str,
|
||||
credit: &ComposerCredit,
|
||||
synced_at: i64,
|
||||
track_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
insert.execute(params![
|
||||
server_id,
|
||||
library_id,
|
||||
credit.id,
|
||||
credit.name,
|
||||
sort_key_for_display_name(&credit.name, DEFAULT_IGNORED_ARTICLES),
|
||||
identity_key(&credit.name),
|
||||
album_id,
|
||||
synced_at,
|
||||
track_id,
|
||||
])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconcile_composer_metadata(
|
||||
tx: &Transaction<'_>,
|
||||
composers: HashSet<(String, String)>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let mut canonical = tx.prepare_cached(
|
||||
"SELECT composer_name FROM composer_album_projection \
|
||||
WHERE server_id = ?1 AND composer_id = ?2 \
|
||||
ORDER BY synced_at DESC, representative_track_id, album_id LIMIT 1",
|
||||
)?;
|
||||
let mut update = tx.prepare_cached(
|
||||
"UPDATE composer_album_projection \
|
||||
SET composer_name = ?3, name_sort = ?4, identity_key = ?5 \
|
||||
WHERE server_id = ?1 AND composer_id = ?2",
|
||||
)?;
|
||||
for (server_id, composer_id) in composers {
|
||||
let name: String = canonical.query_row(params![server_id, composer_id], |row| row.get(0))?;
|
||||
update.execute(params![
|
||||
server_id,
|
||||
composer_id,
|
||||
name,
|
||||
sort_key_for_display_name(&name, DEFAULT_IGNORED_ARTICLES),
|
||||
identity_key(&name),
|
||||
])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_album_scopes(
|
||||
tx: &Transaction<'_>,
|
||||
scopes: &HashSet<AlbumScope>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let mut delete = tx.prepare_cached(
|
||||
"DELETE FROM composer_album_projection \
|
||||
WHERE server_id = ?1 AND library_id = ?2 AND album_id = ?3",
|
||||
)?;
|
||||
let mut tracks = tx.prepare_cached(
|
||||
"SELECT id, synced_at, raw_json FROM track \
|
||||
WHERE server_id = ?1 AND COALESCE(library_id, '') = ?2 AND album_id = ?3 \
|
||||
AND deleted = 0 ORDER BY id",
|
||||
)?;
|
||||
let mut insert = tx.prepare_cached(
|
||||
"INSERT INTO composer_album_projection ( \
|
||||
server_id, library_id, composer_id, composer_name, name_sort, identity_key, \
|
||||
album_id, synced_at, representative_track_id \
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
let mut affected_composers = HashSet::new();
|
||||
|
||||
for (server_id, library_id, album_id) in scopes {
|
||||
delete.execute(params![server_id, library_id, album_id])?;
|
||||
let rows = tracks
|
||||
.query_map(params![server_id, library_id, album_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let mut composers = BTreeMap::<String, (ComposerCredit, i64, String)>::new();
|
||||
for (track_id, synced_at, raw_json) in rows {
|
||||
for credit in extract_composer_credits(&raw_json) {
|
||||
match composers.entry(credit.id.clone()) {
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert((credit, synced_at, track_id.clone()));
|
||||
}
|
||||
Entry::Occupied(mut entry)
|
||||
if synced_at > entry.get().1
|
||||
|| (synced_at == entry.get().1 && track_id < entry.get().2) =>
|
||||
{
|
||||
entry.insert((credit, synced_at, track_id.clone()));
|
||||
}
|
||||
Entry::Occupied(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, (credit, synced_at, track_id)) in composers {
|
||||
affected_composers.insert((server_id.clone(), credit.id.clone()));
|
||||
insert_credit(
|
||||
&mut insert,
|
||||
server_id,
|
||||
library_id,
|
||||
album_id,
|
||||
&credit,
|
||||
synced_at,
|
||||
&track_id,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
drop(insert);
|
||||
reconcile_composer_metadata(tx, affected_composers)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn rebuild_scope(
|
||||
tx: &Transaction<'_>,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
if library_scope.is_empty() {
|
||||
tx.execute(
|
||||
"DELETE FROM composer_album_projection WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
)?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"DELETE FROM composer_album_projection WHERE server_id = ?1 AND library_id = ?2",
|
||||
params![server_id, library_scope],
|
||||
)?;
|
||||
}
|
||||
|
||||
let sql = if library_scope.is_empty() {
|
||||
"SELECT COALESCE(library_id, ''), album_id, id, synced_at, raw_json \
|
||||
FROM track WHERE server_id = ?1 AND deleted = 0 \
|
||||
AND album_id IS NOT NULL AND album_id != '' \
|
||||
ORDER BY COALESCE(library_id, ''), album_id, id"
|
||||
} else {
|
||||
"SELECT COALESCE(library_id, ''), album_id, id, synced_at, raw_json \
|
||||
FROM track WHERE server_id = ?1 AND library_id = ?2 AND deleted = 0 \
|
||||
AND album_id IS NOT NULL AND album_id != '' \
|
||||
ORDER BY album_id, id"
|
||||
};
|
||||
let mut statement = tx.prepare(sql)?;
|
||||
let rows = if library_scope.is_empty() {
|
||||
statement
|
||||
.query_map(params![server_id], map_rebuild_track)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
} else {
|
||||
statement
|
||||
.query_map(params![server_id, library_scope], map_rebuild_track)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
drop(statement);
|
||||
|
||||
let mut composers = BTreeMap::<(String, String, String), (ComposerCredit, i64, String)>::new();
|
||||
for (library_id, album_id, track_id, synced_at, raw_json) in rows {
|
||||
for credit in extract_composer_credits(&raw_json) {
|
||||
let key = (library_id.clone(), album_id.clone(), credit.id.clone());
|
||||
match composers.entry(key) {
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert((credit, synced_at, track_id.clone()));
|
||||
}
|
||||
Entry::Occupied(mut entry)
|
||||
if synced_at > entry.get().1
|
||||
|| (synced_at == entry.get().1 && track_id < entry.get().2) =>
|
||||
{
|
||||
entry.insert((credit, synced_at, track_id.clone()));
|
||||
}
|
||||
Entry::Occupied(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut insert = tx.prepare_cached(
|
||||
"INSERT INTO composer_album_projection ( \
|
||||
server_id, library_id, composer_id, composer_name, name_sort, identity_key, \
|
||||
album_id, synced_at, representative_track_id \
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
let mut affected_composers = HashSet::new();
|
||||
for ((library_id, album_id, _), (credit, synced_at, track_id)) in composers {
|
||||
affected_composers.insert((server_id.to_string(), credit.id.clone()));
|
||||
insert_credit(
|
||||
&mut insert,
|
||||
server_id,
|
||||
&library_id,
|
||||
&album_id,
|
||||
&credit,
|
||||
synced_at,
|
||||
&track_id,
|
||||
)?;
|
||||
}
|
||||
drop(insert);
|
||||
reconcile_composer_metadata(tx, affected_composers)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_rebuild_track(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<(String, String, String, i64, String)> {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
|
||||
}
|
||||
|
||||
fn migration_completed(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let completed: Option<Option<i64>> = conn
|
||||
.query_row(
|
||||
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
|
||||
params![MIGRATION_ID],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(completed.flatten().is_some())
|
||||
}
|
||||
|
||||
fn cursor_rowid(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT cursor_rowid FROM library_data_migration WHERE id = ?1",
|
||||
params![MIGRATION_ID],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|cursor| cursor.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(crate) fn inspect(store: &LibraryStore) -> Result<ScopeBrowseProjectionInspectDto, String> {
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let total: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if total == 0 || migration_completed(conn)? {
|
||||
return Ok(ScopeBrowseProjectionInspectDto {
|
||||
needed: false,
|
||||
total_tracks: total.max(0) as u64,
|
||||
done_tracks: total.max(0) as u64,
|
||||
});
|
||||
}
|
||||
let migration_started: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM library_data_migration WHERE id = ?1)",
|
||||
params![MIGRATION_ID],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let has_projection: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM composer_album_projection)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !migration_started && has_projection {
|
||||
return Ok(ScopeBrowseProjectionInspectDto {
|
||||
needed: false,
|
||||
total_tracks: total.max(0) as u64,
|
||||
done_tracks: total.max(0) as u64,
|
||||
});
|
||||
}
|
||||
let cursor = cursor_rowid(conn)?;
|
||||
let done: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
|
||||
params![cursor],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(ScopeBrowseProjectionInspectDto {
|
||||
needed: true,
|
||||
total_tracks: total.max(0) as u64,
|
||||
done_tracks: done.max(0) as u64,
|
||||
})
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn run_backfill(store: &LibraryStore, app: Option<&AppHandle>) -> Result<(), String> {
|
||||
let inspect_result = inspect(store)?;
|
||||
if !inspect_result.needed {
|
||||
return Ok(());
|
||||
}
|
||||
loop {
|
||||
let (done, finished) = store.with_conn_mut("composer_projection.backfill", |conn| {
|
||||
if migration_completed(conn)? {
|
||||
return Ok((inspect_result.total_tracks, true));
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO library_data_migration (id, cursor_rowid, started_at) \
|
||||
VALUES (?1, 0, strftime('%s','now')) ON CONFLICT(id) DO NOTHING",
|
||||
params![MIGRATION_ID],
|
||||
)?;
|
||||
let cursor = cursor_rowid(conn)?;
|
||||
let tx = conn.transaction()?;
|
||||
let rows = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT rowid, server_id, COALESCE(library_id, ''), album_id \
|
||||
FROM track WHERE deleted = 0 AND rowid > ?1 ORDER BY rowid LIMIT ?2",
|
||||
)?;
|
||||
let mapped = stmt.query_map(params![cursor, BACKFILL_BATCH_SIZE], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
))
|
||||
})?;
|
||||
mapped.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
if let Some(last_rowid) = rows.last().map(|row| row.0) {
|
||||
let scopes = rows
|
||||
.into_iter()
|
||||
.filter_map(|(_, server_id, library_id, album_id)| {
|
||||
album_id
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(|album_id| (server_id, library_id, album_id))
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
refresh_album_scopes(&tx, &scopes)?;
|
||||
tx.execute(
|
||||
"UPDATE library_data_migration SET cursor_rowid = ?2 WHERE id = ?1",
|
||||
params![MIGRATION_ID, last_rowid],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
let done: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
|
||||
params![last_rowid],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok((done.max(0) as u64, false))
|
||||
} else {
|
||||
tx.execute(
|
||||
"UPDATE library_data_migration SET completed_at = strftime('%s','now') WHERE id = ?1",
|
||||
params![MIGRATION_ID],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok((inspect_result.total_tracks, true))
|
||||
}
|
||||
})?;
|
||||
if let Some(app) = app {
|
||||
app.emit(
|
||||
"scope_browse_projection:progress",
|
||||
ScopeBrowseProjectionProgressEvent {
|
||||
done,
|
||||
total: inspect_result.total_tracks,
|
||||
},
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
if finished {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "composer_projection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,156 +0,0 @@
|
||||
use super::*;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
|
||||
fn track(id: &str, album_id: &str, raw_json: Value) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: "s1".into(),
|
||||
id: id.into(),
|
||||
title: id.into(),
|
||||
title_sort: None,
|
||||
artist: Some("Performer".into()),
|
||||
artist_id: Some("performer".into()),
|
||||
album: "Album".into(),
|
||||
album_id: Some(album_id.into()),
|
||||
album_artist: Some("Performer".into()),
|
||||
duration_sec: 120,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
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: Some("lib".into()),
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: raw_json.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_nested_and_flat_composer_credits() {
|
||||
let raw = serde_json::json!({
|
||||
"contributors": [
|
||||
{ "role": " Composer ", "artist": { "id": "c1", "name": "One" } },
|
||||
{ "role": "COMPOSER", "artistId": "c2", "name": "Two" },
|
||||
{ "role": "producer", "artistId": "p1", "name": "Producer" },
|
||||
{ "role": "composer", "artistId": "", "name": "Missing" }
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
extract_composer_credits(&raw.to_string()),
|
||||
vec![
|
||||
ComposerCredit { id: "c1".into(), name: "One".into() },
|
||||
ComposerCredit { id: "c2".into(), name: "Two".into() },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_dedupes_album_credit_and_removes_stale_composer() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = TrackRepository::new(&store);
|
||||
let raw = serde_json::json!({
|
||||
"contributors": [{ "role": "composer", "artistId": "c1", "name": "Composer" }]
|
||||
});
|
||||
repo.upsert_batch(&[
|
||||
track("t1", "a1", raw.clone()),
|
||||
track("t2", "a1", raw),
|
||||
])
|
||||
.unwrap();
|
||||
let count: i64 = store
|
||||
.with_conn("test", |conn| {
|
||||
conn.query_row("SELECT COUNT(*) FROM composer_album_projection", [], |row| row.get(0))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
repo.upsert_batch(&[track("t1", "a1", serde_json::json!({}))])
|
||||
.unwrap();
|
||||
repo.upsert_batch(&[track("t2", "a1", serde_json::json!({}))])
|
||||
.unwrap();
|
||||
let count: i64 = store
|
||||
.with_conn("test", |conn| {
|
||||
conn.query_row("SELECT COUNT(*) FROM composer_album_projection", [], |row| row.get(0))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_follows_album_moves_and_tombstones() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = TrackRepository::new(&store);
|
||||
let raw = serde_json::json!({
|
||||
"contributors": [{ "role": "composer", "artistId": "c1", "name": "Composer" }]
|
||||
});
|
||||
let mut row = track("t1", "a1", raw);
|
||||
repo.upsert_batch(&[row.clone()]).unwrap();
|
||||
|
||||
row.album_id = Some("a2".into());
|
||||
row.album = "Moved Album".into();
|
||||
row.library_id = Some("lib2".into());
|
||||
repo.upsert_batch(&[row]).unwrap();
|
||||
let owners: Vec<(String, String)> = store
|
||||
.with_conn("test", |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT library_id, album_id FROM composer_album_projection ORDER BY library_id, album_id",
|
||||
)?;
|
||||
let mapped = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
|
||||
mapped.collect()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(owners, vec![("lib2".into(), "a2".into())]);
|
||||
|
||||
repo.apply_tombstone_results("s1", "", &[], &["t1".into()])
|
||||
.unwrap();
|
||||
let count: i64 = store
|
||||
.with_conn("test", |conn| {
|
||||
conn.query_row("SELECT COUNT(*) FROM composer_album_projection", [], |row| row.get(0))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_is_idempotent_and_marks_completion() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let raw = serde_json::json!({
|
||||
"contributors": [{ "role": "composer", "artistId": "c1", "name": "Composer" }]
|
||||
});
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track("t1", "a1", raw)])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn_mut("test", |conn| {
|
||||
conn.execute("DELETE FROM composer_album_projection", [])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(inspect(&store).unwrap().needed);
|
||||
run_backfill(&store, None).unwrap();
|
||||
run_backfill(&store, None).unwrap();
|
||||
assert!(!inspect(&store).unwrap().needed);
|
||||
let count: i64 = store
|
||||
.with_conn("test", |conn| {
|
||||
conn.query_row("SELECT COUNT(*) FROM composer_album_projection", [], |row| row.get(0))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
//! Priority-deduped composer browse and detail over selected library scopes.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use rusqlite::{params_from_iter, OptionalExtension};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dto::{
|
||||
LibraryAlbumDto, LibraryArtistDto, LibraryScopeComposerDetailRequest,
|
||||
LibraryScopeComposerDetailResponse, LibraryScopeListRequest, LibraryScopePair,
|
||||
};
|
||||
use crate::scope_merge::{non_empty_scopes, scope_cte_sql};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
fn parse_raw_json(raw: Option<String>) -> Value {
|
||||
raw.and_then(|value| serde_json::from_str(&value).ok())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
pub fn list_composers(
|
||||
store: &LibraryStore,
|
||||
request: &LibraryScopeListRequest,
|
||||
) -> Result<Vec<LibraryArtistDto>, String> {
|
||||
let scopes = non_empty_scopes(&request.scopes)?;
|
||||
crate::scope_merge::ensure_cluster_keys_for_scopes(store, scopes)?;
|
||||
let limit = request.limit.unwrap_or(10_000).clamp(1, 10_000);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
let (cte, mut binds) = scope_cte_sql(scopes);
|
||||
let order = match request.sort.as_deref().map(str::trim) {
|
||||
Some("albumCount") | Some("album_count") => {
|
||||
"ORDER BY g.album_count DESC, k.name_sort COLLATE NOCASE, k.composer_id"
|
||||
}
|
||||
_ => "ORDER BY k.name_sort COLLATE NOCASE, k.composer_id",
|
||||
};
|
||||
let sql = format!(
|
||||
"{cte}, \
|
||||
base AS MATERIALIZED ( \
|
||||
SELECT cp.server_id, cp.library_id, cp.composer_id, cp.composer_name, \
|
||||
cp.name_sort, cp.identity_key, cp.album_id, cp.synced_at, s.pr \
|
||||
FROM scope s CROSS JOIN composer_album_projection cp \
|
||||
ON cp.server_id = s.server_id AND cp.library_id = s.library_id \
|
||||
), \
|
||||
ambiguous AS ( \
|
||||
SELECT server_id, identity_key FROM base \
|
||||
GROUP BY server_id, identity_key HAVING COUNT(DISTINCT composer_id) > 1 \
|
||||
), \
|
||||
keyed AS ( \
|
||||
SELECT b.*, \
|
||||
CASE WHEN a.identity_key IS NULL THEN ('name:' || b.identity_key) \
|
||||
ELSE ('owner:' || b.server_id || ':' || b.composer_id) END AS dedup_key, \
|
||||
COALESCE(ap.identity_key, 'owner:' || b.server_id || ':' || b.album_id) AS album_key, \
|
||||
printf('%08d|%s|%s|%s|%s', b.pr, b.server_id, b.library_id, b.composer_id, b.album_id) AS pick_key \
|
||||
FROM base b \
|
||||
LEFT JOIN ambiguous a ON a.server_id = b.server_id AND a.identity_key = b.identity_key \
|
||||
LEFT JOIN album_browse_projection ap \
|
||||
ON ap.server_id = b.server_id AND ap.library_id = b.library_id AND ap.album_id = b.album_id \
|
||||
), \
|
||||
grouped AS ( \
|
||||
SELECT dedup_key, COUNT(DISTINCT album_key) AS album_count, \
|
||||
MAX(synced_at) AS synced_at, MIN(pick_key) AS pick_key \
|
||||
FROM keyed GROUP BY dedup_key \
|
||||
) \
|
||||
SELECT k.server_id, k.composer_id, k.composer_name, k.name_sort, \
|
||||
g.album_count, g.synced_at, ar.raw_json \
|
||||
FROM grouped g JOIN keyed k ON k.dedup_key = g.dedup_key AND k.pick_key = g.pick_key \
|
||||
LEFT JOIN artist ar ON ar.server_id = k.server_id AND ar.id = k.composer_id \
|
||||
{order} LIMIT ? OFFSET ?"
|
||||
);
|
||||
binds.push(SqlValue::Integer(i64::from(limit)));
|
||||
binds.push(SqlValue::Integer(i64::from(offset)));
|
||||
store.with_read_conn(|conn| {
|
||||
let mut statement = conn.prepare(&sql)?;
|
||||
let mapped = statement
|
||||
.query_map(params_from_iter(binds.iter()), |row| {
|
||||
Ok(LibraryArtistDto {
|
||||
server_id: row.get(0)?,
|
||||
id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
name_sort: Some(row.get(3)?),
|
||||
album_count: Some(row.get(4)?),
|
||||
synced_at: row.get(5)?,
|
||||
raw_json: parse_raw_json(row.get(6)?),
|
||||
})
|
||||
})?;
|
||||
mapped.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DetailRow {
|
||||
album_identity_key: Option<String>,
|
||||
album: LibraryAlbumDto,
|
||||
}
|
||||
|
||||
fn anchor_identity(
|
||||
store: &LibraryStore,
|
||||
scopes: &[LibraryScopePair],
|
||||
server_id: &str,
|
||||
composer_id: &str,
|
||||
) -> Result<(String, String, String, i64, Option<String>), String> {
|
||||
let (cte, mut binds) = scope_cte_sql(scopes);
|
||||
binds.push(SqlValue::Text(server_id.to_string()));
|
||||
binds.push(SqlValue::Text(composer_id.to_string()));
|
||||
let sql = format!(
|
||||
"{cte} \
|
||||
SELECT cp.identity_key, cp.composer_name, cp.name_sort, MAX(cp.synced_at), ar.raw_json \
|
||||
FROM scope s CROSS JOIN composer_album_projection cp \
|
||||
ON cp.server_id = s.server_id AND cp.library_id = s.library_id \
|
||||
LEFT JOIN artist ar ON ar.server_id = cp.server_id AND ar.id = cp.composer_id \
|
||||
WHERE cp.server_id = ? AND cp.composer_id = ? \
|
||||
GROUP BY cp.identity_key, cp.composer_name, cp.name_sort, ar.raw_json \
|
||||
ORDER BY MIN(s.pr) LIMIT 1"
|
||||
);
|
||||
store.with_scope_detail_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
&sql,
|
||||
params_from_iter(binds.iter()),
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or(rusqlite::Error::QueryReturnedNoRows)
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn anchor_is_ambiguous(
|
||||
store: &LibraryStore,
|
||||
scopes: &[LibraryScopePair],
|
||||
server_id: &str,
|
||||
identity_key: &str,
|
||||
) -> Result<bool, String> {
|
||||
let (cte, mut binds) = scope_cte_sql(scopes);
|
||||
binds.push(SqlValue::Text(server_id.to_string()));
|
||||
binds.push(SqlValue::Text(identity_key.to_string()));
|
||||
let sql = format!(
|
||||
"{cte} SELECT COUNT(DISTINCT cp.composer_id) > 1 \
|
||||
FROM scope s CROSS JOIN composer_album_projection cp \
|
||||
ON cp.server_id = s.server_id AND cp.library_id = s.library_id \
|
||||
WHERE cp.server_id = ? AND cp.identity_key = ?"
|
||||
);
|
||||
store
|
||||
.with_scope_detail_read_conn(|conn| {
|
||||
conn.query_row(&sql, params_from_iter(binds.iter()), |row| row.get(0))
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn composer_detail(
|
||||
store: &LibraryStore,
|
||||
request: &LibraryScopeComposerDetailRequest,
|
||||
) -> Result<LibraryScopeComposerDetailResponse, String> {
|
||||
let scopes = non_empty_scopes(&request.scopes)?;
|
||||
crate::scope_merge::ensure_cluster_keys_for_scopes(store, scopes)?;
|
||||
let server_id = request.server_id.trim();
|
||||
let composer_id = request.composer_id.trim();
|
||||
if server_id.is_empty() || composer_id.is_empty() {
|
||||
return Err("server_id and composer_id are required".into());
|
||||
}
|
||||
let (identity_key, name, name_sort, synced_at, raw_json) =
|
||||
anchor_identity(store, scopes, server_id, composer_id)?;
|
||||
let ambiguous = anchor_is_ambiguous(store, scopes, server_id, &identity_key)?;
|
||||
|
||||
let (cte, mut binds) = scope_cte_sql(scopes);
|
||||
binds.push(SqlValue::Integer(i64::from(ambiguous)));
|
||||
binds.push(SqlValue::Text(server_id.to_string()));
|
||||
binds.push(SqlValue::Text(composer_id.to_string()));
|
||||
binds.push(SqlValue::Text(identity_key));
|
||||
let sql = format!(
|
||||
"{cte} \
|
||||
SELECT cp.server_id, cp.composer_id, cp.composer_name, cp.name_sort, cp.synced_at, ar.raw_json, \
|
||||
ap.identity_key, ap.album_id, ap.name, ap.artist, ap.artist_id, ap.song_count, \
|
||||
ap.duration_sec, ap.year, ap.genre, ap.cover_art_id, ap.starred_at, ap.synced_at \
|
||||
FROM scope s CROSS JOIN composer_album_projection cp \
|
||||
ON cp.server_id = s.server_id AND cp.library_id = s.library_id \
|
||||
JOIN album_browse_projection ap \
|
||||
ON ap.server_id = cp.server_id AND ap.library_id = cp.library_id AND ap.album_id = cp.album_id \
|
||||
LEFT JOIN artist ar ON ar.server_id = cp.server_id AND ar.id = cp.composer_id \
|
||||
WHERE ( \
|
||||
(? = 1 AND cp.server_id = ? AND cp.composer_id = ?) OR \
|
||||
(? = 0 AND cp.identity_key = ? AND ( \
|
||||
SELECT COUNT(DISTINCT other.composer_id) \
|
||||
FROM scope other_scope CROSS JOIN composer_album_projection other \
|
||||
ON other.server_id = other_scope.server_id AND other.library_id = other_scope.library_id \
|
||||
WHERE other.server_id = cp.server_id AND other.identity_key = cp.identity_key \
|
||||
) = 1) \
|
||||
) \
|
||||
ORDER BY s.pr, ap.name COLLATE NOCASE, ap.album_id"
|
||||
);
|
||||
// The ambiguity flag is used twice in the WHERE clause.
|
||||
binds.insert(binds.len() - 1, SqlValue::Integer(i64::from(ambiguous)));
|
||||
|
||||
let rows = store.with_scope_detail_read_conn(|conn| {
|
||||
let mut statement = conn.prepare(&sql)?;
|
||||
let mapped = statement
|
||||
.query_map(params_from_iter(binds.iter()), |row| {
|
||||
Ok(DetailRow {
|
||||
album_identity_key: row.get(6)?,
|
||||
album: LibraryAlbumDto {
|
||||
server_id: row.get(0)?,
|
||||
id: row.get(7)?,
|
||||
name: row.get(8)?,
|
||||
artist: row.get(9)?,
|
||||
artist_id: row.get(10)?,
|
||||
song_count: Some(row.get(11)?),
|
||||
duration_sec: Some(row.get(12)?),
|
||||
year: row.get(13)?,
|
||||
genre: row.get(14)?,
|
||||
cover_art_id: row.get(15)?,
|
||||
starred_at: row.get(16)?,
|
||||
synced_at: row.get(17)?,
|
||||
raw_json: Value::Null,
|
||||
},
|
||||
})
|
||||
})?;
|
||||
mapped.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})?;
|
||||
|
||||
let mut seen_albums = HashSet::new();
|
||||
let mut albums = Vec::new();
|
||||
for row in rows {
|
||||
let album_key = row.album_identity_key.unwrap_or_else(|| {
|
||||
format!("owner:{}:{}", row.album.server_id, row.album.id)
|
||||
});
|
||||
if seen_albums.insert(album_key) {
|
||||
albums.push(row.album);
|
||||
}
|
||||
}
|
||||
Ok(LibraryScopeComposerDetailResponse {
|
||||
composer: LibraryArtistDto {
|
||||
server_id: server_id.to_string(),
|
||||
id: composer_id.to_string(),
|
||||
name,
|
||||
name_sort: Some(name_sort),
|
||||
album_count: Some(albums.len() as i64),
|
||||
synced_at,
|
||||
raw_json: parse_raw_json(raw_json),
|
||||
},
|
||||
albums,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
|
||||
fn track(server_id: &str, id: &str, album_id: &str, composer_id: &str, composer: &str) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server_id.into(),
|
||||
id: id.into(),
|
||||
title: id.into(),
|
||||
title_sort: None,
|
||||
artist: Some("Performer".into()),
|
||||
artist_id: Some("performer".into()),
|
||||
album: format!("Album {album_id}"),
|
||||
album_id: Some(album_id.into()),
|
||||
album_artist: Some("Performer".into()),
|
||||
duration_sec: 60,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
year: None,
|
||||
genre: None,
|
||||
suffix: None,
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: Some(album_id.into()),
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: Some("lib".into()),
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: serde_json::json!({
|
||||
"contributors": [{ "role": "composer", "artistId": composer_id, "name": composer }]
|
||||
})
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn scopes() -> Vec<LibraryScopePair> {
|
||||
vec![
|
||||
LibraryScopePair { server_id: "s1".into(), library_id: Some("lib".into()) },
|
||||
LibraryScopePair { server_id: "s2".into(), library_id: Some("lib".into()) },
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_server_scope_includes_empty_library_composers() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut empty = track("s1", "t-empty", "a-empty", "c-empty", "Empty Composer");
|
||||
empty.library_id = Some(String::new());
|
||||
let tagged = track("s1", "t-tagged", "a-tagged", "c-tagged", "Tagged Composer");
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[empty, tagged])
|
||||
.unwrap();
|
||||
|
||||
let composers = list_composers(
|
||||
&store,
|
||||
&LibraryScopeListRequest {
|
||||
scopes: vec![LibraryScopePair {
|
||||
server_id: "s1".into(),
|
||||
library_id: None,
|
||||
}],
|
||||
sort: None,
|
||||
limit: None,
|
||||
offset: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
composers.iter().map(|composer| composer.name.as_str()).collect::<Vec<_>>(),
|
||||
vec!["Empty Composer", "Tagged Composer"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browse_merges_unique_names_across_servers_and_detail_dedupes_album() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn_mut("test.composer_scope.artist", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO artist(server_id, id, name, synced_at) VALUES \
|
||||
('s1', 'performer', 'Performer', 1), \
|
||||
('s2', 'performer', 'Performer', 1)",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let repo = TrackRepository::new(&store);
|
||||
let mut first = track("s1", "t1", "a1", "c1", "Composer");
|
||||
first.album = "Shared Album".into();
|
||||
let mut second = track("s2", "t2", "a2", "c2", "Composer");
|
||||
second.album = "Shared Album".into();
|
||||
repo.upsert_batch(&[first, second]).unwrap();
|
||||
|
||||
let composers = list_composers(
|
||||
&store,
|
||||
&LibraryScopeListRequest { scopes: scopes(), sort: None, limit: None, offset: None },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(composers.len(), 1);
|
||||
assert_eq!(composers[0].server_id, "s1");
|
||||
assert_eq!(composers[0].album_count, Some(1));
|
||||
|
||||
let detail = composer_detail(
|
||||
&store,
|
||||
&LibraryScopeComposerDetailRequest {
|
||||
scopes: scopes(),
|
||||
composer_id: "c1".into(),
|
||||
server_id: "s1".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(detail.albums.len(), 1);
|
||||
assert_eq!(detail.composer.server_id, "s1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_same_server_name_stays_separate() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "a1", "c1", "Shared Name"),
|
||||
track("s1", "t2", "a2", "c2", "Shared Name"),
|
||||
track("s2", "t3", "a3", "c3", "Shared Name"),
|
||||
])
|
||||
.unwrap();
|
||||
let composers = list_composers(
|
||||
&store,
|
||||
&LibraryScopeListRequest { scopes: scopes(), sort: None, limit: None, offset: None },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(composers.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_composer_id_uses_one_name_and_keeps_every_album() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut renamed = track("s1", "t2", "a2", "c1", "New Name");
|
||||
renamed.synced_at = 2;
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "a1", "c1", "Old Name"),
|
||||
renamed,
|
||||
])
|
||||
.unwrap();
|
||||
let single_scope = vec![LibraryScopePair {
|
||||
server_id: "s1".into(),
|
||||
library_id: Some("lib".into()),
|
||||
}];
|
||||
let composers = list_composers(
|
||||
&store,
|
||||
&LibraryScopeListRequest {
|
||||
scopes: single_scope.clone(),
|
||||
sort: None,
|
||||
limit: None,
|
||||
offset: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(composers.len(), 1);
|
||||
assert_eq!(composers[0].album_count, Some(2));
|
||||
assert_eq!(composers[0].name, "New Name");
|
||||
|
||||
let detail = composer_detail(
|
||||
&store,
|
||||
&LibraryScopeComposerDetailRequest {
|
||||
scopes: single_scope,
|
||||
composer_id: "c1".into(),
|
||||
server_id: "s1".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(detail.albums.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -235,26 +235,6 @@ pub struct TrackRefDto {
|
||||
pub content_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Owner-scoped cache key for a user rating on a track, album, or artist.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EntityUserRatingRefDto {
|
||||
pub server_id: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
}
|
||||
|
||||
/// Cached user rating together with the time it was fetched from its owner.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EntityUserRatingDto {
|
||||
pub server_id: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub rating: i64,
|
||||
pub fetched_at: i64,
|
||||
}
|
||||
|
||||
/// Input to `library_put_artifact`. Same shape as `TrackArtifactDto`
|
||||
/// minus the server-supplied `server_id` / `track_id` (provided as
|
||||
/// command args) and `fetched_at` (stamped server-side from `now`).
|
||||
@@ -713,171 +693,11 @@ pub struct LibraryArtistLosslessBrowseResponse {
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One `(server_id, library_id)` pair in priority order (index 0 = highest).
|
||||
/// `None` selects every indexed library on the server; `Some("")` selects only
|
||||
/// concrete rows whose library id is empty.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopePair {
|
||||
pub server_id: String,
|
||||
pub library_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Entity kind accepted by `library_resolve_entity_sources`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LibrarySourceEntityType {
|
||||
Track,
|
||||
Album,
|
||||
Artist,
|
||||
}
|
||||
|
||||
/// Resolve one concrete browse entity to every matching concrete source in an
|
||||
/// explicitly ordered scope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryResolveEntitySourcesRequest {
|
||||
pub entity_type: LibrarySourceEntityType,
|
||||
pub anchor_server_id: String,
|
||||
pub anchor_id: String,
|
||||
pub scopes: Vec<LibraryScopePair>,
|
||||
}
|
||||
|
||||
/// Concrete source metadata for one browse identity partition. Identity keys
|
||||
/// remain internal so the frontend cannot persist raw cluster hashes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryEntitySourceDto {
|
||||
pub server_id: String,
|
||||
pub id: String,
|
||||
pub library_id: String,
|
||||
pub priority: u32,
|
||||
pub duration_sec: Option<i64>,
|
||||
pub suffix: Option<String>,
|
||||
pub bit_rate: Option<i64>,
|
||||
pub size_bytes: Option<i64>,
|
||||
pub starred_at: Option<i64>,
|
||||
pub user_rating: Option<i64>,
|
||||
}
|
||||
|
||||
/// One selected server and its optional music-folder filter for aggregate index reads.
|
||||
/// An empty `library_ids` list includes every indexed folder on that server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryStatisticsScope {
|
||||
pub server_id: String,
|
||||
#[serde(default)]
|
||||
pub library_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryStatisticsRequest {
|
||||
pub scopes: Vec<LibraryStatisticsScope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryStatisticsGenreDto {
|
||||
pub value: String,
|
||||
pub song_count: i64,
|
||||
pub album_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryStatisticsFormatDto {
|
||||
pub value: String,
|
||||
pub song_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryStatisticsDto {
|
||||
pub artist_count: i64,
|
||||
pub album_count: i64,
|
||||
pub song_count: i64,
|
||||
pub playtime_sec: i64,
|
||||
pub genres: Vec<LibraryStatisticsGenreDto>,
|
||||
pub formats: Vec<LibraryStatisticsFormatDto>,
|
||||
}
|
||||
|
||||
/// Ranked album row over the selected Statistics-style server/library scopes.
|
||||
/// Equivalent albums remain distinct across folders and servers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryMostPlayedAlbumDto {
|
||||
pub server_id: String,
|
||||
pub library_id: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub artist: String,
|
||||
pub artist_id: Option<String>,
|
||||
pub year: Option<i64>,
|
||||
pub cover_art_id: Option<String>,
|
||||
pub play_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryMostPlayedArtistDto {
|
||||
pub server_id: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub cover_art_id: Option<String>,
|
||||
pub play_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryMostPlayedRequest {
|
||||
pub scopes: Vec<LibraryStatisticsScope>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryMostPlayedResponse {
|
||||
pub albums: Vec<LibraryMostPlayedAlbumDto>,
|
||||
pub artists: Vec<LibraryMostPlayedArtistDto>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Entity surface served by the cursor-based scoped browse engine.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LibraryScopeBrowseEntity {
|
||||
Album,
|
||||
Artist,
|
||||
Track,
|
||||
}
|
||||
|
||||
/// Indexed catalogue browse request. FTS and arbitrary compound filters remain
|
||||
/// on `library_advanced_search`; this contract powers ordinary entity pages.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopeBrowseRequest {
|
||||
pub entity: LibraryScopeBrowseEntity,
|
||||
pub scopes: Vec<LibraryScopePair>,
|
||||
#[serde(default)]
|
||||
pub sort: Vec<LibrarySortClause>,
|
||||
pub limit: u32,
|
||||
#[serde(default)]
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopeBrowseResponse {
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub artists: Vec<LibraryArtistDto>,
|
||||
pub tracks: Vec<LibraryTrackDto>,
|
||||
#[serde(default)]
|
||||
pub next_cursor: Option<String>,
|
||||
pub has_more: bool,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// Derive ordered `(server_id, library_id)` pairs from request fields.
|
||||
@@ -886,26 +706,29 @@ pub(crate) fn ordered_library_scope_pairs(
|
||||
server_id: &str,
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: Option<&[LibraryScopePair]>,
|
||||
) -> Result<Vec<LibraryScopePair>, String> {
|
||||
) -> Vec<LibraryScopePair> {
|
||||
if let Some(scopes) = library_scopes {
|
||||
let pairs = crate::scope_merge::normalize_scope_pairs(scopes)?;
|
||||
let pairs: Vec<LibraryScopePair> = scopes
|
||||
.iter()
|
||||
.filter(|p| !p.server_id.trim().is_empty() && !p.library_id.trim().is_empty())
|
||||
.cloned()
|
||||
.collect();
|
||||
if !pairs.is_empty() {
|
||||
return Ok(pairs);
|
||||
return pairs;
|
||||
}
|
||||
}
|
||||
if let Some(scope) = library_scope.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
return Ok(vec![LibraryScopePair {
|
||||
return vec![LibraryScopePair {
|
||||
server_id: server_id.to_string(),
|
||||
library_id: Some(scope.to_string()),
|
||||
}]);
|
||||
library_id: scope.to_string(),
|
||||
}];
|
||||
}
|
||||
Ok(Vec::new())
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Layer-2 dedup runs for multiple sources and for a whole-server source, which
|
||||
/// may contain matching copies in several concrete libraries.
|
||||
/// Layer-2 dedup runs only when the ordered scope has more than one pair.
|
||||
pub(crate) fn multi_library_merge_enabled(scopes: &[LibraryScopePair]) -> bool {
|
||||
scopes.len() > 1 || scopes.iter().any(|pair| pair.library_id.is_none())
|
||||
scopes.len() > 1
|
||||
}
|
||||
|
||||
/// Layer-1 scoped browse (sargable `library_id`, no cluster join) for one server.
|
||||
@@ -915,7 +738,7 @@ pub(crate) fn scoped_layer1_eligible(scopes: &[LibraryScopePair]) -> bool {
|
||||
};
|
||||
scopes
|
||||
.iter()
|
||||
.all(|p| p.server_id == first.server_id && p.library_id.is_some())
|
||||
.all(|p| p.server_id == first.server_id && !p.library_id.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Paginated album/artist browse over an ordered multi-library scope.
|
||||
@@ -931,43 +754,6 @@ pub struct LibraryScopeListRequest {
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LibraryMainstageAlbumFeed {
|
||||
NewReleases,
|
||||
RecentlyPlayed,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Chronological album feed over one ordered multi-server library scope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryMainstageAlbumsRequest {
|
||||
pub scopes: Vec<LibraryScopePair>,
|
||||
pub feed: LibraryMainstageAlbumFeed,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<u32>,
|
||||
/// OR-matched atomic genres, applied before chronological album grouping.
|
||||
#[serde(default)]
|
||||
pub genres: Vec<String>,
|
||||
/// New Releases page needs genre filter counts; Home's chronological rail does not.
|
||||
#[serde(default = "default_true")]
|
||||
pub include_genre_counts: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryMainstageAlbumsResponse {
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub has_more: bool,
|
||||
pub genre_counts: Vec<GenreAlbumCountDto>,
|
||||
}
|
||||
|
||||
/// FTS track search over an ordered multi-library scope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -994,23 +780,6 @@ pub struct LibraryScopeArtistDetailRequest {
|
||||
pub scopes: Vec<LibraryScopePair>,
|
||||
pub artist_id: String,
|
||||
pub server_id: String,
|
||||
/// Detail views need tracks for top songs; discography-only callers can skip
|
||||
/// the extra scoped track query.
|
||||
#[serde(default = "default_true")]
|
||||
pub include_tracks: bool,
|
||||
/// When set, return only the highest personal-play-count tracks. The
|
||||
/// artist page uses this as a bounded fallback while server Top Songs load.
|
||||
#[serde(default)]
|
||||
pub top_tracks_limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// Aggregated composer detail anchored to one concrete server composer id.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopeComposerDetailRequest {
|
||||
pub scopes: Vec<LibraryScopePair>,
|
||||
pub composer_id: String,
|
||||
pub server_id: String,
|
||||
}
|
||||
|
||||
/// `library_scope_album_detail` response.
|
||||
@@ -1028,20 +797,6 @@ pub struct LibraryScopeArtistDetailResponse {
|
||||
pub artist: LibraryArtistDto,
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub tracks: Vec<LibraryTrackDto>,
|
||||
/// Server with the broadest scoped catalog for this artist. Present only
|
||||
/// for bounded Top Tracks requests.
|
||||
pub top_tracks_server_id: Option<String>,
|
||||
/// Stable hash of the scoped artist tracks used to invalidate Top Songs
|
||||
/// rankings only when that catalog slice changes.
|
||||
pub top_tracks_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
/// `library_scope_composer_detail` response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopeComposerDetailResponse {
|
||||
pub composer: LibraryArtistDto,
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
}
|
||||
|
||||
/// `library_search_cross_server` response (§5.5B / §5.9).
|
||||
|
||||
@@ -114,13 +114,13 @@ pub fn list_albums_by_genre(
|
||||
&req.server_id,
|
||||
req.library_scope.as_deref(),
|
||||
req.library_scopes.as_deref(),
|
||||
)?;
|
||||
);
|
||||
// Any >1-library scope collapses duplicates via cluster keys — including the
|
||||
// Layer-1 same-server path, whose genre `EXISTS` sets `merge_by_album_key`.
|
||||
// Build keys first so dedup works on a cold index (not only after a prior
|
||||
// search / sync-idle rebuild happened to populate them).
|
||||
if multi_library_merge_enabled(&scope_pairs) {
|
||||
crate::scope_merge::ensure_cluster_keys_for_scopes(store, &scope_pairs)?;
|
||||
crate::identity::ensure_cluster_keys_built(store, &req.server_id)?;
|
||||
}
|
||||
if scoped_layer1_eligible(&scope_pairs) {
|
||||
return list_albums_by_genre_layer1_scope(store, req, &scope_pairs, genre, limit, offset);
|
||||
@@ -132,7 +132,7 @@ pub fn list_albums_by_genre(
|
||||
let mut legacy = req.clone();
|
||||
if legacy.library_scope.is_none() {
|
||||
if let Some(pair) = scope_pairs.first() {
|
||||
legacy.library_scope = pair.library_id.clone();
|
||||
legacy.library_scope = Some(pair.library_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ use rusqlite::Connection;
|
||||
pub const CLUSTER_SCHEMA: &str = "cluster";
|
||||
|
||||
pub const CLUSTER_DB_FILENAME: &str = "library-cluster.db";
|
||||
const CLUSTER_SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
const CLUSTER_SCHEMA_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS cluster.track_cluster_key (
|
||||
@@ -27,54 +26,12 @@ CREATE INDEX IF NOT EXISTS cluster.idx_ck_scope_artist
|
||||
ON track_cluster_key(server_id, library_id, artist_key);
|
||||
CREATE INDEX IF NOT EXISTS cluster.idx_ck_scope_track
|
||||
ON track_cluster_key(server_id, library_id, cluster_key);
|
||||
CREATE INDEX IF NOT EXISTS cluster.idx_ck_server_album
|
||||
ON track_cluster_key(server_id, album_key);
|
||||
CREATE INDEX IF NOT EXISTS cluster.idx_ck_server_artist
|
||||
ON track_cluster_key(server_id, artist_key);
|
||||
CREATE INDEX IF NOT EXISTS cluster.idx_ck_server_track
|
||||
ON track_cluster_key(server_id, cluster_key);
|
||||
CREATE TABLE IF NOT EXISTS cluster.cluster_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
";
|
||||
|
||||
fn expected_schema_object(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"track_cluster_key"
|
||||
| "cluster_meta"
|
||||
| "idx_ck_scope_album"
|
||||
| "idx_ck_scope_artist"
|
||||
| "idx_ck_scope_track"
|
||||
| "idx_ck_server_album"
|
||||
| "idx_ck_server_artist"
|
||||
| "idx_ck_server_track"
|
||||
)
|
||||
}
|
||||
|
||||
fn cluster_schema_is_compatible(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let version: i64 = conn.query_row("PRAGMA cluster.user_version", [], |row| row.get(0))?;
|
||||
if version != 0 && version != CLUSTER_SCHEMA_VERSION {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT name FROM cluster.sqlite_master \
|
||||
WHERE type IN ('table', 'index') AND name NOT LIKE 'sqlite_%'",
|
||||
)?;
|
||||
let names = statement
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(names.iter().all(|name| expected_schema_object(name)))
|
||||
}
|
||||
|
||||
fn initialize_cluster_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(CLUSTER_SCHEMA_SQL)?;
|
||||
conn.execute_batch(&format!(
|
||||
"PRAGMA cluster.user_version = {CLUSTER_SCHEMA_VERSION}"
|
||||
))
|
||||
}
|
||||
|
||||
pub fn cluster_db_path_for_library(library_db_path: &Path) -> PathBuf {
|
||||
library_db_path
|
||||
.parent()
|
||||
@@ -123,17 +80,8 @@ fn attach_file_write(conn: &Connection, cluster_path: &Path) -> rusqlite::Result
|
||||
conn.execute_batch(&format!(
|
||||
"ATTACH DATABASE '{literal}' AS {CLUSTER_SCHEMA}"
|
||||
))?;
|
||||
if !cluster_schema_is_compatible(conn)? {
|
||||
crate::app_eprintln!(
|
||||
"[library-cluster] incompatible sidecar schema; recreating rebuildable database"
|
||||
);
|
||||
conn.execute_batch(&format!("DETACH DATABASE {CLUSTER_SCHEMA}"))?;
|
||||
remove_cluster_files(cluster_path);
|
||||
conn.execute_batch(&format!(
|
||||
"ATTACH DATABASE '{literal}' AS {CLUSTER_SCHEMA}"
|
||||
))?;
|
||||
}
|
||||
initialize_cluster_schema(conn)
|
||||
conn.execute_batch(CLUSTER_SCHEMA_SQL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only attach — only after the write connection has created the file + schema.
|
||||
@@ -222,147 +170,11 @@ pub fn attach_cluster_read_file(
|
||||
|
||||
pub fn attach_cluster_write_memory(conn: &Connection, cluster_uri: &str) -> rusqlite::Result<()> {
|
||||
attach_memory(conn, cluster_uri)?;
|
||||
initialize_cluster_schema(conn)
|
||||
conn.execute_batch(CLUSTER_SCHEMA_SQL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shared-cache in-memory identity DB — attach after write side created schema.
|
||||
pub fn attach_cluster_read_memory(conn: &Connection, cluster_uri: &str) -> rusqlite::Result<()> {
|
||||
attach_memory(conn, cluster_uri)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_TEST_DB: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
fn new(label: &str) -> Self {
|
||||
let id = NEXT_TEST_DB.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"psysonic-cluster-{label}-{}-{id}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn library_path(&self) -> PathBuf {
|
||||
self.0.join("library.sqlite")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_unversioned_sidecar_is_preserved_and_stamped() {
|
||||
let directory = TestDirectory::new("compatible");
|
||||
let library_path = directory.library_path();
|
||||
{
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
attach_cluster_write_file(&conn, &library_path).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO cluster.track_cluster_key( \
|
||||
server_id, library_id, track_id, duration_sec \
|
||||
) VALUES ('s1', 'lib', 't1', 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute_batch("PRAGMA cluster.user_version = 0; DETACH DATABASE cluster")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
attach_cluster_write_file(&conn, &library_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM cluster.track_cluster_key", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
let version: i64 = conn
|
||||
.query_row("PRAGMA cluster.user_version", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(version, CLUSTER_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_unversioned_sidecar_is_recreated() {
|
||||
let directory = TestDirectory::new("incompatible");
|
||||
let library_path = directory.library_path();
|
||||
{
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
attach_cluster_write_file(&conn, &library_path).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO cluster.track_cluster_key( \
|
||||
server_id, library_id, track_id, duration_sec \
|
||||
) VALUES ('s1', 'lib', 't1', 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE cluster.obsolete_identity_source(id TEXT); \
|
||||
PRAGMA cluster.user_version = 0; \
|
||||
DETACH DATABASE cluster",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
attach_cluster_write_file(&conn, &library_path).unwrap();
|
||||
let key_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM cluster.track_cluster_key", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
let obsolete_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM cluster.sqlite_master \
|
||||
WHERE name = 'obsolete_identity_source'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let version: i64 = conn
|
||||
.query_row("PRAGMA cluster.user_version", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(key_count, 0);
|
||||
assert_eq!(obsolete_count, 0);
|
||||
assert_eq!(version, CLUSTER_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_sidecar_with_unknown_objects_is_recreated() {
|
||||
let directory = TestDirectory::new("versioned-incompatible");
|
||||
let library_path = directory.library_path();
|
||||
{
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
attach_cluster_write_file(&conn, &library_path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE INDEX cluster.idx_abandoned_branch \
|
||||
ON track_cluster_key(server_id, cluster_key); \
|
||||
DETACH DATABASE cluster",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
attach_cluster_write_file(&conn, &library_path).unwrap();
|
||||
let unknown_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM cluster.sqlite_master \
|
||||
WHERE name = 'idx_abandoned_branch'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(unknown_count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::{params, Connection, Transaction};
|
||||
|
||||
use crate::browse_projection::AlbumScope;
|
||||
|
||||
pub(crate) const SERVER_KIND: &str = "server";
|
||||
pub(crate) const TRACK_KIND: &str = "track";
|
||||
pub(crate) const ALBUM_KIND: &str = "album";
|
||||
pub(crate) const ARTIST_KIND: &str = "artist";
|
||||
|
||||
fn record<'a>(
|
||||
tx: &Transaction<'_>,
|
||||
kind: &str,
|
||||
entities: impl IntoIterator<Item = (&'a str, &'a str)>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut statement = tx.prepare_cached(
|
||||
"INSERT OR IGNORE INTO identity_invalidation(server_id, kind, entity_id) \
|
||||
VALUES (?1, ?2, ?3)",
|
||||
)?;
|
||||
for (server_id, entity_id) in entities {
|
||||
if seen.insert((server_id, entity_id)) {
|
||||
statement.execute(params![server_id, kind, entity_id])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn record_servers<'a>(
|
||||
tx: &Transaction<'_>,
|
||||
server_ids: impl IntoIterator<Item = &'a str>,
|
||||
) -> rusqlite::Result<()> {
|
||||
record(tx, SERVER_KIND, server_ids.into_iter().map(|id| (id, "")))
|
||||
}
|
||||
|
||||
pub(crate) fn record_tracks<'a>(
|
||||
tx: &Transaction<'_>,
|
||||
tracks: impl IntoIterator<Item = (&'a str, &'a str)>,
|
||||
) -> rusqlite::Result<()> {
|
||||
record(tx, TRACK_KIND, tracks)
|
||||
}
|
||||
|
||||
pub(crate) fn record_album_scopes(
|
||||
tx: &Transaction<'_>,
|
||||
scopes: &HashSet<AlbumScope>,
|
||||
) -> rusqlite::Result<()> {
|
||||
record(
|
||||
tx,
|
||||
ALBUM_KIND,
|
||||
scopes
|
||||
.iter()
|
||||
.map(|(server_id, _, album_id)| (server_id.as_str(), album_id.as_str())),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn record_artists<'a>(
|
||||
tx: &Transaction<'_>,
|
||||
server_id: &'a str,
|
||||
artist_ids: impl IntoIterator<Item = &'a str>,
|
||||
) -> rusqlite::Result<()> {
|
||||
record(
|
||||
tx,
|
||||
ARTIST_KIND,
|
||||
artist_ids.into_iter().map(|artist_id| (server_id, artist_id)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn has_server_invalidation(
|
||||
conn: &Connection,
|
||||
server_id: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS( \
|
||||
SELECT 1 FROM identity_invalidation \
|
||||
WHERE server_id = ?1 AND kind = 'server' \
|
||||
)",
|
||||
params![server_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn has_any(conn: &Connection, server_id: &str) -> rusqlite::Result<bool> {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM identity_invalidation WHERE server_id = ?1)",
|
||||
params![server_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn clear(tx: &Transaction<'_>, server_id: Option<&str>) -> rusqlite::Result<()> {
|
||||
match server_id {
|
||||
Some(server_id) => {
|
||||
tx.execute(
|
||||
"DELETE FROM identity_invalidation WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
tx.execute("DELETE FROM identity_invalidation", [])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,10 +17,6 @@ fn album_identity_source<'a>(album_artist: Option<&'a str>, artist: Option<&'a s
|
||||
.or_else(|| artist.map(str::trim).filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
pub(super) fn build_album_key(artist: Option<&str>, album: &str) -> Option<String> {
|
||||
join_norm_parts([norm_part(artist.unwrap_or("")), norm_part(album)])
|
||||
}
|
||||
|
||||
pub fn build_track_cluster_keys(
|
||||
artist: Option<&str>,
|
||||
title: &str,
|
||||
@@ -34,7 +30,10 @@ pub fn build_track_cluster_keys(
|
||||
let cluster_key = join_norm_parts([artist_norm.clone(), title_norm, album_norm.clone()]);
|
||||
|
||||
let album_source = album_identity_source(album_artist, artist);
|
||||
let album_key = build_album_key(album_source, album);
|
||||
let album_key = join_norm_parts([
|
||||
norm_part(album_source.unwrap_or("")),
|
||||
album_norm,
|
||||
]);
|
||||
|
||||
TrackClusterKeys {
|
||||
cluster_key,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Precomputed identity keys for multi-library dedup (spec §3.1).
|
||||
|
||||
mod attach;
|
||||
mod invalidation;
|
||||
mod keys;
|
||||
mod norm;
|
||||
mod rebuild;
|
||||
@@ -12,15 +11,6 @@ pub use attach::{
|
||||
remove_cluster_files_for_library, CLUSTER_DB_FILENAME, CLUSTER_SCHEMA,
|
||||
};
|
||||
pub use norm::NORM_VERSION;
|
||||
pub(crate) use norm::norm_part;
|
||||
pub(crate) use invalidation::{record_album_scopes, record_artists, record_tracks};
|
||||
pub use rebuild::{
|
||||
cluster_rebuild_needed, ensure_cluster_keys_built, ensure_pending_cluster_keys,
|
||||
rebuild_cluster_keys,
|
||||
};
|
||||
pub(crate) use rebuild::{
|
||||
concrete_physical_album_key, delete_cluster_keys_for_tracks, mark_cluster_keys_dirty,
|
||||
prune_cluster_keys_for_scope, refresh_library_ids_for_albums,
|
||||
};
|
||||
pub use rebuild::{cluster_rebuild_needed, ensure_cluster_keys_built, rebuild_cluster_keys};
|
||||
|
||||
pub use keys::{build_track_cluster_keys, TrackClusterKeys};
|
||||
|
||||
@@ -19,12 +19,9 @@
|
||||
/// Separator for composite keys — U+001F cannot appear in normalized output.
|
||||
pub(crate) const KEY_SEP: char = '\u{001f}';
|
||||
|
||||
/// Bump when cluster-key derivation changes; stored in `cluster.cluster_meta.norm_version`.
|
||||
/// Bump when normalization rules change; stored in `cluster.cluster_meta.norm_version`.
|
||||
/// v2: locale-aware folding (ß→ss, æ→ae, œ→oe, Romanian ș/ț, Cyrillic ё/й).
|
||||
/// v3: artist keys use the canonical artist entity name when the track has an artist id.
|
||||
/// v4: physical albums use one canonical or server-qualified album key.
|
||||
/// v5: materialized album browse rows use the same physical-album identity partition.
|
||||
pub const NORM_VERSION: &str = "5";
|
||||
pub const NORM_VERSION: &str = "2";
|
||||
|
||||
/// Normalize one identity field. Returns `None` when input is empty/whitespace-only
|
||||
/// or when normalization strips everything (punctuation-only, etc.).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,23 +7,21 @@
|
||||
//! - `filter` — `FilterFieldRegistry` (Rust source of truth for Advanced Search)
|
||||
//! - `sync` — capability probe + orchestrator (PR-3*)
|
||||
|
||||
pub(crate) mod bulk_ingest;
|
||||
pub mod advanced_search;
|
||||
mod advanced_search_mood;
|
||||
pub mod album_compilation_filter;
|
||||
pub mod browse_support;
|
||||
mod advanced_search_mood;
|
||||
pub mod analysis_backfill;
|
||||
pub mod analysis_backfill_policy;
|
||||
pub mod library_readiness;
|
||||
pub mod artist_artwork;
|
||||
pub mod artist_lossless_browse;
|
||||
pub mod artist_sort;
|
||||
pub mod browse_projection;
|
||||
pub mod browse_support;
|
||||
pub(crate) mod bulk_ingest;
|
||||
pub mod canonical;
|
||||
pub mod commands;
|
||||
pub mod composer_projection;
|
||||
pub mod composer_scope;
|
||||
pub mod cover_backfill;
|
||||
pub mod cover_resolve;
|
||||
pub mod canonical;
|
||||
pub mod commands;
|
||||
pub mod cross_server;
|
||||
pub mod dto;
|
||||
pub mod enrichment;
|
||||
@@ -32,28 +30,21 @@ pub mod genre_album_browse;
|
||||
pub mod genre_tags;
|
||||
pub mod genre_tags_backfill;
|
||||
pub mod identity;
|
||||
pub mod library_readiness;
|
||||
pub mod mood_groups;
|
||||
pub mod live_search;
|
||||
pub mod orphan_cleanup;
|
||||
pub mod lossless_albums;
|
||||
pub mod lossless_formats;
|
||||
pub mod mainstage_browse;
|
||||
pub mod mood_groups;
|
||||
pub mod most_played;
|
||||
pub mod orphan_cleanup;
|
||||
pub mod payload;
|
||||
pub mod random_artists;
|
||||
pub mod repos;
|
||||
pub mod runtime;
|
||||
pub mod scope_browse;
|
||||
pub mod scope_merge;
|
||||
pub mod search;
|
||||
pub mod starred_browse;
|
||||
pub mod statistics;
|
||||
pub mod store;
|
||||
pub mod sync;
|
||||
pub(crate) mod track_fts;
|
||||
|
||||
pub use payload::{LibrarySyncIdlePayload, LibrarySyncProgressPayload};
|
||||
pub use payload::LibrarySyncProgressPayload;
|
||||
pub use runtime::LibraryRuntime;
|
||||
|
||||
pub use store::{LibraryStore, LIBRARY_DB_SCHEMA_VERSION};
|
||||
|
||||
@@ -46,9 +46,9 @@ pub fn run_live_search(
|
||||
});
|
||||
}
|
||||
|
||||
let scope_pairs = ordered_library_scope_pairs(server_id, library_scope, library_scopes)?;
|
||||
let scope_pairs = ordered_library_scope_pairs(server_id, library_scope, library_scopes);
|
||||
if multi_library_merge_enabled(&scope_pairs) {
|
||||
crate::scope_merge::ensure_cluster_keys_for_scopes(store, &scope_pairs)?;
|
||||
crate::identity::ensure_cluster_keys_built(store, server_id)?;
|
||||
return run_live_search_multi_scope(
|
||||
store,
|
||||
&scope_pairs,
|
||||
@@ -63,7 +63,7 @@ pub fn run_live_search(
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| scope_pairs.first().and_then(|p| p.library_id.clone()));
|
||||
.or_else(|| scope_pairs.first().map(|p| p.library_id.clone()));
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let scopes = scopes_from_option(effective_scope.as_deref());
|
||||
@@ -864,27 +864,16 @@ mod tests {
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn_mut("test.multi_scope_artists", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO artist (server_id, id, name, synced_at) VALUES \
|
||||
('s1', 'ar-a', 'Shared Artist', 1), \
|
||||
('s1', 'ar-b', 'Shared Artist', 1)",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
|
||||
let scopes = vec![
|
||||
LibraryScopePair {
|
||||
server_id: "s1".into(),
|
||||
library_id: Some("lib-a".into()),
|
||||
library_id: "lib-a".into(),
|
||||
},
|
||||
LibraryScopePair {
|
||||
server_id: "s1".into(),
|
||||
library_id: Some("lib-b".into()),
|
||||
library_id: "lib-b".into(),
|
||||
},
|
||||
];
|
||||
let resp = run_live_search(
|
||||
|
||||
@@ -50,8 +50,6 @@ pub fn list_lossless_albums(
|
||||
"t.deleted = 0".to_string(),
|
||||
"t.server_id = ?1".to_string(),
|
||||
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
|
||||
// Kept explicit so SQLite can prove the partial browse index applies.
|
||||
"t.suffix IS NOT NULL".to_string(),
|
||||
lossless_sql,
|
||||
];
|
||||
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
|
||||
@@ -77,29 +75,29 @@ pub fn list_lossless_albums(
|
||||
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.album_artist) AS album_artist, \
|
||||
MAX(t.artist_id) AS artist_id, \
|
||||
MAX(t.year) AS year, \
|
||||
MAX(t.genre) AS genre, \
|
||||
MAX(t.cover_art_id) AS cover_art_id, \
|
||||
MAX(t.starred_at) AS starred_at, \
|
||||
MAX(t.synced_at) AS synced_at, \
|
||||
(SELECT COUNT(*) FROM track c \
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0) AS track_count, \
|
||||
(SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c \
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0) AS duration_sec, \
|
||||
MAX(COALESCE(CAST(json_extract(t.raw_json, '$.bitDepth') AS INTEGER), 0)) AS max_bit_depth \
|
||||
FROM track t INDEXED BY idx_track_lossless_album_browse \
|
||||
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 \
|
||||
FROM ( \
|
||||
SELECT \
|
||||
t.server_id, \
|
||||
t.album_id, \
|
||||
MAX(t.album) AS album_name, \
|
||||
MAX(t.artist) AS artist, \
|
||||
MAX(t.album_artist) AS album_artist, \
|
||||
MAX(t.artist_id) AS artist_id, \
|
||||
MAX(t.year) AS year, \
|
||||
MAX(t.genre) AS genre, \
|
||||
MAX(t.cover_art_id) AS cover_art_id, \
|
||||
MAX(t.starred_at) AS starred_at, \
|
||||
MAX(t.synced_at) AS synced_at, \
|
||||
(SELECT COUNT(*) FROM track c \
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0) AS track_count, \
|
||||
(SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c \
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0) AS duration_sec, \
|
||||
MAX(COALESCE(CAST(json_extract(t.raw_json, '$.bitDepth') AS INTEGER), 0)) AS max_bit_depth \
|
||||
FROM track t \
|
||||
WHERE {where_sql} \
|
||||
GROUP BY t.server_id, t.album_id \
|
||||
) la \
|
||||
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
|
||||
ORDER BY la.max_bit_depth DESC, \
|
||||
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, \
|
||||
la.album_id ASC \
|
||||
@@ -109,24 +107,13 @@ pub fn list_lossless_albums(
|
||||
params.push(SqlValue::Integer(limit as i64));
|
||||
params.push(SqlValue::Integer(offset as i64));
|
||||
|
||||
let (albums, timing) = store.with_read_conn_timed(|conn| {
|
||||
let albums = store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), map_row)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
})?;
|
||||
let blocked_by = timing
|
||||
.blocked_by
|
||||
.map(|owner| format!("{}:{}", owner.file, owner.line))
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
crate::app_deprintln!(
|
||||
"[library-db][lossless] server={} lock_wait_ms={} query_ms={} blocked_by={}",
|
||||
req.server_id,
|
||||
timing.lock_wait_ms,
|
||||
timing.exec_ms,
|
||||
blocked_by,
|
||||
);
|
||||
|
||||
let has_more = albums.len() as u32 == limit;
|
||||
Ok(LibraryLosslessAlbumsResponse {
|
||||
|
||||
@@ -7,14 +7,14 @@ pub const LOSSLESS_SUFFIXES: &[&str] = &[
|
||||
"flac", "wav", "wave", "aiff", "aif", "dsf", "dff", "ape", "wv", "shn", "tta",
|
||||
];
|
||||
|
||||
/// Case-insensitive suffix predicate that can use the lossless browse index.
|
||||
/// `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!("{table_alias}.suffix COLLATE NOCASE IN ({list})")
|
||||
format!("LOWER({table_alias}.suffix) IN ({list})")
|
||||
}
|
||||
|
||||
/// Album has at least one indexed lossless track (same allowlist as browse).
|
||||
@@ -50,6 +50,6 @@ mod tests {
|
||||
let sql = track_is_lossless_sql("t");
|
||||
assert!(sql.contains("'flac'"));
|
||||
assert!(sql.contains("'tta'"));
|
||||
assert!(sql.starts_with("t.suffix COLLATE NOCASE IN ("));
|
||||
assert!(sql.starts_with("LOWER(t.suffix) IN ("));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,922 +0,0 @@
|
||||
//! Global chronological album feeds over an ordered multi-server library scope.
|
||||
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use rusqlite::params_from_iter;
|
||||
|
||||
use crate::album_compilation_filter::pick_album_group_artist;
|
||||
use crate::browse_support::overlay_album_starred_at_rows;
|
||||
use crate::dto::{
|
||||
GenreAlbumCountDto, LibraryAlbumDto, LibraryMainstageAlbumFeed,
|
||||
LibraryMainstageAlbumsRequest, LibraryMainstageAlbumsResponse, LibraryScopePair,
|
||||
};
|
||||
use crate::scope_merge::{
|
||||
non_empty_scopes, scope_cte_sql, ALBUM_DEDUP_KEY, ALBUM_PICK_KEY,
|
||||
};
|
||||
use crate::search::PAGE_LIMIT_MAX;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
const CANDIDATE_MULTIPLIER: u32 = 8;
|
||||
const CANDIDATE_MARGIN: u32 = 128;
|
||||
const MAX_CANDIDATE_LIMIT: u32 = 65_536;
|
||||
|
||||
fn candidate_limit(offset: u32, fetch_limit: u32) -> u32 {
|
||||
offset
|
||||
.saturating_add(fetch_limit)
|
||||
.saturating_mul(CANDIDATE_MULTIPLIER)
|
||||
.saturating_add(CANDIDATE_MARGIN)
|
||||
}
|
||||
|
||||
fn candidate_columns(feed_at: &str, priority: usize) -> String {
|
||||
format!(
|
||||
"t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \
|
||||
t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.id, \
|
||||
{priority} AS pr, ck.album_key, {ALBUM_DEDUP_KEY} AS album_dedup, \
|
||||
{feed_at} AS feed_at"
|
||||
)
|
||||
}
|
||||
|
||||
fn new_release_candidates_sql(scopes: &[LibraryScopePair], genre_count: usize) -> String {
|
||||
scopes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(priority, pair)| {
|
||||
let columns = candidate_columns("t.server_created_at", priority);
|
||||
let library_predicate = if pair.library_id.is_some() {
|
||||
" AND t.library_id = ?"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let genre_predicate = if genre_count == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
let placeholders = (0..genre_count).map(|_| "?").collect::<Vec<_>>().join(", ");
|
||||
format!(
|
||||
" AND EXISTS (SELECT 1 FROM track_genre tg \
|
||||
WHERE tg.server_id = t.server_id AND tg.track_id = t.id \
|
||||
AND tg.genre COLLATE NOCASE IN ({placeholders}))"
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"SELECT * FROM ( \
|
||||
SELECT {columns} \
|
||||
FROM track t INDEXED BY idx_track_library_created_album \
|
||||
LEFT JOIN cluster.track_cluster_key ck \
|
||||
ON ck.server_id = t.server_id AND ck.track_id = t.id \
|
||||
WHERE t.server_id = ? {library_predicate} \
|
||||
AND t.deleted = 0 AND t.server_created_at IS NOT NULL \
|
||||
AND t.album_id IS NOT NULL AND t.album_id != '' {genre_predicate} \
|
||||
ORDER BY t.server_created_at DESC, t.album_id ASC, t.id ASC \
|
||||
LIMIT ? \
|
||||
)"
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" UNION ALL ")
|
||||
}
|
||||
|
||||
fn recently_played_candidates_sql() -> String {
|
||||
let columns = candidate_columns("ps.started_at_ms", 0);
|
||||
format!(
|
||||
"SELECT {columns} \
|
||||
FROM play_session ps INDEXED BY idx_play_session_started \
|
||||
INNER JOIN track t INDEXED BY sqlite_autoindex_track_1 \
|
||||
ON t.server_id = ps.server_id AND t.id = ps.track_id \
|
||||
INNER JOIN scope matched_scope \
|
||||
ON matched_scope.server_id = t.server_id \
|
||||
AND matched_scope.library_id = t.library_id \
|
||||
LEFT JOIN cluster.track_cluster_key ck \
|
||||
ON ck.server_id = t.server_id AND ck.track_id = t.id \
|
||||
WHERE t.deleted = 0 AND t.album_id IS NOT NULL AND t.album_id != '' \
|
||||
ORDER BY ps.started_at_ms DESC \
|
||||
LIMIT ?"
|
||||
)
|
||||
.replace("0 AS pr", "matched_scope.pr AS pr")
|
||||
}
|
||||
|
||||
fn build_mainstage_query(
|
||||
scopes: &[LibraryScopePair],
|
||||
feed: LibraryMainstageAlbumFeed,
|
||||
genres: &[String],
|
||||
bounded_candidates: u32,
|
||||
result_offset: u32,
|
||||
result_limit: u32,
|
||||
) -> (String, Vec<SqlValue>) {
|
||||
let (cte, mut binds) = scope_cte_sql(scopes);
|
||||
let candidates_sql = match feed {
|
||||
LibraryMainstageAlbumFeed::NewReleases => {
|
||||
for pair in scopes {
|
||||
binds.push(SqlValue::Text(pair.server_id.clone()));
|
||||
if let Some(library_id) = &pair.library_id {
|
||||
binds.push(SqlValue::Text(library_id.clone()));
|
||||
}
|
||||
for genre in genres {
|
||||
binds.push(SqlValue::Text(genre.clone()));
|
||||
}
|
||||
binds.push(SqlValue::Integer(i64::from(bounded_candidates)));
|
||||
}
|
||||
new_release_candidates_sql(scopes, genres.len())
|
||||
}
|
||||
LibraryMainstageAlbumFeed::RecentlyPlayed => {
|
||||
binds.push(SqlValue::Integer(i64::from(bounded_candidates)));
|
||||
recently_played_candidates_sql()
|
||||
}
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"{cte}, \
|
||||
candidates AS MATERIALIZED ({candidates_sql}), \
|
||||
candidate_groups AS ( \
|
||||
SELECT album_dedup, MAX(feed_at) AS feed_at, MAX(album_key) AS album_key \
|
||||
FROM candidates GROUP BY album_dedup \
|
||||
), \
|
||||
representative_pool AS ( \
|
||||
SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \
|
||||
t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.id, \
|
||||
s.pr, grouped.album_dedup \
|
||||
FROM candidate_groups grouped \
|
||||
CROSS JOIN scope s \
|
||||
CROSS JOIN cluster.track_cluster_key ck INDEXED BY idx_ck_scope_album \
|
||||
ON ck.server_id = s.server_id AND ck.library_id = s.library_id \
|
||||
AND ck.album_key = grouped.album_key \
|
||||
INNER JOIN track t INDEXED BY sqlite_autoindex_track_1 \
|
||||
ON t.server_id = ck.server_id AND t.id = ck.track_id \
|
||||
WHERE grouped.album_key IS NOT NULL AND t.deleted = 0 \
|
||||
AND t.library_id = s.library_id \
|
||||
AND t.album_id IS NOT NULL AND t.album_id != '' \
|
||||
UNION ALL \
|
||||
SELECT server_id, album_id, album, artist, artist_id, album_artist, \
|
||||
year, genre, cover_art_id, starred_at, synced_at, id, pr, album_dedup \
|
||||
FROM candidates WHERE album_key IS NULL \
|
||||
), \
|
||||
representatives AS ( \
|
||||
SELECT server_id, album_id, album, artist, artist_id, album_artist, \
|
||||
year, genre, cover_art_id, starred_at, synced_at, album_dedup, \
|
||||
MIN({ALBUM_PICK_KEY}) AS _pick \
|
||||
FROM representative_pool GROUP BY album_dedup \
|
||||
) \
|
||||
SELECT representative.server_id, representative.album_id, representative.album, \
|
||||
representative.artist, representative.artist_id, representative.album_artist, \
|
||||
representative.year, representative.genre, representative.cover_art_id, \
|
||||
representative.starred_at, representative.synced_at, \
|
||||
grouped.feed_at, \
|
||||
(SELECT COUNT(*) FROM candidates) AS candidate_count \
|
||||
FROM representatives representative \
|
||||
INNER JOIN candidate_groups grouped \
|
||||
ON grouped.album_dedup = representative.album_dedup \
|
||||
ORDER BY grouped.feed_at DESC, representative.album COLLATE NOCASE ASC, \
|
||||
representative.server_id ASC, representative.album_id ASC \
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
binds.push(SqlValue::Integer(i64::from(result_limit)));
|
||||
binds.push(SqlValue::Integer(i64::from(result_offset)));
|
||||
(sql, binds)
|
||||
}
|
||||
|
||||
fn new_release_genre_counts(
|
||||
conn: &rusqlite::Connection,
|
||||
scopes: &[LibraryScopePair],
|
||||
) -> rusqlite::Result<Vec<GenreAlbumCountDto>> {
|
||||
let (cte, binds) = scope_cte_sql(scopes);
|
||||
let sql = format!(
|
||||
"{cte} \
|
||||
SELECT tg.genre, COUNT(DISTINCT t.album_id), COUNT(DISTINCT t.id) \
|
||||
FROM scope s CROSS JOIN track t \
|
||||
ON t.server_id = s.server_id AND t.library_id = s.library_id \
|
||||
INNER JOIN track_genre tg ON tg.server_id = t.server_id AND tg.track_id = t.id \
|
||||
WHERE t.deleted = 0 AND t.server_created_at IS NOT NULL \
|
||||
AND t.album_id IS NOT NULL AND t.album_id != '' \
|
||||
GROUP BY tg.genre COLLATE NOCASE \
|
||||
ORDER BY COUNT(DISTINCT t.album_id) DESC, tg.genre COLLATE NOCASE ASC"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_from_iter(binds.iter()), |row| {
|
||||
Ok(GenreAlbumCountDto {
|
||||
value: row.get(0)?,
|
||||
album_count: row.get::<_, i64>(1)?.max(0) as u32,
|
||||
song_count: row.get::<_, i64>(2)?.max(0) as u32,
|
||||
})
|
||||
})?.collect::<rusqlite::Result<Vec<_>>>();
|
||||
rows
|
||||
}
|
||||
|
||||
fn map_mainstage_album(
|
||||
r: &rusqlite::Row<'_>,
|
||||
include_catalog_created_at: bool,
|
||||
) -> rusqlite::Result<(LibraryAlbumDto, u32)> {
|
||||
let track_artist = r.get(3)?;
|
||||
let album_artist = r.get(5)?;
|
||||
Ok((
|
||||
LibraryAlbumDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
artist: pick_album_group_artist(track_artist, album_artist),
|
||||
artist_id: r.get(4)?,
|
||||
song_count: None,
|
||||
duration_sec: None,
|
||||
year: r.get(6)?,
|
||||
genre: r.get(7)?,
|
||||
cover_art_id: r.get(8)?,
|
||||
starred_at: r.get(9)?,
|
||||
synced_at: r.get(10)?,
|
||||
raw_json: if include_catalog_created_at {
|
||||
serde_json::json!({ "createdMs": r.get::<_, i64>(11)? })
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
},
|
||||
},
|
||||
r.get(12)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn list_mainstage_albums(
|
||||
store: &LibraryStore,
|
||||
request: &LibraryMainstageAlbumsRequest,
|
||||
) -> Result<LibraryMainstageAlbumsResponse, String> {
|
||||
let scopes = non_empty_scopes(&request.scopes)?;
|
||||
|
||||
let limit = request.limit.unwrap_or(30).clamp(1, PAGE_LIMIT_MAX);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
let fetch_limit = limit.saturating_add(1);
|
||||
let requested_results = offset.saturating_add(fetch_limit);
|
||||
let initial_candidates = candidate_limit(offset, fetch_limit);
|
||||
|
||||
store.with_mainstage_read_conn(|conn| {
|
||||
let genre_counts_start = std::time::Instant::now();
|
||||
let genre_counts = if request.include_genre_counts
|
||||
&& request.feed == LibraryMainstageAlbumFeed::NewReleases
|
||||
{
|
||||
new_release_genre_counts(conn, scopes)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let genre_counts_ms = genre_counts_start.elapsed().as_millis();
|
||||
let feed_start = std::time::Instant::now();
|
||||
let mut bounded_candidates = initial_candidates;
|
||||
loop {
|
||||
let (sql, binds) = build_mainstage_query(
|
||||
scopes,
|
||||
request.feed,
|
||||
&request.genres,
|
||||
bounded_candidates,
|
||||
0,
|
||||
requested_results,
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(params_from_iter(binds.iter()), |row| {
|
||||
map_mainstage_album(row, request.feed == LibraryMainstageAlbumFeed::NewReleases)
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let candidate_count = rows.first().map(|(_, count)| *count).unwrap_or(0);
|
||||
let candidate_capacity = match request.feed {
|
||||
LibraryMainstageAlbumFeed::NewReleases => {
|
||||
bounded_candidates.saturating_mul(scopes.len() as u32)
|
||||
}
|
||||
LibraryMainstageAlbumFeed::RecentlyPlayed => bounded_candidates,
|
||||
};
|
||||
if rows.len() < requested_results as usize
|
||||
&& candidate_count >= candidate_capacity
|
||||
&& bounded_candidates < MAX_CANDIDATE_LIMIT
|
||||
{
|
||||
bounded_candidates = bounded_candidates
|
||||
.saturating_mul(2)
|
||||
.min(MAX_CANDIDATE_LIMIT);
|
||||
continue;
|
||||
}
|
||||
let mut albums = rows
|
||||
.into_iter()
|
||||
.skip(offset as usize)
|
||||
.map(|(album, _)| album)
|
||||
.collect::<Vec<_>>();
|
||||
let has_more = albums.len() > limit as usize;
|
||||
albums.truncate(limit as usize);
|
||||
overlay_album_starred_at_rows(conn, &mut albums);
|
||||
if psysonic_core::logging::should_log_debug() {
|
||||
crate::app_deprintln!(
|
||||
"[frontend][mainstage-browse] {}",
|
||||
serde_json::json!({
|
||||
"feed": request.feed,
|
||||
"scopeCount": scopes.len(),
|
||||
"includeGenreCounts": request.include_genre_counts,
|
||||
"genreCountMs": genre_counts_ms,
|
||||
"feedMs": feed_start.elapsed().as_millis(),
|
||||
"candidateLimit": bounded_candidates,
|
||||
"resultCount": albums.len(),
|
||||
})
|
||||
);
|
||||
}
|
||||
return Ok(LibraryMainstageAlbumsResponse { albums, has_more, genre_counts });
|
||||
}
|
||||
}).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::{LibraryScopePair, PlaySessionInputDto};
|
||||
use crate::identity::ensure_cluster_keys_built;
|
||||
use crate::repos::{PlaySessionRepository, TrackRepository, TrackRow};
|
||||
use rusqlite::params;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn scope(server_id: &str, library_id: &str) -> LibraryScopePair {
|
||||
LibraryScopePair {
|
||||
server_id: server_id.into(),
|
||||
library_id: Some(library_id.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn whole_scope(server_id: &str) -> LibraryScopePair {
|
||||
LibraryScopePair {
|
||||
server_id: server_id.into(),
|
||||
library_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn track(
|
||||
server_id: &str,
|
||||
id: &str,
|
||||
album: &str,
|
||||
album_id: &str,
|
||||
library_id: &str,
|
||||
created_at: Option<i64>,
|
||||
) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server_id.into(),
|
||||
id: id.into(),
|
||||
title: format!("Track {id}"),
|
||||
title_sort: None,
|
||||
artist: Some("Artist".into()),
|
||||
artist_id: Some(format!("artist-{server_id}")),
|
||||
album: album.into(),
|
||||
album_id: Some(album_id.into()),
|
||||
album_artist: Some("Artist".into()),
|
||||
duration_sec: 180,
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
year: Some(2026),
|
||||
genre: None,
|
||||
suffix: Some("flac".into()),
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: Some(format!("cover-{album_id}")),
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: Some(library_id.into()),
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: created_at,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(
|
||||
scopes: Vec<LibraryScopePair>,
|
||||
feed: LibraryMainstageAlbumFeed,
|
||||
) -> LibraryMainstageAlbumsRequest {
|
||||
LibraryMainstageAlbumsRequest {
|
||||
scopes,
|
||||
feed,
|
||||
limit: Some(30),
|
||||
offset: None,
|
||||
genres: Vec::new(),
|
||||
include_genre_counts: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn play(store: &LibraryStore, server_id: &str, track_id: &str, started_at_ms: i64) {
|
||||
PlaySessionRepository::new(store)
|
||||
.insert(&PlaySessionInputDto {
|
||||
server_id: server_id.into(),
|
||||
track_id: track_id.into(),
|
||||
started_at_ms,
|
||||
listened_sec: 20.0,
|
||||
position_max_sec: 20.0,
|
||||
end_reason: "skip".into(),
|
||||
duration_sec_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_artist(store: &LibraryStore, server_id: &str) {
|
||||
store
|
||||
.with_conn_mut("test.mainstage_artist", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO artist (server_id, id, name, synced_at) VALUES (?1, ?2, 'Artist', 1) \
|
||||
ON CONFLICT(server_id, id) DO NOTHING",
|
||||
params![server_id, format!("artist-{server_id}")],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_releases_are_globally_ordered_and_exclude_null_created_at() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t-old", "Old", "a-old", "l1", Some(100)),
|
||||
track("s2", "t-new", "New", "a-new", "l2", Some(300)),
|
||||
track("s1", "t-mid", "Mid", "a-mid", "l1", Some(200)),
|
||||
track("s2", "t-null", "Unknown", "a-null", "l2", None),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let response = list_mainstage_albums(
|
||||
&store,
|
||||
&request(
|
||||
vec![scope("s1", "l1"), scope("s2", "l2")],
|
||||
LibraryMainstageAlbumFeed::NewReleases,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.albums.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
|
||||
vec!["New", "Mid", "Old"]
|
||||
);
|
||||
assert_eq!(response.albums[0].raw_json["createdMs"], 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_server_new_releases_include_empty_library_rows() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t-empty", "Empty", "a-empty", "", Some(100)),
|
||||
track("s1", "t-tagged", "Tagged", "a-tagged", "lib-b", Some(200)),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let response = list_mainstage_albums(
|
||||
&store,
|
||||
&request(vec![whole_scope("s1")], LibraryMainstageAlbumFeed::NewReleases),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.albums.iter().map(|album| album.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["a-tagged", "a-empty"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_played_does_not_expose_play_time_as_catalog_created_at() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track("s1", "t1", "Album", "a1", "l1", Some(100))])
|
||||
.unwrap();
|
||||
play(&store, "s1", "t1", 999);
|
||||
|
||||
let response = list_mainstage_albums(
|
||||
&store,
|
||||
&request(vec![scope("s1", "l1")], LibraryMainstageAlbumFeed::RecentlyPlayed),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.albums[0].raw_json, serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_selected_libraries_contribute() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t-selected", "Selected", "a1", "wanted", Some(100)),
|
||||
track("s1", "t-hidden", "Hidden", "a2", "other", Some(999)),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let response = list_mainstage_albums(
|
||||
&store,
|
||||
&request(vec![scope("s1", "wanted")], LibraryMainstageAlbumFeed::NewReleases),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response.albums.len(), 1);
|
||||
assert_eq!(response.albums[0].name, "Selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genre_filter_and_counts_stay_within_dated_selected_release_scope() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "rock", "Rock release", "a-rock", "l1", Some(200)),
|
||||
track("s2", "jazz", "Jazz release", "a-jazz", "l2", Some(300)),
|
||||
track("s1", "missing-date", "Undated", "a-undated", "l1", None),
|
||||
track("s1", "outside", "Outside", "a-outside", "other", Some(400)),
|
||||
])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn_mut("test.mainstage_genres", |conn| {
|
||||
for (server, track_id, genre) in [
|
||||
("s1", "rock", "Rock"),
|
||||
("s2", "jazz", "Jazz"),
|
||||
("s1", "missing-date", "Ambient"),
|
||||
("s1", "outside", "Metal"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO track_genre (server_id, track_id, genre, album_id, library_id) \
|
||||
VALUES (?1, ?2, ?3, (SELECT album_id FROM track WHERE server_id = ?1 AND id = ?2), \
|
||||
(SELECT library_id FROM track WHERE server_id = ?1 AND id = ?2))",
|
||||
rusqlite::params![server, track_id, genre],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut req = request(
|
||||
vec![scope("s1", "l1"), scope("s2", "l2")],
|
||||
LibraryMainstageAlbumFeed::NewReleases,
|
||||
);
|
||||
req.genres = vec!["rock".into()];
|
||||
let response = list_mainstage_albums(&store, &req).unwrap();
|
||||
|
||||
assert_eq!(response.albums.iter().map(|album| album.id.as_str()).collect::<Vec<_>>(), ["a-rock"]);
|
||||
assert_eq!(
|
||||
response.genre_counts.iter().map(|row| (row.value.as_str(), row.album_count)).collect::<Vec<_>>(),
|
||||
[("Jazz", 1), ("Rock", 1)],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_feed_skips_genre_counts_when_not_requested() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track("s1", "rock", "Rock release", "a-rock", "l1", Some(200))])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn_mut("test.mainstage_skip_genres", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track_genre (server_id, track_id, genre, album_id, library_id) \
|
||||
VALUES ('s1', 'rock', 'Rock', 'a-rock', 'l1')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut req = request(vec![scope("s1", "l1")], LibraryMainstageAlbumFeed::NewReleases);
|
||||
req.include_genre_counts = false;
|
||||
let response = list_mainstage_albums(&store, &req).unwrap();
|
||||
|
||||
assert_eq!(response.albums.len(), 1);
|
||||
assert!(response.genre_counts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_played_collapses_repeated_sessions_and_uses_latest_global_time() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t-a", "Album A", "a", "l1", Some(1)),
|
||||
track("s2", "t-b", "Album B", "b", "l2", Some(1)),
|
||||
])
|
||||
.unwrap();
|
||||
play(&store, "s1", "t-a", 100);
|
||||
play(&store, "s1", "t-a", 400);
|
||||
play(&store, "s2", "t-b", 300);
|
||||
|
||||
let response = list_mainstage_albums(
|
||||
&store,
|
||||
&request(
|
||||
vec![scope("s1", "l1"), scope("s2", "l2")],
|
||||
LibraryMainstageAlbumFeed::RecentlyPlayed,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response.albums.len(), 2);
|
||||
assert_eq!(response.albums[0].name, "Album A");
|
||||
assert_eq!(response.albums[1].name, "Album B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_album_uses_priority_owner_but_global_feed_timestamp() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t-priority", "Shared", "priority-id", "l1", Some(100)),
|
||||
track("s2", "t-later", "Shared", "later-id", "l2", Some(500)),
|
||||
])
|
||||
.unwrap();
|
||||
insert_artist(&store, "s1");
|
||||
insert_artist(&store, "s2");
|
||||
ensure_cluster_keys_built(&store, "s1").unwrap();
|
||||
ensure_cluster_keys_built(&store, "s2").unwrap();
|
||||
|
||||
let response = list_mainstage_albums(
|
||||
&store,
|
||||
&request(
|
||||
vec![scope("s1", "l1"), scope("s2", "l2")],
|
||||
LibraryMainstageAlbumFeed::NewReleases,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response.albums.len(), 1);
|
||||
assert_eq!(response.albums[0].server_id, "s1");
|
||||
assert_eq!(response.albums[0].id, "priority-id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_cluster_keys_use_non_merge_fallback_without_rebuild() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "Shared", "a1", "l1", Some(200)),
|
||||
track("s2", "t2", "Shared", "a2", "l2", Some(100)),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let response = list_mainstage_albums(
|
||||
&store,
|
||||
&request(
|
||||
vec![scope("s1", "l1"), scope("s2", "l2")],
|
||||
LibraryMainstageAlbumFeed::NewReleases,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response.albums.len(), 2);
|
||||
|
||||
let key_count: i64 = store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row("SELECT COUNT(*) FROM cluster.track_cluster_key", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(key_count, 0, "latency-sensitive browse must not rebuild keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_fetches_one_extra_for_has_more() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "One", "a1", "l1", Some(300)),
|
||||
track("s1", "t2", "Two", "a2", "l1", Some(200)),
|
||||
track("s1", "t3", "Three", "a3", "l1", Some(100)),
|
||||
])
|
||||
.unwrap();
|
||||
let mut req = request(vec![scope("s1", "l1")], LibraryMainstageAlbumFeed::NewReleases);
|
||||
req.limit = Some(2);
|
||||
|
||||
let first = list_mainstage_albums(&store, &req).unwrap();
|
||||
assert_eq!(first.albums.len(), 2);
|
||||
assert!(first.has_more);
|
||||
|
||||
req.offset = Some(2);
|
||||
let second = list_mainstage_albums(&store, &req).unwrap();
|
||||
assert_eq!(second.albums.len(), 1);
|
||||
assert!(!second.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_window_expands_when_one_album_dominates_newest_tracks() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut tracks = (0..220)
|
||||
.map(|n| track("s1", &format!("shared-{n}"), "Shared", "shared", "l1", Some(1_000 - n)))
|
||||
.collect::<Vec<_>>();
|
||||
tracks.push(track("s1", "other", "Other", "other", "l1", Some(700)));
|
||||
TrackRepository::new(&store).upsert_batch(&tracks).unwrap();
|
||||
|
||||
let mut req = request(vec![scope("s1", "l1")], LibraryMainstageAlbumFeed::NewReleases);
|
||||
req.limit = Some(2);
|
||||
let response = list_mainstage_albums(&store, &req).unwrap();
|
||||
|
||||
assert_eq!(response.albums.len(), 2);
|
||||
assert_eq!(response.albums[0].name, "Shared");
|
||||
assert_eq!(response.albums[1].name, "Other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_window_expands_when_one_album_dominates_recent_sessions() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "shared", "Shared", "shared", "l1", Some(1)),
|
||||
track("s1", "other", "Other", "other", "l1", Some(1)),
|
||||
])
|
||||
.unwrap();
|
||||
for started_at_ms in 1..=220 {
|
||||
play(&store, "s1", "shared", 1_000 + started_at_ms);
|
||||
}
|
||||
play(&store, "s1", "other", 700);
|
||||
|
||||
let mut req = request(vec![scope("s1", "l1")], LibraryMainstageAlbumFeed::RecentlyPlayed);
|
||||
req.limit = Some(2);
|
||||
let response = list_mainstage_albums(&store, &req).unwrap();
|
||||
|
||||
assert_eq!(response.albums.len(), 2);
|
||||
assert_eq!(response.albums[0].name, "Shared");
|
||||
assert_eq!(response.albums[1].name, "Other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_and_response_serialize_with_ipc_camel_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(LibraryMainstageAlbumFeed::NewReleases).unwrap(),
|
||||
"newReleases"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(LibraryMainstageAlbumFeed::RecentlyPlayed).unwrap(),
|
||||
"recentlyPlayed"
|
||||
);
|
||||
let response = LibraryMainstageAlbumsResponse {
|
||||
albums: Vec::new(),
|
||||
has_more: true,
|
||||
genre_counts: Vec::new(),
|
||||
};
|
||||
assert_eq!(serde_json::to_value(response).unwrap()["hasMore"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_star_overlay_uses_priority_representative_album_row() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t-priority", "Shared", "priority-id", "l1", Some(100)),
|
||||
track("s2", "t-later", "Shared", "later-id", "l2", Some(500)),
|
||||
])
|
||||
.unwrap();
|
||||
insert_artist(&store, "s1");
|
||||
insert_artist(&store, "s2");
|
||||
ensure_cluster_keys_built(&store, "s1").unwrap();
|
||||
ensure_cluster_keys_built(&store, "s2").unwrap();
|
||||
store
|
||||
.with_conn("test.mainstage_star", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO album (server_id, id, name, starred_at, synced_at, raw_json) \
|
||||
VALUES ('s1', 'priority-id', 'Shared', 1234, 1, '{}'), \
|
||||
('s2', 'later-id', 'Shared', 5678, 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let response = list_mainstage_albums(
|
||||
&store,
|
||||
&request(
|
||||
vec![scope("s1", "l1"), scope("s2", "l2")],
|
||||
LibraryMainstageAlbumFeed::NewReleases,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response.albums[0].server_id, "s1");
|
||||
assert_eq!(response.albums[0].starred_at, Some(1234));
|
||||
}
|
||||
|
||||
fn query_plan(
|
||||
store: &LibraryStore,
|
||||
scopes: &[LibraryScopePair],
|
||||
feed: LibraryMainstageAlbumFeed,
|
||||
) -> Vec<String> {
|
||||
let (sql, binds) = build_mainstage_query(
|
||||
scopes,
|
||||
feed,
|
||||
&[],
|
||||
candidate_limit(0, 31),
|
||||
0,
|
||||
31,
|
||||
);
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
|
||||
let plan = stmt
|
||||
.query_map(params_from_iter(binds.iter()), |row| row.get(3))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(plan)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mainstage_query_plans_use_bounded_feed_indexes() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let scopes = vec![scope("s1", "l1"), scope("s2", "l2")];
|
||||
|
||||
let releases = query_plan(&store, &scopes, LibraryMainstageAlbumFeed::NewReleases);
|
||||
assert!(
|
||||
releases
|
||||
.iter()
|
||||
.any(|line| line.contains("idx_track_library_created_album")),
|
||||
"New Releases plan did not use created index: {releases:#?}"
|
||||
);
|
||||
assert!(
|
||||
!releases
|
||||
.iter()
|
||||
.any(|line| line == "SCAN t" || line.contains("SCAN track")),
|
||||
"New Releases plan contains an unindexed track scan: {releases:#?}"
|
||||
);
|
||||
|
||||
let recent = query_plan(&store, &scopes, LibraryMainstageAlbumFeed::RecentlyPlayed);
|
||||
assert!(
|
||||
recent
|
||||
.iter()
|
||||
.any(|line| line.contains("idx_play_session_started")),
|
||||
"Recently Played plan did not drive from newest sessions: {recent:#?}"
|
||||
);
|
||||
assert!(
|
||||
recent
|
||||
.iter()
|
||||
.any(|line| line.contains("sqlite_autoindex_track_1")),
|
||||
"Recently Played plan did not use the track primary key: {recent:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_scoped_feeds_stay_bounded() {
|
||||
const TRACKS: i64 = 214_000;
|
||||
const SESSIONS: i64 = 40_000;
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn_mut("test.seed_mainstage_perf", |conn| {
|
||||
conn.execute_batch(
|
||||
"DROP TRIGGER track_ai; DROP TRIGGER track_ad; DROP TRIGGER track_au;",
|
||||
)?;
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut insert_track = tx.prepare(
|
||||
"INSERT INTO track (server_id, id, title, artist, artist_id, album, \
|
||||
album_id, album_artist, duration_sec, year, genre, cover_art_id, \
|
||||
library_id, server_created_at, deleted, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, ?3, 'Artist', 'artist', ?4, ?5, 'Artist', 180, \
|
||||
2026, 'Rock', ?6, ?7, ?8, 0, 1, '{}')",
|
||||
)?;
|
||||
for n in 0..TRACKS {
|
||||
let server = if n % 2 == 0 { "s1" } else { "s2" };
|
||||
let library = if n % 2 == 0 { "l1" } else { "l2" };
|
||||
let album = n / 10;
|
||||
insert_track.execute(params![
|
||||
server,
|
||||
format!("track-{n}"),
|
||||
format!("Track {n}"),
|
||||
format!("Album {album}"),
|
||||
format!("album-{album}"),
|
||||
format!("cover-{album}"),
|
||||
library,
|
||||
n,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut insert_session = tx.prepare(
|
||||
"INSERT INTO play_session \
|
||||
(server_id, track_id, started_at_ms, listened_sec, position_max_sec, \
|
||||
completion, end_reason) \
|
||||
VALUES (?1, ?2, ?3, 20.0, 20.0, 'partial', 'skip')",
|
||||
)?;
|
||||
for n in 0..SESSIONS {
|
||||
let track_number = TRACKS - 1 - n;
|
||||
let server = if track_number % 2 == 0 { "s1" } else { "s2" };
|
||||
insert_session.execute(params![
|
||||
server,
|
||||
format!("track-{track_number}"),
|
||||
n,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let scopes = vec![scope("s1", "l1"), scope("s2", "l2")];
|
||||
let release_request = request(scopes.clone(), LibraryMainstageAlbumFeed::NewReleases);
|
||||
let started = Instant::now();
|
||||
let releases = list_mainstage_albums(&store, &release_request).unwrap();
|
||||
let release_elapsed = started.elapsed();
|
||||
|
||||
let recent_request = request(scopes, LibraryMainstageAlbumFeed::RecentlyPlayed);
|
||||
let started = Instant::now();
|
||||
let recent = list_mainstage_albums(&store, &recent_request).unwrap();
|
||||
let recent_elapsed = started.elapsed();
|
||||
|
||||
eprintln!(
|
||||
"mainstage 214k fixture: releases={release_elapsed:?}, recent={recent_elapsed:?}"
|
||||
);
|
||||
assert_eq!(releases.albums.len(), 30);
|
||||
assert_eq!(recent.albums.len(), 30);
|
||||
assert!(releases.albums.iter().all(|album| album.song_count.is_none()));
|
||||
assert!(
|
||||
release_elapsed < Duration::from_millis(500),
|
||||
"New Releases regressed to an unbounded query: {release_elapsed:?}"
|
||||
);
|
||||
assert!(
|
||||
recent_elapsed < Duration::from_millis(500),
|
||||
"Recently Played regressed to an unbounded query: {recent_elapsed:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
//! Local-index ranked albums over the selected server/library scope.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use rusqlite::{params_from_iter, types::Value as SqlValue};
|
||||
|
||||
use crate::dto::{
|
||||
LibraryMostPlayedAlbumDto, LibraryMostPlayedArtistDto, LibraryMostPlayedRequest,
|
||||
LibraryMostPlayedResponse, LibraryStatisticsScope,
|
||||
};
|
||||
use crate::search::PAGE_LIMIT_MAX;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
type NormalizedScopes = BTreeMap<String, Option<BTreeSet<String>>>;
|
||||
|
||||
fn normalize_scopes(scopes: &[LibraryStatisticsScope]) -> NormalizedScopes {
|
||||
let mut normalized = BTreeMap::new();
|
||||
|
||||
for scope in scopes
|
||||
.iter()
|
||||
.filter(|scope| !scope.server_id.trim().is_empty())
|
||||
{
|
||||
let library_ids: BTreeSet<String> = scope
|
||||
.library_ids
|
||||
.iter()
|
||||
.filter(|id| !id.is_empty())
|
||||
.cloned()
|
||||
.collect();
|
||||
let selected = normalized
|
||||
.entry(scope.server_id.clone())
|
||||
.or_insert_with(|| Some(BTreeSet::new()));
|
||||
|
||||
if library_ids.is_empty() {
|
||||
*selected = None;
|
||||
} else if let Some(selected) = selected {
|
||||
selected.extend(library_ids);
|
||||
}
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn scopes_where(scopes: &NormalizedScopes) -> (String, Vec<SqlValue>) {
|
||||
let mut clauses = Vec::new();
|
||||
let mut params = Vec::new();
|
||||
|
||||
for (server_id, library_ids) in scopes {
|
||||
let Some(library_ids) = library_ids else {
|
||||
clauses.push("t.server_id = ?".to_string());
|
||||
params.push(SqlValue::Text(server_id.clone()));
|
||||
continue;
|
||||
};
|
||||
|
||||
let placeholders = std::iter::repeat_n("?", library_ids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
clauses.push(format!(
|
||||
"(t.server_id = ? AND t.library_id IN ({placeholders}))"
|
||||
));
|
||||
params.push(SqlValue::Text(server_id.clone()));
|
||||
params.extend(library_ids.iter().cloned().map(SqlValue::Text));
|
||||
}
|
||||
|
||||
(format!("({})", clauses.join(" OR ")), params)
|
||||
}
|
||||
|
||||
fn album_plays_cte(scope_where: &str) -> String {
|
||||
format!(
|
||||
"album_plays AS (\
|
||||
SELECT t.server_id, COALESCE(t.library_id, '') AS library_id, t.album_id, \
|
||||
NULLIF(MAX(t.album), '') AS track_name, \
|
||||
NULLIF(MAX(t.album_artist), '') AS track_album_artist, \
|
||||
NULLIF(MAX(t.artist), '') AS track_artist, \
|
||||
NULLIF(MAX(t.artist_id), '') AS track_artist_id, \
|
||||
MAX(t.year) AS track_year, \
|
||||
NULLIF(MAX(t.cover_art_id), '') AS track_cover_art_id, \
|
||||
SUM(COALESCE(t.play_count, 0)) AS play_count \
|
||||
FROM track t \
|
||||
WHERE {scope_where} AND t.deleted = 0 \
|
||||
AND t.album_id IS NOT NULL AND t.album_id != '' \
|
||||
GROUP BY t.server_id, COALESCE(t.library_id, ''), t.album_id \
|
||||
HAVING SUM(COALESCE(t.play_count, 0)) > 0\
|
||||
)"
|
||||
)
|
||||
}
|
||||
|
||||
fn album_sql(scope_where: &str) -> String {
|
||||
format!(
|
||||
"WITH {} \
|
||||
SELECT ap.server_id, ap.library_id, ap.album_id, \
|
||||
COALESCE(NULLIF(p.name, ''), ap.track_name, '') AS name, \
|
||||
COALESCE(NULLIF(p.artist, ''), ap.track_album_artist, ap.track_artist, '') AS artist, \
|
||||
COALESCE(NULLIF(p.artist_id, ''), ap.track_artist_id) AS artist_id, \
|
||||
COALESCE(p.year, ap.track_year) AS year, \
|
||||
COALESCE(NULLIF(p.cover_art_id, ''), ap.track_cover_art_id) AS cover_art_id, \
|
||||
ap.play_count \
|
||||
FROM album_plays ap \
|
||||
LEFT JOIN album_browse_projection p \
|
||||
ON p.server_id = ap.server_id \
|
||||
AND p.library_id = ap.library_id \
|
||||
AND p.album_id = ap.album_id \
|
||||
ORDER BY ap.play_count DESC, name COLLATE NOCASE ASC, \
|
||||
ap.server_id ASC, ap.library_id ASC, ap.album_id ASC \
|
||||
LIMIT ? OFFSET ?",
|
||||
album_plays_cte(scope_where)
|
||||
)
|
||||
}
|
||||
|
||||
fn artist_sql(scope_where: &str) -> String {
|
||||
format!(
|
||||
"WITH {}, \
|
||||
artist_albums AS ( \
|
||||
SELECT ap.server_id, \
|
||||
COALESCE(NULLIF(p.artist_id, ''), ap.track_artist_id, \
|
||||
NULLIF(p.artist, ''), ap.track_album_artist, ap.track_artist) AS id, \
|
||||
COALESCE(NULLIF(p.artist, ''), ap.track_album_artist, ap.track_artist, '') AS name, \
|
||||
COALESCE(NULLIF(p.cover_art_id, ''), ap.track_cover_art_id) AS cover_art_id, \
|
||||
ap.play_count \
|
||||
FROM album_plays ap \
|
||||
LEFT JOIN album_browse_projection p \
|
||||
ON p.server_id = ap.server_id \
|
||||
AND p.library_id = ap.library_id \
|
||||
AND p.album_id = ap.album_id \
|
||||
), \
|
||||
ranked_artists AS ( \
|
||||
SELECT server_id, id, \
|
||||
FIRST_VALUE(name) OVER ( \
|
||||
PARTITION BY server_id, id \
|
||||
ORDER BY play_count DESC, name COLLATE NOCASE ASC, \
|
||||
COALESCE(cover_art_id, '') ASC \
|
||||
) AS name, \
|
||||
FIRST_VALUE(cover_art_id) OVER ( \
|
||||
PARTITION BY server_id, id \
|
||||
ORDER BY (cover_art_id IS NULL) ASC, play_count DESC, \
|
||||
cover_art_id ASC \
|
||||
) AS cover_art_id, \
|
||||
SUM(play_count) OVER (PARTITION BY server_id, id) AS play_count, \
|
||||
ROW_NUMBER() OVER ( \
|
||||
PARTITION BY server_id, id \
|
||||
ORDER BY play_count DESC, name COLLATE NOCASE ASC, \
|
||||
COALESCE(cover_art_id, '') ASC \
|
||||
) AS artist_row \
|
||||
FROM artist_albums \
|
||||
WHERE id IS NOT NULL AND id != '' \
|
||||
) \
|
||||
SELECT server_id, id, name, cover_art_id, play_count \
|
||||
FROM ranked_artists \
|
||||
WHERE artist_row = 1 \
|
||||
ORDER BY play_count DESC, name COLLATE NOCASE ASC, server_id ASC, id ASC \
|
||||
LIMIT 50",
|
||||
album_plays_cte(scope_where)
|
||||
)
|
||||
}
|
||||
|
||||
/// Aggregate `track.play_count` for the selected scopes without any REST reads.
|
||||
pub fn query_most_played(
|
||||
store: &LibraryStore,
|
||||
request: &LibraryMostPlayedRequest,
|
||||
) -> Result<LibraryMostPlayedResponse, String> {
|
||||
let limit = request.limit.unwrap_or(50).clamp(1, PAGE_LIMIT_MAX);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
let fetch_limit = limit.saturating_add(1);
|
||||
let normalized_scopes = normalize_scopes(&request.scopes);
|
||||
if normalized_scopes.is_empty() {
|
||||
return Ok(LibraryMostPlayedResponse {
|
||||
albums: Vec::new(),
|
||||
artists: Vec::new(),
|
||||
has_more: false,
|
||||
});
|
||||
}
|
||||
let (scope_where, scope_params) = scopes_where(&normalized_scopes);
|
||||
let artist_sql = artist_sql(&scope_where);
|
||||
let sql = album_sql(&scope_where);
|
||||
let mut album_params = scope_params.clone();
|
||||
album_params.push(SqlValue::Integer(i64::from(fetch_limit)));
|
||||
album_params.push(SqlValue::Integer(i64::from(offset)));
|
||||
|
||||
store
|
||||
.with_scope_detail_read_conn(|conn| {
|
||||
let mut artist_stmt = conn.prepare(&artist_sql)?;
|
||||
let artists = artist_stmt
|
||||
.query_map(params_from_iter(scope_params.iter()), |row| {
|
||||
Ok(LibraryMostPlayedArtistDto {
|
||||
server_id: row.get(0)?,
|
||||
id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
cover_art_id: row.get(3)?,
|
||||
play_count: row.get(4)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut albums = stmt
|
||||
.query_map(params_from_iter(album_params.iter()), |row| {
|
||||
Ok(LibraryMostPlayedAlbumDto {
|
||||
server_id: row.get(0)?,
|
||||
library_id: row.get(1)?,
|
||||
id: row.get(2)?,
|
||||
name: row.get(3)?,
|
||||
artist: row.get(4)?,
|
||||
artist_id: row.get(5)?,
|
||||
year: row.get(6)?,
|
||||
cover_art_id: row.get(7)?,
|
||||
play_count: row.get(8)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let has_more = albums.len() > limit as usize;
|
||||
albums.truncate(limit as usize);
|
||||
Ok(LibraryMostPlayedResponse {
|
||||
albums,
|
||||
artists,
|
||||
has_more,
|
||||
})
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn insert_track(
|
||||
conn: &Connection,
|
||||
server: &str,
|
||||
library: &str,
|
||||
album: &str,
|
||||
track: &str,
|
||||
artist: &str,
|
||||
plays: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO track \
|
||||
(server_id, id, title, artist, artist_id, album, album_id, album_artist, \
|
||||
library_id, play_count, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, ?2, ?3, ?3, ?4, ?4, ?3, ?5, ?6, 1, '{}')",
|
||||
rusqlite::params![server, track, artist, album, library, plays,],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_projection(
|
||||
conn: &Connection,
|
||||
server: &str,
|
||||
library: &str,
|
||||
album: &str,
|
||||
artist: &str,
|
||||
artist_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO album_browse_projection \
|
||||
(server_id, library_id, album_id, name, artist, artist_id, song_count, \
|
||||
duration_sec, cover_art_id, synced_at, representative_track_id) \
|
||||
VALUES (?1, ?2, ?3, ?3, ?4, ?5, 1, 0, ?3, 1, ?3)",
|
||||
rusqlite::params![server, library, album, artist, artist_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scope(server_id: &str, library_ids: &[&str]) -> LibraryStatisticsScope {
|
||||
LibraryStatisticsScope {
|
||||
server_id: server_id.into(),
|
||||
library_ids: library_ids.iter().map(|id| (*id).to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(
|
||||
scopes: Vec<LibraryStatisticsScope>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> LibraryMostPlayedRequest {
|
||||
LibraryMostPlayedRequest {
|
||||
scopes,
|
||||
limit: Some(limit),
|
||||
offset: Some(offset),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranks_selected_scopes_and_keeps_server_library_identities() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn("most_played.test", |conn| {
|
||||
for (server, library, album, track, artist, plays) in [
|
||||
("s1", "one", "a1", "t1", "Artist One", 4),
|
||||
("s1", "one", "a1", "t2", "Artist One", 6),
|
||||
("s1", "two", "a2", "t3", "Excluded", 99),
|
||||
("s2", "one", "a1", "t4", "Artist Two", 12),
|
||||
] {
|
||||
insert_track(conn, server, library, album, track, artist, plays)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = query_most_played(
|
||||
&store,
|
||||
&request(vec![scope("s1", &["one"]), scope("s2", &[])], 2, 0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.albums.len(), 2);
|
||||
assert!(!result.has_more);
|
||||
assert_eq!(result.albums[0].server_id, "s2");
|
||||
assert_eq!(result.albums[0].id, "a1");
|
||||
assert_eq!(result.albums[0].play_count, 12);
|
||||
assert_eq!(result.albums[1].server_id, "s1");
|
||||
assert_eq!(result.albums[1].library_id, "one");
|
||||
assert_eq!(result.albums[1].play_count, 10);
|
||||
assert_eq!(result.artists[0].server_id, "s2");
|
||||
assert_eq!(result.artists[0].play_count, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginates_albums_and_reports_has_more_from_the_extra_row() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn("most_played.pagination", |conn| {
|
||||
for (album, track, plays) in [
|
||||
("a1", "t1", 40),
|
||||
("a2", "t2", 30),
|
||||
("a3", "t3", 20),
|
||||
("a4", "t4", 10),
|
||||
] {
|
||||
insert_track(conn, "s1", "one", album, track, "Artist", plays)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let first = query_most_played(&store, &request(vec![scope("s1", &["one"])], 2, 0)).unwrap();
|
||||
assert_eq!(
|
||||
first
|
||||
.albums
|
||||
.iter()
|
||||
.map(|album| album.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["a1", "a2"]
|
||||
);
|
||||
assert!(first.has_more);
|
||||
|
||||
let second =
|
||||
query_most_played(&store, &request(vec![scope("s1", &["one"])], 2, 2)).unwrap();
|
||||
assert_eq!(
|
||||
second
|
||||
.albums
|
||||
.iter()
|
||||
.map(|album| album.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["a3", "a4"]
|
||||
);
|
||||
assert!(!second.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_and_overlapping_scopes_do_not_double_count() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn("most_played.duplicate_scopes", |conn| {
|
||||
insert_track(conn, "s1", "one", "a1", "t1", "Artist", 4)?;
|
||||
insert_track(conn, "s1", "two", "a2", "t2", "Artist", 6)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let duplicate_folder = query_most_played(
|
||||
&store,
|
||||
&request(
|
||||
vec![scope("s1", &["one", "one"]), scope("s1", &["one"])],
|
||||
10,
|
||||
0,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(duplicate_folder.albums.len(), 1);
|
||||
assert_eq!(duplicate_folder.albums[0].play_count, 4);
|
||||
|
||||
let all_folders = query_most_played(
|
||||
&store,
|
||||
&request(vec![scope("s1", &["one"]), scope("s1", &[])], 10, 0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(all_folders.albums.len(), 2);
|
||||
assert_eq!(
|
||||
all_folders
|
||||
.albums
|
||||
.iter()
|
||||
.map(|album| album.play_count)
|
||||
.sum::<i64>(),
|
||||
10
|
||||
);
|
||||
assert_eq!(all_folders.artists[0].play_count, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_invalid_scopes_return_empty_results() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn("most_played.empty_scopes", |conn| {
|
||||
insert_track(conn, "s1", "one", "a1", "t1", "Artist", 4)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
for scopes in [Vec::new(), vec![scope(" ", &["one"])]] {
|
||||
let result = query_most_played(&store, &request(scopes, 10, 0)).unwrap();
|
||||
assert!(result.albums.is_empty());
|
||||
assert!(result.artists.is_empty());
|
||||
assert!(!result.has_more);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artists_use_album_artist_and_aggregate_folders_but_not_servers() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn("most_played.artist_aggregation", |conn| {
|
||||
for (server, library, album, track, artist, plays) in [
|
||||
("s1", "one", "a1", "t1", "Guest One", 4),
|
||||
("s1", "two", "a2", "t2", "Guest Two", 6),
|
||||
("s2", "one", "a3", "t3", "Guest Three", 8),
|
||||
("s1", "one", "a4", "t4", "Soloist", 7),
|
||||
] {
|
||||
insert_track(conn, server, library, album, track, artist, plays)?;
|
||||
}
|
||||
insert_projection(conn, "s1", "one", "a1", "The Band", "band")?;
|
||||
insert_projection(conn, "s1", "two", "a2", "The Band", "band")?;
|
||||
insert_projection(conn, "s2", "one", "a3", "The Band", "band")?;
|
||||
insert_projection(conn, "s1", "one", "a4", "Various Artists", "various")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = query_most_played(
|
||||
&store,
|
||||
&request(vec![scope("s1", &[]), scope("s2", &[])], 10, 0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let s1_band = result
|
||||
.artists
|
||||
.iter()
|
||||
.find(|artist| artist.server_id == "s1" && artist.id == "band")
|
||||
.unwrap();
|
||||
assert_eq!(s1_band.name, "The Band");
|
||||
assert_eq!(s1_band.play_count, 10);
|
||||
assert_eq!(s1_band.cover_art_id.as_deref(), Some("a2"));
|
||||
let s2_band = result
|
||||
.artists
|
||||
.iter()
|
||||
.find(|artist| artist.server_id == "s2" && artist.id == "band")
|
||||
.unwrap();
|
||||
assert_eq!(s2_band.play_count, 8);
|
||||
assert!(result
|
||||
.artists
|
||||
.iter()
|
||||
.all(|artist| !artist.id.starts_with("Guest")));
|
||||
assert_eq!(
|
||||
result
|
||||
.artists
|
||||
.iter()
|
||||
.filter(|artist| artist.id == "band")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_query_plans_use_library_album_and_projection_indexes() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let normalized = normalize_scopes(&[scope("s1", &["one"])]);
|
||||
let (scope_where, scope_params) = scopes_where(&normalized);
|
||||
let mut album_params = scope_params.clone();
|
||||
album_params.push(SqlValue::Integer(11));
|
||||
album_params.push(SqlValue::Integer(0));
|
||||
|
||||
let plan = store
|
||||
.with_scope_detail_read_conn(|conn| {
|
||||
let mut details = Vec::new();
|
||||
for (sql, params) in [
|
||||
(album_sql(&scope_where), album_params),
|
||||
(artist_sql(&scope_where), scope_params),
|
||||
] {
|
||||
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
|
||||
details.extend(
|
||||
stmt.query_map(params_from_iter(params.iter()), |row| row.get(3))?
|
||||
.collect::<rusqlite::Result<Vec<String>>>()?,
|
||||
);
|
||||
}
|
||||
Ok(details)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
plan.iter()
|
||||
.any(|detail| detail.contains("idx_track_library_album")),
|
||||
"track aggregation did not use the scoped album index: {plan:#?}"
|
||||
);
|
||||
assert!(
|
||||
plan.iter()
|
||||
.any(|detail| detail.contains("sqlite_autoindex_album_browse_projection_1")),
|
||||
"projection join did not use its primary-key index: {plan:#?}"
|
||||
);
|
||||
assert!(
|
||||
!plan
|
||||
.iter()
|
||||
.any(|detail| detail == "SCAN t" || detail.contains("SCAN track")),
|
||||
"scoped query plan contains an unindexed track scan: {plan:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -90,91 +90,11 @@ impl LibrarySyncProgressPayload {
|
||||
pub const IDLE_EVENT_NAME: &'static str = "library:sync-idle";
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibrarySyncIdlePayload {
|
||||
pub server_id: String,
|
||||
pub library_scope: String,
|
||||
pub kind: String,
|
||||
pub source: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_id: Option<String>,
|
||||
pub ok: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl LibrarySyncIdlePayload {
|
||||
pub fn ok(server_id: &str, library_scope: &str, kind: &str, source: &str) -> Self {
|
||||
Self {
|
||||
server_id: server_id.to_string(),
|
||||
library_scope: library_scope.to_string(),
|
||||
kind: kind.to_string(),
|
||||
source: source.to_string(),
|
||||
job_id: None,
|
||||
ok: true,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn err(
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
kind: &str,
|
||||
source: &str,
|
||||
message: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
server_id: server_id.to_string(),
|
||||
library_scope: library_scope.to_string(),
|
||||
kind: kind.to_string(),
|
||||
source: source.to_string(),
|
||||
job_id: None,
|
||||
ok: false,
|
||||
error: Some(message.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_job_id(mut self, job_id: &str) -> Self {
|
||||
self.job_id = Some(job_id.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::RemapEntry;
|
||||
|
||||
#[test]
|
||||
fn idle_payload_serializes_sync_source() {
|
||||
let foreground = serde_json::to_value(LibrarySyncIdlePayload::ok(
|
||||
"s1",
|
||||
"scope",
|
||||
"delta_sync",
|
||||
"foreground",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(foreground["source"], "foreground");
|
||||
|
||||
let foreground_job = serde_json::to_value(
|
||||
LibrarySyncIdlePayload::ok("s1", "scope", "delta_sync", "foreground")
|
||||
.with_job_id("job-1"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(foreground_job["jobId"], "job-1");
|
||||
|
||||
let background = serde_json::to_value(LibrarySyncIdlePayload::err(
|
||||
"s1",
|
||||
"scope",
|
||||
"delta_sync",
|
||||
"background",
|
||||
"failed",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(background["source"], "background");
|
||||
assert!(background.get("jobId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_changed_maps_to_phase_kind() {
|
||||
let p = LibrarySyncProgressPayload::from_event(
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
//! Bounded random artist samples for local-only Home discovery.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use crate::dto::LibraryArtistDto;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
pub const RANDOM_ARTISTS_LIMIT: u32 = 50;
|
||||
|
||||
pub fn list_random_artists(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<LibraryArtistDto>, String> {
|
||||
let limit = limit.unwrap_or(RANDOM_ARTISTS_LIMIT).clamp(1, RANDOM_ARTISTS_LIMIT);
|
||||
let (artists, timing) = store
|
||||
.with_read_conn_timed(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT server_id, id, name, name_sort, album_count, synced_at, raw_json \
|
||||
FROM artist WHERE server_id = ?1 ORDER BY RANDOM() LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![server_id, i64::from(limit)], |row| {
|
||||
let raw_json = row
|
||||
.get::<_, Option<String>>(6)?
|
||||
.and_then(|raw| serde_json::from_str(&raw).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
Ok(LibraryArtistDto {
|
||||
server_id: row.get(0)?,
|
||||
id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
name_sort: row.get(3)?,
|
||||
album_count: row.get(4)?,
|
||||
synced_at: row.get(5)?,
|
||||
raw_json,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>();
|
||||
rows
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
let blocked_by = timing
|
||||
.blocked_by
|
||||
.map(|owner| format!("{}:{}", owner.file, owner.line))
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
crate::app_deprintln!(
|
||||
"[library-db][random-artists] server={} lock_wait_ms={} query_ms={} blocked_by={}",
|
||||
server_id,
|
||||
timing.lock_wait_ms,
|
||||
timing.exec_ms,
|
||||
blocked_by,
|
||||
);
|
||||
Ok(artists)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn insert_artists(store: &LibraryStore, server_id: &str, count: u32) {
|
||||
store.with_conn_mut("random_artists.test", |conn| {
|
||||
for index in 0..count {
|
||||
conn.execute(
|
||||
"INSERT INTO artist (server_id, id, name, synced_at) VALUES (?1, ?2, ?3, 1)",
|
||||
params![server_id, format!("artist-{index}"), format!("Artist {index}")],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_only_the_requested_server() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artists(&store, "server-a", 5);
|
||||
insert_artists(&store, "server-b", 5);
|
||||
|
||||
let artists = list_random_artists(&store, "server-a", Some(5)).unwrap();
|
||||
|
||||
assert_eq!(artists.len(), 5);
|
||||
assert!(artists.iter().all(|artist| artist.server_id == "server-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_the_sample_to_fifty_rows() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artists(&store, "server-a", RANDOM_ARTISTS_LIMIT + 10);
|
||||
|
||||
let artists = list_random_artists(&store, "server-a", Some(500)).unwrap();
|
||||
|
||||
assert_eq!(artists.len(), RANDOM_ARTISTS_LIMIT as usize);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
//! `artist` table — browse index rows from `getArtists` and track-derived backfill.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::{params, OptionalExtension, Transaction};
|
||||
use rusqlite::{params, Transaction};
|
||||
|
||||
use crate::artist_sort::{ignored_articles_or_default, sort_key_for_display_name};
|
||||
use crate::store::LibraryStore;
|
||||
@@ -28,18 +26,8 @@ impl<'a> ArtistRepository<'a> {
|
||||
let mut count = 0u32;
|
||||
self.store.with_conn_mut("artist.upsert_index", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut changed_identity = HashSet::new();
|
||||
let mut previous_name = tx.prepare_cached(
|
||||
"SELECT name FROM artist WHERE server_id = ?1 AND id = ?2",
|
||||
)?;
|
||||
for bucket in &index.index {
|
||||
for artist in &bucket.artist {
|
||||
let previous = previous_name
|
||||
.query_row(params![server_id, artist.id], |row| row.get::<_, String>(0))
|
||||
.optional()?;
|
||||
if previous.as_deref() != Some(artist.name.as_str()) {
|
||||
changed_identity.insert(artist.id.as_str());
|
||||
}
|
||||
let name_sort = sort_key_for_display_name(&artist.name, ignored);
|
||||
upsert_artist_row(
|
||||
&tx,
|
||||
@@ -53,8 +41,6 @@ impl<'a> ArtistRepository<'a> {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
drop(previous_name);
|
||||
crate::identity::record_artists(&tx, server_id, changed_identity)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -104,11 +90,6 @@ impl<'a> ArtistRepository<'a> {
|
||||
upsert_artist_row(&tx, server_id, id, name, &name_sort, None, synced_at)?;
|
||||
count += 1;
|
||||
}
|
||||
crate::identity::record_artists(
|
||||
&tx,
|
||||
server_id,
|
||||
rows.iter().map(|(id, _)| id.as_str()),
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -167,15 +148,14 @@ fn upsert_artist_row(
|
||||
synced_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
tx.execute(
|
||||
"INSERT INTO artist (server_id, id, name, name_sort, name_fold, album_count, synced_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
|
||||
"INSERT INTO artist (server_id, id, name, name_sort, album_count, synced_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
|
||||
ON CONFLICT(server_id, id) DO UPDATE SET \
|
||||
name = excluded.name, \
|
||||
name_sort = excluded.name_sort, \
|
||||
name_fold = excluded.name_fold, \
|
||||
album_count = COALESCE(excluded.album_count, artist.album_count), \
|
||||
synced_at = excluded.synced_at",
|
||||
params![server_id, id, name, name_sort, name.trim().to_lowercase(), album_count, synced_at],
|
||||
name = excluded.name, \
|
||||
name_sort = excluded.name_sort, \
|
||||
album_count = COALESCE(excluded.album_count, artist.album_count), \
|
||||
synced_at = excluded.synced_at",
|
||||
params![server_id, id, name, name_sort, album_count, synced_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -73,17 +73,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
cursor: &Value,
|
||||
) -> Result<(), String> {
|
||||
let json = serde_json::to_string(cursor).map_err(|e| e.to_string())?;
|
||||
self.store
|
||||
.with_conn("sync_state.set_initial_sync_cursor", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json) \
|
||||
self.store.with_conn("sync_state.set_initial_sync_cursor", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
initial_sync_cursor_json = excluded.initial_sync_cursor_json",
|
||||
params![server_id, library_scope, json],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, json],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Single write-lock acquisition for cursor + local count during ingest.
|
||||
@@ -108,33 +107,6 @@ impl<'a> SyncStateRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically publish a completed initial sync. Keeping the cursor clear,
|
||||
/// count snapshot, timestamp, and ready phase in one statement prevents a
|
||||
/// crash from exposing `ready` with an ingest cursor still present.
|
||||
pub fn complete_initial_sync(
|
||||
&self,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
local_track_count: i64,
|
||||
finished_at: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.complete_initial_sync", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, local_track_count, \
|
||||
last_full_sync_at, initial_sync_cursor_json, sync_phase, last_error) \
|
||||
VALUES (?1, ?2, ?3, ?4, '{}', 'ready', NULL) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
local_track_count = excluded.local_track_count, \
|
||||
last_full_sync_at = excluded.last_full_sync_at, \
|
||||
initial_sync_cursor_json = '{}', \
|
||||
sync_phase = 'ready', \
|
||||
last_error = NULL",
|
||||
params![server_id, library_scope, local_track_count, finished_at],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `capability_flags` (spec §6.1.1). Returns `None` when the
|
||||
/// row doesn't exist; SQL DEFAULT is 0 so a freshly-ensured row
|
||||
/// reads back as `Some(0)`.
|
||||
@@ -162,17 +134,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
flags: u32,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_capability_flags", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, capability_flags) \
|
||||
self.store.with_conn("sync_state.set_capability_flags", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, capability_flags) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
capability_flags = excluded.capability_flags",
|
||||
params![server_id, library_scope, flags as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, flags as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `sync_phase` (state-machine values per spec §6.2:
|
||||
@@ -233,26 +204,6 @@ impl<'a> SyncStateRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Change `sync_phase` only when it still matches `expected_phase`.
|
||||
/// Returns `false` when another writer advanced the state first.
|
||||
pub(crate) fn set_sync_phase_if(
|
||||
&self,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
expected_phase: &str,
|
||||
phase: &str,
|
||||
) -> Result<bool, String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_sync_phase_if", |conn| {
|
||||
let changed = conn.execute(
|
||||
"UPDATE sync_state SET sync_phase = ?3 \
|
||||
WHERE server_id = ?1 AND library_scope = ?2 AND sync_phase = ?4",
|
||||
params![server_id, library_scope, phase, expected_phase],
|
||||
)?;
|
||||
Ok(changed == 1)
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `server_last_scan_iso` — server-reported timestamp of the
|
||||
/// last completed scan, captured from `getScanStatus.lastScan`.
|
||||
pub fn set_server_last_scan_iso(
|
||||
@@ -261,17 +212,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_scan_iso: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_server_last_scan_iso", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, server_last_scan_iso) \
|
||||
self.store.with_conn("sync_state.set_server_last_scan_iso", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, server_last_scan_iso) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
server_last_scan_iso = excluded.server_last_scan_iso",
|
||||
params![server_id, library_scope, last_scan_iso],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, last_scan_iso],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `indexes_last_modified_ms` — watermark for the file-tree
|
||||
@@ -282,17 +232,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_modified_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_indexes_last_modified_ms", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, indexes_last_modified_ms) \
|
||||
self.store.with_conn("sync_state.set_indexes_last_modified_ms", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, indexes_last_modified_ms) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
indexes_last_modified_ms = excluded.indexes_last_modified_ms",
|
||||
params![server_id, library_scope, last_modified_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, last_modified_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `artists_last_modified_ms`. Returns `None` when the row
|
||||
@@ -426,17 +375,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
stats: &Value,
|
||||
) -> Result<(), String> {
|
||||
let json = serde_json::to_string(stats).map_err(|e| e.to_string())?;
|
||||
self.store
|
||||
.with_conn("sync_state.set_poll_stats_json", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, poll_stats_json) \
|
||||
self.store.with_conn("sync_state.set_poll_stats_json", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, poll_stats_json) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
poll_stats_json = excluded.poll_stats_json",
|
||||
params![server_id, library_scope, json],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, json],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `local_track_count` snapshot (counts kept in sync by C8 /
|
||||
@@ -464,17 +412,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
count: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_local_track_count", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, local_track_count) \
|
||||
self.store.with_conn("sync_state.set_local_track_count", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, local_track_count) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
local_track_count = excluded.local_track_count",
|
||||
params![server_id, library_scope, count],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, count],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_server_track_count(
|
||||
@@ -500,17 +447,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
count: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_server_track_count", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, server_track_count) \
|
||||
self.store.with_conn("sync_state.set_server_track_count", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, server_track_count) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
server_track_count = excluded.server_track_count",
|
||||
params![server_id, library_scope, count],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, count],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `n1_bulk_unreliable` — per-server learned flag (R7-15). When
|
||||
@@ -542,17 +488,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
unreliable: bool,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_n1_bulk_unreliable", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, n1_bulk_unreliable) \
|
||||
self.store.with_conn("sync_state.set_n1_bulk_unreliable", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, n1_bulk_unreliable) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
n1_bulk_unreliable = excluded.n1_bulk_unreliable",
|
||||
params![server_id, library_scope, unreliable as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, unreliable as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Stamp `last_full_sync_at = now` (epoch ms). Called by IS-6 when
|
||||
@@ -563,17 +508,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_last_full_sync_at", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_full_sync_at) \
|
||||
self.store.with_conn("sync_state.set_last_full_sync_at", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_full_sync_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
last_full_sync_at = excluded.last_full_sync_at",
|
||||
params![server_id, library_scope, epoch_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, epoch_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Stamp `last_delta_sync_at = now` (epoch ms). Called by DS-9 at
|
||||
@@ -584,17 +528,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_last_delta_sync_at", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_delta_sync_at) \
|
||||
self.store.with_conn("sync_state.set_last_delta_sync_at", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_delta_sync_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
last_delta_sync_at = excluded.last_delta_sync_at",
|
||||
params![server_id, library_scope, epoch_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, epoch_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `artists_last_modified_ms` — watermark for the ID3 path
|
||||
@@ -606,17 +549,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_modified_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_artists_last_modified_ms", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, artists_last_modified_ms) \
|
||||
self.store.with_conn("sync_state.set_artists_last_modified_ms", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, artists_last_modified_ms) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
artists_last_modified_ms = excluded.artists_last_modified_ms",
|
||||
params![server_id, library_scope, last_modified_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, last_modified_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `ignored_articles` from the last `getArtists` pass (Navidrome
|
||||
@@ -645,17 +587,16 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
ignored_articles: &str,
|
||||
) -> Result<(), String> {
|
||||
self.store
|
||||
.with_conn("sync_state.set_ignored_articles", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, ignored_articles) \
|
||||
self.store.with_conn("sync_state.set_ignored_articles", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, ignored_articles) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
ignored_articles = excluded.ignored_articles",
|
||||
params![server_id, library_scope, ignored_articles],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
params![server_id, library_scope, ignored_articles],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `library_tier` (spec §6.2.2 — `small` / `medium` / `huge`
|
||||
@@ -692,11 +633,7 @@ mod tests {
|
||||
repo.ensure("s1", "").unwrap();
|
||||
|
||||
let cursor = repo.get_initial_sync_cursor("s1", "").unwrap();
|
||||
assert_eq!(
|
||||
cursor,
|
||||
Some(json!({})),
|
||||
"DEFAULT must read back as empty object"
|
||||
);
|
||||
assert_eq!(cursor, Some(json!({})), "DEFAULT must read back as empty object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -707,9 +644,7 @@ mod tests {
|
||||
repo.ensure("s1", "").unwrap();
|
||||
|
||||
let count: i64 = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row("SELECT COUNT(*) FROM sync_state", [], |r| r.get(0))
|
||||
})
|
||||
.with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM sync_state", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
@@ -740,10 +675,8 @@ mod tests {
|
||||
fn set_overwrites_prior_cursor() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = SyncStateRepository::new(&store);
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"offset": 1}))
|
||||
.unwrap();
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"offset": 2}))
|
||||
.unwrap();
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"offset": 1})).unwrap();
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"offset": 2})).unwrap();
|
||||
let got = repo.get_initial_sync_cursor("s1", "").unwrap();
|
||||
assert_eq!(got, Some(json!({"offset": 2})));
|
||||
}
|
||||
@@ -754,8 +687,7 @@ mod tests {
|
||||
// DEFAULT-backed fields stay at their initial values across upserts.
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = SyncStateRepository::new(&store);
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"x": 1}))
|
||||
.unwrap();
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"x": 1})).unwrap();
|
||||
|
||||
// Mutate a sibling column out-of-band to detect any accidental reset.
|
||||
store
|
||||
@@ -768,8 +700,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// Second cursor write must not touch sync_phase.
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"x": 2}))
|
||||
.unwrap();
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"x": 2})).unwrap();
|
||||
let phase: String = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
@@ -786,10 +717,8 @@ mod tests {
|
||||
fn library_scope_separates_rows_per_server() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = SyncStateRepository::new(&store);
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"all": true}))
|
||||
.unwrap();
|
||||
repo.set_initial_sync_cursor("s1", "lib-1", &json!({"lib": "one"}))
|
||||
.unwrap();
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"all": true})).unwrap();
|
||||
repo.set_initial_sync_cursor("s1", "lib-1", &json!({"lib": "one"})).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
repo.get_initial_sync_cursor("s1", "").unwrap(),
|
||||
@@ -826,10 +755,7 @@ mod tests {
|
||||
let repo = SyncStateRepository::new(&store);
|
||||
repo.set_capability_flags("s1", "", 0x008).unwrap();
|
||||
// sync_phase defaulted to 'idle' on the implicit insert.
|
||||
assert_eq!(
|
||||
repo.get_sync_phase("s1", "").unwrap().as_deref(),
|
||||
Some("idle")
|
||||
);
|
||||
assert_eq!(repo.get_sync_phase("s1", "").unwrap().as_deref(), Some("idle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -837,10 +763,7 @@ mod tests {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = SyncStateRepository::new(&store);
|
||||
repo.ensure("s1", "").unwrap();
|
||||
assert_eq!(
|
||||
repo.get_sync_phase("s1", "").unwrap().as_deref(),
|
||||
Some("idle")
|
||||
);
|
||||
assert_eq!(repo.get_sync_phase("s1", "").unwrap().as_deref(), Some("idle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -856,39 +779,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_phase_conditional_transition_only_updates_expected_phase() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = SyncStateRepository::new(&store);
|
||||
repo.ensure("s1", "").unwrap();
|
||||
|
||||
assert!(!repo
|
||||
.set_sync_phase_if("s1", "", "ready", "probing")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
repo.get_sync_phase("s1", "").unwrap().as_deref(),
|
||||
Some("idle")
|
||||
);
|
||||
|
||||
assert!(repo.set_sync_phase_if("s1", "", "idle", "probing").unwrap());
|
||||
assert_eq!(
|
||||
repo.get_sync_phase("s1", "").unwrap().as_deref(),
|
||||
Some("probing")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_setters_preserve_each_other() {
|
||||
// Each setter must scope its `ON CONFLICT … DO UPDATE` to its own
|
||||
// column. Set three and read all three back unchanged.
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = SyncStateRepository::new(&store);
|
||||
repo.set_server_last_scan_iso("s1", "", Some("2026-05-01T12:00:00Z"))
|
||||
.unwrap();
|
||||
repo.set_indexes_last_modified_ms("s1", "", 1_700_000_000_000)
|
||||
.unwrap();
|
||||
repo.set_artists_last_modified_ms("s1", "", 1_700_000_500_000)
|
||||
.unwrap();
|
||||
repo.set_server_last_scan_iso("s1", "", Some("2026-05-01T12:00:00Z")).unwrap();
|
||||
repo.set_indexes_last_modified_ms("s1", "", 1_700_000_000_000).unwrap();
|
||||
repo.set_artists_last_modified_ms("s1", "", 1_700_000_500_000).unwrap();
|
||||
|
||||
let (iso, idx_ms, art_ms): (Option<String>, Option<i64>, Option<i64>) = store
|
||||
.with_conn("misc", |c| {
|
||||
@@ -941,8 +840,7 @@ mod tests {
|
||||
fn n1_bulk_unreliable_set_does_not_clobber_cursor() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = SyncStateRepository::new(&store);
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"offset": 7}))
|
||||
.unwrap();
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"offset": 7})).unwrap();
|
||||
repo.set_n1_bulk_unreliable("s1", "", true).unwrap();
|
||||
assert_eq!(
|
||||
repo.get_initial_sync_cursor("s1", "").unwrap(),
|
||||
@@ -956,8 +854,7 @@ mod tests {
|
||||
// `initial_sync_cursor_json` back to '{}'.
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = SyncStateRepository::new(&store);
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"offset": 42}))
|
||||
.unwrap();
|
||||
repo.set_initial_sync_cursor("s1", "", &json!({"offset": 42})).unwrap();
|
||||
repo.set_capability_flags("s1", "", 0x002).unwrap();
|
||||
assert_eq!(
|
||||
repo.get_initial_sync_cursor("s1", "").unwrap(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,18 +12,12 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::{
|
||||
Mutex as AsyncMutex, Notify, OwnedMutexGuard, OwnedRwLockReadGuard, OwnedRwLockWriteGuard,
|
||||
RwLock,
|
||||
};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::analysis_backfill::LibraryAnalysisProgressDto;
|
||||
use crate::store::LibraryStore;
|
||||
use crate::sync::bandwidth::PlaybackHint;
|
||||
|
||||
const CURRENT_JOB_CANCEL_GRACE: Duration = Duration::from_millis(500);
|
||||
const CURRENT_JOB_ABORT_COMPLETION_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnalysisProgressCacheEntry {
|
||||
pub value: LibraryAnalysisProgressDto,
|
||||
@@ -34,7 +28,7 @@ pub struct AnalysisProgressCacheEntry {
|
||||
/// Per-server credentials cache for the sync runner. Lives only in
|
||||
/// `LibraryRuntime` process memory; `library_sync_clear_session`
|
||||
/// removes it on logout / index disable / purge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncSession {
|
||||
pub server_id: String,
|
||||
pub base_url: String,
|
||||
@@ -60,22 +54,10 @@ pub struct CurrentJob {
|
||||
/// `"initial_sync"` or `"delta_sync"`.
|
||||
pub kind: String,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
/// Production runner task cancellation. Synthetic tests may omit it.
|
||||
pub abort_handle: Option<tokio::task::AbortHandle>,
|
||||
/// Signaled when this job's runner task finishes (success, error, or cancel).
|
||||
pub done: Arc<Notify>,
|
||||
}
|
||||
|
||||
/// Exclusive access to sync-capable database mutation.
|
||||
///
|
||||
/// The lifecycle guard prevents a new foreground job from being installed,
|
||||
/// while the scheduler guard waits for active ticks and blocks new ones.
|
||||
#[must_use]
|
||||
pub struct SyncDrainBarrier {
|
||||
_lifecycle: OwnedMutexGuard<()>,
|
||||
_scheduler: OwnedRwLockWriteGuard<()>,
|
||||
}
|
||||
|
||||
pub struct LibraryRuntime {
|
||||
pub store: Arc<LibraryStore>,
|
||||
/// Per-`server_id` sync session. Mutex over a `HashMap` — single
|
||||
@@ -88,11 +70,6 @@ pub struct LibraryRuntime {
|
||||
/// trips `cancel`; the orchestrator task clears the slot when
|
||||
/// the job's `join` returns.
|
||||
pub current_job: Mutex<Option<CurrentJob>>,
|
||||
/// Serializes foreground replacement, purge, and database swaps.
|
||||
sync_lifecycle: Arc<AsyncMutex<()>>,
|
||||
/// Scheduler ticks take shared access; destructive operations take exclusive
|
||||
/// access after draining the relevant foreground job.
|
||||
sync_activity: Arc<RwLock<()>>,
|
||||
/// Top-crate scheduler tick task watches this flag; set true on
|
||||
/// app shutdown / library index disabled.
|
||||
pub scheduler_cancel: Arc<AtomicBool>,
|
||||
@@ -110,8 +87,6 @@ impl LibraryRuntime {
|
||||
sync_sessions: Mutex::new(HashMap::new()),
|
||||
playback_hint: Mutex::new(PlaybackHint::default()),
|
||||
current_job: Mutex::new(None),
|
||||
sync_lifecycle: Arc::new(AsyncMutex::new(())),
|
||||
sync_activity: Arc::new(RwLock::new(())),
|
||||
scheduler_cancel: Arc::new(AtomicBool::new(false)),
|
||||
live_search_epoch: AtomicU64::new(0),
|
||||
analysis_progress_cache: Mutex::new(HashMap::new()),
|
||||
@@ -127,38 +102,20 @@ impl LibraryRuntime {
|
||||
self.live_search_epoch.load(Ordering::Acquire) == epoch
|
||||
}
|
||||
|
||||
pub fn install_current_job(&self, job: CurrentJob) -> Result<(), String> {
|
||||
let mut slot = self
|
||||
.current_job
|
||||
.lock()
|
||||
.map_err(|_| "library current job lock poisoned".to_string())?;
|
||||
if let Some(current) = slot.as_ref() {
|
||||
return Err(format!("sync job `{}` is still running", current.job_id));
|
||||
pub fn set_current_job(&self, job: CurrentJob) {
|
||||
if let Ok(mut slot) = self.current_job.lock() {
|
||||
// Best-effort cancel any in-flight job before we replace.
|
||||
if let Some(prev) = slot.as_ref() {
|
||||
prev.cancel.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
*slot = Some(job);
|
||||
}
|
||||
*slot = Some(job);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn current_job(&self) -> Option<CurrentJob> {
|
||||
self.current_job.lock().ok().and_then(|s| s.clone())
|
||||
}
|
||||
|
||||
pub fn attach_current_job_abort_handle(
|
||||
&self,
|
||||
job_id: &str,
|
||||
abort_handle: tokio::task::AbortHandle,
|
||||
) -> Result<(), String> {
|
||||
let mut slot = self
|
||||
.current_job
|
||||
.lock()
|
||||
.map_err(|_| "library current job lock poisoned".to_string())?;
|
||||
let Some(job) = slot.as_mut().filter(|job| job.job_id == job_id) else {
|
||||
return Err(format!("sync job `{job_id}` is no longer current"));
|
||||
};
|
||||
job.abort_handle = Some(abort_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_current_job_if_matches(&self, job_id: &str) {
|
||||
if let Ok(mut slot) = self.current_job.lock() {
|
||||
if slot.as_ref().is_some_and(|j| j.job_id == job_id) {
|
||||
@@ -167,13 +124,6 @@ impl LibraryRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the completed job before publishing the stored `Notify` permit.
|
||||
/// Waiters that wake are therefore guaranteed not to observe the old slot.
|
||||
pub fn complete_current_job(&self, job_id: &str, done: &Notify) {
|
||||
self.clear_current_job_if_matches(job_id);
|
||||
done.notify_one();
|
||||
}
|
||||
|
||||
pub fn cancel_current_job(&self) -> bool {
|
||||
if let Ok(slot) = self.current_job.lock() {
|
||||
if let Some(job) = slot.as_ref() {
|
||||
@@ -184,73 +134,6 @@ impl LibraryRuntime {
|
||||
false
|
||||
}
|
||||
|
||||
/// Cancel and await a foreground job, then wait for all scheduler ticks and
|
||||
/// block new sync-capable activity until the returned guard is dropped.
|
||||
/// `job_id` and `server_id` are optional selectors checked while holding the
|
||||
/// lifecycle lock. Scheduler writes are excluded globally in every case.
|
||||
pub async fn cancel_and_drain_sync(
|
||||
&self,
|
||||
job_id: Option<&str>,
|
||||
server_id: Option<&str>,
|
||||
) -> Result<SyncDrainBarrier, String> {
|
||||
self.cancel_and_drain_sync_with_timeouts(
|
||||
job_id,
|
||||
server_id,
|
||||
CURRENT_JOB_CANCEL_GRACE,
|
||||
CURRENT_JOB_ABORT_COMPLETION_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn cancel_and_drain_sync_with_timeouts(
|
||||
&self,
|
||||
job_id: Option<&str>,
|
||||
server_id: Option<&str>,
|
||||
grace: Duration,
|
||||
abort_completion_timeout: Duration,
|
||||
) -> Result<SyncDrainBarrier, String> {
|
||||
let lifecycle = Arc::clone(&self.sync_lifecycle).lock_owned().await;
|
||||
let current = self
|
||||
.current_job
|
||||
.lock()
|
||||
.map_err(|_| "library current job lock poisoned".to_string())?
|
||||
.clone();
|
||||
if let Some(job) = current.filter(|job| {
|
||||
job_id.is_none_or(|id| job.job_id == id)
|
||||
&& server_id.is_none_or(|id| job.server_id == id)
|
||||
}) {
|
||||
let completion = job.done.notified();
|
||||
tokio::pin!(completion);
|
||||
job.cancel.store(true, Ordering::SeqCst);
|
||||
if tokio::time::timeout(grace, &mut completion).await.is_err() {
|
||||
if let Some(abort_handle) = job.abort_handle.as_ref() {
|
||||
if !abort_handle.is_finished() {
|
||||
abort_handle.abort();
|
||||
}
|
||||
}
|
||||
if tokio::time::timeout(abort_completion_timeout, &mut completion)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(format!(
|
||||
"sync job `{}` did not stop after cancellation and abort",
|
||||
job.job_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let scheduler = Arc::clone(&self.sync_activity).write_owned().await;
|
||||
Ok(SyncDrainBarrier {
|
||||
_lifecycle: lifecycle,
|
||||
_scheduler: scheduler,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared scheduler access held for the full write-capable tick.
|
||||
pub async fn sync_activity_guard(&self) -> OwnedRwLockReadGuard<()> {
|
||||
Arc::clone(&self.sync_activity).read_owned().await
|
||||
}
|
||||
|
||||
/// Snapshot all bound sessions — used by the scheduler tick task
|
||||
/// in the top crate so it doesn't hold the mutex across an `await`.
|
||||
pub fn snapshot_sessions(&self) -> Vec<SyncSession> {
|
||||
@@ -267,13 +150,10 @@ impl LibraryRuntime {
|
||||
.and_then(|s| s.get(server_id).cloned())
|
||||
}
|
||||
|
||||
pub fn set_session(&self, session: SyncSession) -> Result<(), String> {
|
||||
let mut sessions = self
|
||||
.sync_sessions
|
||||
.lock()
|
||||
.map_err(|_| "library sync session lock poisoned".to_string())?;
|
||||
sessions.insert(session.server_id.clone(), session);
|
||||
Ok(())
|
||||
pub fn set_session(&self, session: SyncSession) {
|
||||
if let Ok(mut sessions) = self.sync_sessions.lock() {
|
||||
sessions.insert(session.server_id.clone(), session);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_session(&self, server_id: &str) {
|
||||
@@ -283,7 +163,10 @@ impl LibraryRuntime {
|
||||
}
|
||||
|
||||
pub fn current_playback_hint(&self) -> PlaybackHint {
|
||||
self.playback_hint.lock().map(|h| *h).unwrap_or_default()
|
||||
self.playback_hint
|
||||
.lock()
|
||||
.map(|h| *h)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set_playback_hint(&self, hint: PlaybackHint) {
|
||||
@@ -332,7 +215,11 @@ impl LibraryRuntime {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn set_analysis_progress(&self, server_id: &str, value: LibraryAnalysisProgressDto) {
|
||||
pub fn set_analysis_progress(
|
||||
&self,
|
||||
server_id: &str,
|
||||
value: LibraryAnalysisProgressDto,
|
||||
) {
|
||||
if let Ok(mut cache) = self.analysis_progress_cache.lock() {
|
||||
cache.insert(
|
||||
server_id.to_string(),
|
||||
@@ -369,17 +256,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_job(server_id: &str, kind: &str) -> CurrentJob {
|
||||
CurrentJob {
|
||||
job_id: format!("{server_id}-{kind}"),
|
||||
server_id: server_id.into(),
|
||||
kind: kind.into(),
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
abort_handle: None,
|
||||
done: Arc::new(Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_runtime_has_empty_sessions_and_idle_hint() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
@@ -395,7 +271,7 @@ mod tests {
|
||||
fn set_and_get_session_roundtrip() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let rt = LibraryRuntime::new(store);
|
||||
rt.set_session(sample_session("s1")).unwrap();
|
||||
rt.set_session(sample_session("s1"));
|
||||
let got = rt.get_session("s1").unwrap();
|
||||
assert_eq!(got.base_url, "https://nas.example.com");
|
||||
assert_eq!(got.username, "u");
|
||||
@@ -405,8 +281,8 @@ mod tests {
|
||||
fn clear_session_removes_one_server_only() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let rt = LibraryRuntime::new(store);
|
||||
rt.set_session(sample_session("s1")).unwrap();
|
||||
rt.set_session(sample_session("s2")).unwrap();
|
||||
rt.set_session(sample_session("s1"));
|
||||
rt.set_session(sample_session("s2"));
|
||||
rt.clear_session("s1");
|
||||
assert!(rt.get_session("s1").is_none());
|
||||
assert!(rt.get_session("s2").is_some());
|
||||
@@ -416,10 +292,10 @@ mod tests {
|
||||
fn snapshot_returns_clones_so_lock_drops_after_call() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let rt = LibraryRuntime::new(store);
|
||||
rt.set_session(sample_session("s1")).unwrap();
|
||||
rt.set_session(sample_session("s1"));
|
||||
let snap = rt.snapshot_sessions();
|
||||
// Should be free to mutate after the snapshot.
|
||||
rt.set_session(sample_session("s2")).unwrap();
|
||||
rt.set_session(sample_session("s2"));
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(rt.snapshot_sessions().len(), 2);
|
||||
}
|
||||
@@ -456,288 +332,4 @@ mod tests {
|
||||
"notify_waiters must not store a permit — resync drain uses notify_one instead"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancel_and_drain_replaces_any_foreground_job() {
|
||||
let runtime = Arc::new(LibraryRuntime::new(
|
||||
Arc::new(LibraryStore::open_in_memory()),
|
||||
));
|
||||
let job = sample_job("old-server", "delta_sync");
|
||||
let cancel = Arc::clone(&job.cancel);
|
||||
let done = Arc::clone(&job.done);
|
||||
let job_id = job.job_id.clone();
|
||||
runtime.install_current_job(job).unwrap();
|
||||
|
||||
let runtime_for_job = Arc::clone(&runtime);
|
||||
let task = tokio::spawn(async move {
|
||||
while !cancel.load(Ordering::SeqCst) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
runtime_for_job.complete_current_job(&job_id, &done);
|
||||
});
|
||||
|
||||
let barrier = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
runtime.cancel_and_drain_sync(None, None),
|
||||
)
|
||||
.await
|
||||
.expect("drain timed out")
|
||||
.expect("drain failed");
|
||||
assert!(runtime.current_job().is_none());
|
||||
drop(barrier);
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancel_and_drain_aborts_never_responding_runner_within_bound() {
|
||||
let runtime = Arc::new(LibraryRuntime::new(
|
||||
Arc::new(LibraryStore::open_in_memory()),
|
||||
));
|
||||
let job = sample_job("s1", "delta_sync");
|
||||
let cancel = Arc::clone(&job.cancel);
|
||||
let done = Arc::clone(&job.done);
|
||||
let job_id = job.job_id.clone();
|
||||
runtime.install_current_job(job).unwrap();
|
||||
|
||||
let runner = tokio::spawn(std::future::pending::<()>());
|
||||
runtime
|
||||
.attach_current_job_abort_handle(&job_id, runner.abort_handle())
|
||||
.unwrap();
|
||||
let runtime_for_completion = Arc::clone(&runtime);
|
||||
let completion = tokio::spawn(async move {
|
||||
let _ = runner.await;
|
||||
runtime_for_completion.complete_current_job(&job_id, &done);
|
||||
});
|
||||
|
||||
let barrier = tokio::time::timeout(
|
||||
Duration::from_millis(250),
|
||||
runtime.cancel_and_drain_sync_with_timeouts(
|
||||
None,
|
||||
None,
|
||||
Duration::from_millis(10),
|
||||
Duration::from_millis(100),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("bounded drain hung")
|
||||
.expect("abortable runner did not drain");
|
||||
assert!(cancel.load(Ordering::SeqCst));
|
||||
assert!(runtime.current_job().is_none());
|
||||
drop(barrier);
|
||||
completion.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancel_and_drain_fails_bounded_when_synthetic_job_cannot_abort() {
|
||||
let runtime = LibraryRuntime::new(Arc::new(LibraryStore::open_in_memory()));
|
||||
let job = sample_job("s1", "delta_sync");
|
||||
let cancel = Arc::clone(&job.cancel);
|
||||
runtime.install_current_job(job).unwrap();
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_millis(250),
|
||||
runtime.cancel_and_drain_sync_with_timeouts(
|
||||
None,
|
||||
None,
|
||||
Duration::from_millis(10),
|
||||
Duration::from_millis(20),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("bounded drain hung");
|
||||
let error = match result {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("non-abortable synthetic job unexpectedly drained"),
|
||||
};
|
||||
assert!(error.contains("did not stop"));
|
||||
assert!(cancel.load(Ordering::SeqCst));
|
||||
assert!(runtime.current_job().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn exclusive_barrier_waits_for_scheduler_activity() {
|
||||
let runtime = Arc::new(LibraryRuntime::new(
|
||||
Arc::new(LibraryStore::open_in_memory()),
|
||||
));
|
||||
let scheduler = runtime.sync_activity_guard().await;
|
||||
let acquired = Arc::new(AtomicBool::new(false));
|
||||
let acquired_for_task = Arc::clone(&acquired);
|
||||
let runtime_for_task = Arc::clone(&runtime);
|
||||
let task = tokio::spawn(async move {
|
||||
let barrier = runtime_for_task
|
||||
.cancel_and_drain_sync(None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
acquired_for_task.store(true, Ordering::SeqCst);
|
||||
barrier
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
assert!(!acquired.load(Ordering::SeqCst));
|
||||
drop(scheduler);
|
||||
|
||||
let barrier = tokio::time::timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("barrier stayed blocked")
|
||||
.unwrap();
|
||||
assert!(acquired.load(Ordering::SeqCst));
|
||||
drop(barrier);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn stale_job_selector_does_not_cancel_replacement() {
|
||||
let runtime = LibraryRuntime::new(Arc::new(LibraryStore::open_in_memory()));
|
||||
let job = sample_job("s1", "delta_sync");
|
||||
let cancel = Arc::clone(&job.cancel);
|
||||
let done = Arc::clone(&job.done);
|
||||
let job_id = job.job_id.clone();
|
||||
runtime.install_current_job(job).unwrap();
|
||||
|
||||
let barrier = runtime
|
||||
.cancel_and_drain_sync(Some("already-finished"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!cancel.load(Ordering::SeqCst));
|
||||
assert_eq!(runtime.current_job().unwrap().job_id, job_id);
|
||||
drop(barrier);
|
||||
|
||||
runtime.complete_current_job(&job_id, &done);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn database_swap_drains_http_waiting_job_before_switching_files() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/in-flight"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_millis(200))
|
||||
.set_body_string("ok"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"psysonic-library-drain-swap-{}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let active_path = root.join("library.sqlite");
|
||||
let import_path = root.join("library-import.sqlite");
|
||||
|
||||
let store = Arc::new(LibraryStore::open_path_for_test(&active_path).unwrap());
|
||||
store
|
||||
.with_conn("test.seed-active", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track (server_id, id, title, album, synced_at, raw_json) \
|
||||
VALUES ('s1', 'before', 'Before', '', 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
{
|
||||
let imported = LibraryStore::open_path_for_test(&import_path).unwrap();
|
||||
imported
|
||||
.with_conn("test.seed-import", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track (server_id, id, title, album, synced_at, raw_json) \
|
||||
VALUES ('s1', 'imported', 'Imported', '', 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
imported
|
||||
.checkpoint_wal("test.seed-import.checkpoint")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let runtime = Arc::new(LibraryRuntime::new(store));
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let done = Arc::new(Notify::new());
|
||||
let job_id = "http-writer".to_string();
|
||||
runtime
|
||||
.install_current_job(CurrentJob {
|
||||
job_id: job_id.clone(),
|
||||
server_id: "s1".into(),
|
||||
kind: "initial_sync".into(),
|
||||
cancel: Arc::clone(&cancel),
|
||||
abort_handle: None,
|
||||
done: Arc::clone(&done),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let runtime_for_job = Arc::clone(&runtime);
|
||||
let request_url = format!("{}/in-flight", server.uri());
|
||||
let writer = tokio::spawn(async move {
|
||||
reqwest::get(request_url)
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
runtime_for_job
|
||||
.store
|
||||
.with_conn("test.late-write", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track (server_id, id, title, album, synced_at, raw_json) \
|
||||
VALUES ('s1', 'late', 'Late', '', 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
runtime_for_job.complete_current_job(&job_id, &done);
|
||||
});
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("requests captured")
|
||||
.is_empty()
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("HTTP request did not start");
|
||||
|
||||
let barrier = runtime.cancel_and_drain_sync(None, None).await.unwrap();
|
||||
assert!(cancel.load(Ordering::SeqCst));
|
||||
runtime
|
||||
.store
|
||||
.swap_database_file(&active_path, &import_path)
|
||||
.unwrap()
|
||||
.expect("active database backup");
|
||||
drop(barrier);
|
||||
writer.await.unwrap();
|
||||
|
||||
let ids = runtime
|
||||
.store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare("SELECT id FROM track ORDER BY id")?;
|
||||
let ids = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(ids)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(ids, vec!["imported"]);
|
||||
|
||||
drop(runtime);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,870 +0,0 @@
|
||||
//! Candidate-first, cursor-paginated browse over ordered library scopes.
|
||||
//!
|
||||
//! Advanced Search remains responsible for FTS and arbitrary compound filters.
|
||||
//! This module serves ordinary catalogue pages from materialized/indexed rows.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::{params_from_iter, types::Value as SqlValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::{
|
||||
LibraryAlbumDto, LibraryScopeBrowseEntity, LibraryScopeBrowseRequest,
|
||||
LibraryScopeBrowseResponse, LibraryScopePair, LibrarySortClause, LibraryTrackDto,
|
||||
};
|
||||
use crate::repos::{row_to_track_row, TrackRow};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
const CANDIDATE_PAGE_SIZE: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
enum AlbumSort {
|
||||
Name,
|
||||
Artist,
|
||||
ArtistYear,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct AlbumCursor {
|
||||
scope_key: String,
|
||||
sort: AlbumSort,
|
||||
positions: Vec<Option<AlbumCursorPosition>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct AlbumCursorPosition {
|
||||
name: String,
|
||||
artist: String,
|
||||
year: i64,
|
||||
album_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AlbumCandidate {
|
||||
priority: usize,
|
||||
server_id: String,
|
||||
library_id: String,
|
||||
album_id: String,
|
||||
identity_key: Option<String>,
|
||||
name: String,
|
||||
artist: Option<String>,
|
||||
artist_id: Option<String>,
|
||||
song_count: i64,
|
||||
duration_sec: i64,
|
||||
year: Option<i64>,
|
||||
genre: Option<String>,
|
||||
cover_art_id: Option<String>,
|
||||
starred_at: Option<i64>,
|
||||
synced_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TrackCursor {
|
||||
scope_key: String,
|
||||
positions: Vec<Option<TrackCursorPosition>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TrackCursorPosition {
|
||||
title: String,
|
||||
track_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TrackCandidate {
|
||||
priority: usize,
|
||||
library_id: String,
|
||||
track: TrackRow,
|
||||
identity_key: Option<String>,
|
||||
}
|
||||
|
||||
fn album_sort(sort: &[LibrarySortClause]) -> Result<AlbumSort, String> {
|
||||
let fields: Vec<&str> = sort.iter().map(|clause| clause.field.as_str()).collect();
|
||||
match fields.as_slice() {
|
||||
[] | ["name"] | ["name", "artist"] => Ok(AlbumSort::Name),
|
||||
["artist"] | ["artist", "name"] => Ok(AlbumSort::Artist),
|
||||
["artist", "year"] | ["artist", "year", "name"] => Ok(AlbumSort::ArtistYear),
|
||||
_ => Err("unsupported scope browse album sort".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn order_sql(sort: AlbumSort) -> &'static str {
|
||||
match sort {
|
||||
AlbumSort::Name => {
|
||||
"name COLLATE NOCASE ASC, COALESCE(artist, '') COLLATE NOCASE ASC, album_id ASC"
|
||||
}
|
||||
AlbumSort::Artist => {
|
||||
"COALESCE(artist, '') COLLATE NOCASE ASC, name COLLATE NOCASE ASC, album_id ASC"
|
||||
}
|
||||
AlbumSort::ArtistYear => {
|
||||
"COALESCE(artist, '') COLLATE NOCASE ASC, COALESCE(year, 0) ASC, name COLLATE NOCASE ASC, album_id ASC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_cmp(sort: AlbumSort, a: &AlbumCandidate, b: &AlbumCandidate) -> Ordering {
|
||||
let fold = |value: &str| value.to_lowercase();
|
||||
let by_name = || fold(&a.name).cmp(&fold(&b.name));
|
||||
let by_artist = || fold(a.artist.as_deref().unwrap_or("")).cmp(&fold(b.artist.as_deref().unwrap_or("")));
|
||||
let order = match sort {
|
||||
AlbumSort::Name => by_name().then_with(by_artist),
|
||||
AlbumSort::Artist => by_artist().then_with(by_name),
|
||||
AlbumSort::ArtistYear => by_artist()
|
||||
.then_with(|| a.year.unwrap_or(0).cmp(&b.year.unwrap_or(0)))
|
||||
.then_with(by_name),
|
||||
};
|
||||
order
|
||||
.then_with(|| a.priority.cmp(&b.priority))
|
||||
.then_with(|| a.server_id.cmp(&b.server_id))
|
||||
.then_with(|| a.library_id.cmp(&b.library_id))
|
||||
.then_with(|| a.album_id.cmp(&b.album_id))
|
||||
}
|
||||
|
||||
fn candidate_to_dto(candidate: AlbumCandidate) -> LibraryAlbumDto {
|
||||
LibraryAlbumDto {
|
||||
server_id: candidate.server_id,
|
||||
id: candidate.album_id,
|
||||
name: candidate.name,
|
||||
artist: candidate.artist,
|
||||
artist_id: candidate.artist_id,
|
||||
song_count: Some(candidate.song_count),
|
||||
duration_sec: Some(candidate.duration_sec),
|
||||
year: candidate.year,
|
||||
genre: candidate.genre,
|
||||
cover_art_id: candidate.cover_art_id,
|
||||
starred_at: candidate.starred_at,
|
||||
synced_at: candidate.synced_at,
|
||||
raw_json: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn scope_key(scopes: &[LibraryScopePair]) -> String {
|
||||
scopes
|
||||
.iter()
|
||||
.map(|scope| {
|
||||
format!(
|
||||
"{}\u{1f}{}",
|
||||
scope.server_id,
|
||||
scope.library_id.as_deref().unwrap_or("\u{0}")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\u{1e}")
|
||||
}
|
||||
|
||||
fn parse_cursor(
|
||||
cursor: Option<&str>,
|
||||
scopes: &[LibraryScopePair],
|
||||
sort: AlbumSort,
|
||||
) -> Result<Option<AlbumCursor>, String> {
|
||||
let Some(raw) = cursor else {
|
||||
return Ok(None);
|
||||
};
|
||||
let parsed: AlbumCursor = serde_json::from_str(raw).map_err(|_| "invalid scope browse cursor")?;
|
||||
if parsed.scope_key != scope_key(scopes) || parsed.sort != sort || parsed.positions.len() != scopes.len() {
|
||||
return Err("scope browse cursor does not match the current scope or sort".into());
|
||||
}
|
||||
Ok(Some(parsed))
|
||||
}
|
||||
|
||||
fn cursor_position(candidate: &AlbumCandidate) -> AlbumCursorPosition {
|
||||
AlbumCursorPosition {
|
||||
name: candidate.name.clone(),
|
||||
artist: candidate.artist.clone().unwrap_or_default(),
|
||||
year: candidate.year.unwrap_or(0),
|
||||
album_id: candidate.album_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn seek_sql(sort: AlbumSort, position: Option<&AlbumCursorPosition>) -> (String, Vec<SqlValue>) {
|
||||
let Some(position) = position else {
|
||||
return (String::new(), Vec::new());
|
||||
};
|
||||
match sort {
|
||||
AlbumSort::Name => (
|
||||
"AND (name COLLATE NOCASE > ? OR (name COLLATE NOCASE = ? AND (COALESCE(artist, '') COLLATE NOCASE > ? OR (COALESCE(artist, '') COLLATE NOCASE = ? AND album_id > ?))))".into(),
|
||||
vec![
|
||||
SqlValue::Text(position.name.clone()),
|
||||
SqlValue::Text(position.name.clone()),
|
||||
SqlValue::Text(position.artist.clone()),
|
||||
SqlValue::Text(position.artist.clone()),
|
||||
SqlValue::Text(position.album_id.clone()),
|
||||
],
|
||||
),
|
||||
AlbumSort::Artist => (
|
||||
"AND (COALESCE(artist, '') COLLATE NOCASE > ? OR (COALESCE(artist, '') COLLATE NOCASE = ? AND (name COLLATE NOCASE > ? OR (name COLLATE NOCASE = ? AND album_id > ?))))".into(),
|
||||
vec![
|
||||
SqlValue::Text(position.artist.clone()),
|
||||
SqlValue::Text(position.artist.clone()),
|
||||
SqlValue::Text(position.name.clone()),
|
||||
SqlValue::Text(position.name.clone()),
|
||||
SqlValue::Text(position.album_id.clone()),
|
||||
],
|
||||
),
|
||||
AlbumSort::ArtistYear => (
|
||||
"AND (COALESCE(artist, '') COLLATE NOCASE > ? OR (COALESCE(artist, '') COLLATE NOCASE = ? AND (COALESCE(year, 0) > ? OR (COALESCE(year, 0) = ? AND (name COLLATE NOCASE > ? OR (name COLLATE NOCASE = ? AND album_id > ?))))))".into(),
|
||||
vec![
|
||||
SqlValue::Text(position.artist.clone()),
|
||||
SqlValue::Text(position.artist.clone()),
|
||||
SqlValue::Integer(position.year),
|
||||
SqlValue::Integer(position.year),
|
||||
SqlValue::Text(position.name.clone()),
|
||||
SqlValue::Text(position.name.clone()),
|
||||
SqlValue::Text(position.album_id.clone()),
|
||||
],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_scope_candidates(
|
||||
store: &LibraryStore,
|
||||
pair: &LibraryScopePair,
|
||||
priority: usize,
|
||||
sort: AlbumSort,
|
||||
cursor_position: Option<&AlbumCursorPosition>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AlbumCandidate>, String> {
|
||||
let (seek, mut binds) = seek_sql(sort, cursor_position);
|
||||
let library_filter = if pair.library_id.is_some() {
|
||||
" AND library_id = ?"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT server_id, library_id, album_id, identity_key, name, artist, artist_id, song_count, \
|
||||
duration_sec, year, genre, cover_art_id, starred_at, synced_at \
|
||||
FROM album_browse_projection \
|
||||
WHERE server_id = ? {library_filter} {seek} \
|
||||
ORDER BY {} LIMIT ?",
|
||||
order_sql(sort),
|
||||
);
|
||||
let mut scope_binds = vec![SqlValue::Text(pair.server_id.clone())];
|
||||
if let Some(library_id) = &pair.library_id {
|
||||
scope_binds.push(SqlValue::Text(library_id.clone()));
|
||||
}
|
||||
binds.splice(0..0, scope_binds);
|
||||
binds.push(SqlValue::Integer(limit as i64));
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(
|
||||
params_from_iter(binds.iter()),
|
||||
|row| {
|
||||
Ok(AlbumCandidate {
|
||||
priority,
|
||||
server_id: row.get(0)?,
|
||||
library_id: row.get(1)?,
|
||||
album_id: row.get(2)?,
|
||||
identity_key: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
artist: row.get(5)?,
|
||||
artist_id: row.get(6)?,
|
||||
song_count: row.get(7)?,
|
||||
duration_sec: row.get(8)?,
|
||||
year: row.get(9)?,
|
||||
genre: row.get(10)?,
|
||||
cover_art_id: row.get(11)?,
|
||||
starred_at: row.get(12)?,
|
||||
synced_at: row.get(13)?,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn exists_in_higher_priority_scope(
|
||||
store: &LibraryStore,
|
||||
scopes: &[LibraryScopePair],
|
||||
priority: usize,
|
||||
identity_key: &str,
|
||||
) -> Result<bool, String> {
|
||||
if priority == 0 || identity_key.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let clauses = scopes
|
||||
.iter()
|
||||
.take(priority)
|
||||
.map(|scope| {
|
||||
if scope.library_id.is_some() {
|
||||
"(server_id = ? AND library_id = ?)"
|
||||
} else {
|
||||
"(server_id = ?)"
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ");
|
||||
let sql = format!(
|
||||
"SELECT 1 FROM album_browse_projection \
|
||||
WHERE identity_key = ? AND ({clauses}) LIMIT 1",
|
||||
);
|
||||
let mut binds = vec![SqlValue::Text(identity_key.to_string())];
|
||||
for scope in scopes.iter().take(priority) {
|
||||
binds.push(SqlValue::Text(scope.server_id.clone()));
|
||||
if let Some(library_id) = &scope.library_id {
|
||||
binds.push(SqlValue::Text(library_id.clone()));
|
||||
}
|
||||
}
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let present = conn
|
||||
.query_row(&sql, params_from_iter(binds.iter()), |_| Ok(()))
|
||||
.is_ok();
|
||||
Ok(present)
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn browse_albums(
|
||||
store: &LibraryStore,
|
||||
request: &LibraryScopeBrowseRequest,
|
||||
) -> Result<LibraryScopeBrowseResponse, String> {
|
||||
let sort = album_sort(&request.sort)?;
|
||||
let cursor = parse_cursor(request.cursor.as_deref(), &request.scopes, sort)?;
|
||||
if cursor.is_none() {
|
||||
crate::scope_merge::ensure_cluster_keys_for_scopes(store, &request.scopes)?;
|
||||
}
|
||||
let limit = request.limit.clamp(1, 200) as usize;
|
||||
let candidate_limit = CANDIDATE_PAGE_SIZE.max(limit.saturating_add(1));
|
||||
let mut candidates = Vec::with_capacity(request.scopes.len());
|
||||
let mut stream_exhausted = Vec::with_capacity(request.scopes.len());
|
||||
for (priority, scope) in request.scopes.iter().enumerate() {
|
||||
let stream = query_scope_candidates(
|
||||
store,
|
||||
scope,
|
||||
priority,
|
||||
sort,
|
||||
cursor.as_ref().and_then(|cursor| cursor.positions.get(priority)).and_then(Option::as_ref),
|
||||
candidate_limit,
|
||||
)?;
|
||||
stream_exhausted.push(stream.len() < candidate_limit);
|
||||
candidates.push(stream);
|
||||
}
|
||||
|
||||
let mut albums = Vec::with_capacity(limit.saturating_add(1));
|
||||
let mut offsets = vec![0usize; candidates.len()];
|
||||
let mut positions = cursor
|
||||
.map(|cursor| cursor.positions)
|
||||
.unwrap_or_else(|| vec![None; request.scopes.len()]);
|
||||
while albums.len() < limit {
|
||||
for scope_index in 0..candidates.len() {
|
||||
if offsets[scope_index] < candidates[scope_index].len() || stream_exhausted[scope_index] {
|
||||
continue;
|
||||
}
|
||||
let stream = query_scope_candidates(
|
||||
store,
|
||||
&request.scopes[scope_index],
|
||||
scope_index,
|
||||
sort,
|
||||
positions[scope_index].as_ref(),
|
||||
candidate_limit,
|
||||
)?;
|
||||
stream_exhausted[scope_index] = stream.len() < candidate_limit;
|
||||
candidates[scope_index] = stream;
|
||||
offsets[scope_index] = 0;
|
||||
}
|
||||
let next_scope = candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, stream)| offsets[*index] < stream.len())
|
||||
.min_by(|(left_index, left_stream), (right_index, right_stream)| {
|
||||
candidate_cmp(
|
||||
sort,
|
||||
&left_stream[offsets[*left_index]],
|
||||
&right_stream[offsets[*right_index]],
|
||||
)
|
||||
})
|
||||
.map(|(index, _)| index);
|
||||
let Some(scope_index) = next_scope else { break; };
|
||||
let candidate = &candidates[scope_index][offsets[scope_index]];
|
||||
offsets[scope_index] += 1;
|
||||
positions[scope_index] = Some(cursor_position(candidate));
|
||||
if let Some(identity_key) = candidate.identity_key.as_deref() {
|
||||
if exists_in_higher_priority_scope(store, &request.scopes, candidate.priority, identity_key)? {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
albums.push(candidate_to_dto(candidate.clone()));
|
||||
}
|
||||
let has_more = candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, stream)| offsets[index] < stream.len() || !stream_exhausted[index]);
|
||||
let next_cursor = has_more.then(|| {
|
||||
serde_json::to_string(&AlbumCursor {
|
||||
scope_key: scope_key(&request.scopes),
|
||||
sort,
|
||||
positions,
|
||||
})
|
||||
.expect("scope browse cursor serializes")
|
||||
});
|
||||
Ok(LibraryScopeBrowseResponse {
|
||||
albums,
|
||||
artists: Vec::new(),
|
||||
tracks: Vec::new(),
|
||||
next_cursor,
|
||||
has_more,
|
||||
source: "local".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_track_cursor(
|
||||
cursor: Option<&str>,
|
||||
scopes: &[LibraryScopePair],
|
||||
) -> Result<Option<TrackCursor>, String> {
|
||||
let Some(raw) = cursor else {
|
||||
return Ok(None);
|
||||
};
|
||||
let parsed: TrackCursor = serde_json::from_str(raw).map_err(|_| "invalid scope browse cursor")?;
|
||||
if parsed.scope_key != scope_key(scopes) || parsed.positions.len() != scopes.len() {
|
||||
return Err("scope browse cursor does not match the current scope".into());
|
||||
}
|
||||
Ok(Some(parsed))
|
||||
}
|
||||
|
||||
fn track_cursor_position(candidate: &TrackCandidate) -> TrackCursorPosition {
|
||||
TrackCursorPosition {
|
||||
title: candidate.track.title.clone(),
|
||||
track_id: candidate.track.id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_track_scope_candidates(
|
||||
store: &LibraryStore,
|
||||
pair: &LibraryScopePair,
|
||||
priority: usize,
|
||||
cursor_position: Option<&TrackCursorPosition>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<TrackCandidate>, String> {
|
||||
let (seek, mut binds) = match cursor_position {
|
||||
Some(position) => (
|
||||
"AND (t.title COLLATE NOCASE > ? OR (t.title COLLATE NOCASE = ? AND t.id > ?))",
|
||||
vec![
|
||||
SqlValue::Text(position.title.clone()),
|
||||
SqlValue::Text(position.title.clone()),
|
||||
SqlValue::Text(position.track_id.clone()),
|
||||
],
|
||||
),
|
||||
None => ("", Vec::new()),
|
||||
};
|
||||
let columns = crate::search::aliased_track_columns("t");
|
||||
let library_filter = if pair.library_id.is_some() {
|
||||
" AND t.library_id = ?"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT {columns}, CASE WHEN ck.cluster_key IS NOT NULL \
|
||||
THEN ck.cluster_key || ':' || CAST((ck.duration_sec / 5) AS TEXT) END \
|
||||
FROM track t \
|
||||
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
|
||||
WHERE t.server_id = ? {library_filter} AND t.deleted = 0 {seek} \
|
||||
ORDER BY t.title COLLATE NOCASE ASC, t.id ASC LIMIT ?",
|
||||
);
|
||||
let mut scope_binds = vec![SqlValue::Text(pair.server_id.clone())];
|
||||
if let Some(library_id) = &pair.library_id {
|
||||
scope_binds.push(SqlValue::Text(library_id.clone()));
|
||||
}
|
||||
binds.splice(0..0, scope_binds);
|
||||
binds.push(SqlValue::Integer(limit as i64));
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_from_iter(binds.iter()), |row| {
|
||||
let track = row_to_track_row(row)?;
|
||||
Ok(TrackCandidate {
|
||||
priority,
|
||||
library_id: track.library_id.clone().unwrap_or_default(),
|
||||
track,
|
||||
identity_key: row.get(crate::search::track_projection_column_count())?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn track_candidate_cmp(a: &TrackCandidate, b: &TrackCandidate) -> Ordering {
|
||||
a.track.title.to_lowercase().cmp(&b.track.title.to_lowercase())
|
||||
.then_with(|| a.priority.cmp(&b.priority))
|
||||
.then_with(|| a.track.server_id.cmp(&b.track.server_id))
|
||||
.then_with(|| a.library_id.cmp(&b.library_id))
|
||||
.then_with(|| a.track.id.cmp(&b.track.id))
|
||||
}
|
||||
|
||||
/// Resolve the highest-priority selected scope for every identity represented
|
||||
/// in the candidate streams. This keeps cursor pages correct when the winner
|
||||
/// was consumed on an earlier page, without doing an `EXISTS` query per row.
|
||||
fn track_identity_priorities(
|
||||
store: &LibraryStore,
|
||||
scopes: &[LibraryScopePair],
|
||||
candidates: &[Vec<TrackCandidate>],
|
||||
) -> Result<HashMap<String, usize>, String> {
|
||||
let identities = candidates
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|candidate| candidate.identity_key.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
if identities.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let (scope_cte, mut binds) = crate::scope_merge::scope_cte_sql(scopes);
|
||||
let placeholders = (0..identities.len())
|
||||
.map(|_| "?")
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let identity_sql = "ck.cluster_key || ':' || CAST((ck.duration_sec / 5) AS TEXT)";
|
||||
let sql = format!(
|
||||
"{scope_cte} SELECT {identity_sql}, MIN(scope.pr) \
|
||||
FROM scoped_track scope \
|
||||
INNER JOIN track t ON t.rowid = scope.rowid \
|
||||
INNER JOIN cluster.track_cluster_key ck \
|
||||
ON ck.server_id = t.server_id AND ck.track_id = t.id \
|
||||
WHERE t.deleted = 0 AND {identity_sql} IN ({placeholders}) \
|
||||
GROUP BY {identity_sql}",
|
||||
);
|
||||
binds.extend(identities.into_iter().map(SqlValue::Text));
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_from_iter(binds.iter()), |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<HashMap<_, _>>>()
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn browse_tracks(
|
||||
store: &LibraryStore,
|
||||
request: &LibraryScopeBrowseRequest,
|
||||
) -> Result<LibraryScopeBrowseResponse, String> {
|
||||
if !request.sort.is_empty() && request.sort.iter().any(|clause| clause.field != "title") {
|
||||
return Err("unsupported scope browse track sort".into());
|
||||
}
|
||||
let cursor = parse_track_cursor(request.cursor.as_deref(), &request.scopes)?;
|
||||
// Initial browse ensures a newly synced scope has identity rows. Cursor
|
||||
// pages reuse that prepared snapshot and must stay read-only hot paths.
|
||||
if cursor.is_none() {
|
||||
crate::scope_merge::ensure_cluster_keys_for_scopes(store, &request.scopes)?;
|
||||
}
|
||||
let limit = request.limit.clamp(1, 200) as usize;
|
||||
let candidate_limit = CANDIDATE_PAGE_SIZE.max(limit.saturating_add(1));
|
||||
let mut candidates = Vec::with_capacity(request.scopes.len());
|
||||
let mut stream_exhausted = Vec::with_capacity(request.scopes.len());
|
||||
for (priority, scope) in request.scopes.iter().enumerate() {
|
||||
let stream = query_track_scope_candidates(
|
||||
store,
|
||||
scope,
|
||||
priority,
|
||||
cursor.as_ref().and_then(|cursor| cursor.positions.get(priority)).and_then(Option::as_ref),
|
||||
candidate_limit,
|
||||
)?;
|
||||
stream_exhausted.push(stream.len() < candidate_limit);
|
||||
candidates.push(stream);
|
||||
}
|
||||
let mut identity_priorities = track_identity_priorities(store, &request.scopes, &candidates)?;
|
||||
|
||||
let mut tracks = Vec::with_capacity(limit);
|
||||
let mut offsets = vec![0usize; candidates.len()];
|
||||
let mut positions = cursor
|
||||
.map(|cursor| cursor.positions)
|
||||
.unwrap_or_else(|| vec![None; request.scopes.len()]);
|
||||
while tracks.len() < limit {
|
||||
for scope_index in 0..candidates.len() {
|
||||
if offsets[scope_index] < candidates[scope_index].len() || stream_exhausted[scope_index] {
|
||||
continue;
|
||||
}
|
||||
let stream = query_track_scope_candidates(
|
||||
store,
|
||||
&request.scopes[scope_index],
|
||||
scope_index,
|
||||
positions[scope_index].as_ref(),
|
||||
candidate_limit,
|
||||
)?;
|
||||
stream_exhausted[scope_index] = stream.len() < candidate_limit;
|
||||
candidates[scope_index] = stream;
|
||||
offsets[scope_index] = 0;
|
||||
identity_priorities = track_identity_priorities(store, &request.scopes, &candidates)?;
|
||||
}
|
||||
let next_scope = candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, stream)| offsets[*index] < stream.len())
|
||||
.min_by(|(left_index, left_stream), (right_index, right_stream)| {
|
||||
track_candidate_cmp(
|
||||
&left_stream[offsets[*left_index]],
|
||||
&right_stream[offsets[*right_index]],
|
||||
)
|
||||
})
|
||||
.map(|(index, _)| index);
|
||||
let Some(scope_index) = next_scope else { break; };
|
||||
let candidate = &candidates[scope_index][offsets[scope_index]];
|
||||
offsets[scope_index] += 1;
|
||||
positions[scope_index] = Some(track_cursor_position(candidate));
|
||||
if let Some(identity_key) = candidate.identity_key.as_deref() {
|
||||
if identity_priorities
|
||||
.get(identity_key)
|
||||
.is_some_and(|priority| *priority < candidate.priority)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
tracks.push(LibraryTrackDto::from_row(&candidate.track));
|
||||
}
|
||||
let has_more = candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, stream)| offsets[index] < stream.len() || !stream_exhausted[index]);
|
||||
let next_cursor = has_more.then(|| {
|
||||
serde_json::to_string(&TrackCursor {
|
||||
scope_key: scope_key(&request.scopes),
|
||||
positions,
|
||||
})
|
||||
.expect("scope browse cursor serializes")
|
||||
});
|
||||
Ok(LibraryScopeBrowseResponse {
|
||||
albums: Vec::new(),
|
||||
artists: Vec::new(),
|
||||
tracks,
|
||||
next_cursor,
|
||||
has_more,
|
||||
source: "local".into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn browse(
|
||||
store: &LibraryStore,
|
||||
request: &LibraryScopeBrowseRequest,
|
||||
) -> Result<LibraryScopeBrowseResponse, String> {
|
||||
crate::scope_merge::non_empty_scopes(&request.scopes)?;
|
||||
match request.entity {
|
||||
LibraryScopeBrowseEntity::Album => {
|
||||
if !crate::browse_projection::is_ready(store)? {
|
||||
return Err("scope browse projection is not ready".into());
|
||||
}
|
||||
browse_albums(store, request)
|
||||
}
|
||||
LibraryScopeBrowseEntity::Track => browse_tracks(store, request),
|
||||
LibraryScopeBrowseEntity::Artist => Err("scope browse entity is not implemented yet".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request(scopes: Vec<LibraryScopePair>, limit: u32, cursor: Option<String>) -> LibraryScopeBrowseRequest {
|
||||
LibraryScopeBrowseRequest {
|
||||
entity: LibraryScopeBrowseEntity::Album,
|
||||
scopes,
|
||||
sort: vec![
|
||||
LibrarySortClause { field: "name".into(), dir: crate::dto::SortDir::Asc },
|
||||
LibrarySortClause { field: "artist".into(), dir: crate::dto::SortDir::Asc },
|
||||
],
|
||||
limit,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
fn track_request(scopes: Vec<LibraryScopePair>, limit: u32, cursor: Option<String>) -> LibraryScopeBrowseRequest {
|
||||
LibraryScopeBrowseRequest {
|
||||
entity: LibraryScopeBrowseEntity::Track,
|
||||
scopes,
|
||||
sort: vec![LibrarySortClause { field: "title".into(), dir: crate::dto::SortDir::Asc }],
|
||||
limit,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_track(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
library_id: &str,
|
||||
track_id: &str,
|
||||
title: &str,
|
||||
cluster_key: Option<&str>,
|
||||
) {
|
||||
store.with_conn_mut("test.scope_browse.track_seed", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track (server_id, id, title, artist, album, library_id, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, ?3, 'Artist', 'Album', ?4, 1, '{}')",
|
||||
rusqlite::params![server_id, track_id, title, library_id],
|
||||
)?;
|
||||
if let Some(cluster_key) = cluster_key {
|
||||
conn.execute(
|
||||
"INSERT INTO cluster.track_cluster_key \
|
||||
(server_id, library_id, track_id, cluster_key, duration_sec) \
|
||||
VALUES (?1, ?2, ?3, ?4, 100)",
|
||||
rusqlite::params![server_id, library_id, track_id, cluster_key],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
fn insert_projection(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
library_id: &str,
|
||||
album_id: &str,
|
||||
name: &str,
|
||||
identity_key: Option<&str>,
|
||||
) {
|
||||
store.with_conn_mut("test.scope_browse.seed", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO album_browse_projection ( \
|
||||
server_id, library_id, album_id, identity_key, name, artist, artist_id, song_count, \
|
||||
duration_sec, year, genre, cover_art_id, starred_at, synced_at, representative_track_id \
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, 'Artist', NULL, 1, 1, 2024, NULL, NULL, NULL, 1, ?3)",
|
||||
rusqlite::params![server_id, library_id, album_id, identity_key, name],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO library_data_migration (id, cursor_rowid, started_at, completed_at) \
|
||||
VALUES ('scope_browse_album_projection_v1', 0, 1, 1) \
|
||||
ON CONFLICT(id) DO UPDATE SET completed_at = 1",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_server_streams_include_empty_library_and_exact_empty_stays_narrow() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_projection(&store, "s1", "", "empty-album", "Alpha", Some("empty"));
|
||||
insert_projection(&store, "s1", "lib-b", "tagged-album", "Bravo", Some("tagged"));
|
||||
insert_track(&store, "s1", "", "empty-track", "Alpha", Some("empty-track"));
|
||||
insert_track(&store, "s1", "lib-b", "tagged-track", "Bravo", Some("tagged-track"));
|
||||
|
||||
let whole = vec![LibraryScopePair { server_id: "s1".into(), library_id: None }];
|
||||
let albums = browse(&store, &request(whole.clone(), 10, None)).unwrap();
|
||||
assert_eq!(
|
||||
albums.albums.iter().map(|album| album.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["empty-album", "tagged-album"]
|
||||
);
|
||||
let tracks = browse(&store, &track_request(whole, 10, None)).unwrap();
|
||||
assert_eq!(
|
||||
tracks.tracks.iter().map(|track| track.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["empty-track", "tagged-track"]
|
||||
);
|
||||
|
||||
let exact_empty = vec![LibraryScopePair {
|
||||
server_id: "s1".into(),
|
||||
library_id: Some(String::new()),
|
||||
}];
|
||||
let albums = browse(&store, &request(exact_empty.clone(), 10, None)).unwrap();
|
||||
assert_eq!(albums.albums.len(), 1);
|
||||
assert_eq!(albums.albums[0].id, "empty-album");
|
||||
let tracks = browse(&store, &track_request(exact_empty, 10, None)).unwrap();
|
||||
assert_eq!(tracks.tracks.len(), 1);
|
||||
assert_eq!(tracks.tracks[0].id, "empty-track");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_scope_wins_even_when_its_duplicate_sorts_later() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_projection(&store, "high", "lib", "high-dup", "Zulu", Some("same"));
|
||||
insert_projection(&store, "low", "lib", "low-dup", "Alpha", Some("same"));
|
||||
insert_projection(&store, "low", "lib", "low-unique", "Bravo", Some("other"));
|
||||
let response = browse(&store, &request(vec![
|
||||
LibraryScopePair { server_id: "high".into(), library_id: Some("lib".into()) },
|
||||
LibraryScopePair { server_id: "low".into(), library_id: Some("lib".into()) },
|
||||
], 10, None)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response.albums.iter().map(|album| album.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["low-unique", "high-dup"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_keeps_each_scope_position_without_skipping_tied_global_order() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_projection(&store, "a", "lib", "a-bravo", "Bravo", Some("a-bravo"));
|
||||
insert_projection(&store, "a", "lib", "a-delta", "Delta", Some("a-delta"));
|
||||
insert_projection(&store, "b", "lib", "b-alpha", "Alpha", Some("b-alpha"));
|
||||
insert_projection(&store, "b", "lib", "b-charlie", "Charlie", Some("b-charlie"));
|
||||
let scopes = vec![
|
||||
LibraryScopePair { server_id: "a".into(), library_id: Some("lib".into()) },
|
||||
LibraryScopePair { server_id: "b".into(), library_id: Some("lib".into()) },
|
||||
];
|
||||
|
||||
let first = browse(&store, &request(scopes.clone(), 2, None)).unwrap();
|
||||
assert_eq!(
|
||||
first.albums.iter().map(|album| album.name.as_str()).collect::<Vec<_>>(),
|
||||
vec!["Alpha", "Bravo"],
|
||||
);
|
||||
let second = browse(&store, &request(scopes, 2, first.next_cursor)).unwrap();
|
||||
assert_eq!(
|
||||
second.albums.iter().map(|album| album.name.as_str()).collect::<Vec<_>>(),
|
||||
vec!["Charlie", "Delta"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_priority_scope_wins_even_when_its_duplicate_sorts_later() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_track(&store, "high", "lib", "high-dup", "Same", Some("same"));
|
||||
insert_track(&store, "low", "lib", "low-dup", "Same", Some("same"));
|
||||
insert_track(&store, "low", "lib", "low-unique", "Bravo", Some("other"));
|
||||
let response = browse(&store, &track_request(vec![
|
||||
LibraryScopePair { server_id: "high".into(), library_id: Some("lib".into()) },
|
||||
LibraryScopePair { server_id: "low".into(), library_id: Some("lib".into()) },
|
||||
], 10, None)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response.tracks.iter().map(|track| track.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["low-unique", "high-dup"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_cursor_keeps_each_scope_position_without_skipping_tied_global_order() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_track(&store, "a", "lib", "a-bravo", "Bravo", None);
|
||||
insert_track(&store, "a", "lib", "a-delta", "Delta", None);
|
||||
insert_track(&store, "b", "lib", "b-alpha", "Alpha", None);
|
||||
insert_track(&store, "b", "lib", "b-charlie", "Charlie", None);
|
||||
let scopes = vec![
|
||||
LibraryScopePair { server_id: "a".into(), library_id: Some("lib".into()) },
|
||||
LibraryScopePair { server_id: "b".into(), library_id: Some("lib".into()) },
|
||||
];
|
||||
|
||||
let first = browse(&store, &track_request(scopes.clone(), 2, None)).unwrap();
|
||||
assert_eq!(
|
||||
first.tracks.iter().map(|track| track.title.as_str()).collect::<Vec<_>>(),
|
||||
vec!["Alpha", "Bravo"],
|
||||
);
|
||||
let second = browse(&store, &track_request(scopes, 2, first.next_cursor)).unwrap();
|
||||
assert_eq!(
|
||||
second.tracks.iter().map(|track| track.title.as_str()).collect::<Vec<_>>(),
|
||||
vec!["Charlie", "Delta"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_priority_dedup_holds_across_cursor_pages() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_track(&store, "high", "lib", "high-dup", "Same", Some("same"));
|
||||
insert_track(&store, "low", "lib", "low-dup", "Same", Some("same"));
|
||||
let scopes = vec![
|
||||
LibraryScopePair { server_id: "high".into(), library_id: Some("lib".into()) },
|
||||
LibraryScopePair { server_id: "low".into(), library_id: Some("lib".into()) },
|
||||
];
|
||||
|
||||
let candidates = vec![
|
||||
query_track_scope_candidates(&store, &scopes[0], 0, None, 10).unwrap(),
|
||||
query_track_scope_candidates(&store, &scopes[1], 1, None, 10).unwrap(),
|
||||
];
|
||||
assert_eq!(track_identity_priorities(&store, &scopes, &candidates).unwrap().get("same:20"), Some(&0));
|
||||
|
||||
let first = browse(&store, &track_request(scopes.clone(), 1, None)).unwrap();
|
||||
assert_eq!(first.tracks.iter().map(|track| track.id.as_str()).collect::<Vec<_>>(), vec!["high-dup"]);
|
||||
let second = browse(&store, &track_request(scopes, 1, first.next_cursor)).unwrap();
|
||||
assert!(second.tracks.is_empty());
|
||||
assert!(!second.has_more);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,186 +0,0 @@
|
||||
//! Narrow local read for Favorites initial paint.
|
||||
//!
|
||||
//! This deliberately avoids the generic Advanced Search pipeline: favorites only
|
||||
//! need rows with persisted album/track stars, not FTS, scope merging, or totals.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use crate::dto::{LibraryAlbumDto, LibraryTrackDto};
|
||||
use crate::repos::{row_to_track_row, track_columns};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryStarredResponse {
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub tracks: Vec<LibraryTrackDto>,
|
||||
pub read_lock_wait_ms: u64,
|
||||
pub sql_ms: u64,
|
||||
pub blocked_by: Option<String>,
|
||||
}
|
||||
|
||||
pub fn list_starred(store: &LibraryStore, server_id: &str) -> Result<LibraryStarredResponse, String> {
|
||||
let server_id = server_id.trim();
|
||||
if server_id.is_empty() {
|
||||
return Ok(LibraryStarredResponse {
|
||||
albums: Vec::new(),
|
||||
tracks: Vec::new(),
|
||||
read_lock_wait_ms: 0,
|
||||
sql_ms: 0,
|
||||
blocked_by: None,
|
||||
});
|
||||
}
|
||||
|
||||
let ((albums, tracks), timing) = store
|
||||
.with_read_conn_timed(|conn| {
|
||||
let albums = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT server_id, id, name, artist, artist_id, song_count, duration_sec, year, \
|
||||
genre, cover_art_id, starred_at, synced_at, raw_json \
|
||||
FROM album \
|
||||
WHERE server_id = ?1 AND starred_at IS NOT NULL \
|
||||
ORDER BY starred_at DESC, id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![server_id], |row| {
|
||||
let raw_json: Option<String> = row.get(12)?;
|
||||
Ok(LibraryAlbumDto {
|
||||
server_id: row.get(0)?,
|
||||
id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
artist: row.get(3)?,
|
||||
artist_id: row.get(4)?,
|
||||
song_count: row.get(5)?,
|
||||
duration_sec: row.get(6)?,
|
||||
year: row.get(7)?,
|
||||
genre: row.get(8)?,
|
||||
cover_art_id: row.get(9)?,
|
||||
starred_at: row.get(10)?,
|
||||
synced_at: row.get(11)?,
|
||||
raw_json: raw_json
|
||||
.and_then(|raw| serde_json::from_str(&raw).ok())
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
|
||||
let tracks = {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND starred_at IS NOT NULL \
|
||||
ORDER BY starred_at DESC, id ASC",
|
||||
track_columns(),
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![server_id], row_to_track_row)?;
|
||||
rows
|
||||
.map(|row| row.map(|track| LibraryTrackDto::from_row(&track)))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
|
||||
Ok((albums, tracks))
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
?;
|
||||
Ok(LibraryStarredResponse {
|
||||
albums,
|
||||
tracks,
|
||||
read_lock_wait_ms: timing.lock_wait_ms,
|
||||
sql_ms: timing.exec_ms,
|
||||
blocked_by: timing.blocked_by.map(|owner| format!("{}:{}", owner.file, owner.line)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
|
||||
fn track(id: &str, starred_at: Option<i64>) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: "s1".into(),
|
||||
id: id.into(),
|
||||
title: id.into(),
|
||||
title_sort: None,
|
||||
artist: Some("Artist".into()),
|
||||
artist_id: Some("artist".into()),
|
||||
album: "Album".into(),
|
||||
album_id: Some("album".into()),
|
||||
album_artist: Some("Artist".into()),
|
||||
duration_sec: 1,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
year: None,
|
||||
genre: None,
|
||||
suffix: None,
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: None,
|
||||
starred_at,
|
||||
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,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_only_persisted_album_and_track_stars() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn("test", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO album (server_id, id, name, starred_at, synced_at, raw_json) \
|
||||
VALUES ('s1', 'album-starred', 'Starred', 20, 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO album (server_id, id, name, synced_at, raw_json) \
|
||||
VALUES ('s1', 'album-plain', 'Plain', 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track("track-starred", Some(10)), track("track-plain", None)])
|
||||
.unwrap();
|
||||
|
||||
let response = list_starred(&store, "s1").unwrap();
|
||||
|
||||
assert_eq!(response.albums.iter().map(|album| album.id.as_str()).collect::<Vec<_>>(), ["album-starred"]);
|
||||
assert_eq!(response.tracks.iter().map(|track| track.id.as_str()).collect::<Vec<_>>(), ["track-starred"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_star_query_uses_the_partial_index() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let plan = store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"EXPLAIN QUERY PLAN SELECT id FROM album \
|
||||
WHERE server_id = ?1 AND starred_at IS NOT NULL \
|
||||
ORDER BY starred_at DESC, id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params!["s1"], |row| row.get::<_, String>(3))?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
.join(" ");
|
||||
|
||||
assert!(plan.contains("idx_album_starred"), "query plan: {plan}");
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
|
||||
use crate::dto::{
|
||||
LibraryStatisticsDto, LibraryStatisticsFormatDto, LibraryStatisticsGenreDto,
|
||||
LibraryStatisticsRequest, LibraryStatisticsScope,
|
||||
};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
fn scope_where(scope: &LibraryStatisticsScope, alias: &str) -> (String, Vec<SqlValue>) {
|
||||
let mut clauses = vec![format!("{alias}.server_id = ?")];
|
||||
let mut params = vec![SqlValue::Text(scope.server_id.clone())];
|
||||
let library_ids: Vec<&str> = scope
|
||||
.library_ids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect();
|
||||
if !library_ids.is_empty() {
|
||||
clauses.push(format!(
|
||||
"{alias}.library_id IN ({})",
|
||||
std::iter::repeat_n("?", library_ids.len()).collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
params.extend(library_ids.into_iter().map(|id| SqlValue::Text(id.to_string())));
|
||||
}
|
||||
(clauses.join(" AND "), params)
|
||||
}
|
||||
|
||||
/// Aggregate the selected index rows without merging equivalent entities across
|
||||
/// servers or music folders. This keeps multi-server Statistics bounded to SQL
|
||||
/// aggregate reads instead of walking each server's REST album catalogue.
|
||||
pub fn query_statistics(
|
||||
store: &LibraryStore,
|
||||
request: &LibraryStatisticsRequest,
|
||||
) -> Result<LibraryStatisticsDto, String> {
|
||||
store.with_scope_detail_read_conn(|conn| {
|
||||
let mut artist_count = 0_i64;
|
||||
let mut album_count = 0_i64;
|
||||
let mut song_count = 0_i64;
|
||||
let mut playtime_sec = 0_i64;
|
||||
let mut genres = BTreeMap::<String, (i64, i64)>::new();
|
||||
let mut formats = BTreeMap::<String, i64>::new();
|
||||
|
||||
for scope in &request.scopes {
|
||||
if scope.server_id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (track_where, track_params) = scope_where(scope, "t");
|
||||
let track_where = format!("{track_where} AND t.deleted = 0");
|
||||
let (tracks, duration): (i64, i64) = conn.query_row(
|
||||
&format!(
|
||||
"SELECT COUNT(*), COALESCE(SUM(t.duration_sec), 0) FROM track t WHERE {track_where}"
|
||||
),
|
||||
rusqlite::params_from_iter(track_params.iter()),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
song_count += tracks;
|
||||
playtime_sec += duration;
|
||||
|
||||
let format_sql = format!(
|
||||
"SELECT COALESCE(NULLIF(UPPER(TRIM(t.suffix)), ''), 'Unknown'), COUNT(*) \
|
||||
FROM track t WHERE {track_where} \
|
||||
GROUP BY COALESCE(NULLIF(UPPER(TRIM(t.suffix)), ''), 'Unknown')"
|
||||
);
|
||||
let mut format_stmt = conn.prepare(&format_sql)?;
|
||||
let format_rows = format_stmt.query_map(
|
||||
rusqlite::params_from_iter(track_params.iter()),
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
|
||||
)?;
|
||||
for format in format_rows {
|
||||
let (value, songs) = format?;
|
||||
*formats.entry(value).or_default() += songs;
|
||||
}
|
||||
|
||||
let (projection_where, projection_params) = scope_where(scope, "p");
|
||||
album_count += conn.query_row(
|
||||
&format!("SELECT COUNT(*) FROM album_browse_projection p WHERE {projection_where}"),
|
||||
rusqlite::params_from_iter(projection_params.iter()),
|
||||
|row| row.get::<_, i64>(0),
|
||||
)?;
|
||||
|
||||
// Keep duplicate artists from separately selected folders/servers.
|
||||
// The DISTINCT is only within one folder, where it defines an artist count.
|
||||
let artist_sql = format!(
|
||||
"SELECT COUNT(DISTINCT COALESCE(NULLIF(t.artist_id, ''), NULLIF(t.artist, ''))) \
|
||||
FROM track t WHERE {track_where} GROUP BY COALESCE(t.library_id, '')"
|
||||
);
|
||||
let mut artist_stmt = conn.prepare(&artist_sql)?;
|
||||
let artist_rows = artist_stmt.query_map(
|
||||
rusqlite::params_from_iter(track_params.iter()),
|
||||
|row| row.get::<_, i64>(0),
|
||||
)?;
|
||||
for count in artist_rows {
|
||||
artist_count += count?;
|
||||
}
|
||||
|
||||
// `track_genre` keeps every indexed tag instead of the projection's
|
||||
// representative album genre, so multi-tag albums are not discarded.
|
||||
let genre_sql = format!(
|
||||
"SELECT COALESCE(NULLIF(TRIM(g.genre), ''), ''), COUNT(*), \
|
||||
COUNT(DISTINCT COALESCE(NULLIF(g.album_id, ''), g.track_id)) \
|
||||
FROM track_genre g \
|
||||
INNER JOIN track t ON t.server_id = g.server_id AND t.id = g.track_id \
|
||||
WHERE {track_where} \
|
||||
GROUP BY COALESCE(NULLIF(TRIM(g.genre), ''), '')"
|
||||
);
|
||||
let mut genre_stmt = conn.prepare(&genre_sql)?;
|
||||
let genre_rows = genre_stmt.query_map(
|
||||
rusqlite::params_from_iter(track_params.iter()),
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?)),
|
||||
)?;
|
||||
for genre in genre_rows {
|
||||
let (value, songs, albums) = genre?;
|
||||
let entry = genres.entry(value).or_default();
|
||||
entry.0 += songs;
|
||||
entry.1 += albums;
|
||||
}
|
||||
}
|
||||
|
||||
let mut genres: Vec<LibraryStatisticsGenreDto> = genres
|
||||
.into_iter()
|
||||
.map(|(value, (song_count, album_count))| LibraryStatisticsGenreDto {
|
||||
value,
|
||||
song_count,
|
||||
album_count,
|
||||
})
|
||||
.collect();
|
||||
genres.sort_by(|a, b| b.song_count.cmp(&a.song_count).then_with(|| a.value.cmp(&b.value)));
|
||||
let mut formats: Vec<LibraryStatisticsFormatDto> = formats
|
||||
.into_iter()
|
||||
.map(|(value, song_count)| LibraryStatisticsFormatDto { value, song_count })
|
||||
.collect();
|
||||
formats.sort_by(|a, b| b.song_count.cmp(&a.song_count).then_with(|| a.value.cmp(&b.value)));
|
||||
|
||||
Ok(LibraryStatisticsDto {
|
||||
artist_count,
|
||||
album_count,
|
||||
song_count,
|
||||
playtime_sec,
|
||||
genres,
|
||||
formats,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sums_selected_servers_without_cross_server_deduplication() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store.with_conn("statistics.test", |conn| {
|
||||
for (server, library, album, track, artist, duration, suffix, genre) in [
|
||||
("s1", "one", "a1", "t1", "shared", 120, "flac", "Rock"),
|
||||
("s1", "two", "a2", "t2", "shared", 180, "mp3", "Jazz"),
|
||||
("s2", "one", "a1", "t3", "shared", 240, "flac", "Rock"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO track (server_id, id, title, artist, album, album_id, library_id, duration_sec, suffix, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, ?2, ?3, ?4, ?4, ?5, ?6, ?7, 1, '{}')",
|
||||
rusqlite::params![server, track, artist, album, library, duration, suffix],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO track_genre (server_id, track_id, genre, album_id, library_id) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![server, track, genre, album, library],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO album_browse_projection \
|
||||
(server_id, library_id, album_id, name, song_count, duration_sec, genre, synced_at, representative_track_id) \
|
||||
VALUES (?1, ?2, ?3, ?3, 1, ?4, ?5, 1, ?6)",
|
||||
rusqlite::params![server, library, album, duration, genre, track],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
|
||||
let result = query_statistics(&store, &LibraryStatisticsRequest {
|
||||
scopes: vec![
|
||||
LibraryStatisticsScope { server_id: "s1".into(), library_ids: vec!["one".into(), "two".into()] },
|
||||
LibraryStatisticsScope { server_id: "s2".into(), library_ids: vec![] },
|
||||
],
|
||||
}).unwrap();
|
||||
|
||||
assert_eq!(result.song_count, 3);
|
||||
assert_eq!(result.album_count, 3);
|
||||
assert_eq!(result.playtime_sec, 540);
|
||||
assert_eq!(result.artist_count, 3, "each selected folder/server keeps its own artist row");
|
||||
assert_eq!(result.genres[0].value, "Rock");
|
||||
assert_eq!(result.genres[0].song_count, 2);
|
||||
assert_eq!(result.formats[0].value, "FLAC");
|
||||
assert_eq!(result.formats[0].song_count, 2);
|
||||
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,9 +18,6 @@ pub enum PassKind {
|
||||
DeltaMismatch,
|
||||
/// Initial sync — unlimited (only user cancel stops it).
|
||||
InitialSync,
|
||||
/// Manual integrity verification — finite captured row set, internally
|
||||
/// chunked, but no total request cap before the pass is complete.
|
||||
VerifyIntegrity,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -34,14 +31,13 @@ impl RequestBudget {
|
||||
pub const POLL_TICK_CAP: u32 = 1;
|
||||
pub const DELTA_LIGHT_CAP: u32 = 50;
|
||||
pub const DELTA_MISMATCH_CAP: u32 = 200;
|
||||
pub const VERIFY_CHUNK_SIZE: u32 = 200;
|
||||
|
||||
pub fn for_pass(kind: PassKind) -> Self {
|
||||
let cap = match kind {
|
||||
PassKind::PollTick => Some(Self::POLL_TICK_CAP),
|
||||
PassKind::DeltaLight => Some(Self::DELTA_LIGHT_CAP),
|
||||
PassKind::DeltaMismatch => Some(Self::DELTA_MISMATCH_CAP),
|
||||
PassKind::InitialSync | PassKind::VerifyIntegrity => None,
|
||||
PassKind::InitialSync => None,
|
||||
};
|
||||
Self { kind, cap }
|
||||
}
|
||||
@@ -94,11 +90,4 @@ mod tests {
|
||||
assert!(b.is_unlimited());
|
||||
assert!(b.has_room(u32::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_is_full_pass_with_bounded_chunks() {
|
||||
let b = RequestBudget::for_pass(PassKind::VerifyIntegrity);
|
||||
assert!(b.is_unlimited());
|
||||
assert_eq!(RequestBudget::VERIFY_CHUNK_SIZE, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
//! ingest strategy (§6.3). PR-3a only writes flags from the responses;
|
||||
//! interpretation lives in PR-3b's `IngestStrategy` selector.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use psysonic_integration::navidrome::probe::native_bulk_available;
|
||||
use psysonic_integration::subsonic::{ServerInfo, SubsonicClient, SubsonicError};
|
||||
|
||||
@@ -81,9 +78,10 @@ pub struct CapabilityProbeResult {
|
||||
pub server_track_count: Option<i64>,
|
||||
}
|
||||
|
||||
/// Run `CapabilityProbe::run` and persist the resulting flags while temporarily
|
||||
/// transitioning `sync_phase` to `probing`. Phase restoration is conditional so
|
||||
/// a sync runner that advances the phase while the probe is in flight wins.
|
||||
/// Run `CapabilityProbe::run` and persist the resulting flags +
|
||||
/// transition `sync_phase` from whatever it was to `probing` →
|
||||
/// `idle` (caller is responsible for advancing to `initial_sync` /
|
||||
/// `ready` once the appropriate runner starts).
|
||||
///
|
||||
/// PR-3d wires this in front of every initial / delta run so the
|
||||
/// stored `capability_flags` always reflects the current server.
|
||||
@@ -97,151 +95,69 @@ pub async fn probe_and_persist(
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> {
|
||||
probe_and_persist_inner(
|
||||
store,
|
||||
subsonic,
|
||||
navidrome,
|
||||
http_registry,
|
||||
server_id,
|
||||
library_scope,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn probe_and_persist_with_timeout(
|
||||
store: &crate::store::LibraryStore,
|
||||
subsonic: &psysonic_integration::subsonic::SubsonicClient,
|
||||
navidrome: Option<&NavidromeProbeCredentials>,
|
||||
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> {
|
||||
probe_and_persist_inner(
|
||||
store,
|
||||
subsonic,
|
||||
navidrome,
|
||||
http_registry,
|
||||
server_id,
|
||||
library_scope,
|
||||
Some(timeout),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn probe_and_persist_inner(
|
||||
store: &crate::store::LibraryStore,
|
||||
subsonic: &psysonic_integration::subsonic::SubsonicClient,
|
||||
navidrome: Option<&NavidromeProbeCredentials>,
|
||||
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> {
|
||||
with_probing_phase(store, server_id, library_scope, async {
|
||||
let sync_state = crate::repos::SyncStateRepository::new(store);
|
||||
let existing_flags = sync_state
|
||||
.get_capability_flags(server_id, library_scope)
|
||||
.map_err(SubsonicError::Transport)?
|
||||
.unwrap_or(0);
|
||||
|
||||
let probe = CapabilityProbe::run(subsonic, navidrome, http_registry, Some(server_id));
|
||||
let mut result = match timeout {
|
||||
Some(limit) => tokio::time::timeout(limit, probe).await.map_err(|_| {
|
||||
SubsonicError::Transport(format!(
|
||||
"capability probe timed out after {} ms",
|
||||
limit.as_millis()
|
||||
))
|
||||
})??,
|
||||
None => probe.await?,
|
||||
};
|
||||
|
||||
// R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it
|
||||
// must not drop a previously-learned NavidromeNativeBulk capability — the
|
||||
// server still supports `/api/song`; only the token is missing this bind.
|
||||
// Token availability gates actual N1 use per run (see library_sync_start).
|
||||
if navidrome.is_none() && existing_flags & CapabilityFlags::NAVIDROME_NATIVE_BULK != 0 {
|
||||
result.flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK);
|
||||
}
|
||||
|
||||
sync_state
|
||||
.set_capability_flags(server_id, library_scope, result.flags.bits())
|
||||
.map_err(SubsonicError::Transport)?;
|
||||
// Refresh the track-count watermark only when the probe learned one — a
|
||||
// missing `getScanStatus.count` must not clobber a count from a prior run.
|
||||
if let Some(count) = result.server_track_count {
|
||||
sync_state
|
||||
.set_server_track_count(server_id, library_scope, count)
|
||||
.map_err(SubsonicError::Transport)?;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn with_probing_phase<T>(
|
||||
store: &crate::store::LibraryStore,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
operation: impl Future<Output = Result<T, SubsonicError>>,
|
||||
) -> Result<T, SubsonicError> {
|
||||
let sync_state = crate::repos::SyncStateRepository::new(store);
|
||||
sync_state
|
||||
.ensure(server_id, library_scope)
|
||||
.map_err(SubsonicError::Transport)?;
|
||||
|
||||
let phase_before = loop {
|
||||
let phase = sync_state
|
||||
.get_sync_phase(server_id, library_scope)
|
||||
.map_err(SubsonicError::Transport)?
|
||||
.unwrap_or_else(|| "idle".to_string());
|
||||
if sync_state
|
||||
.set_sync_phase_if(server_id, library_scope, &phase, "probing")
|
||||
.map_err(SubsonicError::Transport)?
|
||||
{
|
||||
break phase;
|
||||
}
|
||||
};
|
||||
|
||||
let completed = match operation.await {
|
||||
Ok(value) => {
|
||||
match phase_after_success(&sync_state, server_id, library_scope, &phase_before) {
|
||||
Ok(phase) => Ok((value, phase)),
|
||||
Err(error) => Err(SubsonicError::Transport(error)),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let restore_phase = match &completed {
|
||||
Ok((_, phase)) => phase.as_str(),
|
||||
Err(_) => phase_before.as_str(),
|
||||
};
|
||||
|
||||
// Runtime lifecycle/activity serialization owns production bind ordering.
|
||||
// The compare-and-set still preserves a newer phase if another writer
|
||||
// advanced it while a direct caller or test probe was in flight.
|
||||
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?;
|
||||
let phase_before = sync_state
|
||||
.get_sync_phase(server_id, library_scope)
|
||||
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?;
|
||||
sync_state
|
||||
.set_sync_phase_if(server_id, library_scope, "probing", restore_phase)
|
||||
.map_err(SubsonicError::Transport)?;
|
||||
.set_sync_phase(server_id, library_scope, "probing")
|
||||
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?;
|
||||
|
||||
completed.map(|(value, _)| value)
|
||||
}
|
||||
let existing_flags = sync_state
|
||||
.get_capability_flags(server_id, library_scope)
|
||||
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?
|
||||
.unwrap_or(0);
|
||||
|
||||
fn phase_after_success(
|
||||
sync_state: &crate::repos::SyncStateRepository<'_>,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
phase_before: &str,
|
||||
) -> Result<String, String> {
|
||||
match phase_before {
|
||||
// Re-bind on app restart must not clobber a finished or active index —
|
||||
// callers gate local search on `ready` (§9.3 / P8).
|
||||
"ready" | "initial_sync" | "error" => Ok(phase_before.to_string()),
|
||||
_ if sync_state.has_last_full_sync_at(server_id, library_scope)? => Ok("ready".to_string()),
|
||||
_ => Ok("idle".to_string()),
|
||||
let mut result = CapabilityProbe::run(subsonic, navidrome, http_registry, Some(server_id)).await?;
|
||||
|
||||
// R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it
|
||||
// must not drop a previously-learned NavidromeNativeBulk capability — the
|
||||
// server still supports `/api/song`; only the token is missing this bind.
|
||||
// Token availability gates actual N1 use per run (see library_sync_start).
|
||||
if navidrome.is_none()
|
||||
&& existing_flags & CapabilityFlags::NAVIDROME_NATIVE_BULK != 0
|
||||
{
|
||||
result.flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK);
|
||||
}
|
||||
|
||||
sync_state
|
||||
.set_capability_flags(server_id, library_scope, result.flags.bits())
|
||||
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?;
|
||||
// Refresh the track-count watermark only when the probe learned one — a
|
||||
// missing `getScanStatus.count` must not clobber a count from a prior run.
|
||||
if let Some(count) = result.server_track_count {
|
||||
sync_state
|
||||
.set_server_track_count(server_id, library_scope, count)
|
||||
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?;
|
||||
}
|
||||
sync_state
|
||||
.set_sync_phase(
|
||||
server_id,
|
||||
library_scope,
|
||||
match phase_before.as_deref() {
|
||||
// Re-bind on app restart must not clobber a finished index —
|
||||
// callers gate local search on `ready` (§9.3 / P8).
|
||||
Some("ready") => "ready",
|
||||
Some("initial_sync") => "initial_sync",
|
||||
Some("error") => "error",
|
||||
_ => {
|
||||
if sync_state
|
||||
.has_last_full_sync_at(server_id, library_scope)
|
||||
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?
|
||||
{
|
||||
"ready"
|
||||
} else {
|
||||
"idle"
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub struct CapabilityProbe;
|
||||
@@ -325,7 +241,6 @@ mod tests {
|
||||
use super::*;
|
||||
use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use wiremock::matchers::{header, method as wm_method, path as wm_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
@@ -418,10 +333,10 @@ mod tests {
|
||||
// getScanStatus
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getScanStatus.view"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(ok_envelope("scanStatus", json!({ "scanning": false }))),
|
||||
)
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope(
|
||||
"scanStatus",
|
||||
json!({ "scanning": false }),
|
||||
)))
|
||||
.mount(server)
|
||||
.await;
|
||||
// getIndexes
|
||||
@@ -443,19 +358,13 @@ mod tests {
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result
|
||||
.flags
|
||||
.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
|
||||
assert!(result
|
||||
.flags
|
||||
.contains(CapabilityFlags::SCAN_STATUS_AVAILABLE));
|
||||
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
|
||||
assert!(result.flags.contains(CapabilityFlags::SCAN_STATUS_AVAILABLE));
|
||||
assert!(result.flags.contains(CapabilityFlags::FILE_TREE_BROWSE));
|
||||
assert!(result.flags.contains(CapabilityFlags::OPEN_SUBSONIC));
|
||||
assert!(result.flags.contains(CapabilityFlags::UNSTABLE_TRACK_IDS));
|
||||
// No navidrome probe creds passed → N1 stays clear.
|
||||
assert!(!result
|
||||
.flags
|
||||
.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
||||
assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
||||
assert_eq!(result.server_info.server_type.as_deref(), Some("navidrome"));
|
||||
}
|
||||
|
||||
@@ -494,9 +403,10 @@ mod tests {
|
||||
.await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/search3.view"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(ok_envelope("searchResult3", json!({}))),
|
||||
)
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(ok_envelope(
|
||||
"searchResult3",
|
||||
json!({}),
|
||||
)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(wm_method("GET"))
|
||||
@@ -518,12 +428,8 @@ mod tests {
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result
|
||||
.flags
|
||||
.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
|
||||
assert!(!result
|
||||
.flags
|
||||
.contains(CapabilityFlags::SCAN_STATUS_AVAILABLE));
|
||||
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
|
||||
assert!(!result.flags.contains(CapabilityFlags::SCAN_STATUS_AVAILABLE));
|
||||
assert!(!result.flags.contains(CapabilityFlags::FILE_TREE_BROWSE));
|
||||
assert!(!result.flags.contains(CapabilityFlags::OPEN_SUBSONIC));
|
||||
assert!(!result.flags.contains(CapabilityFlags::UNSTABLE_TRACK_IDS));
|
||||
@@ -544,13 +450,10 @@ mod tests {
|
||||
server_url: server.uri(),
|
||||
bearer_token: "nd-tok".into(),
|
||||
};
|
||||
let result =
|
||||
CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result
|
||||
.flags
|
||||
.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
||||
}
|
||||
|
||||
// ── probe_and_persist round-trip ──────────────────────────────────────
|
||||
@@ -619,130 +522,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn failed_probe_and_persist_restores_ready_phase() {
|
||||
use crate::repos::SyncStateRepository;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/ping.view"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": {
|
||||
"status": "failed",
|
||||
"error": { "code": 40, "message": "Wrong credentials" }
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let sync_state = SyncStateRepository::new(&store);
|
||||
sync_state.ensure("failed-s1", "").unwrap();
|
||||
sync_state.set_sync_phase("failed-s1", "", "ready").unwrap();
|
||||
|
||||
let error = super::probe_and_persist(
|
||||
&store,
|
||||
&test_subsonic_client(&server.uri()),
|
||||
None,
|
||||
None,
|
||||
"failed-s1",
|
||||
"",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, SubsonicError::Api { code: 40, .. }));
|
||||
assert_eq!(
|
||||
sync_state
|
||||
.get_sync_phase("failed-s1", "")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("ready")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn overlapping_probe_phase_guards_do_not_serialize_network_or_clobber_sync() {
|
||||
use crate::repos::SyncStateRepository;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let sync_state = SyncStateRepository::new(&store);
|
||||
sync_state.ensure("overlap-s1", "").unwrap();
|
||||
|
||||
let first_started = Arc::new(tokio::sync::Notify::new());
|
||||
let first_release = Arc::new(tokio::sync::Notify::new());
|
||||
let first = {
|
||||
let store = Arc::clone(&store);
|
||||
let started = Arc::clone(&first_started);
|
||||
let release = Arc::clone(&first_release);
|
||||
tokio::spawn(async move {
|
||||
super::with_probing_phase(&store, "overlap-s1", "", async move {
|
||||
started.notify_one();
|
||||
release.notified().await;
|
||||
Ok::<(), SubsonicError>(())
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
|
||||
first_started.notified().await;
|
||||
assert_eq!(
|
||||
sync_state
|
||||
.get_sync_phase("overlap-s1", "")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("probing")
|
||||
);
|
||||
|
||||
let second_attempting = Arc::new(tokio::sync::Notify::new());
|
||||
let second_started = Arc::new(tokio::sync::Notify::new());
|
||||
let second_release = Arc::new(tokio::sync::Notify::new());
|
||||
let second = {
|
||||
let store = Arc::clone(&store);
|
||||
let attempting = Arc::clone(&second_attempting);
|
||||
let started = Arc::clone(&second_started);
|
||||
let release = Arc::clone(&second_release);
|
||||
tokio::spawn(async move {
|
||||
attempting.notify_one();
|
||||
super::with_probing_phase(&store, "overlap-s1", "", async move {
|
||||
started.notify_one();
|
||||
release.notified().await;
|
||||
Ok::<(), SubsonicError>(())
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
|
||||
second_attempting.notified().await;
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(100),
|
||||
second_started.notified(),
|
||||
)
|
||||
.await
|
||||
.expect("overlapping probes must not hold a process-global network mutex");
|
||||
|
||||
// A sync runner advances the phase while the first bind is deferred.
|
||||
// Neither the stale first snapshot nor the queued second bind may leave
|
||||
// the row downgraded after both probes finish.
|
||||
sync_state
|
||||
.set_sync_phase("overlap-s1", "", "initial_sync")
|
||||
.unwrap();
|
||||
first_release.notify_one();
|
||||
second_release.notify_one();
|
||||
first.await.unwrap().unwrap();
|
||||
second.await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sync_state
|
||||
.get_sync_phase("overlap-s1", "")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("initial_sync")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn probe_and_persist_promotes_idle_to_ready_when_full_sync_stamped() {
|
||||
use crate::repos::SyncStateRepository;
|
||||
@@ -817,16 +596,10 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let result = super::probe_and_persist(
|
||||
&store,
|
||||
&test_subsonic_client(&server.uri()),
|
||||
None,
|
||||
None,
|
||||
"s1",
|
||||
"",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result =
|
||||
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.server_track_count, Some(170_000));
|
||||
|
||||
let sync_state = SyncStateRepository::new(&store);
|
||||
@@ -864,9 +637,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
result
|
||||
.flags
|
||||
.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK),
|
||||
result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK),
|
||||
"result must keep the previously-learned N1 capability"
|
||||
);
|
||||
let persisted = sync_state.get_capability_flags("s1", "").unwrap().unwrap();
|
||||
@@ -889,16 +660,10 @@ mod tests {
|
||||
let server = MockServer::start().await;
|
||||
mount_subsonic_full_navidrome(&server).await; // scanStatus has no count
|
||||
|
||||
let result = super::probe_and_persist(
|
||||
&store,
|
||||
&test_subsonic_client(&server.uri()),
|
||||
None,
|
||||
None,
|
||||
"s1",
|
||||
"",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result =
|
||||
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.server_track_count, None);
|
||||
// Watermark from the prior run survives the count-less probe.
|
||||
assert_eq!(
|
||||
@@ -921,12 +686,9 @@ mod tests {
|
||||
server_url: server.uri(),
|
||||
bearer_token: "nd-tok".into(),
|
||||
};
|
||||
let result =
|
||||
CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result
|
||||
.flags
|
||||
.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ pub struct DeltaSyncRunner<'a> {
|
||||
/// DS-8 budget. `None` skips the tombstone chunk entirely; `Some(n)`
|
||||
/// drives `TombstoneReconciler::reconcile_chunk(n)` after DS-4.
|
||||
tombstone_budget: Option<u32>,
|
||||
full_tombstone_pass: bool,
|
||||
progress: Arc<dyn Progress + Send + Sync>,
|
||||
}
|
||||
|
||||
@@ -106,7 +105,6 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
batch_size: DEFAULT_BATCH_SIZE,
|
||||
sleep_enabled: true,
|
||||
tombstone_budget: None,
|
||||
full_tombstone_pass: false,
|
||||
progress: Arc::new(NoopProgress),
|
||||
}
|
||||
}
|
||||
@@ -146,13 +144,6 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Manual Verify mode: bypass watermark/delta short-circuits and inspect
|
||||
/// every live row in one stable, internally chunked pass.
|
||||
pub fn with_full_tombstone_pass(mut self) -> Self {
|
||||
self.full_tombstone_pass = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_progress(mut self, progress: Arc<dyn Progress + Send + Sync>) -> Self {
|
||||
self.progress = progress;
|
||||
self
|
||||
@@ -169,31 +160,6 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
|
||||
let mut report = DeltaSyncReport::default();
|
||||
|
||||
if self.full_tombstone_pass {
|
||||
let mut reconciler =
|
||||
TombstoneReconciler::new(self.store, self.subsonic, &self.server_id)
|
||||
.with_library_scope(&self.library_scope);
|
||||
if !self.sleep_enabled {
|
||||
reconciler = reconciler.with_sleep_disabled();
|
||||
}
|
||||
if let Some(flag) = &self.cancel {
|
||||
reconciler = reconciler.with_cancellation(Arc::clone(flag));
|
||||
}
|
||||
let stats = reconciler
|
||||
.reconcile_full_pass(super::budget::RequestBudget::VERIFY_CHUNK_SIZE)
|
||||
.await?;
|
||||
report.tombstones_checked = stats.checked;
|
||||
report.tombstones_deleted = stats.deleted;
|
||||
self.progress.emit(ProgressEvent::Tombstoned {
|
||||
deleted_count: stats.deleted,
|
||||
checked_count: stats.checked,
|
||||
});
|
||||
self.progress.emit(ProgressEvent::Completed {
|
||||
kind: "verify_integrity".into(),
|
||||
});
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
// DS-0 / DS-1 / DS-2 / DS-3 — poll + watermark compare.
|
||||
let probe = self.poll_for_change(&sync_state).await?;
|
||||
report.deferred_scanning = probe.deferred_scanning;
|
||||
@@ -229,8 +195,7 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
if let Some(budget) = self.tombstone_budget {
|
||||
if budget > 0 {
|
||||
let mut reconciler =
|
||||
TombstoneReconciler::new(self.store, self.subsonic, &self.server_id)
|
||||
.with_library_scope(&self.library_scope);
|
||||
TombstoneReconciler::new(self.store, self.subsonic, &self.server_id);
|
||||
if !self.sleep_enabled {
|
||||
reconciler = reconciler.with_sleep_disabled();
|
||||
}
|
||||
@@ -276,7 +241,11 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
}
|
||||
if let Some(iso) = probe.next_last_scan_iso.as_deref() {
|
||||
sync_state
|
||||
.set_server_last_scan_iso(&self.server_id, &self.library_scope, Some(iso))
|
||||
.set_server_last_scan_iso(
|
||||
&self.server_id,
|
||||
&self.library_scope,
|
||||
Some(iso),
|
||||
)
|
||||
.map_err(SyncError::Storage)?;
|
||||
}
|
||||
self.stamp_last_delta(&sync_state)?;
|
||||
@@ -325,10 +294,9 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
}
|
||||
|
||||
fn delta_strategy(&self) -> IngestStrategy {
|
||||
if self.library_scope.is_empty()
|
||||
&& self
|
||||
.capability_flags
|
||||
.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)
|
||||
if self
|
||||
.capability_flags
|
||||
.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK)
|
||||
{
|
||||
IngestStrategy::N1
|
||||
} else {
|
||||
@@ -346,21 +314,29 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
fn local_track_updated_watermark(&self) -> Result<Option<i64>, SyncError> {
|
||||
self.store
|
||||
.with_conn("delta.local_track_watermark", |c| {
|
||||
if self.library_scope.is_empty() {
|
||||
c.query_row(
|
||||
"SELECT MAX(server_updated_at) FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0",
|
||||
rusqlite::params![self.server_id],
|
||||
|row| row.get::<_, Option<i64>>(0),
|
||||
)
|
||||
} else {
|
||||
c.query_row(
|
||||
"SELECT MAX(server_updated_at) FROM track \
|
||||
WHERE server_id = ?1 AND library_id = ?2 AND deleted = 0",
|
||||
rusqlite::params![self.server_id, self.library_scope],
|
||||
|row| row.get::<_, Option<i64>>(0),
|
||||
)
|
||||
}
|
||||
c.query_row(
|
||||
"SELECT MAX(server_updated_at) FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0",
|
||||
rusqlite::params![self.server_id],
|
||||
|row| row.get::<_, Option<i64>>(0),
|
||||
)
|
||||
})
|
||||
.map_err(SyncError::Storage)
|
||||
}
|
||||
|
||||
fn local_album_ids(&self) -> Result<HashSet<String>, SyncError> {
|
||||
self.store
|
||||
.with_conn("delta.local_album_ids", |c| {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT DISTINCT album_id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND album_id IS NOT NULL",
|
||||
)?;
|
||||
let rows: rusqlite::Result<HashSet<String>> = stmt
|
||||
.query_map(rusqlite::params![self.server_id], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?
|
||||
.collect();
|
||||
rows
|
||||
})
|
||||
.map_err(SyncError::Storage)
|
||||
}
|
||||
@@ -496,6 +472,7 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
|
||||
async fn run_s2_delta(&self, report: &mut DeltaSyncReport) -> Result<(), SyncError> {
|
||||
let scope = self.library_scope_opt();
|
||||
let known_albums = self.local_album_ids()?;
|
||||
let mut seen_albums: HashSet<String> = HashSet::new();
|
||||
|
||||
for list_type in ["newest", "recent"] {
|
||||
@@ -520,6 +497,14 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
if !seen_albums.insert(album_summary.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
// S2-delta only fetches album bodies the local
|
||||
// store doesn't already have. `recent` (`getAlbumList2
|
||||
// type=recent`) returns play-time order, so a
|
||||
// known album that just got played still skips
|
||||
// the song-list re-fetch.
|
||||
if known_albums.contains(&album_summary.id) {
|
||||
continue;
|
||||
}
|
||||
self.check_cancellation()?;
|
||||
let (album, raw_album) = retry_with_backoff(
|
||||
self,
|
||||
@@ -559,7 +544,8 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
if !rows.is_empty() {
|
||||
let (changed, remapped) = self.write_batch(&rows)?;
|
||||
report.changed_count = report.changed_count.saturating_add(changed);
|
||||
report.remapped_count = report.remapped_count.saturating_add(remapped);
|
||||
report.remapped_count =
|
||||
report.remapped_count.saturating_add(remapped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,10 +816,10 @@ mod tests {
|
||||
assert_eq!(report.strategy.as_deref(), Some("n1"));
|
||||
}
|
||||
|
||||
// ── DS-4 S2-delta rechecks returned known album ids ──────────────
|
||||
// ── DS-4 S2-delta only fetches unknown album ids ─────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn s2_delta_rechecks_known_album_ids_before_advancing_watermark() {
|
||||
async fn s2_delta_skips_known_album_ids() {
|
||||
let server = MockServer::start().await;
|
||||
// Watermark change: getArtists lastModified differs from stored
|
||||
// (null) → falls through to DS-4.
|
||||
@@ -881,23 +867,9 @@ mod tests {
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getAlbum.view"))
|
||||
.and(query_param("id", "al_known"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": {
|
||||
"status": "ok",
|
||||
"album": {
|
||||
"id": "al_known",
|
||||
"name": "Known",
|
||||
"song": [
|
||||
{ "id": "tr_existing", "title": "Known changed", "duration": 240 }
|
||||
]
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// getAlbum body for the fresh id only. If "al_known" is
|
||||
// accidentally fetched, the test mock returns 404 by default
|
||||
// and the runner errors out — that's the assertion.
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getAlbum.view"))
|
||||
.and(query_param("id", "al_fresh"))
|
||||
@@ -934,41 +906,12 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.strategy.as_deref(), Some("s2"));
|
||||
assert_eq!(
|
||||
report.changed_count, 2,
|
||||
"known and fresh albums are inspected"
|
||||
);
|
||||
assert_eq!(report.changed_count, 1, "only the fresh album got upserted");
|
||||
// The seed plus the new track land in the store.
|
||||
let count: i64 = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0))
|
||||
})
|
||||
.with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
let title: String = store
|
||||
.with_read_conn(|c| {
|
||||
c.query_row(
|
||||
"SELECT title FROM track WHERE id = 'tr_existing'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(title, "Known changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_delta_never_selects_server_wide_n1() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let subsonic = test_subsonic("http://127.0.0.1:1");
|
||||
let runner = DeltaSyncRunner::new(
|
||||
&store,
|
||||
&subsonic,
|
||||
"s1",
|
||||
"lib-1",
|
||||
flags(CapabilityFlags::NAVIDROME_NATIVE_BULK),
|
||||
);
|
||||
assert_eq!(runner.delta_strategy(), IngestStrategy::S2);
|
||||
}
|
||||
|
||||
// ── DS-9 watermarks land + last_delta_sync_at stamped ────────────
|
||||
@@ -1017,7 +960,9 @@ mod tests {
|
||||
|
||||
let sync_state = SyncStateRepository::new(&store);
|
||||
assert_eq!(
|
||||
sync_state.get_artists_last_modified_ms("s1", "").unwrap(),
|
||||
sync_state
|
||||
.get_artists_last_modified_ms("s1", "")
|
||||
.unwrap(),
|
||||
Some(1_716_840_000_000)
|
||||
);
|
||||
let (last_delta,): (Option<i64>,) = store
|
||||
@@ -1111,52 +1056,16 @@ mod tests {
|
||||
// tr_gone is now soft-deleted.
|
||||
let gone_deleted: i64 = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row("SELECT deleted FROM track WHERE id='tr_gone'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
c.query_row(
|
||||
"SELECT deleted FROM track WHERE id='tr_gone'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(gone_deleted, 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn verify_bypasses_watermark_and_checks_every_live_row() {
|
||||
let server = MockServer::start().await;
|
||||
// No getArtists mock is mounted: touching the delta watermark path
|
||||
// would fail this test. Every getSong probe succeeds.
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getSong.view"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": {
|
||||
"status": "ok",
|
||||
"song": { "id": "present", "title": "Present" }
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
for i in 0..205 {
|
||||
seed_track(&store, &format!("tr-{i:03}"), "album", 1_000);
|
||||
}
|
||||
let report = DeltaSyncRunner::new(
|
||||
&store,
|
||||
&test_subsonic(&server.uri()),
|
||||
"s1",
|
||||
"",
|
||||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK),
|
||||
)
|
||||
.with_full_tombstone_pass()
|
||||
.with_sleep_disabled()
|
||||
.run()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.tombstones_checked, 205);
|
||||
assert_eq!(report.tombstones_deleted, 0);
|
||||
assert!(!report.up_to_date);
|
||||
}
|
||||
|
||||
fn parse_test_iso(s: &str) -> i64 {
|
||||
// Tiny helper for the seed track watermark — full date only,
|
||||
// midnight UTC, ms epoch.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,11 +76,7 @@ pub fn subsonic_song_to_track_row(
|
||||
.and_then(|v| v.as_f64()),
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: raw_value
|
||||
.get("created")
|
||||
.or_else(|| raw_value.get("createdAt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(parse_iso_ms_str),
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at,
|
||||
raw_json: raw_value.to_string(),
|
||||
@@ -122,7 +118,7 @@ pub fn navidrome_song_to_track_row(
|
||||
album: string_field(raw, "album").unwrap_or_default(),
|
||||
album_id: string_field(raw, "albumId"),
|
||||
album_artist: string_field(raw, "albumArtist"),
|
||||
duration_sec: duration_seconds(raw),
|
||||
duration_sec: raw.get("duration").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
track_number: raw.get("trackNumber").and_then(|v| v.as_i64()),
|
||||
disc_number: raw.get("discNumber").and_then(|v| v.as_i64()),
|
||||
year: raw.get("year").and_then(|v| v.as_i64()),
|
||||
@@ -167,19 +163,6 @@ fn string_field(raw: &Value, key: &str) -> Option<String> {
|
||||
json_string_field(raw, key)
|
||||
}
|
||||
|
||||
/// Navidrome's native API reports seconds as either an integer or a decimal.
|
||||
/// The local index stores whole seconds, so round rather than silently dropping
|
||||
/// a valid fractional value to zero.
|
||||
fn duration_seconds(raw: &Value) -> i64 {
|
||||
let seconds = raw.get("duration").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let rounded = seconds.round();
|
||||
if rounded.is_finite() && (0.0..=i64::MAX as f64).contains(&rounded) {
|
||||
rounded as i64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_iso_ms(s: Option<&str>) -> Option<i64> {
|
||||
s.and_then(parse_iso_ms_str)
|
||||
}
|
||||
@@ -320,7 +303,6 @@ mod tests {
|
||||
"duration": 240,
|
||||
"track": 3,
|
||||
"year": 2024,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"musicBrainzId": "mb-1",
|
||||
"replayGain": { "trackGain": -1.2, "albumGain": -0.8, "trackPeak": 0.91 }
|
||||
});
|
||||
@@ -333,7 +315,6 @@ mod tests {
|
||||
assert_eq!(row.replay_gain_track_db, Some(-1.2));
|
||||
assert_eq!(row.replay_gain_album_db, Some(-0.8));
|
||||
assert_eq!(row.replay_gain_peak, Some(0.91));
|
||||
assert!(row.server_created_at.unwrap_or(0) > 0);
|
||||
// Fallback library_id kicks in when the song didn't ship one.
|
||||
assert_eq!(row.library_id.as_deref(), Some("lib-fb"));
|
||||
assert!(row.raw_json.contains("replayGain"));
|
||||
@@ -374,7 +355,6 @@ mod tests {
|
||||
assert_eq!(row.mbid_recording.as_deref(), Some("mb-1"));
|
||||
assert_eq!(row.replay_gain_track_db, Some(-1.2));
|
||||
assert_eq!(row.library_id.as_deref(), Some("1"));
|
||||
assert!(row.server_created_at.unwrap_or(0) > 0);
|
||||
assert!(row.server_updated_at.unwrap_or(0) > 0);
|
||||
}
|
||||
|
||||
@@ -389,19 +369,6 @@ mod tests {
|
||||
assert_eq!(row.library_id.as_deref(), Some("3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navidrome_song_rounds_decimal_duration_seconds() {
|
||||
let raw = json!({
|
||||
"id": "tr_1",
|
||||
"title": "Hello",
|
||||
"duration": 229.85,
|
||||
});
|
||||
|
||||
let row = navidrome_song_to_track_row("s1", &raw, 1, None).unwrap();
|
||||
|
||||
assert_eq!(row.duration_sec, 230);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navidrome_song_skips_rows_without_id() {
|
||||
let row = navidrome_song_to_track_row("s1", &json!({"title": "no id"}), 1, None);
|
||||
|
||||
@@ -41,9 +41,7 @@ pub use progress::{ChannelProgress, NoopProgress, Progress, ProgressEvent};
|
||||
pub use scheduler::{BackgroundScheduler, SchedulerTickReport, DEFAULT_TOMBSTONE_THRESHOLD_PCT};
|
||||
pub use strategy::IngestStrategy;
|
||||
pub use supervisor::SyncSupervisor;
|
||||
pub use tombstone::{
|
||||
should_auto_reconcile, should_auto_reconcile_scope, TombstoneReconciler, TombstoneReport,
|
||||
};
|
||||
pub use tombstone::{should_auto_reconcile, TombstoneReconciler, TombstoneReport};
|
||||
|
||||
/// Wall-clock milliseconds since the Unix epoch, saturating to `i64::MAX`.
|
||||
pub(crate) fn now_unix_ms() -> i64 {
|
||||
|
||||
@@ -35,27 +35,16 @@ pub struct IngestBatchMetrics {
|
||||
/// these into Tauri events with their own envelope.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProgressEvent {
|
||||
PhaseChanged {
|
||||
phase: String,
|
||||
},
|
||||
PhaseChanged { phase: String },
|
||||
IngestPage {
|
||||
ingested_total: u32,
|
||||
batch_count: u32,
|
||||
metrics: Option<IngestBatchMetrics>,
|
||||
},
|
||||
Remapped {
|
||||
entries: Vec<RemapEntry>,
|
||||
},
|
||||
Tombstoned {
|
||||
deleted_count: u32,
|
||||
checked_count: u32,
|
||||
},
|
||||
Completed {
|
||||
kind: String,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
Remapped { entries: Vec<RemapEntry> },
|
||||
Tombstoned { deleted_count: u32, checked_count: u32 },
|
||||
Completed { kind: String },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
impl ProgressEvent {
|
||||
@@ -139,7 +128,10 @@ impl ChannelProgress {
|
||||
if self.min_interval.is_zero() {
|
||||
return;
|
||||
}
|
||||
*self.last_emit.lock().expect("progress lock poisoned") = Some(Instant::now());
|
||||
*self
|
||||
.last_emit
|
||||
.lock()
|
||||
.expect("progress lock poisoned") = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,12 +192,8 @@ mod tests {
|
||||
#[test]
|
||||
fn noop_progress_swallows_events_without_panicking() {
|
||||
let p = NoopProgress;
|
||||
p.emit(ProgressEvent::PhaseChanged {
|
||||
phase: "ingest".into(),
|
||||
});
|
||||
p.emit(ProgressEvent::Completed {
|
||||
kind: "initial_sync".into(),
|
||||
});
|
||||
p.emit(ProgressEvent::PhaseChanged { phase: "ingest".into() });
|
||||
p.emit(ProgressEvent::Completed { kind: "initial_sync".into() });
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -233,14 +221,10 @@ mod tests {
|
||||
p.emit(ProgressEvent::IngestPage {
|
||||
ingested_total: 999,
|
||||
batch_count: 3,
|
||||
metrics: None,
|
||||
});
|
||||
p.emit(ProgressEvent::Completed {
|
||||
kind: "delta_sync".into(),
|
||||
});
|
||||
p.emit(ProgressEvent::Error {
|
||||
message: "boom".into(),
|
||||
metrics: None,
|
||||
});
|
||||
p.emit(ProgressEvent::Completed { kind: "delta_sync".into() });
|
||||
p.emit(ProgressEvent::Error { message: "boom".into() });
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ProgressEvent::IngestPage {
|
||||
@@ -248,7 +232,10 @@ mod tests {
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(rx.try_recv(), Ok(ProgressEvent::Completed { .. })));
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ProgressEvent::Completed { .. })
|
||||
));
|
||||
assert!(matches!(rx.try_recv(), Ok(ProgressEvent::Error { .. })));
|
||||
}
|
||||
|
||||
@@ -279,17 +266,17 @@ mod tests {
|
||||
p.emit(ProgressEvent::IngestPage {
|
||||
ingested_total: 500,
|
||||
batch_count: 1,
|
||||
metrics: None,
|
||||
metrics: None,
|
||||
});
|
||||
p.emit(ProgressEvent::IngestPage {
|
||||
ingested_total: 2500,
|
||||
batch_count: 5,
|
||||
metrics: None,
|
||||
metrics: None,
|
||||
});
|
||||
p.emit(ProgressEvent::IngestPage {
|
||||
ingested_total: 5000,
|
||||
batch_count: 10,
|
||||
metrics: None,
|
||||
metrics: None,
|
||||
});
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
@@ -304,7 +291,7 @@ mod tests {
|
||||
p.emit(ProgressEvent::IngestPage {
|
||||
ingested_total: 5500,
|
||||
batch_count: 11,
|
||||
metrics: None,
|
||||
metrics: None,
|
||||
});
|
||||
assert!(
|
||||
matches!(
|
||||
@@ -336,10 +323,7 @@ mod tests {
|
||||
#[test]
|
||||
fn always_emit_true_for_terminal_events() {
|
||||
assert!(ProgressEvent::Completed { kind: "k".into() }.always_emit());
|
||||
assert!(ProgressEvent::Error {
|
||||
message: "m".into()
|
||||
}
|
||||
.always_emit());
|
||||
assert!(ProgressEvent::Error { message: "m".into() }.always_emit());
|
||||
assert!(!ProgressEvent::PhaseChanged { phase: "p".into() }.always_emit());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
use psysonic_integration::subsonic::SubsonicClient;
|
||||
@@ -22,14 +21,12 @@ use super::delta::{DeltaSyncReport, DeltaSyncRunner};
|
||||
use super::error::SyncError;
|
||||
use super::poll_stats::{next_interval_ms, PollStats};
|
||||
use super::progress::{NoopProgress, Progress};
|
||||
use super::tombstone::should_auto_reconcile_scope;
|
||||
use super::tombstone::should_auto_reconcile;
|
||||
use crate::repos::SyncStateRepository;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
/// Default Mode B threshold per §6.7 (5 % gap before auto reconcile).
|
||||
pub const DEFAULT_TOMBSTONE_THRESHOLD_PCT: u32 = 5;
|
||||
const ERROR_RETRY_INTERVAL_MS: i64 = 30_000;
|
||||
const MAX_PERSISTED_ERROR_CHARS: usize = 1_000;
|
||||
|
||||
/// Outcome of one scheduler tick — what happened plus the resolved
|
||||
/// `next_poll_at` so the caller can re-schedule its timer.
|
||||
@@ -45,17 +42,6 @@ pub struct SchedulerTickReport {
|
||||
pub next_poll_at_ms: i64,
|
||||
}
|
||||
|
||||
impl SchedulerTickReport {
|
||||
/// A delta completed far enough to validate the server watermark or apply
|
||||
/// data. Deferred scans and all scheduler short-circuits are not success
|
||||
/// signals for error clearing or frontend refresh events.
|
||||
pub fn completed_delta(&self) -> bool {
|
||||
self.delta
|
||||
.as_ref()
|
||||
.is_some_and(|delta| !delta.deferred_scanning)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BackgroundScheduler<'a> {
|
||||
store: &'a LibraryStore,
|
||||
subsonic: &'a SubsonicClient,
|
||||
@@ -159,28 +145,6 @@ impl<'a> BackgroundScheduler<'a> {
|
||||
/// Run one tick — runs a delta sync if due and bulk isn't paused
|
||||
/// by the playback signal, then writes the new `next_poll_at`.
|
||||
pub async fn tick(&self, now_ms: i64) -> Result<SchedulerTickReport, SyncError> {
|
||||
let result = self.tick_inner(now_ms).await;
|
||||
self.finish_tick(now_ms, result)
|
||||
}
|
||||
|
||||
/// Bound a complete server tick so one unresponsive endpoint cannot hold a
|
||||
/// scheduler concurrency slot indefinitely.
|
||||
pub async fn tick_with_timeout(
|
||||
&self,
|
||||
now_ms: i64,
|
||||
timeout: Duration,
|
||||
) -> Result<SchedulerTickReport, SyncError> {
|
||||
let result = match tokio::time::timeout(timeout, self.tick_inner(now_ms)).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(SyncError::Transport(format!(
|
||||
"background scheduler timed out after {} ms",
|
||||
timeout.as_millis()
|
||||
))),
|
||||
};
|
||||
self.finish_tick(now_ms, result)
|
||||
}
|
||||
|
||||
async fn tick_inner(&self, now_ms: i64) -> Result<SchedulerTickReport, SyncError> {
|
||||
let sync_state = SyncStateRepository::new(self.store);
|
||||
sync_state
|
||||
.ensure(&self.server_id, &self.library_scope)
|
||||
@@ -241,12 +205,7 @@ impl<'a> BackgroundScheduler<'a> {
|
||||
.map_err(SyncError::Storage)?,
|
||||
) {
|
||||
let (local_u, server_u) = (local.max(0) as u32, server.max(0) as u32);
|
||||
if should_auto_reconcile_scope(
|
||||
&self.library_scope,
|
||||
local_u,
|
||||
server_u,
|
||||
self.tombstone_threshold_pct,
|
||||
) {
|
||||
if should_auto_reconcile(local_u, server_u, self.tombstone_threshold_pct) {
|
||||
tombstone_budget = RequestBudget::DELTA_MISMATCH_CAP;
|
||||
}
|
||||
}
|
||||
@@ -337,70 +296,6 @@ impl<'a> BackgroundScheduler<'a> {
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn finish_tick(
|
||||
&self,
|
||||
now_ms: i64,
|
||||
result: Result<SchedulerTickReport, SyncError>,
|
||||
) -> Result<SchedulerTickReport, SyncError> {
|
||||
match result {
|
||||
Ok(report) => {
|
||||
if report.completed_delta() {
|
||||
if let Err(storage_err) = self.clear_tick_error() {
|
||||
let err = SyncError::Storage(storage_err);
|
||||
self.record_tick_error(now_ms, &err);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
Err(err) => {
|
||||
self.record_tick_error(now_ms, &err);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_tick_error(&self) -> Result<(), String> {
|
||||
self.store.with_conn("scheduler.clear_error", |conn| {
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET last_error = NULL \
|
||||
WHERE server_id = ?1 AND library_scope = ?2",
|
||||
rusqlite::params![self.server_id, self.library_scope],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn record_tick_error(&self, now_ms: i64, err: &SyncError) {
|
||||
let rendered = err.to_string();
|
||||
let persisted: String = rendered.chars().take(MAX_PERSISTED_ERROR_CHARS).collect();
|
||||
crate::app_eprintln!(
|
||||
"[library-sync] scheduler tick failed server_id={} scope={}: {}",
|
||||
self.server_id,
|
||||
self.library_scope,
|
||||
rendered
|
||||
);
|
||||
let next_poll_at = now_ms.saturating_add(ERROR_RETRY_INTERVAL_MS);
|
||||
if let Err(storage_err) = self.store.with_conn("scheduler.record_error", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_error, next_poll_at) \
|
||||
VALUES (?1, ?2, ?3, ?4) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
last_error = excluded.last_error, \
|
||||
next_poll_at = excluded.next_poll_at",
|
||||
rusqlite::params![self.server_id, self.library_scope, persisted, next_poll_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}) {
|
||||
crate::app_eprintln!(
|
||||
"[library-sync] scheduler error persistence failed server_id={} scope={}: {}",
|
||||
self.server_id,
|
||||
self.library_scope,
|
||||
storage_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_poll_stats(
|
||||
&self,
|
||||
sync_state: &SyncStateRepository<'_>,
|
||||
@@ -434,8 +329,14 @@ impl<'a> BackgroundScheduler<'a> {
|
||||
}
|
||||
|
||||
fn count_local_tracks(&self) -> Result<i64, SyncError> {
|
||||
crate::repos::TrackRepository::new(self.store)
|
||||
.count_live_tracks_in_scope(&self.server_id, &self.library_scope)
|
||||
self.store
|
||||
.with_conn("scheduler.count_local_tracks", |c| {
|
||||
c.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE server_id = ?1 AND deleted = 0",
|
||||
rusqlite::params![self.server_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})
|
||||
.map_err(SyncError::Storage)
|
||||
}
|
||||
}
|
||||
@@ -532,7 +433,9 @@ mod tests {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let sync_state = SyncStateRepository::new(&store);
|
||||
sync_state.ensure("s1", "").unwrap();
|
||||
sync_state.set_sync_phase("s1", "", "initial_sync").unwrap();
|
||||
sync_state
|
||||
.set_sync_phase("s1", "", "initial_sync")
|
||||
.unwrap();
|
||||
|
||||
let subsonic = test_subsonic(&server.uri());
|
||||
let report = BackgroundScheduler::new(
|
||||
@@ -578,39 +481,13 @@ mod tests {
|
||||
assert_eq!(report.next_poll_at_ms, 30_000);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn tick_skips_while_global_bulk_ingest_is_active() {
|
||||
let server = MockServer::start().await;
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store.set_bulk_ingest_active(true);
|
||||
|
||||
let subsonic = test_subsonic(&server.uri());
|
||||
let report = BackgroundScheduler::new(
|
||||
&store,
|
||||
&subsonic,
|
||||
"s1",
|
||||
"",
|
||||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK),
|
||||
)
|
||||
.with_sleep_disabled()
|
||||
.tick(0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(report.skipped_sync_pass_active);
|
||||
assert!(report.delta.is_none());
|
||||
assert_eq!(report.next_poll_at_ms, 30_000);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn tick_skips_when_not_due_and_reports_next_poll() {
|
||||
let server = MockServer::start().await;
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let sync_state = SyncStateRepository::new(&store);
|
||||
sync_state.ensure("s1", "").unwrap();
|
||||
sync_state
|
||||
.set_next_poll_at("s1", "", 1_000_000_000)
|
||||
.unwrap();
|
||||
sync_state.set_next_poll_at("s1", "", 1_000_000_000).unwrap();
|
||||
|
||||
let subsonic = test_subsonic(&server.uri());
|
||||
let report = BackgroundScheduler::new(
|
||||
@@ -630,47 +507,6 @@ mod tests {
|
||||
assert!(report.next_poll_at_ms > 500);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn skipped_tick_preserves_previous_scheduler_error() {
|
||||
let server = MockServer::start().await;
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn("test.seed_skipped_scheduler_error", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_error, next_poll_at) \
|
||||
VALUES ('s1', '', 'old failure', 1000000)",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let subsonic = test_subsonic(&server.uri());
|
||||
let report = BackgroundScheduler::new(
|
||||
&store,
|
||||
&subsonic,
|
||||
"s1",
|
||||
"",
|
||||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK),
|
||||
)
|
||||
.with_sleep_disabled()
|
||||
.tick(500)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(report.skipped_not_due);
|
||||
|
||||
let last_error: Option<String> = store
|
||||
.with_conn("test.skipped_scheduler_error", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT last_error FROM sync_state WHERE server_id = 's1'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(last_error.as_deref(), Some("old failure"));
|
||||
}
|
||||
|
||||
// ── tick pauses when PrefetchActive ──────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -732,121 +568,6 @@ mod tests {
|
||||
assert!(next > 1_000);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn tick_failure_is_persisted_and_retried_soon() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getArtists.view"))
|
||||
.respond_with(ResponseTemplate::new(503))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let subsonic = test_subsonic(&server.uri());
|
||||
let err = BackgroundScheduler::new(
|
||||
&store,
|
||||
&subsonic,
|
||||
"s1",
|
||||
"",
|
||||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK),
|
||||
)
|
||||
.with_sleep_disabled()
|
||||
.tick(1_000)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, SyncError::Transport(_)));
|
||||
|
||||
let (last_error, next_poll_at): (Option<String>, Option<i64>) = store
|
||||
.with_conn("test.scheduler_error", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT last_error, next_poll_at FROM sync_state \
|
||||
WHERE server_id = 's1' AND library_scope = ''",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(last_error.is_some_and(|message| message.contains("503")));
|
||||
assert_eq!(next_poll_at, Some(31_000));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn tick_timeout_is_persisted_without_waiting_for_server() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getArtists.view"))
|
||||
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let subsonic = test_subsonic(&server.uri());
|
||||
let err = BackgroundScheduler::new(
|
||||
&store,
|
||||
&subsonic,
|
||||
"s1",
|
||||
"",
|
||||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK),
|
||||
)
|
||||
.with_sleep_disabled()
|
||||
.tick_with_timeout(2_000, Duration::from_millis(20))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("timed out"));
|
||||
|
||||
let last_error: Option<String> = store
|
||||
.with_conn("test.scheduler_timeout", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT last_error FROM sync_state WHERE server_id = 's1'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(last_error.is_some_and(|message| message.contains("timed out")));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn successful_tick_clears_previous_scheduler_error() {
|
||||
let server = MockServer::start().await;
|
||||
empty_probe_and_albumlist(&server, 1_716_840_000_000).await;
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn("test.seed_scheduler_error", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_error) \
|
||||
VALUES ('s1', '', 'old failure')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let subsonic = test_subsonic(&server.uri());
|
||||
BackgroundScheduler::new(
|
||||
&store,
|
||||
&subsonic,
|
||||
"s1",
|
||||
"",
|
||||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK),
|
||||
)
|
||||
.with_sleep_disabled()
|
||||
.tick(0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let last_error: Option<String> = store
|
||||
.with_conn("test.scheduler_error_cleared", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT last_error FROM sync_state WHERE server_id = 's1'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(last_error, None);
|
||||
}
|
||||
|
||||
// ── auto-tombstone trigger ──────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
@@ -61,16 +61,7 @@ impl IngestStrategy {
|
||||
flags: CapabilityFlags,
|
||||
server_track_count: Option<i64>,
|
||||
n1_bulk_unreliable: bool,
|
||||
scoped: bool,
|
||||
) -> Self {
|
||||
// Navidrome's native `/api/song` endpoint is server-wide. Using it for
|
||||
// a music-folder sync would ingest and tag tracks from every library.
|
||||
if scoped {
|
||||
if flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK) {
|
||||
return Self::S1;
|
||||
}
|
||||
return Self::S2;
|
||||
}
|
||||
let is_large = server_track_count
|
||||
.map(|c| c > LARGE_LIBRARY_THRESHOLD)
|
||||
.unwrap_or(false);
|
||||
@@ -155,16 +146,14 @@ mod tests {
|
||||
#[test]
|
||||
fn initial_strategy_small_library_keeps_cheapest_n1() {
|
||||
// Below threshold, not flagged unreliable → unchanged N1-first chain.
|
||||
let s =
|
||||
IngestStrategy::select_initial_strategy(navidrome_full(), Some(1_000), false, false);
|
||||
let s = IngestStrategy::select_initial_strategy(navidrome_full(), Some(1_000), false);
|
||||
assert_eq!(s, IngestStrategy::N1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_strategy_large_library_avoids_n1_for_s1() {
|
||||
// Over threshold → S1 even though N1 is advertised (deep-offset wall).
|
||||
let s =
|
||||
IngestStrategy::select_initial_strategy(navidrome_full(), Some(170_000), false, false);
|
||||
let s = IngestStrategy::select_initial_strategy(navidrome_full(), Some(170_000), false);
|
||||
assert_eq!(s, IngestStrategy::S1);
|
||||
}
|
||||
|
||||
@@ -173,9 +162,9 @@ mod tests {
|
||||
// Learned `n1_bulk_unreliable` forces the non-N1 path even when the
|
||||
// count is small/unknown (covers R7-15 "unknown → large if N1 failed").
|
||||
let small =
|
||||
IngestStrategy::select_initial_strategy(navidrome_full(), Some(500), true, false);
|
||||
IngestStrategy::select_initial_strategy(navidrome_full(), Some(500), true);
|
||||
assert_eq!(small, IngestStrategy::S1);
|
||||
let unknown = IngestStrategy::select_initial_strategy(navidrome_full(), None, true, false);
|
||||
let unknown = IngestStrategy::select_initial_strategy(navidrome_full(), None, true);
|
||||
assert_eq!(unknown, IngestStrategy::S1);
|
||||
}
|
||||
|
||||
@@ -183,7 +172,7 @@ mod tests {
|
||||
fn initial_strategy_large_without_search3_falls_back_to_s2() {
|
||||
// Avoid-N1 path but no search3 bulk → universal album crawl, not N1.
|
||||
let flags = CapabilityFlags::new(CapabilityFlags::NAVIDROME_NATIVE_BULK);
|
||||
let s = IngestStrategy::select_initial_strategy(flags, Some(170_000), false, false);
|
||||
let s = IngestStrategy::select_initial_strategy(flags, Some(170_000), false);
|
||||
assert_eq!(s, IngestStrategy::S2);
|
||||
}
|
||||
|
||||
@@ -191,7 +180,7 @@ mod tests {
|
||||
fn initial_strategy_unknown_count_uses_cheapest_when_not_flagged() {
|
||||
// First run, no count yet, N1 never failed → try cheapest (N1); the
|
||||
// mid-run N1→S1 fallback (R7-15 Q5) handles the wall if hit.
|
||||
let s = IngestStrategy::select_initial_strategy(navidrome_full(), None, false, false);
|
||||
let s = IngestStrategy::select_initial_strategy(navidrome_full(), None, false);
|
||||
assert_eq!(s, IngestStrategy::N1);
|
||||
}
|
||||
|
||||
@@ -202,35 +191,16 @@ mod tests {
|
||||
navidrome_full(),
|
||||
Some(LARGE_LIBRARY_THRESHOLD),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(at, IngestStrategy::N1);
|
||||
let over = IngestStrategy::select_initial_strategy(
|
||||
navidrome_full(),
|
||||
Some(LARGE_LIBRARY_THRESHOLD + 1),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(over, IngestStrategy::S1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_initial_strategy_never_uses_server_wide_n1() {
|
||||
assert_eq!(
|
||||
IngestStrategy::select_initial_strategy(navidrome_full(), Some(10), false, true),
|
||||
IngestStrategy::S1
|
||||
);
|
||||
assert_eq!(
|
||||
IngestStrategy::select_initial_strategy(
|
||||
CapabilityFlags::new(CapabilityFlags::NAVIDROME_NATIVE_BULK),
|
||||
Some(10),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
IngestStrategy::S2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_roundtrip_is_stable_for_cursor_persistence() {
|
||||
for s in [
|
||||
|
||||
@@ -24,7 +24,6 @@ use psysonic_integration::subsonic::{SubsonicClient, SubsonicError};
|
||||
|
||||
use super::backoff::{jitter_salt, with_jitter, Backoff};
|
||||
use super::error::SyncError;
|
||||
use crate::repos::TrackRepository;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
const MAX_ATTEMPTS_PER_BATCH: u32 = 5;
|
||||
@@ -39,7 +38,6 @@ pub struct TombstoneReconciler<'a> {
|
||||
store: &'a LibraryStore,
|
||||
subsonic: &'a SubsonicClient,
|
||||
server_id: String,
|
||||
library_scope: String,
|
||||
cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
sleep_enabled: bool,
|
||||
}
|
||||
@@ -54,17 +52,11 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
store,
|
||||
subsonic,
|
||||
server_id: server_id.into(),
|
||||
library_scope: String::new(),
|
||||
cancel: None,
|
||||
sleep_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_library_scope(mut self, library_scope: impl Into<String>) -> Self {
|
||||
self.library_scope = library_scope.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cancellation(mut self, flag: Arc<std::sync::atomic::AtomicBool>) -> Self {
|
||||
self.cancel = Some(flag);
|
||||
self
|
||||
@@ -85,38 +77,7 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
return Ok(TombstoneReport::default());
|
||||
}
|
||||
let ids = self.next_candidates(budget)?;
|
||||
self.reconcile_ids(ids).await
|
||||
}
|
||||
|
||||
/// Complete one bounded full pass over the rows that were live when the
|
||||
/// pass began. The captured max id and keyset cursor guarantee termination
|
||||
/// even though successful probes refresh `synced_at`.
|
||||
pub async fn reconcile_full_pass(&self, batch_size: u32) -> Result<TombstoneReport, SyncError> {
|
||||
if batch_size == 0 {
|
||||
return Ok(TombstoneReport::default());
|
||||
}
|
||||
let Some(cutoff_id) = self.capture_cutoff_id()? else {
|
||||
return Ok(TombstoneReport::default());
|
||||
};
|
||||
let mut report = TombstoneReport::default();
|
||||
let mut after_id: Option<String> = None;
|
||||
loop {
|
||||
let ids = self.next_candidates_after(after_id.as_deref(), &cutoff_id, batch_size)?;
|
||||
if ids.is_empty() {
|
||||
break;
|
||||
}
|
||||
after_id = ids.last().cloned();
|
||||
let chunk = self.reconcile_ids(ids).await?;
|
||||
report.checked = report.checked.saturating_add(chunk.checked);
|
||||
report.deleted = report.deleted.saturating_add(chunk.deleted);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn reconcile_ids(&self, ids: Vec<String>) -> Result<TombstoneReport, SyncError> {
|
||||
let mut report = TombstoneReport::default();
|
||||
let mut alive_ids = Vec::new();
|
||||
let mut deleted_ids = Vec::new();
|
||||
for id in ids {
|
||||
self.check_cancellation()?;
|
||||
report.checked = report.checked.saturating_add(1);
|
||||
@@ -128,23 +89,18 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(_) => {
|
||||
alive_ids.push(id);
|
||||
// Still present — stamp `synced_at` so it goes to
|
||||
// the back of the queue and we don't re-probe it
|
||||
// again on the next chunk.
|
||||
self.mark_synced(&id)?;
|
||||
}
|
||||
Err(SyncError::NotFound) => {
|
||||
deleted_ids.push(id);
|
||||
self.mark_deleted(&id)?;
|
||||
report.deleted = report.deleted.saturating_add(1);
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
}
|
||||
}
|
||||
TrackRepository::new(self.store)
|
||||
.apply_tombstone_results(
|
||||
&self.server_id,
|
||||
&self.library_scope,
|
||||
&alive_ids,
|
||||
&deleted_ids,
|
||||
)
|
||||
.map_err(SyncError::Storage)?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
@@ -160,100 +116,47 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
fn next_candidates(&self, budget: u32) -> Result<Vec<String>, SyncError> {
|
||||
self.store
|
||||
.with_conn("tombstone.next_candidates", |c| {
|
||||
if self.library_scope.is_empty() {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 \
|
||||
ORDER BY synced_at ASC, id ASC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![self.server_id, budget as i64], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?
|
||||
.collect();
|
||||
rows
|
||||
} else {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND library_id = ?2 AND deleted = 0 \
|
||||
ORDER BY synced_at ASC, id ASC LIMIT ?3",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
rusqlite::params![self.server_id, self.library_scope, budget as i64],
|
||||
|r| r.get::<_, String>(0),
|
||||
)?
|
||||
.collect();
|
||||
rows
|
||||
}
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 \
|
||||
ORDER BY synced_at ASC LIMIT ?2",
|
||||
)?;
|
||||
let rows: rusqlite::Result<Vec<String>> = stmt
|
||||
.query_map(rusqlite::params![self.server_id, budget as i64], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?
|
||||
.collect();
|
||||
rows
|
||||
})
|
||||
.map_err(SyncError::Storage)
|
||||
}
|
||||
|
||||
fn capture_cutoff_id(&self) -> Result<Option<String>, SyncError> {
|
||||
fn mark_deleted(&self, id: &str) -> Result<(), SyncError> {
|
||||
self.store
|
||||
.with_conn("tombstone.capture_cutoff", |c| {
|
||||
if self.library_scope.is_empty() {
|
||||
c.query_row(
|
||||
"SELECT MAX(id) FROM track WHERE server_id = ?1 AND deleted = 0",
|
||||
rusqlite::params![self.server_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
} else {
|
||||
c.query_row(
|
||||
"SELECT MAX(id) FROM track \
|
||||
WHERE server_id = ?1 AND library_id = ?2 AND deleted = 0",
|
||||
rusqlite::params![self.server_id, self.library_scope],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
.with_conn("tombstone.mark_deleted", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET deleted = 1, synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
rusqlite::params![self.server_id, id, now_unix_ms()],
|
||||
)?;
|
||||
c.execute(
|
||||
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
|
||||
rusqlite::params![self.server_id, id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(SyncError::Storage)
|
||||
}
|
||||
|
||||
fn next_candidates_after(
|
||||
&self,
|
||||
after_id: Option<&str>,
|
||||
cutoff_id: &str,
|
||||
budget: u32,
|
||||
) -> Result<Vec<String>, SyncError> {
|
||||
fn mark_synced(&self, id: &str) -> Result<(), SyncError> {
|
||||
self.store
|
||||
.with_conn("tombstone.next_candidates_after", |c| {
|
||||
if self.library_scope.is_empty() {
|
||||
let mut statement = c.prepare(
|
||||
"SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND id <= ?2 \
|
||||
AND (?3 IS NULL OR id > ?3) \
|
||||
ORDER BY id ASC LIMIT ?4",
|
||||
)?;
|
||||
let rows = statement
|
||||
.query_map(
|
||||
rusqlite::params![self.server_id, cutoff_id, after_id, budget as i64],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?
|
||||
.collect();
|
||||
rows
|
||||
} else {
|
||||
let mut statement = c.prepare(
|
||||
"SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND library_id = ?2 AND deleted = 0 \
|
||||
AND id <= ?3 AND (?4 IS NULL OR id > ?4) \
|
||||
ORDER BY id ASC LIMIT ?5",
|
||||
)?;
|
||||
let rows = statement
|
||||
.query_map(
|
||||
rusqlite::params![
|
||||
self.server_id,
|
||||
self.library_scope,
|
||||
cutoff_id,
|
||||
after_id,
|
||||
budget as i64
|
||||
],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?
|
||||
.collect();
|
||||
rows
|
||||
}
|
||||
.with_conn("tombstone.mark_synced", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
rusqlite::params![self.server_id, id, now_unix_ms()],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(SyncError::Storage)
|
||||
}
|
||||
@@ -278,15 +181,12 @@ pub fn should_auto_reconcile(local_count: u32, server_count: u32, threshold_pct:
|
||||
ratio_x100 > threshold_pct
|
||||
}
|
||||
|
||||
/// Scope-aware Mode B threshold check. `getScanStatus.count` is server-wide,
|
||||
/// so scoped runs use the explicit full Verify pass instead of count mismatch.
|
||||
pub fn should_auto_reconcile_scope(
|
||||
library_scope: &str,
|
||||
local_count: u32,
|
||||
server_count: u32,
|
||||
threshold_pct: u32,
|
||||
) -> bool {
|
||||
library_scope.is_empty() && should_auto_reconcile(local_count, server_count, threshold_pct)
|
||||
fn now_unix_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn retry_with_backoff<'a, F, FFut, T, E>(
|
||||
@@ -340,16 +240,6 @@ mod tests {
|
||||
}
|
||||
|
||||
fn seed_track(store: &LibraryStore, id: &str, synced_at: i64) {
|
||||
seed_scoped_track(store, id, synced_at, None, None);
|
||||
}
|
||||
|
||||
fn seed_scoped_track(
|
||||
store: &LibraryStore,
|
||||
id: &str,
|
||||
synced_at: i64,
|
||||
library_id: Option<&str>,
|
||||
album_id: Option<&str>,
|
||||
) {
|
||||
TrackRepository::new(store)
|
||||
.upsert_batch(&[TrackRow {
|
||||
server_id: "s1".into(),
|
||||
@@ -359,7 +249,7 @@ mod tests {
|
||||
artist: None,
|
||||
artist_id: None,
|
||||
album: String::new(),
|
||||
album_id: album_id.map(str::to_string),
|
||||
album_id: None,
|
||||
album_artist: None,
|
||||
duration_sec: 0,
|
||||
track_number: None,
|
||||
@@ -375,7 +265,7 @@ mod tests {
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: library_id.map(str::to_string),
|
||||
library_id: None,
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
@@ -418,11 +308,6 @@ mod tests {
|
||||
assert!(!should_auto_reconcile(1000, 0, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_stays_silent_for_scoped_counts() {
|
||||
assert!(!should_auto_reconcile_scope("music-folder", 110, 100, 5));
|
||||
}
|
||||
|
||||
// ── reconcile_chunk marks deleted on code 70 ─────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -470,12 +355,16 @@ mod tests {
|
||||
// refreshed (so it doesn't get re-picked immediately).
|
||||
let (a_deleted, b_deleted): (i64, i64) = store
|
||||
.with_conn("misc", |c| {
|
||||
let a: i64 = c.query_row("SELECT deleted FROM track WHERE id='tr_a'", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
let b: i64 = c.query_row("SELECT deleted FROM track WHERE id='tr_b'", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
let a: i64 = c.query_row(
|
||||
"SELECT deleted FROM track WHERE id='tr_a'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let b: i64 = c.query_row(
|
||||
"SELECT deleted FROM track WHERE id='tr_b'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok((a, b))
|
||||
})
|
||||
.unwrap();
|
||||
@@ -517,18 +406,13 @@ mod tests {
|
||||
// After the chunk: the two checked rows have a refreshed
|
||||
// synced_at; the un-checked tr_newest still sits at 300.
|
||||
let untouched: i64 = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT synced_at FROM track WHERE id='tr_newest'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.with_conn("misc", |c| c.query_row(
|
||||
"SELECT synced_at FROM track WHERE id='tr_newest'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
untouched, 300,
|
||||
"tr_newest must not be probed within budget=2"
|
||||
);
|
||||
assert_eq!(untouched, 300, "tr_newest must not be probed within budget=2");
|
||||
}
|
||||
|
||||
// ── reconcile_chunk: empty store ───────────────────────────────────
|
||||
@@ -565,137 +449,4 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, SyncError::Cancelled));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn full_pass_checks_more_than_one_budget_and_stays_in_scope() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getSong.view"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": {
|
||||
"status": "ok",
|
||||
"song": { "id": "present", "title": "Present" }
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut rows = Vec::new();
|
||||
for i in 0..205 {
|
||||
let mut row = TrackRow {
|
||||
server_id: "s1".into(),
|
||||
id: format!("a-{i:03}"),
|
||||
title: format!("A {i}"),
|
||||
title_sort: None,
|
||||
artist: None,
|
||||
artist_id: None,
|
||||
album: String::new(),
|
||||
album_id: None,
|
||||
album_artist: None,
|
||||
duration_sec: 0,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
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: Some("lib-a".into()),
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
};
|
||||
rows.push(row.clone());
|
||||
if i < 5 {
|
||||
row.id = format!("z-{i:03}");
|
||||
row.library_id = Some("lib-b".into());
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
TrackRepository::new(&store).upsert_batch(&rows).unwrap();
|
||||
|
||||
let report = TombstoneReconciler::new(&store, &test_subsonic(&server.uri()), "s1")
|
||||
.with_library_scope("lib-a")
|
||||
.with_sleep_disabled()
|
||||
.reconcile_full_pass(200)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.checked, 205);
|
||||
assert_eq!(report.deleted, 0);
|
||||
|
||||
let untouched: i64 = store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM track \
|
||||
WHERE library_id = 'lib-b' AND synced_at = 1 AND deleted = 0",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(untouched, 5);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn tombstone_deletion_refreshes_projection_and_identity() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getSong.view"))
|
||||
.and(query_param("id", "tr_gone"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": {
|
||||
"status": "failed",
|
||||
"error": { "code": 70, "message": "Song not found" }
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_scoped_track(&store, "tr_gone", 1, Some("lib-a"), Some("album-a"));
|
||||
crate::identity::rebuild_cluster_keys(&store, None).unwrap();
|
||||
|
||||
TombstoneReconciler::new(&store, &test_subsonic(&server.uri()), "s1")
|
||||
.with_library_scope("lib-a")
|
||||
.with_sleep_disabled()
|
||||
.reconcile_chunk(10)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (projection, identity): (i64, i64) = store
|
||||
.with_read_conn(|conn| {
|
||||
Ok((
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM album_browse_projection \
|
||||
WHERE server_id = 's1' AND library_id = 'lib-a'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?,
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM cluster.track_cluster_key \
|
||||
WHERE server_id = 's1' AND track_id = 'tr_gone'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(projection, 0);
|
||||
assert_eq!(identity, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
const TRACK_FTS_TRIGGER_NAMES: [&str; 3] = ["track_ai", "track_ad", "track_au"];
|
||||
|
||||
const RESTORE_TRACK_FTS_TRIGGERS: &str = r#"
|
||||
CREATE TRIGGER IF NOT EXISTS track_ai AFTER INSERT ON track BEGIN
|
||||
INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre)
|
||||
@@ -47,41 +45,6 @@ pub fn restore_track_fts_triggers(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(RESTORE_TRACK_FTS_TRIGGERS)
|
||||
}
|
||||
|
||||
/// Repair an interrupted bulk ingest on open. Missing triggers mean the FTS
|
||||
/// table may have missed writes, so rebuild it before restoring all triggers.
|
||||
pub fn ensure_track_fts_triggers(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let trigger_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master \
|
||||
WHERE type = 'trigger' AND name IN ('track_ai', 'track_ad', 'track_au')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if trigger_count == TRACK_FTS_TRIGGER_NAMES.len() as i64 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
suspend_track_fts_triggers(&tx)?;
|
||||
rebuild_track_fts_from_content(&tx)?;
|
||||
restore_track_fts_triggers(&tx)?;
|
||||
tx.commit()
|
||||
}
|
||||
|
||||
pub(crate) fn missing_track_fts_triggers(conn: &Connection) -> rusqlite::Result<Vec<&'static str>> {
|
||||
let mut missing = Vec::new();
|
||||
for name in TRACK_FTS_TRIGGER_NAMES {
|
||||
let present = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = ?1)",
|
||||
[name],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)?;
|
||||
if !present {
|
||||
missing.push(name);
|
||||
}
|
||||
}
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
/// Normalize trigger DDL for equality checks — strips `IF NOT EXISTS`
|
||||
/// and collapses whitespace so migration vs restore sources compare cleanly.
|
||||
#[cfg(test)]
|
||||
@@ -99,6 +62,8 @@ mod tests {
|
||||
use crate::store::LibraryStore;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const TRACK_FTS_TRIGGER_NAMES: [&str; 3] = ["track_ai", "track_ad", "track_au"];
|
||||
|
||||
fn fetch_track_fts_trigger_ddl(conn: &Connection, name: &str) -> String {
|
||||
conn.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1",
|
||||
@@ -134,7 +99,8 @@ mod tests {
|
||||
for name in TRACK_FTS_TRIGGER_NAMES {
|
||||
let after = normalize_trigger_ddl(&fetch_track_fts_trigger_ddl(conn, name));
|
||||
assert_eq!(
|
||||
after, baseline[name],
|
||||
after,
|
||||
baseline[name],
|
||||
"trigger `{name}` body drifted after suspend/restore"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub async fn prune_empty_parents(file_path: &std::path::Path, levels: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubsonicAuthPayload {
|
||||
base_url: String,
|
||||
@@ -83,12 +83,9 @@ pub struct SubsonicAuthPayload {
|
||||
v: String,
|
||||
c: String,
|
||||
f: String,
|
||||
server_id: String,
|
||||
server_index_key: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeviceSyncSourcePayload {
|
||||
#[serde(rename = "type")]
|
||||
source_type: String,
|
||||
@@ -97,29 +94,6 @@ pub struct DeviceSyncSourcePayload {
|
||||
/// computing the playlist-folder path on the device.
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
server_index_key: String,
|
||||
}
|
||||
|
||||
fn device_sync_source_key(source: &DeviceSyncSourcePayload) -> String {
|
||||
serde_json::to_string(&(
|
||||
&source.server_index_key,
|
||||
&source.source_type,
|
||||
&source.id,
|
||||
))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn validate_device_sync_source_owners(
|
||||
sources: &[DeviceSyncSourcePayload],
|
||||
owner_server_index_key: &str,
|
||||
) -> Result<(), String> {
|
||||
if sources
|
||||
.iter()
|
||||
.any(|source| source.server_index_key != owner_server_index_key)
|
||||
{
|
||||
return Err("DEVICE_SYNC_SERVER_OWNER_MISMATCH".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -150,7 +124,7 @@ pub async fn fetch_subsonic_songs(
|
||||
("f", auth.f.as_str()),
|
||||
("id", id),
|
||||
];
|
||||
let res = apply_server_http_get(client, registry, Some(&auth.server_id), &url)
|
||||
let res = apply_server_http_get(client, registry, None, &url)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
@@ -274,7 +248,6 @@ pub async fn calculate_sync_payload(
|
||||
target_dir: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<SyncDeltaResult, String> {
|
||||
validate_device_sync_source_owners(&sources, &auth.server_index_key)?;
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(30))?;
|
||||
let http_registry = app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
@@ -288,7 +261,7 @@ pub async fn calculate_sync_payload(
|
||||
let mut sync_tracks = Vec::new();
|
||||
let (mut del_sources, mut add_sources) = (Vec::new(), Vec::new());
|
||||
for s in sources {
|
||||
if deletion_ids.contains(&device_sync_source_key(&s)) {
|
||||
if deletion_ids.contains(&s.id) {
|
||||
del_sources.push(s);
|
||||
} else {
|
||||
add_sources.push(s);
|
||||
@@ -297,7 +270,10 @@ pub async fn calculate_sync_payload(
|
||||
|
||||
let mut handles: Vec<(DeviceSyncSourcePayload, tokio::task::JoinHandle<Vec<serde_json::Value>>)> = Vec::new();
|
||||
for source in add_sources {
|
||||
let auth_clone = auth.clone();
|
||||
let auth_clone = SubsonicAuthPayload {
|
||||
base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(),
|
||||
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
||||
};
|
||||
let cli = client.clone();
|
||||
let reg_for_task = http_registry.clone();
|
||||
let source_snapshot = source.clone();
|
||||
@@ -311,7 +287,7 @@ pub async fn calculate_sync_payload(
|
||||
} else if source.source_type == "artist" {
|
||||
let url = format!("{}/getArtist.view", auth_clone.base_url);
|
||||
let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)];
|
||||
if let Ok(re) = apply_server_http_get(&cli, registry, Some(&auth_clone.server_id), &url).query(&query).send().await {
|
||||
if let Ok(re) = apply_server_http_get(&cli, registry, None, &url).query(&query).send().await {
|
||||
if let Ok(js) = re.json::<serde_json::Value>().await {
|
||||
if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) {
|
||||
let arr = root.as_array().cloned().unwrap_or_else(|| {
|
||||
@@ -335,7 +311,10 @@ pub async fn calculate_sync_payload(
|
||||
|
||||
let mut del_handles = Vec::new();
|
||||
for source in del_sources {
|
||||
let auth_clone = auth.clone();
|
||||
let auth_clone = SubsonicAuthPayload {
|
||||
base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(),
|
||||
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
||||
};
|
||||
let cli = client.clone();
|
||||
let reg_for_task = http_registry.clone();
|
||||
del_handles.push(tokio::spawn(async move {
|
||||
@@ -360,7 +339,7 @@ pub async fn calculate_sync_payload(
|
||||
let mut playlist_position: u32 = 0;
|
||||
for track in ts {
|
||||
if let Some(tid) = track.get("id").and_then(|i| i.as_str()) {
|
||||
let key = (device_sync_source_key(&source), tid.to_string());
|
||||
let key = (source.id.clone(), tid.to_string());
|
||||
if seen_by_source.contains(&key) { continue; }
|
||||
seen_by_source.insert(key);
|
||||
if is_playlist { playlist_position += 1; }
|
||||
@@ -435,7 +414,6 @@ pub async fn sync_batch_to_device(
|
||||
dest_dir: String,
|
||||
job_id: String,
|
||||
expected_bytes: u64,
|
||||
server_id: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<SyncBatchResult, String> {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
@@ -506,7 +484,6 @@ pub async fn sync_batch_to_device(
|
||||
let f = failed.clone();
|
||||
let le = last_emit.clone();
|
||||
let cancel = cancel_flag.clone();
|
||||
let request_server_id = server_id.clone();
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await.expect("semaphore closed");
|
||||
@@ -537,7 +514,7 @@ pub async fn sync_batch_to_device(
|
||||
}
|
||||
}
|
||||
|
||||
let response = match apply_server_http_get(&cli, registry, request_server_id.as_deref(), &track.url).send().await {
|
||||
let response = match apply_server_http_get(&cli, registry, None, &track.url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
Ok(r) => {
|
||||
f.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -661,44 +638,9 @@ mod tests {
|
||||
v: "1.16.1".into(),
|
||||
c: "psysonic".into(),
|
||||
f: "json".into(),
|
||||
server_id: "server-id".into(),
|
||||
server_index_key: "server.test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_identity_includes_server_type_and_raw_id() {
|
||||
let album = DeviceSyncSourcePayload {
|
||||
source_type: "album".into(),
|
||||
id: "shared".into(),
|
||||
name: Some("Album".into()),
|
||||
server_index_key: "https://server-a.test".into(),
|
||||
};
|
||||
let mut playlist = album.clone();
|
||||
playlist.source_type = "playlist".into();
|
||||
let mut other_server = album.clone();
|
||||
other_server.server_index_key = "https://server-b.test".into();
|
||||
|
||||
assert_ne!(device_sync_source_key(&album), device_sync_source_key(&playlist));
|
||||
assert_ne!(device_sync_source_key(&album), device_sync_source_key(&other_server));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_owner_must_match_the_captured_auth_owner() {
|
||||
let source = DeviceSyncSourcePayload {
|
||||
source_type: "album".into(),
|
||||
id: "album-1".into(),
|
||||
name: Some("Album".into()),
|
||||
server_index_key: "server-a.test".into(),
|
||||
};
|
||||
|
||||
assert!(validate_device_sync_source_owners(std::slice::from_ref(&source), "server-a.test").is_ok());
|
||||
assert_eq!(
|
||||
validate_device_sync_source_owners(&[source], "server-b.test"),
|
||||
Err("DEVICE_SYNC_SERVER_OWNER_MISMATCH".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// ── prune_empty_parents ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -42,31 +42,13 @@ pub fn get_removable_drives() -> Vec<RemovableDrive> {
|
||||
/// The file records which sources (albums/playlists/artists) are synced to this
|
||||
/// device so that another machine can pick them up without relying on localStorage.
|
||||
#[tauri::command]
|
||||
pub fn write_device_manifest(
|
||||
dest_dir: String,
|
||||
owner_server_index_key: String,
|
||||
sources: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
if owner_server_index_key.trim().is_empty() {
|
||||
return Err("DEVICE_SYNC_SERVER_OWNER_MISSING".to_string());
|
||||
}
|
||||
let source_list = sources
|
||||
.as_array()
|
||||
.ok_or_else(|| "DEVICE_SYNC_SOURCES_INVALID".to_string())?;
|
||||
if source_list.iter().any(|source| {
|
||||
source
|
||||
.get("serverIndexKey")
|
||||
.and_then(|value| value.as_str())
|
||||
!= Some(owner_server_index_key.as_str())
|
||||
}) {
|
||||
return Err("DEVICE_SYNC_SERVER_OWNER_MISMATCH".to_string());
|
||||
}
|
||||
pub fn write_device_manifest(dest_dir: String, sources: serde_json::Value) -> Result<(), String> {
|
||||
let path = std::path::Path::new(&dest_dir).join("psysonic-sync.json");
|
||||
// Manifest v3 pins raw Subsonic IDs to one durable URL-derived server owner.
|
||||
// Manifest v2: fixed "{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}" schema,
|
||||
// no user-configurable filename template. Readers still accept v1 manifests.
|
||||
let payload = serde_json::json!({
|
||||
"version": 3,
|
||||
"version": 2,
|
||||
"schema": "fixed-v1",
|
||||
"ownerServerIndexKey": owner_server_index_key,
|
||||
"sources": sources
|
||||
});
|
||||
let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
|
||||
@@ -446,47 +428,6 @@ pub fn compute_sync_paths(tracks: Vec<TrackSyncInfo>, dest_dir: String) -> Vec<S
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manifest_v3_persists_the_server_owner() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let owner = "server.test";
|
||||
let sources = serde_json::json!([{
|
||||
"type": "album",
|
||||
"id": "album-1",
|
||||
"name": "Album",
|
||||
"serverIndexKey": owner,
|
||||
}]);
|
||||
|
||||
write_device_manifest(
|
||||
dir.path().to_string_lossy().to_string(),
|
||||
owner.to_string(),
|
||||
sources.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let manifest = read_device_manifest(dir.path().to_string_lossy().to_string()).unwrap();
|
||||
assert_eq!(manifest["version"], 3);
|
||||
assert_eq!(manifest["ownerServerIndexKey"], owner);
|
||||
assert_eq!(manifest["sources"], sources);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_sources_from_another_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = write_device_manifest(
|
||||
dir.path().to_string_lossy().to_string(),
|
||||
"server-a.test".to_string(),
|
||||
serde_json::json!([{
|
||||
"type": "album",
|
||||
"id": "album-1",
|
||||
"name": "Album",
|
||||
"serverIndexKey": "server-b.test",
|
||||
}]),
|
||||
);
|
||||
|
||||
assert_eq!(result, Err("DEVICE_SYNC_SERVER_OWNER_MISMATCH".to_string()));
|
||||
}
|
||||
|
||||
fn track(builder: impl FnOnce(&mut TrackSyncInfo)) -> TrackSyncInfo {
|
||||
let mut t = TrackSyncInfo {
|
||||
id: "t1".into(),
|
||||
|
||||
+68
-449
@@ -3,21 +3,20 @@
|
||||
|
||||
pub mod cli;
|
||||
mod cover_cache;
|
||||
mod lib_commands;
|
||||
mod library_identity_maintenance;
|
||||
pub(crate) mod library_analysis_backfill;
|
||||
pub mod theme_animation;
|
||||
mod lib_commands;
|
||||
pub(crate) mod theme_import;
|
||||
pub mod theme_animation;
|
||||
|
||||
pub use psysonic_integration::discord;
|
||||
|
||||
pub use psysonic_analysis::{analysis_cache, analysis_runtime};
|
||||
pub use psysonic_audio as audio;
|
||||
pub use psysonic_core::logging;
|
||||
pub use psysonic_core::{app_eprintln, app_deprintln};
|
||||
pub use psysonic_core::user_agent::{
|
||||
default_subsonic_wire_user_agent, runtime_subsonic_wire_user_agent, subsonic_wire_user_agent,
|
||||
};
|
||||
pub use psysonic_core::{app_deprintln, app_eprintln};
|
||||
pub use psysonic_analysis::{analysis_cache, analysis_runtime};
|
||||
pub use psysonic_audio as audio;
|
||||
pub use psysonic_syncfs::{sync_cancel_flags, DownloadSemaphore};
|
||||
#[cfg(target_os = "windows")]
|
||||
mod taskbar_win;
|
||||
@@ -26,13 +25,10 @@ mod tray_runtime;
|
||||
pub(crate) use tray_runtime::*;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use lib_commands::*;
|
||||
use tauri::{Emitter, Manager};
|
||||
use lib_commands::*;
|
||||
|
||||
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
|
||||
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
||||
@@ -41,52 +37,6 @@ type ShortcutMap = Mutex<HashMap<String, String>>;
|
||||
/// Maximum number of offline track downloads that can run concurrently.
|
||||
/// The frontend queues more tasks than this; Rust is the real throttle.
|
||||
const MAX_DL_CONCURRENCY: usize = 4;
|
||||
const MAX_BACKGROUND_SCHEDULER_CONCURRENCY: usize = 2;
|
||||
const BACKGROUND_SCHEDULER_TICK_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
async fn run_bounded_scheduler_sessions<I, F, Fut>(sessions: I, run: F)
|
||||
where
|
||||
I: IntoIterator,
|
||||
F: FnMut(I::Item) -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
stream::iter(sessions)
|
||||
.for_each_concurrent(MAX_BACKGROUND_SCHEDULER_CONCURRENCY, run)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn foreground_blocks_scheduler_session(
|
||||
job: Option<&psysonic_library::runtime::CurrentJob>,
|
||||
server_id: &str,
|
||||
) -> bool {
|
||||
job.is_some_and(|job| job.kind == "initial_sync" || job.server_id == server_id)
|
||||
}
|
||||
|
||||
fn scheduler_session_still_current(
|
||||
runtime: &psysonic_library::LibraryRuntime,
|
||||
snapshot: &psysonic_library::runtime::SyncSession,
|
||||
) -> bool {
|
||||
runtime.get_session(&snapshot.server_id).as_ref() == Some(snapshot)
|
||||
}
|
||||
|
||||
fn scheduler_idle_payload(
|
||||
report: &psysonic_library::sync::scheduler::SchedulerTickReport,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
) -> Option<psysonic_library::LibrarySyncIdlePayload> {
|
||||
report
|
||||
.delta
|
||||
.as_ref()
|
||||
.is_some_and(|delta| !delta.deferred_scanning && !delta.up_to_date)
|
||||
.then(|| {
|
||||
psysonic_library::LibrarySyncIdlePayload::ok(
|
||||
server_id,
|
||||
library_scope,
|
||||
"delta_sync",
|
||||
"background",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
|
||||
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
|
||||
@@ -167,16 +117,12 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
psysonic_library::commands::library_analysis_progress,
|
||||
psysonic_library::commands::library_count_live_tracks,
|
||||
psysonic_library::commands::library_get_status,
|
||||
psysonic_library::commands::library_scope_statistics,
|
||||
psysonic_library::commands::library_scope_most_played,
|
||||
psysonic_library::commands::library_get_artifact,
|
||||
psysonic_library::commands::library_get_entity_user_ratings,
|
||||
psysonic_library::commands::library_get_facts,
|
||||
psysonic_library::commands::library_get_offline_path,
|
||||
psysonic_library::commands::library_genre_tags_inspect,
|
||||
psysonic_library::commands::library_genre_tags_run,
|
||||
psysonic_library::commands::library_cluster_rebuild,
|
||||
psysonic_library::commands::library_resolve_entity_sources,
|
||||
psysonic_library::commands::library_sync_bind_session,
|
||||
psysonic_library::commands::library_sync_clear_session,
|
||||
psysonic_library::commands::library_set_playback_hint,
|
||||
@@ -185,7 +131,6 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
psysonic_library::commands::library_sync_verify_integrity,
|
||||
psysonic_library::commands::library_sync_cancel,
|
||||
psysonic_library::commands::library_put_artifact,
|
||||
psysonic_library::commands::library_put_entity_user_ratings,
|
||||
psysonic_library::commands::library_put_fact,
|
||||
psysonic_library::commands::library_record_play_session,
|
||||
psysonic_library::commands::library_get_player_stats_year_summary,
|
||||
@@ -410,9 +355,7 @@ pub fn run() {
|
||||
|
||||
let builder = tauri::Builder::default()
|
||||
.manage(audio_engine)
|
||||
.manage(Arc::new(
|
||||
psysonic_core::server_http::ServerHttpRegistry::new(),
|
||||
))
|
||||
.manage(Arc::new(psysonic_core::server_http::ServerHttpRegistry::new()))
|
||||
.manage(ShortcutMap::default())
|
||||
.manage(discord::DiscordState::new())
|
||||
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
|
||||
@@ -430,7 +373,7 @@ pub fn run() {
|
||||
& !tauri_plugin_window_state::StateFlags::VISIBLE,
|
||||
)
|
||||
.with_denylist(&["mini"])
|
||||
.build(),
|
||||
.build()
|
||||
)
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
@@ -675,11 +618,11 @@ pub fn run() {
|
||||
.map_err(|e| format!("library store init failed: {e}"))?;
|
||||
let runtime = psysonic_library::LibraryRuntime::new(std::sync::Arc::new(store));
|
||||
app.manage(runtime);
|
||||
library_identity_maintenance::setup_library_sync_idle_listener(app.handle());
|
||||
|
||||
let app_for_sched = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use tokio::time::MissedTickBehavior;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
@@ -703,122 +646,57 @@ pub fn run() {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or(0);
|
||||
let runtime = state.inner();
|
||||
let registry = Arc::clone(
|
||||
app_for_sched
|
||||
.state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.inner(),
|
||||
);
|
||||
run_bounded_scheduler_sessions(sessions, |session| {
|
||||
let registry = Arc::clone(®istry);
|
||||
let app_for_session = app_for_sched.clone();
|
||||
async move {
|
||||
// Acquire per session. A queued writer blocks new
|
||||
// tasks without waiting for the full snapshot,
|
||||
// then stale sessions are rejected after release.
|
||||
let _sync_activity = runtime.sync_activity_guard().await;
|
||||
if runtime.scheduler_cancel.load(Ordering::SeqCst)
|
||||
|| !scheduler_session_still_current(runtime, &session)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let foreground_active = foreground_blocks_scheduler_session(
|
||||
runtime.current_job().as_ref(),
|
||||
&session.server_id,
|
||||
);
|
||||
let scope = session.library_scope.clone().unwrap_or_default();
|
||||
let flags_bits = psysonic_library::repos::SyncStateRepository::new(
|
||||
&runtime.store,
|
||||
for session in sessions {
|
||||
let scope = session.library_scope.clone().unwrap_or_default();
|
||||
let flags_bits = psysonic_library::repos::SyncStateRepository::new(
|
||||
&state.store,
|
||||
)
|
||||
.get_capability_flags(&session.server_id, &scope)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(0);
|
||||
let flags = psysonic_library::sync::capability::CapabilityFlags::new(
|
||||
flags_bits,
|
||||
);
|
||||
let registry = app_for_sched.state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>();
|
||||
let subsonic = psysonic_integration::subsonic::subsonic_client_with_registry(
|
||||
Some(registry.as_ref()),
|
||||
&session.server_id,
|
||||
session.base_url.clone(),
|
||||
session.username.clone(),
|
||||
session.password.clone(),
|
||||
);
|
||||
let mut sched =
|
||||
psysonic_library::sync::scheduler::BackgroundScheduler::new(
|
||||
&state.store,
|
||||
&subsonic,
|
||||
session.server_id.clone(),
|
||||
scope.clone(),
|
||||
flags,
|
||||
)
|
||||
.get_capability_flags(&session.server_id, &scope)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(0);
|
||||
let flags =
|
||||
psysonic_library::sync::capability::CapabilityFlags::new(
|
||||
flags_bits,
|
||||
);
|
||||
let subsonic = psysonic_integration::subsonic::subsonic_client_with_registry(
|
||||
Some(registry.as_ref()),
|
||||
&session.server_id,
|
||||
session.base_url.clone(),
|
||||
session.username.clone(),
|
||||
session.password.clone(),
|
||||
.with_playback_hint(hint)
|
||||
.with_http_registry(Some(Arc::clone(®istry)));
|
||||
if let Some(tok) = session.navidrome_token.clone() {
|
||||
sched = sched.with_navidrome_credentials(
|
||||
psysonic_library::sync::capability::NavidromeProbeCredentials {
|
||||
server_url: session.base_url.clone(),
|
||||
bearer_token: tok,
|
||||
},
|
||||
);
|
||||
let mut sched =
|
||||
psysonic_library::sync::scheduler::BackgroundScheduler::new(
|
||||
&runtime.store,
|
||||
&subsonic,
|
||||
session.server_id.clone(),
|
||||
scope.clone(),
|
||||
flags,
|
||||
)
|
||||
.with_playback_hint(hint)
|
||||
.with_http_registry(Some(Arc::clone(®istry)));
|
||||
if let Some(tok) = session.navidrome_token.clone() {
|
||||
sched = sched.with_navidrome_credentials(
|
||||
psysonic_library::sync::capability::NavidromeProbeCredentials {
|
||||
server_url: session.base_url.clone(),
|
||||
bearer_token: tok,
|
||||
},
|
||||
);
|
||||
}
|
||||
if foreground_active {
|
||||
sched = sched.with_foreground_sync_job_active(true);
|
||||
}
|
||||
// Errors and timeouts are logged and stored in
|
||||
// sync_state by the scheduler. Continue the
|
||||
// independent server sweep either way.
|
||||
match sched
|
||||
.tick_with_timeout(
|
||||
now_ms,
|
||||
BACKGROUND_SCHEDULER_TICK_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(report) => {
|
||||
let identity_store = Arc::clone(&runtime.store);
|
||||
let identity_server_id = session.server_id.clone();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
psysonic_library::identity::ensure_cluster_keys_built(
|
||||
&identity_store,
|
||||
&identity_server_id,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(error)) => crate::app_eprintln!(
|
||||
"[library-cluster] background maintenance failed server_id={}: {}",
|
||||
session.server_id,
|
||||
error
|
||||
),
|
||||
Err(error) => crate::app_eprintln!(
|
||||
"[library-cluster] background maintenance task failed server_id={}: {}",
|
||||
session.server_id,
|
||||
error
|
||||
),
|
||||
}
|
||||
if let Some(payload) = scheduler_idle_payload(
|
||||
&report,
|
||||
&session.server_id,
|
||||
&scope,
|
||||
) {
|
||||
let _ = app_for_session.emit(
|
||||
psysonic_library::LibrarySyncProgressPayload::IDLE_EVENT_NAME,
|
||||
&payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => crate::app_deprintln!(
|
||||
"[library-sync] scheduler recorded server failure server_id={}: {}",
|
||||
session.server_id,
|
||||
err
|
||||
),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let foreground_job = state
|
||||
.current_job()
|
||||
.is_some_and(|j| j.server_id == session.server_id);
|
||||
if foreground_job {
|
||||
sched = sched.with_foreground_sync_job_active(true);
|
||||
}
|
||||
let _ = sched.tick(now_ms).await;
|
||||
// Background ticks stay silent in PR-5b — Tauri
|
||||
// emit for the scheduler path lands when the
|
||||
// Settings panel needs it (PR-5c). Manual
|
||||
// `library_sync_start` already emits via its
|
||||
// own orchestrator.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1327,30 +1205,19 @@ pub fn run() {
|
||||
psysonic_analysis::commands::analysis_get_backfill_queue_stats,
|
||||
psysonic_analysis::commands::analysis_prune_pending_to_track_ids,
|
||||
psysonic_library::commands::library_get_status,
|
||||
psysonic_library::commands::library_scope_statistics,
|
||||
psysonic_library::commands::library_scope_most_played,
|
||||
psysonic_library::commands::library_search,
|
||||
psysonic_library::commands::library_live_search,
|
||||
psysonic_library::commands::library_advanced_search,
|
||||
psysonic_library::commands::library_list_starred,
|
||||
psysonic_library::commands::library_list_lossless_albums,
|
||||
psysonic_library::commands::library_list_albums_by_genre,
|
||||
psysonic_library::commands::library_genre_tags_inspect,
|
||||
psysonic_library::commands::library_genre_tags_run,
|
||||
psysonic_library::commands::library_cluster_rebuild,
|
||||
psysonic_library::commands::library_resolve_entity_sources,
|
||||
psysonic_library::commands::library_scope_list_albums,
|
||||
psysonic_library::commands::library_scope_browse,
|
||||
psysonic_library::commands::library_scope_browse_projection_inspect,
|
||||
psysonic_library::commands::library_scope_browse_projection_run,
|
||||
psysonic_library::commands::library_scope_list_mainstage_albums,
|
||||
psysonic_library::commands::library_list_random_artists,
|
||||
psysonic_library::commands::library_scope_list_artists,
|
||||
psysonic_library::commands::library_scope_list_composers,
|
||||
psysonic_library::commands::library_scope_search_tracks,
|
||||
psysonic_library::commands::library_scope_album_detail,
|
||||
psysonic_library::commands::library_scope_artist_detail,
|
||||
psysonic_library::commands::library_scope_composer_detail,
|
||||
psysonic_library::commands::library_get_artist_lossless_browse,
|
||||
psysonic_library::commands::library_search_cross_server,
|
||||
psysonic_library::commands::library_get_track,
|
||||
@@ -1358,7 +1225,6 @@ pub fn run() {
|
||||
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_entity_user_ratings,
|
||||
psysonic_library::commands::library_get_facts,
|
||||
psysonic_library::commands::library_get_offline_path,
|
||||
psysonic_library::commands::library_analysis_progress,
|
||||
@@ -1376,7 +1242,6 @@ pub fn run() {
|
||||
psysonic_library::browse_support::library_get_catalog_year_bounds,
|
||||
psysonic_library::browse_support::library_get_genre_album_counts,
|
||||
psysonic_library::commands::library_put_artifact,
|
||||
psysonic_library::commands::library_put_entity_user_ratings,
|
||||
psysonic_library::commands::library_put_fact,
|
||||
psysonic_library::commands::library_record_play_session,
|
||||
psysonic_library::commands::library_get_player_stats_year_summary,
|
||||
@@ -1496,222 +1361,6 @@ pub fn run() {
|
||||
.expect("error while running Psysonic");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scheduler_driver_tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use tokio::sync::{Notify, Semaphore};
|
||||
|
||||
fn foreground_job(server_id: &str, kind: &str) -> psysonic_library::runtime::CurrentJob {
|
||||
psysonic_library::runtime::CurrentJob {
|
||||
job_id: format!("{server_id}-{kind}"),
|
||||
server_id: server_id.to_string(),
|
||||
kind: kind.to_string(),
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
abort_handle: None,
|
||||
done: Arc::new(Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_sync_blocks_all_servers_but_delta_only_blocks_its_server() {
|
||||
let initial = foreground_job("s1", "initial_sync");
|
||||
assert!(foreground_blocks_scheduler_session(Some(&initial), "s1"));
|
||||
assert!(foreground_blocks_scheduler_session(Some(&initial), "s2"));
|
||||
|
||||
let delta = foreground_job("s1", "delta_sync");
|
||||
assert!(foreground_blocks_scheduler_session(Some(&delta), "s1"));
|
||||
assert!(!foreground_blocks_scheduler_session(Some(&delta), "s2"));
|
||||
assert!(!foreground_blocks_scheduler_session(None, "s1"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn slow_session_does_not_block_an_independent_session() {
|
||||
let slow_started = Arc::new(Notify::new());
|
||||
let release_slow = Arc::new(Notify::new());
|
||||
let fast_finished = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let slow_started_for_task = Arc::clone(&slow_started);
|
||||
let release_slow_for_task = Arc::clone(&release_slow);
|
||||
let fast_finished_for_task = Arc::clone(&fast_finished);
|
||||
let driver = tokio::spawn(async move {
|
||||
run_bounded_scheduler_sessions(["slow", "fast"], |session| {
|
||||
let slow_started = Arc::clone(&slow_started_for_task);
|
||||
let release_slow = Arc::clone(&release_slow_for_task);
|
||||
let fast_finished = Arc::clone(&fast_finished_for_task);
|
||||
async move {
|
||||
if session == "slow" {
|
||||
slow_started.notify_one();
|
||||
release_slow.notified().await;
|
||||
} else {
|
||||
fast_finished.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), slow_started.notified())
|
||||
.await
|
||||
.expect("slow session did not start");
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while !fast_finished.load(Ordering::SeqCst) {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("fast session was suppressed by slow session");
|
||||
|
||||
release_slow.notify_one();
|
||||
driver.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn queued_writer_preempts_remaining_batch_and_stale_session_stays_skipped() {
|
||||
let runtime = Arc::new(psysonic_library::LibraryRuntime::new(Arc::new(
|
||||
psysonic_library::LibraryStore::open_in_memory(),
|
||||
)));
|
||||
let session = |server_id: &str| psysonic_library::runtime::SyncSession {
|
||||
server_id: server_id.into(),
|
||||
base_url: format!("https://{server_id}.example.com"),
|
||||
username: "u".into(),
|
||||
password: "p".into(),
|
||||
navidrome_token: None,
|
||||
library_scope: None,
|
||||
};
|
||||
for server_id in ["s1", "s2", "s3"] {
|
||||
runtime.set_session(session(server_id)).unwrap();
|
||||
}
|
||||
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let release = Arc::new(Semaphore::new(0));
|
||||
let runtime_for_driver = Arc::clone(&runtime);
|
||||
let started_for_driver = Arc::clone(&started);
|
||||
let release_for_driver = Arc::clone(&release);
|
||||
let driver = tokio::spawn(async move {
|
||||
run_bounded_scheduler_sessions(["s1", "s2", "s3"], |server_id| {
|
||||
let runtime = Arc::clone(&runtime_for_driver);
|
||||
let started = Arc::clone(&started_for_driver);
|
||||
let release = Arc::clone(&release_for_driver);
|
||||
async move {
|
||||
let snapshot = runtime.get_session(server_id).unwrap();
|
||||
let _activity = runtime.sync_activity_guard().await;
|
||||
if !scheduler_session_still_current(&runtime, &snapshot) {
|
||||
return;
|
||||
}
|
||||
let ordinal = started.fetch_add(1, Ordering::SeqCst);
|
||||
if ordinal < 2 {
|
||||
release.acquire().await.unwrap().forget();
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while started.load(Ordering::SeqCst) < 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("first scheduler slots did not start");
|
||||
|
||||
let runtime_for_writer = Arc::clone(&runtime);
|
||||
let writer = tokio::spawn(async move {
|
||||
runtime_for_writer
|
||||
.cancel_and_drain_sync(None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
release.add_permits(2);
|
||||
let barrier = tokio::time::timeout(Duration::from_secs(1), writer)
|
||||
.await
|
||||
.expect("writer waited for the full scheduler batch")
|
||||
.unwrap();
|
||||
assert_eq!(started.load(Ordering::SeqCst), 2);
|
||||
|
||||
runtime.clear_session("s3");
|
||||
drop(barrier);
|
||||
driver.await.unwrap();
|
||||
assert_eq!(started.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_scheduler_session_is_rejected_after_clear_or_rebind() {
|
||||
let runtime = psysonic_library::LibraryRuntime::new(Arc::new(
|
||||
psysonic_library::LibraryStore::open_in_memory(),
|
||||
));
|
||||
let session = psysonic_library::runtime::SyncSession {
|
||||
server_id: "s1".into(),
|
||||
base_url: "https://one.example.com".into(),
|
||||
username: "u".into(),
|
||||
password: "p".into(),
|
||||
navidrome_token: None,
|
||||
library_scope: None,
|
||||
};
|
||||
runtime.set_session(session.clone()).unwrap();
|
||||
assert!(scheduler_session_still_current(&runtime, &session));
|
||||
|
||||
let mut rebound = session.clone();
|
||||
rebound.base_url = "https://two.example.com".into();
|
||||
runtime.set_session(rebound).unwrap();
|
||||
assert!(!scheduler_session_still_current(&runtime, &session));
|
||||
|
||||
runtime.clear_session("s1");
|
||||
assert!(!scheduler_session_still_current(&runtime, &session));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_idle_payload_only_follows_refreshable_delta() {
|
||||
let skipped = psysonic_library::sync::scheduler::SchedulerTickReport {
|
||||
skipped_not_due: true,
|
||||
skipped_bulk_paused: false,
|
||||
skipped_sync_pass_active: false,
|
||||
delta: None,
|
||||
next_poll_at_ms: 1,
|
||||
};
|
||||
assert!(scheduler_idle_payload(&skipped, "s1", "").is_none());
|
||||
|
||||
let up_to_date = psysonic_library::sync::scheduler::SchedulerTickReport {
|
||||
skipped_not_due: false,
|
||||
skipped_bulk_paused: false,
|
||||
skipped_sync_pass_active: false,
|
||||
delta: Some(psysonic_library::sync::delta::DeltaSyncReport {
|
||||
up_to_date: true,
|
||||
..Default::default()
|
||||
}),
|
||||
next_poll_at_ms: 1,
|
||||
};
|
||||
assert!(scheduler_idle_payload(&up_to_date, "s1", "").is_none());
|
||||
|
||||
let completed = psysonic_library::sync::scheduler::SchedulerTickReport {
|
||||
skipped_not_due: false,
|
||||
skipped_bulk_paused: false,
|
||||
skipped_sync_pass_active: false,
|
||||
delta: Some(psysonic_library::sync::delta::DeltaSyncReport {
|
||||
changed_count: 1,
|
||||
..Default::default()
|
||||
}),
|
||||
next_poll_at_ms: 1,
|
||||
};
|
||||
let payload = scheduler_idle_payload(&completed, "s1", "scope").unwrap();
|
||||
assert!(payload.ok);
|
||||
assert_eq!(payload.server_id, "s1");
|
||||
assert_eq!(payload.library_scope, "scope");
|
||||
assert_eq!(payload.source, "background");
|
||||
|
||||
let deferred = psysonic_library::sync::scheduler::SchedulerTickReport {
|
||||
delta: Some(psysonic_library::sync::delta::DeltaSyncReport {
|
||||
deferred_scanning: true,
|
||||
..Default::default()
|
||||
}),
|
||||
..completed
|
||||
};
|
||||
assert!(scheduler_idle_payload(&deferred, "s1", "").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod specta_export {
|
||||
// Freshness gate. Exports to a throwaway temp path and asserts byte-equality
|
||||
@@ -1803,25 +1452,17 @@ mod specta_export {
|
||||
"get_top_radio_stations",
|
||||
"search_radio_browser",
|
||||
"library_advanced_search",
|
||||
"library_list_starred",
|
||||
"library_get_artist_lossless_browse",
|
||||
"library_get_track",
|
||||
"library_get_tracks_batch",
|
||||
"library_get_tracks_by_album",
|
||||
"library_list_albums_by_genre",
|
||||
"library_list_lossless_albums",
|
||||
"library_list_random_artists",
|
||||
"library_live_search",
|
||||
"library_scope_album_detail",
|
||||
"library_scope_artist_detail",
|
||||
"library_scope_composer_detail",
|
||||
"library_scope_list_albums",
|
||||
"library_scope_browse",
|
||||
"library_scope_browse_projection_inspect",
|
||||
"library_scope_browse_projection_run",
|
||||
"library_scope_list_mainstage_albums",
|
||||
"library_scope_list_artists",
|
||||
"library_scope_list_composers",
|
||||
"library_scope_search_tracks",
|
||||
"library_search",
|
||||
"library_search_cross_server",
|
||||
@@ -1862,18 +1503,12 @@ mod specta_export {
|
||||
collected.len()
|
||||
);
|
||||
|
||||
let mut uncollected: Vec<&str> = handler
|
||||
.iter()
|
||||
.filter(|c| !collected.contains(*c))
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
let mut uncollected: Vec<&str> =
|
||||
handler.iter().filter(|c| !collected.contains(*c)).map(String::as_str).collect();
|
||||
uncollected.sort_unstable();
|
||||
|
||||
let unexplained: Vec<&str> = uncollected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| !UNTYPEABLE.contains(c))
|
||||
.collect();
|
||||
let unexplained: Vec<&str> =
|
||||
uncollected.iter().copied().filter(|c| !UNTYPEABLE.contains(c)).collect();
|
||||
assert!(
|
||||
unexplained.is_empty(),
|
||||
"these commands are in generate_handler! but neither collected into \
|
||||
@@ -1882,11 +1517,8 @@ mod specta_export {
|
||||
UNTYPEABLE with the reason: {unexplained:?}"
|
||||
);
|
||||
|
||||
let stale: Vec<&str> = UNTYPEABLE
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| !uncollected.contains(c))
|
||||
.collect();
|
||||
let stale: Vec<&str> =
|
||||
UNTYPEABLE.iter().copied().filter(|c| !uncollected.contains(c)).collect();
|
||||
assert!(
|
||||
stale.is_empty(),
|
||||
"these commands are in the UNTYPEABLE allowlist but are no longer \
|
||||
@@ -1935,17 +1567,10 @@ mod specta_export {
|
||||
if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let code = line
|
||||
.split("//")
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.trim_end_matches(',');
|
||||
let code = line.split("//").next().unwrap_or("").trim().trim_end_matches(',');
|
||||
let seg = code.rsplit("::").next().unwrap_or("").trim();
|
||||
let ok = seg.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& seg
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
|
||||
&& seg.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
|
||||
if ok {
|
||||
out.insert(seg.to_string());
|
||||
}
|
||||
@@ -1990,11 +1615,8 @@ mod specta_export {
|
||||
for name in &command_fns {
|
||||
*counts.entry(name.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
let mut collisions: Vec<&str> = counts
|
||||
.iter()
|
||||
.filter(|(_, n)| **n > 1)
|
||||
.map(|(name, _)| *name)
|
||||
.collect();
|
||||
let mut collisions: Vec<&str> =
|
||||
counts.iter().filter(|(_, n)| **n > 1).map(|(name, _)| *name).collect();
|
||||
collisions.sort_unstable();
|
||||
assert!(
|
||||
collisions.is_empty(),
|
||||
@@ -2075,10 +1697,7 @@ mod specta_export {
|
||||
|
||||
/// Extract `name` from a `... fn name(...)` declaration line.
|
||||
fn fn_name(line: &str) -> Option<String> {
|
||||
let after = line
|
||||
.split(" fn ")
|
||||
.nth(1)
|
||||
.or_else(|| line.strip_prefix("fn "))?;
|
||||
let after = line.split(" fn ").nth(1).or_else(|| line.strip_prefix("fn "))?;
|
||||
let name: String = after
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
|
||||
|
||||
@@ -12,7 +12,6 @@ const LIBRARY_ARCHIVE_ENTRY: &str = "library.sqlite";
|
||||
const ANALYSIS_ARCHIVE_ENTRY: &str = "audio-analysis.sqlite";
|
||||
const FULL_ARCHIVE_SETTINGS_ENTRY: &str = "settings.json";
|
||||
const FULL_ARCHIVE_VERSION: u64 = 1;
|
||||
const ANALYSIS_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct FullBackupPayload {
|
||||
@@ -21,22 +20,12 @@ struct FullBackupPayload {
|
||||
stores: Value,
|
||||
}
|
||||
|
||||
async fn acquire_sync_drain_barrier(
|
||||
app: &AppHandle,
|
||||
) -> Result<psysonic_library::runtime::SyncDrainBarrier, String> {
|
||||
app.try_state::<psysonic_library::LibraryRuntime>()
|
||||
.ok_or_else(|| "library runtime unavailable".to_string())?
|
||||
.cancel_and_drain_sync(None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub(crate) async fn backup_export_library_db(
|
||||
app: AppHandle,
|
||||
destination_path: String,
|
||||
) -> Result<(), String> {
|
||||
let _barrier = acquire_sync_drain_barrier(&app).await?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
backup_export_library_db_blocking(&app, destination_path)
|
||||
})
|
||||
@@ -44,10 +33,7 @@ pub(crate) async fn backup_export_library_db(
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn backup_export_library_db_blocking(
|
||||
app: &AppHandle,
|
||||
destination_path: String,
|
||||
) -> Result<(), String> {
|
||||
fn backup_export_library_db_blocking(app: &AppHandle, destination_path: String) -> Result<(), String> {
|
||||
let destination = PathBuf::from(destination_path);
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
@@ -68,8 +54,11 @@ fn backup_export_library_db_blocking(
|
||||
remove_db_with_sidecars(&snapshot_analysis_tmp)?;
|
||||
vacuum_copy(&source_library, &snapshot_library_tmp)?;
|
||||
vacuum_copy(&source_analysis, &snapshot_analysis_tmp)?;
|
||||
let result =
|
||||
write_databases_archive(&snapshot_library_tmp, &snapshot_analysis_tmp, &destination);
|
||||
let result = write_databases_archive(
|
||||
&snapshot_library_tmp,
|
||||
&snapshot_analysis_tmp,
|
||||
&destination,
|
||||
);
|
||||
remove_db_with_sidecars(&snapshot_library_tmp).ok();
|
||||
remove_db_with_sidecars(&snapshot_analysis_tmp).ok();
|
||||
result
|
||||
@@ -77,11 +66,7 @@ fn backup_export_library_db_blocking(
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub(crate) async fn backup_import_library_db(
|
||||
app: AppHandle,
|
||||
source_path: String,
|
||||
) -> Result<(), String> {
|
||||
let _barrier = acquire_sync_drain_barrier(&app).await?;
|
||||
pub(crate) async fn backup_import_library_db(app: AppHandle, source_path: String) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
backup_import_library_db_blocking(&app, source_path)
|
||||
})
|
||||
@@ -102,18 +87,8 @@ fn backup_import_library_db_blocking(app: &AppHandle, source_path: String) -> Re
|
||||
remove_db_with_sidecars(&import_library_tmp)?;
|
||||
remove_db_with_sidecars(&import_analysis_tmp)?;
|
||||
extract_databases_archive(&source, &import_library_tmp, &import_analysis_tmp)?;
|
||||
validate_import_database(
|
||||
&import_library_tmp,
|
||||
"library",
|
||||
psysonic_library::store::LIBRARY_DB_MIN_COMPATIBLE_VERSION,
|
||||
psysonic_library::LIBRARY_DB_SCHEMA_VERSION,
|
||||
)?;
|
||||
validate_import_database(
|
||||
&import_analysis_tmp,
|
||||
"analysis",
|
||||
ANALYSIS_DB_MIN_COMPATIBLE_VERSION,
|
||||
psysonic_analysis::analysis_cache::ANALYSIS_DB_SCHEMA_VERSION,
|
||||
)?;
|
||||
validate_sqlite_file(&import_library_tmp)?;
|
||||
validate_sqlite_file(&import_analysis_tmp)?;
|
||||
import_databases_from_sqlite(app, &import_library_tmp, &import_analysis_tmp)
|
||||
}
|
||||
|
||||
@@ -124,7 +99,6 @@ pub(crate) async fn backup_export_full(
|
||||
stores: Value,
|
||||
app_version: String,
|
||||
) -> Result<(), String> {
|
||||
let _barrier = acquire_sync_drain_barrier(&app).await?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
backup_export_full_blocking(&app, destination_path, stores, app_version)
|
||||
})
|
||||
@@ -179,11 +153,7 @@ fn backup_export_full_blocking(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn backup_import_full(
|
||||
app: AppHandle,
|
||||
source_path: String,
|
||||
) -> Result<Value, String> {
|
||||
let _barrier = acquire_sync_drain_barrier(&app).await?;
|
||||
pub(crate) async fn backup_import_full(app: AppHandle, source_path: String) -> Result<Value, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || backup_import_full_blocking(&app, source_path))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -202,18 +172,8 @@ fn backup_import_full_blocking(app: &AppHandle, source_path: String) -> Result<V
|
||||
remove_db_with_sidecars(&import_library_tmp)?;
|
||||
remove_db_with_sidecars(&import_analysis_tmp)?;
|
||||
let payload = extract_full_archive(&source, &import_library_tmp, &import_analysis_tmp)?;
|
||||
validate_import_database(
|
||||
&import_library_tmp,
|
||||
"library",
|
||||
psysonic_library::store::LIBRARY_DB_MIN_COMPATIBLE_VERSION,
|
||||
psysonic_library::LIBRARY_DB_SCHEMA_VERSION,
|
||||
)?;
|
||||
validate_import_database(
|
||||
&import_analysis_tmp,
|
||||
"analysis",
|
||||
ANALYSIS_DB_MIN_COMPATIBLE_VERSION,
|
||||
psysonic_analysis::analysis_cache::ANALYSIS_DB_SCHEMA_VERSION,
|
||||
)?;
|
||||
validate_sqlite_file(&import_library_tmp)?;
|
||||
validate_sqlite_file(&import_analysis_tmp)?;
|
||||
let stores = payload.stores;
|
||||
if !stores.is_object() {
|
||||
remove_db_with_sidecars(&import_library_tmp).ok();
|
||||
@@ -254,7 +214,8 @@ fn write_databases_archive(
|
||||
) -> Result<(), String> {
|
||||
let file = fs::File::create(destination_archive).map_err(|e| e.to_string())?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
let options =
|
||||
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
zip.start_file(LIBRARY_ARCHIVE_ENTRY, options)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut src = fs::File::open(library_snapshot).map_err(|e| e.to_string())?;
|
||||
@@ -275,7 +236,8 @@ fn write_full_archive(
|
||||
) -> Result<(), String> {
|
||||
let file = fs::File::create(destination_archive).map_err(|e| e.to_string())?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
let options =
|
||||
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
|
||||
zip.start_file(FULL_ARCHIVE_SETTINGS_ENTRY, options)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -317,9 +279,7 @@ fn extract_databases_archive(
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
{
|
||||
let mut library_entry = archive
|
||||
.by_index(library_target_index)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut library_entry = archive.by_index(library_target_index).map_err(|e| e.to_string())?;
|
||||
let mut out = fs::File::create(library_destination_sqlite).map_err(|e| e.to_string())?;
|
||||
io::copy(&mut library_entry, &mut out).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -328,11 +288,8 @@ fn extract_databases_archive(
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
{
|
||||
let mut analysis_entry = archive
|
||||
.by_index(analysis_target_index)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut analysis_out =
|
||||
fs::File::create(analysis_destination_sqlite).map_err(|e| e.to_string())?;
|
||||
let mut analysis_entry = archive.by_index(analysis_target_index).map_err(|e| e.to_string())?;
|
||||
let mut analysis_out = fs::File::create(analysis_destination_sqlite).map_err(|e| e.to_string())?;
|
||||
io::copy(&mut analysis_entry, &mut analysis_out).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -378,35 +335,18 @@ fn validate_sqlite_file(path: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migration_head(path: &Path, database_name: &str) -> Result<i64, String> {
|
||||
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| format!("{database_name} database open failed: {e}"))?;
|
||||
conn.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
|
||||
row.get::<_, Option<i64>>(0)
|
||||
})
|
||||
.map_err(|e| format!("{database_name} migration history unavailable: {e}"))?
|
||||
.ok_or_else(|| format!("{database_name} migration history is empty"))
|
||||
fn library_health_check(active_path: &Path) -> Result<(), String> {
|
||||
let conn = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.query_row("SELECT COUNT(*) FROM track", [], |_row| Ok(()))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn validate_import_database(
|
||||
path: &Path,
|
||||
database_name: &str,
|
||||
minimum_compatible_version: i64,
|
||||
current_version: i64,
|
||||
) -> Result<(), String> {
|
||||
validate_sqlite_file(path)?;
|
||||
let head = migration_head(path, database_name)?;
|
||||
if head < minimum_compatible_version {
|
||||
return Err(format!(
|
||||
"{database_name} backup schema {head} is older than minimum compatible schema {minimum_compatible_version}"
|
||||
));
|
||||
}
|
||||
if head > current_version {
|
||||
return Err(format!(
|
||||
"{database_name} backup schema {head} is newer than supported schema {current_version}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
fn analysis_health_check(active_path: &Path) -> Result<(), String> {
|
||||
let conn = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.query_row("SELECT COUNT(*) FROM analysis_track", [], |_row| Ok(()))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn import_databases_from_sqlite(
|
||||
@@ -431,28 +371,17 @@ fn import_databases_from_sqlite(
|
||||
.store
|
||||
.swap_database_file(&active_path, import_library_tmp)?
|
||||
.ok_or_else(|| "import switch failed".to_string())?;
|
||||
let analysis_backup = match cache.swap_database_file(&analysis_active_path, import_analysis_tmp)
|
||||
{
|
||||
let analysis_backup = match cache.swap_database_file(&analysis_active_path, import_analysis_tmp) {
|
||||
Ok(Some(backup)) => backup,
|
||||
Ok(None) => {
|
||||
rollback_after_analysis_switch_failure(
|
||||
&runtime,
|
||||
&cache,
|
||||
&library_backup,
|
||||
&active_path,
|
||||
)?;
|
||||
let _ = runtime.store.restore_database_backup(&library_backup, &active_path);
|
||||
let _ = remove_db_with_sidecars(&library_backup);
|
||||
let _ = remove_db_with_sidecars(import_library_tmp);
|
||||
let _ = remove_db_with_sidecars(import_analysis_tmp);
|
||||
return Err("analysis import switch failed".to_string());
|
||||
}
|
||||
Err(err) => {
|
||||
rollback_after_analysis_switch_failure(
|
||||
&runtime,
|
||||
&cache,
|
||||
&library_backup,
|
||||
&active_path,
|
||||
)?;
|
||||
let _ = runtime.store.restore_database_backup(&library_backup, &active_path);
|
||||
let _ = remove_db_with_sidecars(&library_backup);
|
||||
let _ = remove_db_with_sidecars(import_library_tmp);
|
||||
let _ = remove_db_with_sidecars(import_analysis_tmp);
|
||||
@@ -460,14 +389,10 @@ fn import_databases_from_sqlite(
|
||||
}
|
||||
};
|
||||
|
||||
let reopened_health = runtime
|
||||
.store
|
||||
.verify_operational_schema()
|
||||
.and_then(|_| cache.verify_operational_schema());
|
||||
if let Err(err) = reopened_health {
|
||||
let _ = runtime
|
||||
.store
|
||||
.restore_database_backup(&library_backup, &active_path);
|
||||
if let Err(err) = library_health_check(&active_path)
|
||||
.and_then(|_| analysis_health_check(&analysis_active_path))
|
||||
{
|
||||
let _ = runtime.store.restore_database_backup(&library_backup, &active_path);
|
||||
let _ = cache.restore_database_backup(&analysis_backup, &analysis_active_path);
|
||||
let _ = remove_db_with_sidecars(&library_backup);
|
||||
let _ = remove_db_with_sidecars(&analysis_backup);
|
||||
@@ -497,31 +422,6 @@ fn import_databases_from_sqlite(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback_after_analysis_switch_failure(
|
||||
runtime: &psysonic_library::LibraryRuntime,
|
||||
cache: &psysonic_analysis::analysis_cache::AnalysisCache,
|
||||
library_backup: &Path,
|
||||
active_library: &Path,
|
||||
) -> Result<(), String> {
|
||||
let library_result = runtime
|
||||
.store
|
||||
.restore_database_backup(library_backup, active_library)
|
||||
.and_then(|_| runtime.store.verify_operational_schema());
|
||||
let analysis_result = cache
|
||||
.verify_operational_schema()
|
||||
.map_err(|error| format!("analysis rollback after switch failed: {error}"));
|
||||
match (library_result, analysis_result) {
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
(Err(library_error), Ok(())) => Err(format!(
|
||||
"library rollback after analysis switch failed: {library_error}"
|
||||
)),
|
||||
(Ok(()), Err(analysis_error)) => Err(analysis_error),
|
||||
(Err(library_error), Err(analysis_error)) => Err(format!(
|
||||
"library rollback after analysis switch failed: {library_error}; {analysis_error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_db_with_sidecars(path: &Path) -> Result<(), String> {
|
||||
remove_if_exists(path)?;
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
@@ -549,65 +449,3 @@ fn remove_if_exists(path: &Path) -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static TEST_DB_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TestDb {
|
||||
dir: PathBuf,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestDb {
|
||||
fn with_migration_head(head: Option<i64>) -> Self {
|
||||
let nonce = TEST_DB_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"psysonic-backup-validation-{}-{nonce}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("candidate.sqlite");
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
if let Some(head) = head {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, 0)",
|
||||
[head],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(conn);
|
||||
Self { dir, path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDb {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_validation_accepts_compatible_older_schema_for_open_pipeline() {
|
||||
let db = TestDb::with_migration_head(Some(12));
|
||||
validate_import_database(&db.path, "library", 1, 23).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_validation_rejects_future_or_unversioned_database() {
|
||||
let future = TestDb::with_migration_head(Some(24));
|
||||
let err = validate_import_database(&future.path, "library", 1, 23).unwrap_err();
|
||||
assert!(err.contains("newer than supported"));
|
||||
|
||||
let unversioned = TestDb::with_migration_head(None);
|
||||
let err = validate_import_database(&unversioned.path, "library", 1, 23).unwrap_err();
|
||||
assert!(err.contains("migration history unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,38 +6,21 @@ use std::sync::{Mutex, OnceLock};
|
||||
use rusqlite::{params_from_iter, Connection, OpenFlags};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ScopedTable {
|
||||
table: &'static str,
|
||||
column: &'static str,
|
||||
}
|
||||
|
||||
const LIBRARY_TABLES: &[ScopedTable] = &[
|
||||
ScopedTable { table: "track_extension", column: "server_id" },
|
||||
ScopedTable { table: "track_fact", column: "server_id" },
|
||||
ScopedTable { table: "track_artifact", column: "server_id" },
|
||||
ScopedTable { table: "track_canonical_link", column: "server_id" },
|
||||
ScopedTable { table: "track_id_history", column: "server_id" },
|
||||
ScopedTable { table: "play_session", column: "server_id" },
|
||||
ScopedTable { table: "track_offline", column: "server_id" },
|
||||
ScopedTable { table: "track_genre", column: "server_id" },
|
||||
ScopedTable { table: "artist_artwork_lookup", column: "server_id" },
|
||||
ScopedTable { table: "library_tag_state", column: "server_id" },
|
||||
ScopedTable { table: "entity_user_rating", column: "server_id" },
|
||||
ScopedTable { table: "album_browse_projection", column: "server_id" },
|
||||
ScopedTable { table: "composer_album_projection", column: "server_id" },
|
||||
ScopedTable { table: "canonical_enrichment_link", column: "owner_server_id" },
|
||||
ScopedTable { table: "track", column: "server_id" },
|
||||
ScopedTable { table: "album", column: "server_id" },
|
||||
ScopedTable { table: "artist", column: "server_id" },
|
||||
ScopedTable { table: "sync_state", column: "server_id" },
|
||||
const LIBRARY_TABLES: &[&str] = &[
|
||||
"track_extension",
|
||||
"track_fact",
|
||||
"track_artifact",
|
||||
"track_canonical_link",
|
||||
"track_id_history",
|
||||
"play_session",
|
||||
"track_offline",
|
||||
"track",
|
||||
"album",
|
||||
"artist",
|
||||
"sync_state",
|
||||
];
|
||||
|
||||
const ANALYSIS_TABLES: &[ScopedTable] = &[
|
||||
ScopedTable { table: "analysis_track", column: "server_id" },
|
||||
ScopedTable { table: "waveform_cache", column: "server_id" },
|
||||
ScopedTable { table: "loudness_cache", column: "server_id" },
|
||||
];
|
||||
const ANALYSIS_TABLES: &[&str] = &["analysis_track", "waveform_cache", "loudness_cache"];
|
||||
|
||||
fn migration_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -110,23 +93,14 @@ pub fn migration_inspect(
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn migration_run(
|
||||
pub fn migration_run(
|
||||
app: AppHandle,
|
||||
mappings: Vec<ServerIndexMapping>,
|
||||
) -> Result<MigrationRunResult, String> {
|
||||
let barrier = match app.try_state::<psysonic_library::LibraryRuntime>() {
|
||||
Some(runtime) => Some(runtime.cancel_and_drain_sync(None, None).await?),
|
||||
None => None,
|
||||
};
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _barrier = barrier;
|
||||
let _guard = migration_lock()
|
||||
.lock()
|
||||
.map_err(|_| "migration lock poisoned".to_string())?;
|
||||
run_internal(&app, mappings)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("migration worker failed: {e}"))?
|
||||
let _guard = migration_lock()
|
||||
.lock()
|
||||
.map_err(|_| "migration lock poisoned".to_string())?;
|
||||
run_internal(&app, mappings)
|
||||
}
|
||||
|
||||
fn inspect_internal(
|
||||
@@ -157,11 +131,11 @@ fn inspect_internal(
|
||||
if paths.analysis_active.exists() {
|
||||
let conn = open_readonly(&paths.analysis_active)?;
|
||||
for table in ANALYSIS_TABLES {
|
||||
let empty_count = count_rows_eq(&conn, *table, "")?;
|
||||
let empty_count = count_rows_eq(&conn, table, "")?;
|
||||
if empty_count > 0 {
|
||||
has_empty_bucket_rows = true;
|
||||
if normalized.len() == 1 {
|
||||
let entry = analysis_tables.entry(table.table.to_string()).or_insert(0);
|
||||
let entry = analysis_tables.entry((*table).to_string()).or_insert(0);
|
||||
*entry = entry.saturating_add(empty_count as u64);
|
||||
analysis_total = analysis_total.saturating_add(empty_count as u64);
|
||||
}
|
||||
@@ -204,10 +178,7 @@ fn inspect_internal(
|
||||
})
|
||||
}
|
||||
|
||||
fn run_internal(
|
||||
app: &AppHandle,
|
||||
mappings: Vec<ServerIndexMapping>,
|
||||
) -> Result<MigrationRunResult, String> {
|
||||
fn run_internal(app: &AppHandle, mappings: Vec<ServerIndexMapping>) -> Result<MigrationRunResult, String> {
|
||||
let inspect = inspect_internal(app, mappings)?;
|
||||
if !inspect.needs_migration {
|
||||
return Ok(MigrationRunResult {
|
||||
@@ -238,7 +209,13 @@ fn run_internal(
|
||||
None
|
||||
};
|
||||
|
||||
emit_progress(app, "library", "prepare", 0, LIBRARY_TABLES.len() as u64)?;
|
||||
emit_progress(
|
||||
app,
|
||||
"library",
|
||||
"prepare",
|
||||
0,
|
||||
LIBRARY_TABLES.len() as u64,
|
||||
)?;
|
||||
let (library_source_rows, library_imported_rows, library_skipped_unknown_rows) =
|
||||
run_library_import(app, &paths, &mappings)?;
|
||||
let (analysis_source_rows, analysis_imported_rows, analysis_skipped_unknown_rows) =
|
||||
@@ -258,51 +235,15 @@ fn run_internal(
|
||||
}
|
||||
}
|
||||
if paths.analysis_v2.exists() {
|
||||
let switch_result =
|
||||
if let Some(cache) = app.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>() {
|
||||
cache.swap_database_file(&paths.analysis_active, &paths.analysis_v2)
|
||||
} else {
|
||||
switch_file(&paths.analysis_active, &paths.analysis_v2).map(Some)
|
||||
};
|
||||
match switch_result {
|
||||
Ok(backup) => analysis_backup = backup,
|
||||
Err(err) => {
|
||||
let mut rollback_errors = Vec::new();
|
||||
if let Some(ref backup) = library_backup {
|
||||
if let Some(runtime) = app.try_state::<psysonic_library::LibraryRuntime>() {
|
||||
if let Err(rollback) = runtime
|
||||
.store
|
||||
.restore_database_backup(backup, &paths.library_active)
|
||||
.and_then(|_| runtime.store.verify_operational_schema())
|
||||
{
|
||||
rollback_errors.push(format!("library rollback failed: {rollback}"));
|
||||
}
|
||||
} else {
|
||||
if let Err(rollback) = restore_backup(backup, &paths.library_active) {
|
||||
rollback_errors.push(format!("library rollback failed: {rollback}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(cache) =
|
||||
app.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>()
|
||||
{
|
||||
if let Err(rollback) = cache.verify_operational_schema() {
|
||||
rollback_errors.push(format!("analysis rollback failed: {rollback}"));
|
||||
}
|
||||
}
|
||||
if !rollback_errors.is_empty() {
|
||||
return Err(format!(
|
||||
"analysis switch failed: {err}; {}",
|
||||
rollback_errors.join("; ")
|
||||
));
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
if let Some(cache) = app.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>() {
|
||||
analysis_backup = cache.swap_database_file(&paths.analysis_active, &paths.analysis_v2)?;
|
||||
} else {
|
||||
analysis_backup = Some(switch_file(&paths.analysis_active, &paths.analysis_v2)?);
|
||||
}
|
||||
}
|
||||
let switched = library_backup.is_some() || analysis_backup.is_some();
|
||||
|
||||
if let Err(err) = health_check(app, &paths.library_active, &paths.analysis_active) {
|
||||
if let Err(err) = health_check(&paths.library_active, &paths.analysis_active) {
|
||||
if let Some(ref backup) = library_backup {
|
||||
if let Some(runtime) = app.try_state::<psysonic_library::LibraryRuntime>() {
|
||||
let _ = runtime
|
||||
@@ -313,8 +254,7 @@ fn run_internal(
|
||||
}
|
||||
}
|
||||
if let Some(ref backup) = analysis_backup {
|
||||
if let Some(cache) = app.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>()
|
||||
{
|
||||
if let Some(cache) = app.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>() {
|
||||
let _ = cache.restore_database_backup(backup, &paths.analysis_active);
|
||||
} else {
|
||||
let _ = restore_backup(backup, &paths.analysis_active);
|
||||
@@ -343,8 +283,7 @@ fn run_internal(
|
||||
source_rows: analysis_source_rows,
|
||||
skipped_unknown_server_rows: analysis_skipped_unknown_rows,
|
||||
},
|
||||
has_skipped_unknown_server_rows: library_skipped_unknown_rows > 0
|
||||
|| analysis_skipped_unknown_rows > 0,
|
||||
has_skipped_unknown_server_rows: library_skipped_unknown_rows > 0 || analysis_skipped_unknown_rows > 0,
|
||||
switched,
|
||||
backup_removed,
|
||||
})
|
||||
@@ -368,15 +307,23 @@ fn run_library_import(
|
||||
let total = LIBRARY_TABLES.len() as u64;
|
||||
let mut done = 0_u64;
|
||||
with_foreign_keys_disabled(&dest, || {
|
||||
rewrite_scoped_tables(&dest, LIBRARY_TABLES, mappings, None, |table| {
|
||||
for table in LIBRARY_TABLES {
|
||||
purge_unknown_rows(&dest, table, &legacy_ids, &index_keys)?;
|
||||
for mapping in mappings {
|
||||
dest.execute(
|
||||
&format!("UPDATE OR REPLACE {table} SET server_id = ?2 WHERE server_id = ?1"),
|
||||
[&mapping.legacy_id, &mapping.index_key],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
done = done.saturating_add(1);
|
||||
emit_progress(app, "library", table, done, total)
|
||||
})
|
||||
emit_progress(app, "library", table, done, total)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let source_rows = sum_table_rows(&source, LIBRARY_TABLES)?;
|
||||
let imported_rows = sum_table_rows(&dest, LIBRARY_TABLES)?;
|
||||
let skipped_unknown_server_rows =
|
||||
sum_unknown_rows(&source, LIBRARY_TABLES, &legacy_ids, &index_keys)?;
|
||||
let skipped_unknown_server_rows = sum_unknown_rows(&source, LIBRARY_TABLES, &legacy_ids, &index_keys)?;
|
||||
Ok((source_rows, imported_rows, skipped_unknown_server_rows))
|
||||
}
|
||||
|
||||
@@ -399,15 +346,30 @@ fn run_analysis_import(
|
||||
let total = ANALYSIS_TABLES.len() as u64;
|
||||
let mut done = 0_u64;
|
||||
with_foreign_keys_disabled(&dest, || {
|
||||
rewrite_scoped_tables(&dest, ANALYSIS_TABLES, mappings, single_mapping, |table| {
|
||||
for table in ANALYSIS_TABLES {
|
||||
purge_unknown_rows(&dest, table, &legacy_ids, &index_keys)?;
|
||||
for mapping in mappings {
|
||||
dest.execute(
|
||||
&format!("UPDATE OR REPLACE {table} SET server_id = ?2 WHERE server_id = ?1"),
|
||||
[&mapping.legacy_id, &mapping.index_key],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(index_key) = single_mapping {
|
||||
dest.execute(
|
||||
&format!("UPDATE OR REPLACE {table} SET server_id = ?2 WHERE server_id = ?1"),
|
||||
["", index_key],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
done = done.saturating_add(1);
|
||||
emit_progress(app, "analysis", table, done, total)
|
||||
})
|
||||
emit_progress(app, "analysis", table, done, total)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let source_rows = sum_table_rows(&source, ANALYSIS_TABLES)?;
|
||||
let imported_rows = sum_table_rows(&dest, ANALYSIS_TABLES)?;
|
||||
let skipped_unknown_server_rows =
|
||||
sum_unknown_rows(&source, ANALYSIS_TABLES, &legacy_ids, &index_keys)?;
|
||||
let skipped_unknown_server_rows = sum_unknown_rows(&source, ANALYSIS_TABLES, &legacy_ids, &index_keys)?;
|
||||
Ok((source_rows, imported_rows, skipped_unknown_server_rows))
|
||||
}
|
||||
|
||||
@@ -431,45 +393,9 @@ fn normalize_mappings(mappings: Vec<ServerIndexMapping>) -> Vec<ServerIndexMappi
|
||||
out
|
||||
}
|
||||
|
||||
fn rewrite_scoped_tables(
|
||||
conn: &Connection,
|
||||
tables: &[ScopedTable],
|
||||
mappings: &[ServerIndexMapping],
|
||||
empty_bucket_index_key: Option<&str>,
|
||||
mut table_completed: impl FnMut(&str) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let legacy_ids: Vec<String> = mappings.iter().map(|m| m.legacy_id.clone()).collect();
|
||||
let index_keys: Vec<String> = mappings.iter().map(|m| m.index_key.clone()).collect();
|
||||
for table in tables {
|
||||
purge_unknown_rows(conn, *table, &legacy_ids, &index_keys)?;
|
||||
for mapping in mappings {
|
||||
conn.execute(
|
||||
&format!(
|
||||
"UPDATE OR REPLACE {} SET {} = ?2 WHERE {} = ?1",
|
||||
table.table, table.column, table.column
|
||||
),
|
||||
[&mapping.legacy_id, &mapping.index_key],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(index_key) = empty_bucket_index_key {
|
||||
conn.execute(
|
||||
&format!(
|
||||
"UPDATE OR REPLACE {} SET {} = ?2 WHERE {} = ?1",
|
||||
table.table, table.column, table.column
|
||||
),
|
||||
["", index_key],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
table_completed(table.table)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_tables(
|
||||
db_path: &Path,
|
||||
tables: &[ScopedTable],
|
||||
tables: &[&str],
|
||||
legacy_ids: &[String],
|
||||
known_index_keys: &[String],
|
||||
) -> Result<(HashMap<String, u64>, u64, u64), String> {
|
||||
@@ -481,38 +407,31 @@ fn inspect_tables(
|
||||
let mut total = 0_u64;
|
||||
let mut skipped_unknown_server_rows = 0_u64;
|
||||
for table in tables {
|
||||
let count = count_rows_in(&conn, *table, legacy_ids)? as u64;
|
||||
let count = count_rows_in(&conn, table, legacy_ids)? as u64;
|
||||
if count > 0 {
|
||||
counts.insert(table.table.to_string(), count);
|
||||
counts.insert((*table).to_string(), count);
|
||||
total = total.saturating_add(count);
|
||||
}
|
||||
let unknown = count_unknown_rows(&conn, *table, legacy_ids, known_index_keys)? as u64;
|
||||
let unknown =
|
||||
count_unknown_rows(&conn, table, legacy_ids, known_index_keys)? as u64;
|
||||
skipped_unknown_server_rows = skipped_unknown_server_rows.saturating_add(unknown);
|
||||
}
|
||||
Ok((counts, total, skipped_unknown_server_rows))
|
||||
}
|
||||
|
||||
fn count_rows_in(conn: &Connection, table: ScopedTable, values: &[String]) -> Result<i64, String> {
|
||||
fn count_rows_in(conn: &Connection, table: &str, values: &[String]) -> Result<i64, String> {
|
||||
if values.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let placeholders = std::iter::repeat_n("?", values.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE {} IN ({placeholders})",
|
||||
table.table, table.column
|
||||
);
|
||||
let placeholders = std::iter::repeat_n("?", values.len()).collect::<Vec<_>>().join(",");
|
||||
let sql = format!("SELECT COUNT(*) FROM {table} WHERE server_id IN ({placeholders})");
|
||||
conn.query_row(&sql, params_from_iter(values.iter()), |row| row.get(0))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn count_rows_eq(conn: &Connection, table: ScopedTable, value: &str) -> Result<i64, String> {
|
||||
fn count_rows_eq(conn: &Connection, table: &str, value: &str) -> Result<i64, String> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE {} = ?1",
|
||||
table.table, table.column
|
||||
),
|
||||
&format!("SELECT COUNT(*) FROM {table} WHERE server_id = ?1"),
|
||||
[&value],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -521,7 +440,7 @@ fn count_rows_eq(conn: &Connection, table: ScopedTable, value: &str) -> Result<i
|
||||
|
||||
fn count_unknown_rows(
|
||||
conn: &Connection,
|
||||
table: ScopedTable,
|
||||
table: &str,
|
||||
known_legacy_ids: &[String],
|
||||
known_index_keys: &[String],
|
||||
) -> Result<i64, String> {
|
||||
@@ -529,10 +448,7 @@ fn count_unknown_rows(
|
||||
if known.is_empty() {
|
||||
return conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE {} <> ''",
|
||||
table.table, table.column
|
||||
),
|
||||
&format!("SELECT COUNT(*) FROM {table} WHERE server_id <> ''"),
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -541,36 +457,29 @@ fn count_unknown_rows(
|
||||
let placeholders = std::iter::repeat_n("?", known.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE {} <> '' AND {} NOT IN ({placeholders})",
|
||||
table.table, table.column, table.column
|
||||
);
|
||||
let sql =
|
||||
format!("SELECT COUNT(*) FROM {table} WHERE server_id <> '' AND server_id NOT IN ({placeholders})");
|
||||
conn.query_row(&sql, params_from_iter(known.iter()), |row| row.get(0))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn purge_unknown_rows(
|
||||
conn: &Connection,
|
||||
table: ScopedTable,
|
||||
table: &str,
|
||||
known_legacy_ids: &[String],
|
||||
known_index_keys: &[String],
|
||||
) -> Result<(), String> {
|
||||
let known = known_server_ids(known_legacy_ids, known_index_keys);
|
||||
if known.is_empty() {
|
||||
conn.execute(
|
||||
&format!("DELETE FROM {} WHERE {} <> ''", table.table, table.column),
|
||||
[],
|
||||
)
|
||||
conn.execute(&format!("DELETE FROM {table} WHERE server_id <> ''"), [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
let placeholders = std::iter::repeat_n("?", known.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"DELETE FROM {} WHERE {} <> '' AND {} NOT IN ({placeholders})",
|
||||
table.table, table.column, table.column
|
||||
);
|
||||
let sql =
|
||||
format!("DELETE FROM {table} WHERE server_id <> '' AND server_id NOT IN ({placeholders})");
|
||||
conn.execute(&sql, params_from_iter(known.iter()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
@@ -585,13 +494,11 @@ fn known_server_ids(known_legacy_ids: &[String], known_index_keys: &[String]) ->
|
||||
known
|
||||
}
|
||||
|
||||
fn sum_table_rows(conn: &Connection, tables: &[ScopedTable]) -> Result<u64, String> {
|
||||
fn sum_table_rows(conn: &Connection, tables: &[&str]) -> Result<u64, String> {
|
||||
let mut total = 0_u64;
|
||||
for table in tables {
|
||||
let rows: i64 = conn
|
||||
.query_row(&format!("SELECT COUNT(*) FROM {}", table.table), [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
total = total.saturating_add(rows.max(0) as u64);
|
||||
}
|
||||
@@ -600,13 +507,13 @@ fn sum_table_rows(conn: &Connection, tables: &[ScopedTable]) -> Result<u64, Stri
|
||||
|
||||
fn sum_unknown_rows(
|
||||
conn: &Connection,
|
||||
tables: &[ScopedTable],
|
||||
tables: &[&str],
|
||||
known_legacy_ids: &[String],
|
||||
known_index_keys: &[String],
|
||||
) -> Result<u64, String> {
|
||||
let mut total = 0_u64;
|
||||
for table in tables {
|
||||
let rows = count_unknown_rows(conn, *table, known_legacy_ids, known_index_keys)?;
|
||||
let rows = count_unknown_rows(conn, table, known_legacy_ids, known_index_keys)?;
|
||||
total = total.saturating_add(rows.max(0) as u64);
|
||||
}
|
||||
Ok(total)
|
||||
@@ -655,13 +562,7 @@ fn ensure_foreign_keys_clean(conn: &Connection) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_progress(
|
||||
app: &AppHandle,
|
||||
stage: &str,
|
||||
table: &str,
|
||||
done: u64,
|
||||
total: u64,
|
||||
) -> Result<(), String> {
|
||||
fn emit_progress(app: &AppHandle, stage: &str, table: &str, done: u64, total: u64) -> Result<(), String> {
|
||||
app.emit(
|
||||
"migration:progress",
|
||||
MigrationProgressEvent {
|
||||
@@ -699,19 +600,10 @@ fn switch_file(active: &Path, destination: &Path) -> Result<PathBuf, String> {
|
||||
.unwrap_or("db.sqlite")
|
||||
));
|
||||
remove_db_with_sidecars(&backup).ok();
|
||||
let mut active_backed_up = false;
|
||||
if active.exists() {
|
||||
fs::rename(active, &backup).map_err(|e| e.to_string())?;
|
||||
active_backed_up = true;
|
||||
}
|
||||
if let Err(error) = fs::rename(destination, active) {
|
||||
if active_backed_up {
|
||||
fs::rename(&backup, active).map_err(|rollback| {
|
||||
format!("database switch failed: {error}; rollback failed: {rollback}")
|
||||
})?;
|
||||
}
|
||||
return Err(error.to_string());
|
||||
}
|
||||
fs::rename(destination, active).map_err(|e| e.to_string())?;
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
@@ -725,24 +617,16 @@ fn restore_backup(backup: &Path, active: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn health_check(app: &AppHandle, library_active: &Path, analysis_active: &Path) -> Result<(), String> {
|
||||
fn health_check(library_active: &Path, analysis_active: &Path) -> Result<(), String> {
|
||||
if library_active.exists() {
|
||||
if let Some(runtime) = app.try_state::<psysonic_library::LibraryRuntime>() {
|
||||
runtime.store.verify_operational_schema()?;
|
||||
} else {
|
||||
let conn = open_readonly(library_active)?;
|
||||
conn.query_row("SELECT COUNT(*) FROM track", [], |_row| Ok(()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let conn = open_readonly(library_active)?;
|
||||
conn.query_row("SELECT COUNT(*) FROM track", [], |_row| Ok(()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if analysis_active.exists() {
|
||||
if let Some(cache) = app.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>() {
|
||||
cache.verify_operational_schema()?;
|
||||
} else {
|
||||
let conn = open_readonly(analysis_active)?;
|
||||
conn.query_row("SELECT COUNT(*) FROM analysis_track", [], |_row| Ok(()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let conn = open_readonly(analysis_active)?;
|
||||
conn.query_row("SELECT COUNT(*) FROM analysis_track", [], |_row| Ok(()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -788,81 +672,6 @@ fn migration_paths(app: &AppHandle) -> Result<MigrationPaths, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_TRACK_TABLE: ScopedTable = ScopedTable {
|
||||
table: "track",
|
||||
column: "server_id",
|
||||
};
|
||||
|
||||
fn full_library_schema() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("in-memory sqlite");
|
||||
for migration in [
|
||||
include_str!("../../../crates/psysonic-library/migrations/001_initial.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/012_track_genre_legacy_repair.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/013_artist_artwork_lookup.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/014_artist_name_sort.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/015_replay_gain_peak.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/016_multi_library_scope.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/017_library_tag_state.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/018_artist_synced_index.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/019_mainstage_feed_indexes.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/020_scope_browse_projection.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/021_scope_browse_tracks.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/022_artist_name_fold.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/023_starred_browse_indexes.sql"),
|
||||
include_str!("../../../crates/psysonic-library/migrations/024_composer_browse_projection.sql"),
|
||||
] {
|
||||
conn.execute_batch(migration).expect("apply library migration");
|
||||
}
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")
|
||||
.expect("enable foreign keys");
|
||||
conn
|
||||
}
|
||||
|
||||
fn populate_all_library_scopes(conn: &Connection) {
|
||||
conn.execute_batch(
|
||||
"INSERT INTO canonical_track(id, created_at, updated_at) VALUES ('canonical-1', 1, 1);
|
||||
INSERT INTO sync_state(server_id, library_scope) VALUES ('legacy-a', '');
|
||||
INSERT INTO artist(server_id, id, name, synced_at) VALUES ('legacy-a', 'artist-1', 'Artist', 1);
|
||||
INSERT INTO album(server_id, id, name, synced_at) VALUES ('legacy-a', 'album-1', 'Album', 1);
|
||||
INSERT INTO track(server_id, id, title, album, duration_sec, synced_at, raw_json)
|
||||
VALUES ('legacy-a', 'track-1', 'Track', 'Album', 1, 1, '{}');
|
||||
INSERT INTO track_extension(server_id, track_id, kind, payload, updated_at)
|
||||
VALUES ('legacy-a', 'track-1', 'waveform', X'01', 1);
|
||||
INSERT INTO track_fact(server_id, track_id, fact_kind, source_kind, source_id, fetched_at)
|
||||
VALUES ('legacy-a', 'track-1', 'bpm', 'server', 'source', 1);
|
||||
INSERT INTO track_artifact(server_id, track_id, artifact_kind, format, source_kind, source_id, fetched_at)
|
||||
VALUES ('legacy-a', 'track-1', 'lyrics', 'text', 'server', 'source', 1);
|
||||
INSERT INTO track_canonical_link(server_id, track_id, canonical_id, match_method, confidence, linked_at)
|
||||
VALUES ('legacy-a', 'track-1', 'canonical-1', 'isrc', 1.0, 1);
|
||||
INSERT INTO track_id_history(server_id, old_id, new_id, remapped_at)
|
||||
VALUES ('legacy-a', 'old-track', 'track-1', 1);
|
||||
INSERT INTO play_session(server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason)
|
||||
VALUES ('legacy-a', 'track-1', 1, 1.0, 1.0, 'full', 'ended');
|
||||
INSERT INTO track_offline(server_id, track_id, local_path, cached_at)
|
||||
VALUES ('legacy-a', 'track-1', '/tmp/track-1', 1);
|
||||
INSERT INTO track_genre(server_id, track_id, genre, album_id)
|
||||
VALUES ('legacy-a', 'track-1', 'Rock', 'album-1');
|
||||
INSERT INTO artist_artwork_lookup(server_id, artist_id, surface_kind, status, updated_at)
|
||||
VALUES ('legacy-a', 'artist-1', 'fanart', 'hit', 1);
|
||||
INSERT INTO library_tag_state(server_id, folders_hash, completed_at)
|
||||
VALUES ('legacy-a', 'hash', 1);
|
||||
INSERT INTO entity_user_rating(server_id, entity_kind, entity_id, rating, fetched_at)
|
||||
VALUES ('legacy-a', 'track', 'track-1', 5, 1);
|
||||
INSERT INTO album_browse_projection(
|
||||
server_id, library_id, album_id, name, song_count, duration_sec, synced_at, representative_track_id
|
||||
) VALUES ('legacy-a', '', 'album-1', 'Album', 1, 1, 1, 'track-1');
|
||||
INSERT INTO composer_album_projection(
|
||||
server_id, library_id, composer_id, composer_name, name_sort, identity_key,
|
||||
album_id, synced_at, representative_track_id
|
||||
) VALUES ('legacy-a', '', 'composer-1', 'Composer', 'composer', 'composer',
|
||||
'album-1', 1, 'track-1');
|
||||
INSERT INTO canonical_enrichment_link(
|
||||
canonical_id, enrichment_kind, owner_server_id, owner_track_id, linked_at
|
||||
) VALUES ('canonical-1', 'lyrics', 'legacy-a', 'track-1', 1);",
|
||||
)
|
||||
.expect("populate every server-scoped table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reports_skipped_unknown_rows() {
|
||||
let conn = Connection::open_in_memory().expect("in-memory sqlite");
|
||||
@@ -875,7 +684,7 @@ mod tests {
|
||||
|
||||
let known_legacy_ids = vec!["legacy-a".to_string()];
|
||||
let known_index_keys = vec!["idx-a".to_string()];
|
||||
let unknown = count_unknown_rows(&conn, TEST_TRACK_TABLE, &known_legacy_ids, &known_index_keys)
|
||||
let unknown = count_unknown_rows(&conn, "track", &known_legacy_ids, &known_index_keys)
|
||||
.expect("unknown count");
|
||||
assert_eq!(unknown, 1);
|
||||
}
|
||||
@@ -885,26 +694,15 @@ mod tests {
|
||||
let conn = Connection::open_in_memory().expect("in-memory sqlite");
|
||||
conn.execute_batch("CREATE TABLE analysis_track (server_id TEXT NOT NULL);")
|
||||
.expect("create table");
|
||||
conn.execute(
|
||||
"INSERT INTO analysis_track(server_id) VALUES (?1)",
|
||||
["legacy-a"],
|
||||
)
|
||||
.expect("insert known legacy");
|
||||
conn.execute(
|
||||
"INSERT INTO analysis_track(server_id) VALUES (?1)",
|
||||
["removed-x"],
|
||||
)
|
||||
.expect("insert unknown");
|
||||
conn.execute("INSERT INTO analysis_track(server_id) VALUES (?1)", ["legacy-a"])
|
||||
.expect("insert known legacy");
|
||||
conn.execute("INSERT INTO analysis_track(server_id) VALUES (?1)", ["removed-x"])
|
||||
.expect("insert unknown");
|
||||
|
||||
let known_legacy_ids = vec!["legacy-a".to_string()];
|
||||
let known_index_keys = vec!["idx-a".to_string()];
|
||||
let skipped = sum_unknown_rows(
|
||||
&conn,
|
||||
&[ANALYSIS_TABLES[0]],
|
||||
&known_legacy_ids,
|
||||
&known_index_keys,
|
||||
)
|
||||
.expect("sum unknown rows");
|
||||
let skipped = sum_unknown_rows(&conn, &["analysis_track"], &known_legacy_ids, &known_index_keys)
|
||||
.expect("sum unknown rows");
|
||||
assert_eq!(skipped, 1);
|
||||
}
|
||||
|
||||
@@ -918,8 +716,8 @@ mod tests {
|
||||
|
||||
let known_legacy_ids = vec!["legacy-a".to_string()];
|
||||
let known_index_keys = vec!["idx-a".to_string()];
|
||||
let legacy = count_rows_in(&conn, TEST_TRACK_TABLE, &known_legacy_ids).expect("legacy count");
|
||||
let unknown = count_unknown_rows(&conn, TEST_TRACK_TABLE, &known_legacy_ids, &known_index_keys)
|
||||
let legacy = count_rows_in(&conn, "track", &known_legacy_ids).expect("legacy count");
|
||||
let unknown = count_unknown_rows(&conn, "track", &known_legacy_ids, &known_index_keys)
|
||||
.expect("unknown count");
|
||||
assert_eq!(legacy, 0);
|
||||
assert_eq!(unknown, 1);
|
||||
@@ -941,7 +739,7 @@ mod tests {
|
||||
|
||||
let known_legacy_ids = vec!["legacy-a".to_string()];
|
||||
let known_index_keys = vec!["idx-a".to_string()];
|
||||
purge_unknown_rows(&conn, TEST_TRACK_TABLE, &known_legacy_ids, &known_index_keys)
|
||||
purge_unknown_rows(&conn, "track", &known_legacy_ids, &known_index_keys)
|
||||
.expect("purge unknown rows");
|
||||
|
||||
let remaining: i64 = conn
|
||||
@@ -957,37 +755,4 @@ mod tests {
|
||||
assert_eq!(remaining, 3);
|
||||
assert_eq!(removed_left, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_library_schema_rewrites_every_server_scope_and_keeps_foreign_keys_clean() {
|
||||
let conn = full_library_schema();
|
||||
populate_all_library_scopes(&conn);
|
||||
let mappings = vec![ServerIndexMapping {
|
||||
legacy_id: "legacy-a".to_string(),
|
||||
index_key: "index-a".to_string(),
|
||||
}];
|
||||
|
||||
with_foreign_keys_disabled(&conn, || {
|
||||
rewrite_scoped_tables(&conn, LIBRARY_TABLES, &mappings, None, |_| Ok(()))
|
||||
})
|
||||
.expect("rewrite populated production schema");
|
||||
|
||||
for table in LIBRARY_TABLES {
|
||||
assert_eq!(
|
||||
count_rows_eq(&conn, *table, "legacy-a").unwrap(),
|
||||
0,
|
||||
"legacy rows remain in {}.{}",
|
||||
table.table,
|
||||
table.column
|
||||
);
|
||||
assert_eq!(
|
||||
count_rows_eq(&conn, *table, "index-a").unwrap(),
|
||||
1,
|
||||
"rewritten row missing from {}.{}",
|
||||
table.table,
|
||||
table.column
|
||||
);
|
||||
}
|
||||
ensure_foreign_keys_clean(&conn).expect("rewritten production schema foreign keys");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Listener, Manager};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncIdlePayload {
|
||||
server_id: String,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
/// Consume durable identity invalidations even when no webview page is mounted.
|
||||
pub fn setup_library_sync_idle_listener(app: &AppHandle) {
|
||||
if let Some(runtime) = app.try_state::<psysonic_library::LibraryRuntime>() {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(error) = psysonic_library::identity::ensure_pending_cluster_keys(&store) {
|
||||
crate::app_eprintln!("[library-cluster] startup maintenance failed: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let app_handle = app.clone();
|
||||
let _ = app.listen(
|
||||
psysonic_library::LibrarySyncProgressPayload::IDLE_EVENT_NAME,
|
||||
move |event| {
|
||||
let Ok(payload) = serde_json::from_str::<SyncIdlePayload>(event.payload()) else {
|
||||
return;
|
||||
};
|
||||
if !payload.ok {
|
||||
return;
|
||||
}
|
||||
let Some(runtime) = app_handle.try_state::<psysonic_library::LibraryRuntime>() else {
|
||||
return;
|
||||
};
|
||||
let store = Arc::clone(&runtime.store);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(error) =
|
||||
psysonic_library::identity::ensure_cluster_keys_built(&store, &payload.server_id)
|
||||
{
|
||||
crate::app_eprintln!(
|
||||
"[library-cluster] sync-idle maintenance failed server_id={}: {}",
|
||||
payload.server_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.51.0-dev",
|
||||
"version": "1.50.0-rc.4",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -78,7 +78,7 @@
|
||||
"installMode": "currentUser"
|
||||
},
|
||||
"wix": {
|
||||
"version": "1.51.0.1"
|
||||
"version": "1.50.0.10004"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, useEffect } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { lazyLoadAlbumsPage } from '@/features/album/utils/albumBrowseRoutePrefetch';
|
||||
import { lazyLoadArtistsPage } from '@/features/artist/utils/artistBrowseRoutePrefetch';
|
||||
import MobilePlayerView from '@/features/nowPlaying/components/MobilePlayerView';
|
||||
@@ -8,7 +8,6 @@ import { useSidebarStore } from '@/features/sidebar';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLuckyMixAvailable } from '@/features/randomMix';
|
||||
import { resolveStartRoute } from '@/features/sidebar';
|
||||
import { scheduleStartupSplashDismiss } from './startupSplash';
|
||||
|
||||
// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in
|
||||
// the entry chunk; each page is fetched when its route is first visited.
|
||||
@@ -68,12 +67,6 @@ function MainstageRoute() {
|
||||
*/
|
||||
export default function AppRoutes() {
|
||||
const isMobile = useIsMobile();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== '/') scheduleStartupSplashDismiss();
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<MainstageRoute />} />
|
||||
|
||||
@@ -17,7 +17,6 @@ import AppRoutes from './AppRoutes';
|
||||
import FullscreenPlayer, { FullscreenPlayerImmersive, FullscreenPlayerPrism } from '@/features/fullscreenPlayer';
|
||||
import ContextMenu from '@/features/contextMenu/components/ContextMenu';
|
||||
import SongInfoModal from '@/features/playback/components/SongInfoModal';
|
||||
import PlaybackAlternativeModal from '@/features/playback/components/PlaybackAlternativeModal';
|
||||
import { DownloadFolderModal, OfflineBanner } from '@/features/offline/ui';
|
||||
import GlobalConfirmModal from '@/ui/GlobalConfirmModal';
|
||||
import ThemeMigrationNotice from '@/ui/ThemeMigrationNotice';
|
||||
@@ -44,8 +43,6 @@ import { useNowPlayingTrayTitle } from '@/app/hooks/useNowPlayingTrayTitle';
|
||||
import { usePrefetchReleaseNotes } from '@/app/hooks/usePrefetchReleaseNotes';
|
||||
import { useTrayMenuI18n } from '@/app/hooks/useTrayMenuI18n';
|
||||
import { useServerCapabilitiesProbe } from '@/app/hooks/useServerCapabilitiesProbe';
|
||||
import { useLibraryServerReachability } from '@/app/hooks/useLibraryServerReachability';
|
||||
import { useMusicFoldersDiscovery } from '@/app/hooks/useMusicFoldersDiscovery';
|
||||
import { useQueueResizer } from '@/features/queue';
|
||||
import { useGlobalDndAndSelectionBlockers } from '@/lib/hooks/useGlobalDndAndSelectionBlockers';
|
||||
import { useAppActivityTracking } from '@/app/hooks/useAppActivityTracking';
|
||||
@@ -98,14 +95,13 @@ export function AppShell() {
|
||||
usePlaybackRateOrbitSync();
|
||||
useTrayMenuI18n();
|
||||
useServerCapabilitiesProbe();
|
||||
useLibraryServerReachability();
|
||||
useMusicFoldersDiscovery();
|
||||
useAccumulatedUsage();
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
const uiScale = useFontStore(s => s.uiScale);
|
||||
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
|
||||
@@ -160,6 +156,10 @@ export function AppShell() {
|
||||
useOfflineAutoNav(connStatus, offlineNav, location, navigate);
|
||||
useOfflineLibraryFilterSuspend();
|
||||
|
||||
useEffect(() => {
|
||||
initializeFromServerQueue();
|
||||
}, [initializeFromServerQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
useEqStore.getState().syncToRust();
|
||||
usePlaybackRateStore.getState().syncToRust();
|
||||
@@ -346,7 +346,6 @@ export function AppShell() {
|
||||
)}
|
||||
<ContextMenu />
|
||||
<SongInfoModal />
|
||||
<PlaybackAlternativeModal />
|
||||
<DownloadFolderModal />
|
||||
<GlobalConfirmModal />
|
||||
<ThemeMigrationNotice />
|
||||
|
||||
@@ -9,22 +9,9 @@ function MigrationModal() {
|
||||
const step = useMigrationStore(s => s.step);
|
||||
const progress = useMigrationStore(s => s.progress);
|
||||
const genreTagsProgress = useMigrationStore(s => s.genreTagsProgress);
|
||||
const scopeBrowseProjectionProgress = useMigrationStore(s => s.scopeBrowseProjectionProgress);
|
||||
const inspect = useMigrationStore(s => s.inspect);
|
||||
const error = useMigrationStore(s => s.lastError);
|
||||
const isGenreTags = step === 'genreTags';
|
||||
const isScopeBrowseProjection = step === 'scopeBrowseProjection';
|
||||
const migrationTitle = isGenreTags
|
||||
? t('migration.genreTagsTitle')
|
||||
: isScopeBrowseProjection
|
||||
? t('migration.scopeBrowseProjectionTitle')
|
||||
: t('migration.migrating');
|
||||
const migrationBody = isGenreTags
|
||||
? t('migration.genreTagsBody')
|
||||
: isScopeBrowseProjection
|
||||
? t('migration.scopeBrowseProjectionBody')
|
||||
: (progress ? `${progress.stage} - ${progress.table}` : t('migration.working'));
|
||||
const activeProgress = isGenreTags ? genreTagsProgress : isScopeBrowseProjection ? scopeBrowseProjectionProgress : progress;
|
||||
|
||||
const migratedRows = (inspect?.library.totalLegacyRows ?? 0) + (inspect?.analysis.totalLegacyRows ?? 0);
|
||||
return (
|
||||
@@ -48,20 +35,26 @@ function MigrationModal() {
|
||||
>
|
||||
{phase === 'inspecting' && (
|
||||
<>
|
||||
<h3>{isGenreTags ? t('migration.genreTagsTitle') : isScopeBrowseProjection ? t('migration.scopeBrowseProjectionTitle') : t('migration.preparing')}</h3>
|
||||
<h3>{isGenreTags ? t('migration.genreTagsTitle') : t('migration.preparing')}</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
{isGenreTags ? t('migration.genreTagsBody') : isScopeBrowseProjection ? t('migration.scopeBrowseProjectionBody') : t('migration.preparingBody')}
|
||||
{isGenreTags ? t('migration.genreTagsBody') : t('migration.preparingBody')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{phase === 'running' && (
|
||||
<>
|
||||
<h3>{migrationTitle}</h3>
|
||||
<h3>{isGenreTags ? t('migration.genreTagsTitle') : t('migration.migrating')}</h3>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{migrationBody}
|
||||
{isGenreTags
|
||||
? t('migration.genreTagsBody')
|
||||
: (progress ? `${progress.stage} - ${progress.table}` : t('migration.working'))}
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
{activeProgress ? `${activeProgress.done} / ${activeProgress.total}` : t('migration.working')}
|
||||
{isGenreTags
|
||||
? (genreTagsProgress
|
||||
? `${genreTagsProgress.done} / ${genreTagsProgress.total}`
|
||||
: t('migration.working'))
|
||||
: (progress ? `${progress.done} / ${progress.total}` : t('migration.working'))}
|
||||
</p>
|
||||
{!isGenreTags && inspect?.hasSkippedUnknownServerRows ? (
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>
|
||||
@@ -72,7 +65,7 @@ function MigrationModal() {
|
||||
)}
|
||||
{phase === 'error' && (
|
||||
<>
|
||||
<h3>{isGenreTags ? t('migration.genreTagsFailed') : isScopeBrowseProjection ? t('migration.scopeBrowseProjectionFailed') : t('migration.failed')}</h3>
|
||||
<h3>{isGenreTags ? t('migration.genreTagsFailed') : t('migration.failed')}</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>{String(error ?? '').slice(0, 200)}</p>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button className="btn-primary" onClick={() => retryBlockingMigration()}>{t('migration.retry')}</button>
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { DragDropProvider } from '@/lib/dnd/DragDropContext';
|
||||
import ConnectionIndicator from './ConnectionIndicator';
|
||||
import { setServerReachability } from '@/lib/network/serverReachability';
|
||||
|
||||
const switchActiveServerMock = vi.fn();
|
||||
|
||||
vi.mock('@/utils/server/switchActiveServer', () => ({
|
||||
switchActiveServer: (...args: unknown[]) => switchActiveServerMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/hooks/usePlayQueueSyncLedState', () => ({
|
||||
usePlayQueueSyncLedState: () => ({
|
||||
ledVariant: 'connected',
|
||||
localQueueSyncPaused: false,
|
||||
queueHandoffReason: null,
|
||||
pullInFlight: false,
|
||||
syncRingVisible: false,
|
||||
pullFromActiveServer: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function setupServers() {
|
||||
const a = useAuthStore.getState().addServer({
|
||||
name: 'Home', url: 'https://home.test', username: 'u', password: 'p',
|
||||
});
|
||||
const b = useAuthStore.getState().addServer({
|
||||
name: 'Remote', url: 'https://remote.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.setState({ activeServerId: a, libraryBrowseServerIds: [a] });
|
||||
return { a, b };
|
||||
}
|
||||
|
||||
function renderIndicator() {
|
||||
renderWithProviders(
|
||||
<DragDropProvider>
|
||||
<ConnectionIndicator status="connected" isLan={false} serverName="Home" />
|
||||
</DragDropProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
switchActiveServerMock.mockReset();
|
||||
switchActiveServerMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('ConnectionIndicator Library server selection', () => {
|
||||
it('uses checkbox selection additively and shows the multi-server badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
setupServers();
|
||||
renderIndicator();
|
||||
|
||||
await user.click(screen.getByText('Home'));
|
||||
await user.click(screen.getByRole('button', { name: /Include Remote.*Library scope/i }));
|
||||
|
||||
expect(useAuthStore.getState().libraryBrowseServerIds).toHaveLength(2);
|
||||
expect(screen.getByText('Multi-server')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 servers')).toBeInTheDocument();
|
||||
expect(switchActiveServerMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('menuitem', { name: 'Home' }).querySelector('.nav-library-dropdown-check')).toBeNull();
|
||||
expect(screen.getByRole('menuitem', { name: 'Home' }).closest('.nav-library-dropdown-item')).toHaveClass(
|
||||
'nav-library-dropdown-item--selected',
|
||||
);
|
||||
expect(screen.getByRole('menuitem', { name: 'Remote' }).closest('.nav-library-dropdown-item')).toHaveClass(
|
||||
'nav-library-dropdown-item--selected',
|
||||
);
|
||||
});
|
||||
|
||||
it('clicking the server row switches active server and makes Library exclusive', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { a, b } = setupServers();
|
||||
useAuthStore.setState({ libraryBrowseServerIds: [a, b] });
|
||||
renderIndicator();
|
||||
|
||||
await user.click(screen.getByText('2 servers'));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Remote' }));
|
||||
|
||||
expect(switchActiveServerMock).toHaveBeenCalledWith(expect.objectContaining({ id: b }));
|
||||
expect(useAuthStore.getState().libraryBrowseServerIds).toEqual([b]);
|
||||
});
|
||||
|
||||
it('crosses out the selected count while an unavailable server is outside the effective scope', () => {
|
||||
const { a, b } = setupServers();
|
||||
useAuthStore.setState({ libraryBrowseServerIds: [a, b] });
|
||||
setServerReachability(b, 'unavailable');
|
||||
|
||||
renderIndicator();
|
||||
|
||||
expect(screen.getByText('2 servers').tagName).toBe('DEL');
|
||||
expect(screen.getByText('1 servers')).toBeInTheDocument();
|
||||
expect(useAuthStore.getState().libraryBrowseServerIds).toEqual([a, b]);
|
||||
|
||||
act(() => setServerReachability(b, 'available'));
|
||||
|
||||
expect(screen.getByText('2 servers').tagName).toBe('SPAN');
|
||||
expect(screen.queryByText('1 servers')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,6 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import { switchActiveServer } from '@/utils/server/switchActiveServer';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
|
||||
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
|
||||
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
|
||||
import { applyListReorderById } from '@/lib/util/listReorder';
|
||||
import { deriveEffectiveLibraryBrowseServerIds } from '@/lib/library/libraryBrowseScope';
|
||||
import { useUnavailableServerIds } from '@/lib/network/serverReachability';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
@@ -28,11 +23,6 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
const navigate = useNavigate();
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const libraryBrowseServerIds = useAuthStore(s => s.libraryBrowseServerIds);
|
||||
const setLibraryBrowseServerExclusive = useAuthStore(s => s.setLibraryBrowseServerExclusive);
|
||||
const setLibraryBrowseServerSelected = useAuthStore(s => s.setLibraryBrowseServerSelected);
|
||||
const setServers = useAuthStore(s => s.setServers);
|
||||
const unavailableServerIds = useUnavailableServerIds();
|
||||
const {
|
||||
ledVariant,
|
||||
localQueueSyncPaused,
|
||||
@@ -46,28 +36,8 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const menuPanelRef = useRef<HTMLDivElement>(null);
|
||||
const serversRef = useRef(servers);
|
||||
// React Compiler refs rule: event handlers need the latest persisted order.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
serversRef.current = servers;
|
||||
|
||||
const multi = servers.length > 1;
|
||||
const multiLibraryScope = libraryBrowseServerIds.length > 1;
|
||||
const effectiveLibraryServerIds = deriveEffectiveLibraryBrowseServerIds({
|
||||
servers,
|
||||
activeServerId,
|
||||
libraryBrowseServerIds,
|
||||
}, unavailableServerIds);
|
||||
const unavailableSelection = multiLibraryScope
|
||||
&& effectiveLibraryServerIds.length < libraryBrowseServerIds.length;
|
||||
const applyServerReorder = useCallback((draggedId: string, target: { id: string; before: boolean }) => {
|
||||
const next = applyListReorderById(serversRef.current, draggedId, target);
|
||||
if (next) setServers(next);
|
||||
}, [setServers]);
|
||||
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({
|
||||
type: 'server_reorder',
|
||||
apply: applyServerReorder,
|
||||
});
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
const el = hostRef.current;
|
||||
@@ -128,7 +98,6 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
|
||||
const onPickServer = async (srv: ServerProfile) => {
|
||||
if (srv.id === activeServerId) {
|
||||
setLibraryBrowseServerExclusive(srv.id);
|
||||
setMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -140,22 +109,10 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
showToast(t('connection.switchFailed'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
setLibraryBrowseServerExclusive(srv.id);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const label = multiLibraryScope ? t('connection.multiServer') : (isLan ? 'LAN' : t('connection.extern'));
|
||||
const displayedServerName = multiLibraryScope ? (
|
||||
unavailableSelection ? (
|
||||
<>
|
||||
<del className="connection-server-count--unavailable">
|
||||
{t('sidebar.serverSelectionCount', { count: libraryBrowseServerIds.length })}
|
||||
</del>
|
||||
<span className="connection-server-count-arrow" aria-hidden>→</span>
|
||||
<span>{t('sidebar.serverSelectionCount', { count: effectiveLibraryServerIds.length })}</span>
|
||||
</>
|
||||
) : t('sidebar.serverSelectionCount', { count: libraryBrowseServerIds.length })
|
||||
) : serverName;
|
||||
const label = isLan ? 'LAN' : t('connection.extern');
|
||||
const tooltip = pullInFlight
|
||||
? t('connection.queuePulling')
|
||||
: ledVariant === 'queue-handoff'
|
||||
@@ -198,7 +155,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
>
|
||||
<span className="connection-type">{label}</span>
|
||||
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
|
||||
<span className="connection-server-count">{displayedServerName}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
|
||||
{multi && (
|
||||
<ChevronDown size={12} className={menuOpen ? 'connection-indicator-chevron--open' : undefined} style={{ flexShrink: 0, opacity: 0.85 }} aria-hidden />
|
||||
)}
|
||||
@@ -210,13 +167,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={element => {
|
||||
menuPanelRef.current = element;
|
||||
setContainer(element);
|
||||
}}
|
||||
ref={menuPanelRef}
|
||||
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
|
||||
role="menu"
|
||||
onMouseMove={onMouseMove}
|
||||
aria-label={t('connection.switchServerTitle')}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -240,46 +193,27 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
{t('connection.switchServerTitle')}
|
||||
</div>
|
||||
{servers.map(srv => {
|
||||
const included = libraryBrowseServerIds.includes(srv.id);
|
||||
const finalIncluded = included && libraryBrowseServerIds.length === 1;
|
||||
const active = srv.id === activeServerId;
|
||||
const busy = switchingId === srv.id;
|
||||
const labelText = serverListDisplayLabel(srv, servers);
|
||||
const edge = isDragging ? dropEdge(srv.id) : null;
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={srv.id}
|
||||
data-reorder-id={srv.id}
|
||||
className={`nav-library-dropdown-item connection-indicator-server-row${included ? ' nav-library-dropdown-item--selected' : ''}${edge ? ` connection-indicator-server-row--drop-${edge}` : ''}`}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
|
||||
disabled={busy}
|
||||
onClick={() => onPickServer(srv)}
|
||||
>
|
||||
<ReorderGripHandle id={srv.id} type="server_reorder" label={labelText} />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="connection-indicator-server-main"
|
||||
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 />
|
||||
) : (
|
||||
<span className="nav-library-dropdown-check-spacer" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-library-dropdown-item-toggle ${included ? 'nav-library-dropdown-item-toggle--on' : ''}`}
|
||||
aria-label={`${included ? t('sidebar.libraryDeselect', { name: labelText }) : t('sidebar.librarySelect', { name: labelText })} · ${t('sidebar.libraryScope')}`}
|
||||
aria-pressed={included}
|
||||
disabled={finalIncluded}
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
setLibraryBrowseServerSelected(srv.id, !included);
|
||||
}}
|
||||
>
|
||||
{included ? <Check size={16} strokeWidth={2.5} /> : <span className="nav-library-dropdown-item-toggle-box" aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
<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
|
||||
|
||||
+2
-9
@@ -1,6 +1,6 @@
|
||||
import { initAudioListeners } from '@/features/playback/store/initAudioListeners';
|
||||
import '@/features/playback/store/playbackEngineBridgeRegister'; // installs the playback-engine bridge at boot
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { preloadMiniPlayer as preloadMiniPlayerWindow } from '@/lib/api/miniPlayer';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
@@ -28,7 +28,6 @@ import { initMiniPlayerBridgeOnMain } from '@/features/miniPlayer';
|
||||
import { runAdvancedModeMigration } from '@/app/migrations/advancedModeMigration';
|
||||
import { bootstrapAllIndexedServers } from '@/lib/library/librarySession';
|
||||
import { hydrateQueueFromIndex } from '@/features/playback/store/queueRestore';
|
||||
import { reconcileStartupPlayQueues } from '@/features/playback/store/startupPlayQueueReconcile';
|
||||
import { useLibraryAnalysisBackfill } from '@/lib/library/hooks/useLibraryAnalysisBackfill';
|
||||
import { useCoverArtPrefetch } from '../cover/useCoverArtPrefetch';
|
||||
import { useLibraryCoverBackfill } from '@/cover/useLibraryCoverBackfill';
|
||||
@@ -68,18 +67,12 @@ export default function MainApp() {
|
||||
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
|
||||
const migrationPhase = useMigrationStore(s => s.phase);
|
||||
const migrationReady = migrationPhase === 'completed';
|
||||
const startupQueueReconcileStartedRef = useRef(false);
|
||||
useMigrationOrchestrator();
|
||||
useEffect(() => {
|
||||
if (!migrationReady) return;
|
||||
const shouldReconcileStartupQueue = !startupQueueReconcileStartedRef.current;
|
||||
if (shouldReconcileStartupQueue) startupQueueReconcileStartedRef.current = true;
|
||||
void (async () => {
|
||||
await bootstrapAllIndexedServers();
|
||||
await hydrateQueueFromIndex();
|
||||
if (shouldReconcileStartupQueue) {
|
||||
await reconcileStartupPlayQueues();
|
||||
}
|
||||
void hydrateQueueFromIndex();
|
||||
})();
|
||||
}, [activeServerId, serverIdsKey, masterEnabled, migrationReady]);
|
||||
|
||||
|
||||
@@ -127,23 +127,17 @@ describe('pushLoggingModeToBackend', () => {
|
||||
});
|
||||
|
||||
describe('runPreReactBootstrap', () => {
|
||||
it('runs all steps in order: warm window-kind cache, push UA, push logging mode, sync traces, install hotkey', () => {
|
||||
it('runs all steps in order: warm window-kind cache, push UA, push logging mode, install hotkey', () => {
|
||||
localStorage.setItem(
|
||||
'psysonic-auth',
|
||||
JSON.stringify({ state: { loggingMode: 'normal' } }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
'psysonic_psylab_debug_traces_v1',
|
||||
JSON.stringify({ albumsBrowse: true, artistsBrowse: false }),
|
||||
);
|
||||
|
||||
runPreReactBootstrap();
|
||||
|
||||
expect(getWindowKind).toHaveBeenCalled();
|
||||
expect(invoke).toHaveBeenCalledWith('set_subsonic_wire_user_agent', expect.any(Object));
|
||||
expect(invoke).toHaveBeenCalledWith('set_logging_mode', { mode: 'normal' });
|
||||
expect(invoke).toHaveBeenCalledWith('set_psylab_albums_browse_trace', { enabled: true });
|
||||
expect(invoke).toHaveBeenCalledWith('set_psylab_artists_browse_trace', { enabled: false });
|
||||
expect(installQueueUndoHotkey).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { migrateThemeSelection } from '@/lib/themes/themeMigration';
|
||||
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
|
||||
import { gateInjectedThemes, syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
|
||||
import { initializePsyLabDebugTraces } from '@/lib/perf/psyLabDebugTraces';
|
||||
|
||||
/** Sync backend HTTP User-Agent from the main webview once at startup. */
|
||||
export function pushUserAgentToBackend(): void {
|
||||
@@ -136,7 +135,6 @@ export function runPreReactBootstrap(): void {
|
||||
markDevBuildDocument();
|
||||
pushUserAgentToBackend();
|
||||
pushLoggingModeToBackend();
|
||||
initializePsyLabDebugTraces();
|
||||
installQueueUndoHotkey();
|
||||
setupMusicNetworkRuntime();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { applyServerPlayQueue } from '@/features/playback/store/applyServerPlayQueue';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOrbitStore } from '@/features/orbit';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { profileIdFromQueueRef } from '@/lib/media/trackServerScope';
|
||||
import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
|
||||
import {
|
||||
getPlaybackIdleSinceMs,
|
||||
isIdleQueuePullSuspended,
|
||||
@@ -19,11 +18,11 @@ import { canAutoIdlePlayQueuePull } from '@/app/hooks/usePlayQueueSyncLedState';
|
||||
const IDLE_THRESHOLD_MS = 30_000;
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
|
||||
/** Background pull when paused/stopped long enough, reconciled by queue owner. */
|
||||
/** Background pull when paused/stopped long enough on a single-server, in-sync browse context. */
|
||||
export function useIdlePlayQueuePull(status: ConnectionStatus) {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const queueItems = usePlayerStore(s => s.queueItems);
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,21 +40,13 @@ export function useIdlePlayQueuePull(status: ConnectionStatus) {
|
||||
if (isPlaying) return;
|
||||
if (!isPlaybackIdleLongEnough(IDLE_THRESHOLD_MS)) return;
|
||||
if (isIdleQueuePullSuspended()) return;
|
||||
if (isQueuePushFailed()) return;
|
||||
if (isQueueNaturallyEnded()) return;
|
||||
const serverIds = [...new Set(queueItems.map(profileIdFromQueueRef).filter(Boolean))];
|
||||
if (serverIds.length === 0) {
|
||||
const playbackServerId = getPlaybackServerId();
|
||||
if (playbackServerId) serverIds.push(playbackServerId);
|
||||
}
|
||||
if (serverIds.length === 0) return;
|
||||
if (hasPendingQueueSync()) return;
|
||||
if (!activeServerId) return;
|
||||
|
||||
inFlightRef.current = true;
|
||||
void (async () => {
|
||||
for (const serverId of serverIds) {
|
||||
if (isQueuePushFailed(serverId) || hasPendingQueueSync(serverId)) continue;
|
||||
await applyServerPlayQueue(serverId, { mode: 'idle', preferServerPosition: true });
|
||||
}
|
||||
})()
|
||||
void applyServerPlayQueue(activeServerId, { mode: 'idle', preferServerPosition: true })
|
||||
.finally(() => {
|
||||
inFlightRef.current = false;
|
||||
});
|
||||
@@ -63,5 +54,5 @@ export function useIdlePlayQueuePull(status: ConnectionStatus) {
|
||||
|
||||
const id = window.setInterval(tick, POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [isPlaying, orbitRole, queueItems, status]);
|
||||
}, [activeServerId, isPlaying, orbitRole, status]);
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import { setServerReachability } from '@/lib/network/serverReachability';
|
||||
import { useLibraryServerReachability } from './useLibraryServerReachability';
|
||||
|
||||
const switchActiveServerMock = vi.hoisted(() => vi.fn());
|
||||
const bootstrapIndexedServerMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/utils/server/switchActiveServer', () => ({
|
||||
switchActiveServer: switchActiveServerMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/perf/perfFlags', () => ({
|
||||
usePerfProbeFlags: () => ({ disableBackgroundPolling: true }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/library/librarySession', () => ({
|
||||
bootstrapIndexedServer: bootstrapIndexedServerMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
switchActiveServerMock.mockReset();
|
||||
bootstrapIndexedServerMock.mockReset().mockResolvedValue('bound');
|
||||
switchActiveServerMock.mockImplementation(async (server: { id: string }) => {
|
||||
useAuthStore.getState().setActiveServer(server.id);
|
||||
return true;
|
||||
});
|
||||
useAuthStore.setState({
|
||||
servers: [
|
||||
{ id: 'a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
|
||||
{ id: 'b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
|
||||
{ id: 'c', name: 'C', url: 'https://c.test', username: 'u', password: 'p' },
|
||||
],
|
||||
activeServerId: 'a',
|
||||
libraryBrowseServerIds: ['a', 'b'],
|
||||
libraryBrowseScopeVersion: 0,
|
||||
isLoggedIn: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLibraryServerReachability', () => {
|
||||
it('switches active server when checkbox membership changes the priority head', async () => {
|
||||
renderHook(() => useLibraryServerReachability());
|
||||
|
||||
act(() => useAuthStore.getState().setLibraryBrowseServerSelected('a', false));
|
||||
|
||||
await waitFor(() => expect(switchActiveServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'b' }),
|
||||
));
|
||||
expect(useAuthStore.getState().activeServerId).toBe('b');
|
||||
});
|
||||
|
||||
it('realigns an independently switched active server on the next checkbox change', async () => {
|
||||
renderHook(() => useLibraryServerReachability());
|
||||
act(() => useAuthStore.getState().setActiveServer('b'));
|
||||
expect(switchActiveServerMock).not.toHaveBeenCalled();
|
||||
|
||||
act(() => useAuthStore.getState().setLibraryBrowseServerSelected('c', true));
|
||||
|
||||
await waitFor(() => expect(switchActiveServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'a' }),
|
||||
));
|
||||
expect(useAuthStore.getState().activeServerId).toBe('a');
|
||||
});
|
||||
|
||||
it('switches active server when reordering changes the priority head', async () => {
|
||||
renderHook(() => useLibraryServerReachability());
|
||||
const { servers } = useAuthStore.getState();
|
||||
|
||||
act(() => useAuthStore.getState().setServers([servers[1], servers[0], servers[2]]));
|
||||
|
||||
await waitFor(() => expect(switchActiveServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'b' }),
|
||||
));
|
||||
expect(useAuthStore.getState().libraryBrowseServerIds).toEqual(['b', 'a']);
|
||||
expect(useAuthStore.getState().activeServerId).toBe('b');
|
||||
});
|
||||
|
||||
it('switches to the first selected server that is not confirmed unavailable', async () => {
|
||||
renderHook(() => useLibraryServerReachability());
|
||||
|
||||
act(() => setServerReachability('a', 'unavailable'));
|
||||
|
||||
await waitFor(() => expect(switchActiveServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'b' }),
|
||||
));
|
||||
expect(useAuthStore.getState().activeServerId).toBe('b');
|
||||
expect(useAuthStore.getState().libraryBrowseServerIds).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('invalidates Library reads only when availability changes the effective scope', async () => {
|
||||
renderHook(() => useLibraryServerReachability());
|
||||
|
||||
act(() => setServerReachability('c', 'unavailable'));
|
||||
expect(useAuthStore.getState().libraryBrowseScopeVersion).toBe(0);
|
||||
|
||||
act(() => setServerReachability('b', 'unavailable'));
|
||||
await waitFor(() => expect(useAuthStore.getState().libraryBrowseScopeVersion).toBe(1));
|
||||
|
||||
act(() => setServerReachability('b', 'available'));
|
||||
await waitFor(() => expect(useAuthStore.getState().libraryBrowseScopeVersion).toBe(2));
|
||||
expect(useAuthStore.getState().libraryBrowseServerIds).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('rebinds a recovered indexed server without requiring an active-server or list change', async () => {
|
||||
act(() => setServerReachability('b', 'unavailable'));
|
||||
renderHook(() => useLibraryServerReachability());
|
||||
|
||||
act(() => setServerReachability('b', 'available'));
|
||||
|
||||
await waitFor(() => expect(bootstrapIndexedServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'b' }),
|
||||
));
|
||||
expect(useAuthStore.getState().activeServerId).toBe('a');
|
||||
expect(switchActiveServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '@/lib/server/serverEndpoint';
|
||||
import {
|
||||
useUnavailableServerIds,
|
||||
} from '@/lib/network/serverReachability';
|
||||
import { deriveEffectiveLibraryBrowseServerIds } from '@/lib/library/libraryBrowseScope';
|
||||
import { bootstrapIndexedServer } from '@/lib/library/librarySession';
|
||||
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { switchActiveServer } from '@/utils/server/switchActiveServer';
|
||||
|
||||
const SERVER_REACHABILITY_POLL_MS = 120_000;
|
||||
|
||||
/** Probe selected servers, align the active profile to scope priority, and invalidate effective reads. */
|
||||
export function useLibraryServerReachability(): void {
|
||||
const isLoggedIn = useAuthStore(state => state.isLoggedIn);
|
||||
const servers = useAuthStore(state => state.servers);
|
||||
const activeServerId = useAuthStore(state => state.activeServerId);
|
||||
const libraryBrowseServerIds = useAuthStore(state => state.libraryBrowseServerIds);
|
||||
const unavailableServerIds = useUnavailableServerIds();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const selectedProfiles = useMemo(() => {
|
||||
const selected = new Set(libraryBrowseServerIds);
|
||||
return servers.filter(server => selected.has(server.id));
|
||||
}, [libraryBrowseServerIds, servers]);
|
||||
const effectiveLibraryServerIds = useMemo(() => deriveEffectiveLibraryBrowseServerIds({
|
||||
servers,
|
||||
activeServerId,
|
||||
libraryBrowseServerIds,
|
||||
}, unavailableServerIds), [activeServerId, libraryBrowseServerIds, servers, unavailableServerIds]);
|
||||
const desiredActiveServerId = effectiveLibraryServerIds[0] ?? null;
|
||||
const libraryBrowsePriorityKey = libraryBrowseServerIds.join('\u0000');
|
||||
const previousUnavailableServerIdsRef = useRef(unavailableServerIds);
|
||||
const desiredActiveServerIdRef = useRef(desiredActiveServerId);
|
||||
const activeSwitchInFlightRef = useRef(false);
|
||||
// React Compiler refs rule: the in-flight loop must always observe the latest priority head.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
desiredActiveServerIdRef.current = desiredActiveServerId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !desiredActiveServerId || activeSwitchInFlightRef.current) return;
|
||||
activeSwitchInFlightRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const targetId = desiredActiveServerIdRef.current;
|
||||
const state = useAuthStore.getState();
|
||||
if (!targetId || state.activeServerId === targetId) return;
|
||||
const target = state.servers.find(server => server.id === targetId);
|
||||
if (!target) return;
|
||||
const switched = await switchActiveServer(target);
|
||||
if (!switched && desiredActiveServerIdRef.current === targetId) return;
|
||||
}
|
||||
} finally {
|
||||
activeSwitchInFlightRef.current = false;
|
||||
}
|
||||
})();
|
||||
}, [desiredActiveServerId, isLoggedIn, libraryBrowsePriorityKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousUnavailableServerIds = previousUnavailableServerIdsRef.current;
|
||||
previousUnavailableServerIdsRef.current = unavailableServerIds;
|
||||
if (previousUnavailableServerIds === unavailableServerIds) return;
|
||||
const state = useAuthStore.getState();
|
||||
if (isLoggedIn) {
|
||||
for (const serverId of previousUnavailableServerIds) {
|
||||
if (unavailableServerIds.has(serverId)) continue;
|
||||
const recovered = state.servers.find(server => server.id === serverId);
|
||||
if (recovered && useLibraryIndexStore.getState().isIndexEnabled(recovered.id)) {
|
||||
void bootstrapIndexedServer(recovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
const previousEffectiveScopeKey = deriveEffectiveLibraryBrowseServerIds(
|
||||
state,
|
||||
previousUnavailableServerIds,
|
||||
).join('\u0000');
|
||||
const effectiveScopeKey = deriveEffectiveLibraryBrowseServerIds(
|
||||
state,
|
||||
unavailableServerIds,
|
||||
).join('\u0000');
|
||||
if (previousEffectiveScopeKey === effectiveScopeKey) return;
|
||||
useAuthStore.setState(state => ({
|
||||
libraryBrowseScopeVersion: state.libraryBrowseScopeVersion + 1,
|
||||
}));
|
||||
}, [isLoggedIn, unavailableServerIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || perfFlags.disableBackgroundPolling || selectedProfiles.length === 0) return;
|
||||
let cancelled = false;
|
||||
|
||||
const probeSelectedServers = async () => {
|
||||
for (const server of selectedProfiles) {
|
||||
if (cancelled) return;
|
||||
await ensureConnectUrlResolved(server);
|
||||
}
|
||||
};
|
||||
const handleOnline = () => {
|
||||
for (const server of selectedProfiles) invalidateReachableEndpointCache(server.id);
|
||||
void probeSelectedServers();
|
||||
};
|
||||
|
||||
void probeSelectedServers();
|
||||
const interval = setInterval(() => void probeSelectedServers(), SERVER_REACHABILITY_POLL_MS);
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, [isLoggedIn, perfFlags.disableBackgroundPolling, selectedProfiles]);
|
||||
}
|
||||
@@ -7,8 +7,6 @@ const migrationInspectMock = vi.fn();
|
||||
const migrationRunMock = vi.fn();
|
||||
const libraryGenreTagsInspectMock = vi.fn();
|
||||
const libraryGenreTagsRunMock = vi.fn();
|
||||
const libraryScopeBrowseProjectionInspectMock = vi.fn();
|
||||
const libraryScopeBrowseProjectionRunMock = vi.fn();
|
||||
const rewriteFrontendStoreKeysMock = vi.fn(async (_servers: unknown) => undefined);
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
@@ -23,8 +21,6 @@ vi.mock('@/lib/api/migration', () => ({
|
||||
vi.mock('@/lib/api/library', () => ({
|
||||
libraryGenreTagsInspect: () => libraryGenreTagsInspectMock(),
|
||||
libraryGenreTagsRun: () => libraryGenreTagsRunMock(),
|
||||
libraryScopeBrowseProjectionInspect: () => libraryScopeBrowseProjectionInspectMock(),
|
||||
libraryScopeBrowseProjectionRun: () => libraryScopeBrowseProjectionRunMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/server/rewriteFrontendStoreKeys', () => ({
|
||||
@@ -42,12 +38,8 @@ describe('useMigrationOrchestrator', () => {
|
||||
migrationRunMock.mockReset();
|
||||
libraryGenreTagsInspectMock.mockReset();
|
||||
libraryGenreTagsRunMock.mockReset();
|
||||
libraryScopeBrowseProjectionInspectMock.mockReset();
|
||||
libraryScopeBrowseProjectionRunMock.mockReset();
|
||||
libraryGenreTagsInspectMock.mockResolvedValue({ needed: false, totalTracks: 0, doneTracks: 0 });
|
||||
libraryGenreTagsRunMock.mockResolvedValue(undefined);
|
||||
libraryScopeBrowseProjectionInspectMock.mockResolvedValue({ needed: false, totalTracks: 0, doneTracks: 0 });
|
||||
libraryScopeBrowseProjectionRunMock.mockResolvedValue(undefined);
|
||||
rewriteFrontendStoreKeysMock.mockClear();
|
||||
localStorage.clear();
|
||||
useAuthStore.setState({
|
||||
@@ -65,8 +57,6 @@ describe('useMigrationOrchestrator', () => {
|
||||
progress: null,
|
||||
genreTagsInspect: null,
|
||||
genreTagsProgress: null,
|
||||
scopeBrowseProjectionInspect: null,
|
||||
scopeBrowseProjectionProgress: null,
|
||||
lastError: null,
|
||||
});
|
||||
(globalThis as Record<string, unknown>)[REAL_MIGRATION_TEST_OVERRIDE] = true;
|
||||
@@ -203,33 +193,4 @@ describe('useMigrationOrchestrator', () => {
|
||||
expect(useMigrationStore.getState().phase).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps startup non-blocking while scope-browse projection inspect is pending', async () => {
|
||||
localStorage.setItem(DONE_FLAG, '1');
|
||||
migrationInspectMock.mockResolvedValue({
|
||||
needsMigration: false, hasSkippedUnknownServerRows: false, canRun: true, warnings: [],
|
||||
unmappedEmptyBucket: false,
|
||||
library: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} },
|
||||
analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} },
|
||||
mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }],
|
||||
});
|
||||
let resolveProjection: ((value: unknown) => void) | undefined;
|
||||
libraryScopeBrowseProjectionInspectMock.mockImplementation(
|
||||
() => new Promise(resolve => { resolveProjection = resolve; }),
|
||||
);
|
||||
|
||||
renderHook(() => useMigrationOrchestrator());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(libraryScopeBrowseProjectionInspectMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(useMigrationStore.getState().phase).toBe('idle');
|
||||
|
||||
if (!resolveProjection) throw new Error('projection inspect resolver not captured');
|
||||
resolveProjection({ needed: false, totalTracks: 100, doneTracks: 100 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useMigrationStore.getState().phase).toBe('completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import {
|
||||
libraryGenreTagsInspect,
|
||||
libraryGenreTagsRun,
|
||||
libraryScopeBrowseProjectionInspect,
|
||||
libraryScopeBrowseProjectionRun,
|
||||
} from '@/lib/api/library';
|
||||
import { libraryGenreTagsInspect, libraryGenreTagsRun } from '@/lib/api/library';
|
||||
import { migrationInspect, migrationRun, type ServerIndexMapping } from '@/lib/api/migration';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMigrationStore } from '@/store/migrationStore';
|
||||
@@ -72,28 +67,6 @@ async function runGenreTagsPhase(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function runScopeBrowseProjectionPhase(): Promise<void> {
|
||||
const state = useMigrationStore.getState();
|
||||
state.setScopeBrowseProjectionProgress(null);
|
||||
const inspect = await libraryScopeBrowseProjectionInspect();
|
||||
state.setScopeBrowseProjectionInspect(inspect);
|
||||
if (!inspect.needed) return;
|
||||
|
||||
state.setStep('scopeBrowseProjection');
|
||||
state.setError(null);
|
||||
state.setPhase('running');
|
||||
await libraryScopeBrowseProjectionRun();
|
||||
const after = await libraryScopeBrowseProjectionInspect();
|
||||
state.setScopeBrowseProjectionInspect(after);
|
||||
if (after.needed) {
|
||||
state.setError('Library browse index update incomplete. Retry after restart.');
|
||||
state.setPhase('error');
|
||||
throw new Error('scope_browse_projection_incomplete');
|
||||
}
|
||||
state.setStep(null);
|
||||
state.setScopeBrowseProjectionProgress(null);
|
||||
}
|
||||
|
||||
async function runOrchestrator(force = false): Promise<void> {
|
||||
if (migrationInFlight) {
|
||||
await migrationInFlight;
|
||||
@@ -128,7 +101,6 @@ async function runOrchestrator(force = false): Promise<void> {
|
||||
skippedLogged = logSkippedUnknownRowsOnce(inspect, skippedLogged);
|
||||
if (!inspect.needsMigration) {
|
||||
await runGenreTagsPhase();
|
||||
await runScopeBrowseProjectionPhase();
|
||||
state.setPhase('completed');
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +115,6 @@ async function runOrchestrator(force = false): Promise<void> {
|
||||
await rewriteFrontendStoreKeys(servers);
|
||||
localStorage.setItem(MIGRATION_DONE_FLAG, '1');
|
||||
await runGenreTagsPhase();
|
||||
await runScopeBrowseProjectionPhase();
|
||||
state.setPhase('completed');
|
||||
return;
|
||||
}
|
||||
@@ -158,8 +129,7 @@ async function runOrchestrator(force = false): Promise<void> {
|
||||
logSkippedUnknownRowsOnce(after, skippedLogged);
|
||||
if (!after.needsMigration) {
|
||||
localStorage.setItem(MIGRATION_DONE_FLAG, '1');
|
||||
await runGenreTagsPhase();
|
||||
await runScopeBrowseProjectionPhase();
|
||||
await runGenreTagsPhase();
|
||||
state.setPhase('completed');
|
||||
return;
|
||||
}
|
||||
@@ -211,10 +181,6 @@ export function retryBlockingMigration(): void {
|
||||
retryGenreTagsMigration();
|
||||
return;
|
||||
}
|
||||
if (step === 'scopeBrowseProjection') {
|
||||
void runOrchestrator();
|
||||
return;
|
||||
}
|
||||
retryServerIndexMigration();
|
||||
}
|
||||
|
||||
@@ -240,13 +206,6 @@ export function useMigrationOrchestrator(): void {
|
||||
total: number;
|
||||
});
|
||||
}),
|
||||
listen('scope_browse_projection:progress', (event) => {
|
||||
if (disposed) return;
|
||||
useMigrationStore.getState().setScopeBrowseProjectionProgress(event.payload as {
|
||||
done: number;
|
||||
total: number;
|
||||
});
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
disposed = true;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { getMusicFoldersForServer } from '@/lib/api/subsonicLibrary';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
/** Refreshes folder lists for the servers selected in Switch server. */
|
||||
export function useMusicFoldersDiscovery(): void {
|
||||
const isLoggedIn = useAuthStore(state => state.isLoggedIn);
|
||||
const servers = useAuthStore(state => state.servers);
|
||||
const selectedServerIds = useAuthStore(state => state.libraryBrowseServerIds);
|
||||
const setMusicFoldersForServer = useAuthStore(state => state.setMusicFoldersForServer);
|
||||
const selectedKey = useMemo(() => selectedServerIds.join('\u0000'), [selectedServerIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || selectedServerIds.length === 0) return;
|
||||
const savedIds = new Set(servers.map(server => server.id));
|
||||
let cancelled = false;
|
||||
|
||||
for (const serverId of selectedServerIds) {
|
||||
if (!savedIds.has(serverId)) continue;
|
||||
void getMusicFoldersForServer(serverId)
|
||||
.then(folders => {
|
||||
if (cancelled) return;
|
||||
const state = useAuthStore.getState();
|
||||
if (!state.servers.some(server => server.id === serverId)) return;
|
||||
setMusicFoldersForServer(serverId, folders);
|
||||
})
|
||||
.catch(() => {
|
||||
// Preserve the last successful list while a server is temporarily unavailable.
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoggedIn, selectedKey, selectedServerIds, servers, setMusicFoldersForServer]);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { canAutoIdlePlayQueuePull, usePlayQueueSyncLedState } from '@/app/hooks/usePlayQueueSyncLedState';
|
||||
import {
|
||||
_resetQueueSyncUiForTest,
|
||||
markQueueHandoffPending,
|
||||
} from '@/features/playback/store/queueSyncUiState';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
|
||||
const pullPlayQueueFromServerMock = vi.fn();
|
||||
|
||||
vi.mock('@/features/playback/store/applyServerPlayQueue', () => ({
|
||||
pullPlayQueueFromServer: (...args: unknown[]) => pullPlayQueueFromServerMock(...args),
|
||||
}));
|
||||
|
||||
describe('usePlayQueueSyncLedState', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
_resetQueueSyncUiForTest();
|
||||
pullPlayQueueFromServerMock.mockReset();
|
||||
useAuthStore.setState({
|
||||
servers: [
|
||||
{ id: 'a', name: 'A', url: 'http://a.test', username: 'u', password: 'p' },
|
||||
{ id: 'b', name: 'B', url: 'http://b.test', username: 'u', password: 'p' },
|
||||
],
|
||||
activeServerId: 'a',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a mixed queue green and pulls each owner projection without a browse handoff', async () => {
|
||||
usePlayerStore.setState({
|
||||
queueItems: [
|
||||
{ serverId: 'a', trackId: 'a1' },
|
||||
{ serverId: 'b', trackId: 'b1' },
|
||||
{ serverId: 'a', trackId: 'a2' },
|
||||
],
|
||||
queueIndex: 1,
|
||||
currentTrack: { id: 'b1', title: 'b1', artist: '', album: '', albumId: '', duration: 60, serverId: 'b' },
|
||||
});
|
||||
markQueueHandoffPending();
|
||||
|
||||
const { result } = renderHook(() => usePlayQueueSyncLedState('connected'));
|
||||
|
||||
expect(result.current.ledVariant).toBe('connected');
|
||||
expect(result.current.needsQueuePull).toBe(false);
|
||||
expect(result.current.queueHandoffReason).toBe(false);
|
||||
expect(result.current.syncRingVisible).toBe(false);
|
||||
|
||||
pullPlayQueueFromServerMock.mockResolvedValue('noop');
|
||||
await act(() => result.current.pullFromActiveServer());
|
||||
expect(pullPlayQueueFromServerMock).toHaveBeenCalledTimes(2);
|
||||
expect(pullPlayQueueFromServerMock).toHaveBeenNthCalledWith(1, 'a');
|
||||
expect(pullPlayQueueFromServerMock).toHaveBeenNthCalledWith(2, 'b');
|
||||
});
|
||||
|
||||
it('keeps a single-server queue synced with its playback owner, not active server', () => {
|
||||
usePlayerStore.setState({
|
||||
queueItems: [{ serverId: 'b', trackId: 'b1' }],
|
||||
queueIndex: 0,
|
||||
currentTrack: { id: 'b1', title: 'b1', artist: '', album: '', albumId: '', duration: 60, serverId: 'b' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePlayQueueSyncLedState('connected'));
|
||||
|
||||
expect(result.current.ledVariant).toBe('connected');
|
||||
expect(result.current.queueHandoffReason).toBe(false);
|
||||
expect(canAutoIdlePlayQueuePull('connected', null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ConnectionStatus } from '@/lib/hooks/useConnectionStatus';
|
||||
import { pullPlayQueueFromServer } from '@/features/playback/store/applyServerPlayQueue';
|
||||
import { pullPlayQueueFromActiveServer } from '@/features/playback/store/applyServerPlayQueue';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOrbitStore } from '@/features/orbit';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { profileIdFromQueueRef } from '@/lib/media/trackServerScope';
|
||||
import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { getPlaybackServerId, queueIsMultiServer } from '@/features/playback/utils/playback/playbackServer';
|
||||
import {
|
||||
getIdleQueuePullSuspendedSnapshot,
|
||||
subscribeIdleQueuePullSuspended,
|
||||
} from '@/features/playback/store/queuePlaybackIdle';
|
||||
import { clearQueueHandoffPending } from '@/features/playback/store/queueSyncUiState';
|
||||
import { clearQueueHandoffPending, isQueueHandoffPending } from '@/features/playback/store/queueSyncUiState';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
|
||||
export function usePlayQueueSyncLedState(status: ConnectionStatus) {
|
||||
const { t } = useTranslation();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
@@ -27,30 +28,36 @@ export function usePlayQueueSyncLedState(status: ConnectionStatus) {
|
||||
const queueItems = usePlayerStore(s => s.queueItems);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrackId = usePlayerStore(s => s.currentTrack?.id);
|
||||
|
||||
const playbackServerId = useMemo(
|
||||
() => getPlaybackServerId(),
|
||||
// getPlaybackServerId() reads global queue/auth state; the listed values
|
||||
// are intentional recompute triggers, not direct inputs to the body.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[queueItems, queueIndex, currentTrackId],
|
||||
[activeServerId, queueItems, queueIndex, currentTrackId],
|
||||
);
|
||||
const queueServerIds = useMemo(() => {
|
||||
const ids = [...new Set(queueItems.map(profileIdFromQueueRef).filter(Boolean))];
|
||||
return ids.length > 0 ? ids : (playbackServerId ? [playbackServerId] : []);
|
||||
}, [playbackServerId, queueItems]);
|
||||
|
||||
useEffect(() => {
|
||||
clearQueueHandoffPending();
|
||||
}, [playbackServerId]);
|
||||
if (activeServerId && playbackServerId && activeServerId === playbackServerId) {
|
||||
clearQueueHandoffPending();
|
||||
}
|
||||
}, [activeServerId, playbackServerId]);
|
||||
|
||||
const autoSyncContext = canAutoIdlePlayQueuePull(status, orbitRole);
|
||||
const localQueueSyncPaused = autoSyncContext && idlePullSuspended && !isPlaying;
|
||||
|
||||
const needsQueuePull = status === 'connected'
|
||||
&& queueServerIds.length > 0
|
||||
&& localQueueSyncPaused;
|
||||
&& Boolean(activeServerId)
|
||||
&& (
|
||||
(Boolean(playbackServerId) && activeServerId !== playbackServerId)
|
||||
|| isQueueHandoffPending()
|
||||
|| localQueueSyncPaused
|
||||
);
|
||||
|
||||
const queueHandoffReason = false;
|
||||
const queueHandoffReason = status === 'connected'
|
||||
&& Boolean(activeServerId)
|
||||
&& Boolean(playbackServerId)
|
||||
&& activeServerId !== playbackServerId;
|
||||
|
||||
const ledVariant = status === 'checking'
|
||||
? 'checking'
|
||||
@@ -60,19 +67,14 @@ export function usePlayQueueSyncLedState(status: ConnectionStatus) {
|
||||
? 'queue-handoff'
|
||||
: 'connected';
|
||||
|
||||
const pullFromQueueServers = useCallback(async () => {
|
||||
const pullFromActiveServer = useCallback(async () => {
|
||||
if (status !== 'connected' || pullInFlight) return;
|
||||
if (orbitRole === 'host' || orbitRole === 'guest') return;
|
||||
if (currentRadio) return;
|
||||
if (queueServerIds.length === 0) return;
|
||||
|
||||
setPullInFlight(true);
|
||||
try {
|
||||
let result: 'applied' | 'noop' | 'empty' | 'error' = 'noop';
|
||||
for (const serverId of queueServerIds) {
|
||||
result = await pullPlayQueueFromServer(serverId);
|
||||
if (result === 'error') break;
|
||||
}
|
||||
const result = await pullPlayQueueFromActiveServer();
|
||||
switch (result) {
|
||||
case 'noop':
|
||||
showToast(t('connection.queueSynced'), 2500, 'info');
|
||||
@@ -92,7 +94,7 @@ export function usePlayQueueSyncLedState(status: ConnectionStatus) {
|
||||
} finally {
|
||||
setPullInFlight(false);
|
||||
}
|
||||
}, [currentRadio, orbitRole, pullInFlight, queueServerIds, status, t]);
|
||||
}, [currentRadio, orbitRole, pullInFlight, status, t]);
|
||||
|
||||
const syncRingVisible = status === 'connected' && (needsQueuePull || pullInFlight);
|
||||
|
||||
@@ -103,7 +105,7 @@ export function usePlayQueueSyncLedState(status: ConnectionStatus) {
|
||||
queueHandoffReason,
|
||||
pullInFlight,
|
||||
syncRingVisible,
|
||||
pullFromActiveServer: pullFromQueueServers,
|
||||
pullFromActiveServer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,6 +116,9 @@ export function canAutoIdlePlayQueuePull(
|
||||
if (status !== 'connected') return false;
|
||||
if (orbitRole === 'host' || orbitRole === 'guest') return false;
|
||||
if (usePlayerStore.getState().currentRadio) return false;
|
||||
if (queueIsMultiServer()) return false;
|
||||
const activeId = useAuthStore.getState().activeServerId;
|
||||
const playbackId = getPlaybackServerId();
|
||||
return Boolean(playbackId);
|
||||
if (!activeId || !playbackId || activeId !== playbackId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { getMusicFolders } from '@/lib/api/subsonicLibrary';
|
||||
import { probeEntityRatingSupport } from '@/lib/api/subsonicStarRating';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { cleanupOrphanedOrbitPlaylists } from '@/features/orbit';
|
||||
|
||||
/**
|
||||
* Per-server one-shot probe run after login:
|
||||
* - Fetches the server's music folders (falls back to []).
|
||||
* - Probes which entity types support star ratings (falls back to
|
||||
* `track_only` for old/non-Navidrome servers).
|
||||
* - Sweeps leftover Orbit session / outbox playlists from crashed or
|
||||
@@ -16,6 +18,7 @@ import { cleanupOrphanedOrbitPlaylists } from '@/features/orbit';
|
||||
export function useServerCapabilitiesProbe(): void {
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,6 +27,12 @@ export function useServerCapabilitiesProbe(): void {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart;
|
||||
try {
|
||||
const folders = await getMusicFolders();
|
||||
if (stillThisServer()) setMusicFolders(folders);
|
||||
} catch {
|
||||
if (stillThisServer()) setMusicFolders([]);
|
||||
}
|
||||
try {
|
||||
const level = await probeEntityRatingSupport();
|
||||
if (stillThisServer()) setEntityRatingSupport(serverAtStart, level);
|
||||
@@ -34,7 +43,7 @@ export function useServerCapabilitiesProbe(): void {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoggedIn, activeServerId, setEntityRatingSupport]);
|
||||
}, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !activeServerId) return;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
STARTUP_SPLASH_ID,
|
||||
STARTUP_ROOT_PENDING_CLASS,
|
||||
configureStartupSplash,
|
||||
dismissStartupSplash,
|
||||
scheduleStartupSplashDismiss,
|
||||
} from './startupSplash';
|
||||
|
||||
vi.mock('./windowKind', () => ({
|
||||
@@ -30,21 +28,19 @@ import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
|
||||
describe('startupSplash', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `<div id="${STARTUP_SPLASH_ID}"></div><div id="root" class="${STARTUP_ROOT_PENDING_CLASS}"></div>`;
|
||||
document.body.innerHTML = `<div id="${STARTUP_SPLASH_ID}"></div><div id="root"></div>`;
|
||||
vi.mocked(getWindowKind).mockReturnValue('main');
|
||||
vi.mocked(applyStartupSplashThemeFromStorage).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('removes splash on mini player webview', () => {
|
||||
vi.mocked(getWindowKind).mockReturnValue('mini');
|
||||
configureStartupSplash();
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
|
||||
expect(document.getElementById('root')).not.toHaveClass(STARTUP_ROOT_PENDING_CLASS);
|
||||
});
|
||||
|
||||
it('re-applies theme from storage on main window', () => {
|
||||
@@ -52,12 +48,6 @@ describe('startupSplash', () => {
|
||||
expect(applyStartupSplashThemeFromStorage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reveals the React tree if the inline splash is already absent', () => {
|
||||
document.getElementById(STARTUP_SPLASH_ID)?.remove();
|
||||
configureStartupSplash();
|
||||
expect(document.getElementById('root')).not.toHaveClass(STARTUP_ROOT_PENDING_CLASS);
|
||||
});
|
||||
|
||||
it('skips reveal when start minimized to tray is enabled', () => {
|
||||
vi.mocked(shouldDeferMainWindowReveal).mockReturnValue(true);
|
||||
const show = vi.fn(() => Promise.resolve());
|
||||
@@ -66,29 +56,12 @@ describe('startupSplash', () => {
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('atomically replaces the splash with the prepared React tree', () => {
|
||||
it('fades out and removes splash', () => {
|
||||
vi.useFakeTimers();
|
||||
dismissStartupSplash();
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)?.classList.contains('app-startup-splash--hide')).toBe(true);
|
||||
vi.runAllTimers();
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
|
||||
expect(document.getElementById('root')).not.toHaveClass(STARTUP_ROOT_PENDING_CLASS);
|
||||
});
|
||||
|
||||
it('keeps the React tree hidden until the ready handoff frame', () => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
}));
|
||||
|
||||
scheduleStartupSplashDismiss();
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)).not.toBeNull();
|
||||
expect(document.getElementById('root')).toHaveClass(STARTUP_ROOT_PENDING_CLASS);
|
||||
|
||||
frames.shift()?.(0);
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)).not.toBeNull();
|
||||
expect(document.getElementById('root')).toHaveClass(STARTUP_ROOT_PENDING_CLASS);
|
||||
|
||||
frames.shift()?.(0);
|
||||
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
|
||||
expect(document.getElementById('root')).not.toHaveClass(STARTUP_ROOT_PENDING_CLASS);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,6 @@ import { applyStartupSplashThemeFromStorage } from '@/lib/themes/startupThemeApp
|
||||
import { getWindowKind } from './windowKind';
|
||||
|
||||
export const STARTUP_SPLASH_ID = 'app-startup-splash';
|
||||
export const STARTUP_ROOT_PENDING_CLASS = 'app-root--startup-pending';
|
||||
|
||||
function revealStartupContent(): void {
|
||||
document.getElementById('root')?.classList.remove(STARTUP_ROOT_PENDING_CLASS);
|
||||
}
|
||||
|
||||
/** Ensure the native shell is visible once the webview bundle is alive. */
|
||||
export function revealStartupWindow(): void {
|
||||
@@ -20,14 +15,10 @@ export function revealStartupWindow(): void {
|
||||
/** Re-apply splash colors after bootstrap theme migration/injection. */
|
||||
export function configureStartupSplash(): void {
|
||||
const splash = document.getElementById(STARTUP_SPLASH_ID);
|
||||
if (!splash) {
|
||||
revealStartupContent();
|
||||
return;
|
||||
}
|
||||
if (!splash) return;
|
||||
|
||||
if (getWindowKind() === 'mini') {
|
||||
splash.remove();
|
||||
revealStartupContent();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,17 +26,19 @@ export function configureStartupSplash(): void {
|
||||
revealStartupWindow();
|
||||
}
|
||||
|
||||
/** Replace the splash with the fully prepared React tree in one paint. */
|
||||
/** Fade out the splash after the first React commit. */
|
||||
export function dismissStartupSplash(): void {
|
||||
const splash = document.getElementById(STARTUP_SPLASH_ID);
|
||||
splash?.remove();
|
||||
revealStartupContent();
|
||||
if (!splash || splash.classList.contains('app-startup-splash--hide')) return;
|
||||
|
||||
splash.classList.add('app-startup-splash--hide');
|
||||
const remove = () => splash.remove();
|
||||
splash.addEventListener('transitionend', remove, { once: true });
|
||||
window.setTimeout(remove, 500);
|
||||
}
|
||||
|
||||
/** Schedule the atomic handoff after the ready React state has committed. */
|
||||
/** Schedule dismiss on the frame after the first paint. */
|
||||
export function scheduleStartupSplashDismiss(): void {
|
||||
const root = document.getElementById('root');
|
||||
if (!document.getElementById(STARTUP_SPLASH_ID) && !root?.classList.contains(STARTUP_ROOT_PENDING_CLASS)) return;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(dismissStartupSplash);
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ export function useCliBridge(navigate: NavigateFunction) {
|
||||
);
|
||||
if (shuffled.length > 0) {
|
||||
const aid = song.artistId?.trim() || undefined;
|
||||
usePlayerStore.getState().enqueueRadio(shuffled, aid, serverId ?? undefined);
|
||||
usePlayerStore.getState().enqueueRadio(shuffled, aid);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
|
||||
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import { ownedOverrideValue } from '@/lib/util/ownedEntityKey';
|
||||
|
||||
/** Half-width of the CLI snapshot queue window (thin-state — like the mini
|
||||
* bridge, the full 50k queue must not serialize over IPC on every change). */
|
||||
@@ -29,10 +27,10 @@ export function usePlayerSnapshotPublisher() {
|
||||
const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all';
|
||||
const ct = s.currentTrack;
|
||||
const currentTrackUserRating =
|
||||
ct != null ? (ownedOverrideValue(s.userRatingOverrides, ct) ?? ct.userRating ?? null) : null;
|
||||
ct != null ? (s.userRatingOverrides[ct.id] ?? ct.userRating ?? null) : null;
|
||||
const currentTrackStarred =
|
||||
ct != null
|
||||
? (ownedOverrideValue(s.starredOverrides, ct) ?? Boolean(ct.starred))
|
||||
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
|
||||
: null;
|
||||
// Thin-state: resolve only a window around the playing track (resolver
|
||||
// cache → placeholder) instead of the whole 50k queue. `queue_length`
|
||||
@@ -62,7 +60,7 @@ export function usePlayerSnapshotPublisher() {
|
||||
};
|
||||
const stableKey = JSON.stringify({
|
||||
trackId: s.currentTrack?.id ?? null,
|
||||
radioId: s.currentRadio ? ownedEntityKey(s.currentRadio) : null,
|
||||
radioId: s.currentRadio?.id ?? null,
|
||||
queueIndex: s.queueIndex,
|
||||
queueLength: total,
|
||||
isPlaying: s.isPlaying,
|
||||
|
||||
@@ -208,7 +208,6 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Playlist and radio custom covers — preserve Navidrome pl-/ra-* getCoverArt ids (fixes blank uploaded covers; PR #1295)',
|
||||
'Playlist cards — Play next and Add to queue from the right-click menu, matching album cards (PR #1307)',
|
||||
'Playlists browse — scoped header search by playlist name (PR #1308)',
|
||||
'Multi-server library scope — unified cross-server browse, source ownership, playback fallback, and incremental identity maintenance (PR #1326)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,20 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const player = {
|
||||
volume: 0.5,
|
||||
currentTrack: null as { id: string; serverId?: string } | null,
|
||||
setVolume: vi.fn((v: number) => {
|
||||
player.volume = v;
|
||||
}),
|
||||
};
|
||||
return { player, queueSongRating: vi.fn() };
|
||||
return { player };
|
||||
});
|
||||
|
||||
vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
usePlayerStore: { getState: () => hoisted.player },
|
||||
}));
|
||||
vi.mock('@/features/playback/store/pendingStarSync', () => ({
|
||||
queueSongRating: hoisted.queueSongRating,
|
||||
}));
|
||||
|
||||
import { executeCliPlayerCommand } from '@/config/shortcutDispatch';
|
||||
|
||||
@@ -24,9 +20,7 @@ const navigate = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.player.volume = 0.5;
|
||||
hoisted.player.currentTrack = null;
|
||||
hoisted.player.setVolume.mockClear();
|
||||
hoisted.queueSongRating.mockClear();
|
||||
navigate.mockClear();
|
||||
});
|
||||
|
||||
@@ -58,16 +52,3 @@ describe('executeCliPlayerCommand set-volume', () => {
|
||||
expect(hoisted.player.setVolume).toHaveBeenCalledWith(0.4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeCliPlayerCommand set-rating-current', () => {
|
||||
it('routes the rating to the current track owner', () => {
|
||||
hoisted.player.currentTrack = { id: 'shared', serverId: 'srv-b' };
|
||||
|
||||
executeCliPlayerCommand({
|
||||
payload: { command: 'set-rating-current', stars: 4 },
|
||||
navigate,
|
||||
});
|
||||
|
||||
expect(hoisted.queueSongRating).toHaveBeenCalledWith('shared', 4, 'srv-b');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ export function executeCliPlayerCommand(ctx: CliContext): void | Promise<void> {
|
||||
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
queueSongRating(track.id, stars, track.serverId);
|
||||
queueSongRating(track.id, stars);
|
||||
return;
|
||||
}
|
||||
// no-op for unknown command
|
||||
|
||||
@@ -37,8 +37,8 @@ export async function invalidateCacheKey(cacheKey: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateCoverArt(entityId: string, ownerServerId?: string): Promise<void> {
|
||||
const serverId = ownerServerId ?? useAuthStore.getState().getActiveServer()?.id ?? '_';
|
||||
export async function invalidateCoverArt(entityId: string): Promise<void> {
|
||||
const serverId = useAuthStore.getState().getActiveServer()?.id ?? '_';
|
||||
await Promise.all(
|
||||
COVER_ART_REGISTERED_SIZES.map(size =>
|
||||
invalidateCacheKey(`${serverId}:cover:${entityId}:${size}`),
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
clearServer: vi.fn(),
|
||||
forgetServer: vi.fn(),
|
||||
invalidateLegacy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/generated/bindings', () => ({
|
||||
commands: { coverCacheClearServer: hoisted.clearServer },
|
||||
}));
|
||||
vi.mock('./diskSrcCache', () => ({ forgetDiskSrcForServer: hoisted.forgetServer }));
|
||||
vi.mock('./imageCache', () => ({ invalidateCoverArt: hoisted.invalidateLegacy }));
|
||||
vi.mock('./ref', () => ({
|
||||
radioCoverRef: () => ({
|
||||
cacheKind: 'album',
|
||||
cacheEntityId: 'ra-shared',
|
||||
fetchCoverArtId: 'ra-shared',
|
||||
serverScope: { kind: 'server', serverId: 'srv-b', url: '', username: '', password: '' },
|
||||
}),
|
||||
}));
|
||||
vi.mock('./storageKeys', () => ({ coverIndexKeyFromRef: () => 'b.test' }));
|
||||
|
||||
import { invalidateRadioCoverArtCache } from './radioCoverInvalidation';
|
||||
|
||||
describe('invalidateRadioCoverArtCache', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.clearServer.mockReset().mockResolvedValue({ status: 'ok', data: null });
|
||||
hoisted.forgetServer.mockReset();
|
||||
hoisted.invalidateLegacy.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('drops memory, legacy, and native cover entries for the station owner', async () => {
|
||||
await invalidateRadioCoverArtCache({ id: 'shared', serverId: 'srv-b' });
|
||||
|
||||
expect(hoisted.forgetServer).toHaveBeenCalledWith('b.test');
|
||||
expect(hoisted.invalidateLegacy).toHaveBeenCalledWith('ra-shared', 'srv-b');
|
||||
expect(hoisted.clearServer).toHaveBeenCalledWith('b.test');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user