mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat(analysis): ship index-key rebuild, strategy controls, and playback/queue pipeline updates (#864)
* feat(analysis): align index settings and per-server strategies Rebuild the local index UX to live under Servers with per-server analytics strategies, and scope analysis queue hints/pruning by playback server so priorities stay isolated across profiles. * feat(analysis): add progress tracking and server analysis deletion functionality Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features. * feat(server): implement server index key migration and enhance server ID resolution Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations. * refactor(library): simplify server ID handling in sync progress and idle subscriptions Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality. * refactor(analytics): rename advanced strategy to aggressive and update descriptions Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code. * fix(audio): update server ID handling in audio progress functions Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure. * refactor(analysis): update server ID handling and drop legacy keys Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability. * refactor(migration): switch to strategy C dual-db flow Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior. * fix(migration): harden runtime db switch and startup gate Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows. * feat(migration): enhance migration reporting with skipped server rows tracking Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status. * fix(migration): avoid startup blocking modal on no-op runs Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed. * fix(migration): enforce startup precheck and purge unknown rows Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB. * fix(migration): block UI during done-flag precheck Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed. * fix(migration): hide precheck modal when no migration is needed Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users. * fix(migration): cleanup legacy db files after path migration Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration. * docs(changelog): add PR #864 release notes and contributor credit Document the full index-key rebuild scope for 1.47.0 and add the corresponding settings credit entry for PR #864. * test(analysis): raise hot-path coverage for analysis cache Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths. * fix(migration): make rebind pass resilient to foreign key ordering Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates. * feat(backup): add dual-database backup flow and blocking UX Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs. * docs(changelog): add PR #864 backup notes and contributor credit Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh. * docs(changelog): sort 1.47.0 entries from old to new Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block. * fix(playback): align offline/hot cache lookup with indexKey scope Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope.
This commit is contained in:
Generated
+146
-1
@@ -8,6 +8,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -417,6 +428,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "biquad"
|
||||
version = "0.6.0"
|
||||
@@ -547,6 +564,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
@@ -677,6 +714,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
@@ -753,6 +800,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -1127,6 +1180,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2110,6 +2164,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2430,6 +2493,15 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.13"
|
||||
@@ -3596,6 +3668,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -3608,6 +3691,18 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
"password-hash",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -3956,6 +4051,7 @@ dependencies = [
|
||||
"webkit2gtk-nvidia-quirk",
|
||||
"windows 0.62.2",
|
||||
"zbus 5.15.0",
|
||||
"zip 0.6.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5838,7 +5934,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7920,6 +8016,26 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"byteorder",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"hmac",
|
||||
"pbkdf2",
|
||||
"sha1",
|
||||
"time",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
@@ -7938,6 +8054,35 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.11.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "5.0.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.16+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "3.15.2"
|
||||
|
||||
@@ -73,6 +73,7 @@ symphonia-adapter-libopus = "0.2.9"
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
ebur128 = "0.1"
|
||||
dasp_sample = "0.11.0"
|
||||
zip = "0.6.6"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -19,3 +19,6 @@ md5 = "0.8"
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
|
||||
@@ -10,7 +10,9 @@ use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use symphonia::core::units::Time;
|
||||
use tauri::Manager;
|
||||
use tauri::{Manager, Runtime};
|
||||
|
||||
use crate::analysis_perf::AnalysisSeedTimings;
|
||||
|
||||
use super::store::{now_unix_ts, AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
|
||||
|
||||
@@ -39,27 +41,31 @@ pub enum SeedFromBytesOutcome {
|
||||
|
||||
/// Full Symphonia + (optional) EBU decode for waveform + loudness. Call only from the
|
||||
/// single CPU-seed worker in `lib.rs` (`spawn_blocking`) so at most one heavy decode runs.
|
||||
pub fn seed_from_bytes_execute(
|
||||
app: &tauri::AppHandle,
|
||||
pub fn seed_from_bytes_execute<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<SeedFromBytesOutcome, String> {
|
||||
) -> Result<(SeedFromBytesOutcome, AnalysisSeedTimings), String> {
|
||||
let seed_started = Instant::now();
|
||||
let Some(cache) = app.try_state::<AnalysisCache>() else {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}",
|
||||
track_id,
|
||||
bytes.len()
|
||||
);
|
||||
return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache);
|
||||
return Ok((
|
||||
SeedFromBytesOutcome::SkippedNoAnalysisCache,
|
||||
AnalysisSeedTimings::default(),
|
||||
));
|
||||
};
|
||||
let (outcome, md5_16kb) = seed_from_bytes_into_cache(&cache, server_id, track_id, bytes)?;
|
||||
let seed_ms = seed_started.elapsed().as_millis() as u64;
|
||||
// E2 bridge (analysis → library content_hash): once the playback-derived
|
||||
// md5_16kb is known — whether freshly written or already cached — record it
|
||||
// as `track.content_hash` via the registered sink. Decoupled from
|
||||
// psysonic-library through the psysonic-core port; a no-op when the library
|
||||
// has no row for this (server_id, track_id). Skipped under the legacy ''
|
||||
// scope (no server known).
|
||||
// has no row for this (server_id, track_id). Skipped when no server is known.
|
||||
if !server_id.is_empty()
|
||||
&& matches!(
|
||||
outcome,
|
||||
@@ -70,15 +76,22 @@ pub fn seed_from_bytes_execute(
|
||||
sink.record_content_hash(server_id, track_id, &md5_16kb);
|
||||
}
|
||||
}
|
||||
if !server_id.is_empty() {
|
||||
let bpm_ms = if !server_id.is_empty() {
|
||||
let bpm_started = Instant::now();
|
||||
let _ = crate::track_enrichment::run_track_enrichment_if_needed(
|
||||
app,
|
||||
server_id,
|
||||
track_id,
|
||||
bytes,
|
||||
);
|
||||
}
|
||||
Ok(outcome)
|
||||
bpm_started.elapsed().as_millis() as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok((
|
||||
outcome,
|
||||
AnalysisSeedTimings { seed_ms, bpm_ms },
|
||||
))
|
||||
}
|
||||
|
||||
/// AppHandle-free entry point for [`seed_from_bytes_execute`]: takes the cache
|
||||
@@ -95,9 +108,7 @@ pub fn seed_from_bytes_into_cache(
|
||||
bytes: &[u8],
|
||||
) -> Result<(SeedFromBytesOutcome, String), String> {
|
||||
let started = Instant::now();
|
||||
// Write under the playback server's scope. An empty `server_id` (caller did
|
||||
// not know the server) lands under the legacy '' pool — the read path's
|
||||
// legacy fallback + lazy re-tag keeps it resolvable.
|
||||
// Write under the playback server's scope.
|
||||
let key = TrackKey {
|
||||
server_id: server_id.to_string(),
|
||||
track_id: track_id.to_string(),
|
||||
@@ -167,6 +178,7 @@ pub fn seed_from_bytes_into_cache(
|
||||
}
|
||||
|
||||
cache.touch_track_status(&key, "ready")?;
|
||||
let _ = cache.checkpoint_wal("analysis.seed");
|
||||
Ok((used_pcm_decode, bins_len))
|
||||
})();
|
||||
|
||||
@@ -950,14 +962,14 @@ mod tests {
|
||||
fn seed_from_bytes_into_cache_upserts_waveform_and_loudness_for_wav() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100);
|
||||
let (outcome, md5) = seed_from_bytes_into_cache(&cache, "", "wav-track", &wav).unwrap();
|
||||
let (outcome, md5) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track", &wav).unwrap();
|
||||
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
|
||||
assert_eq!(md5, md5_first_16kb(&wav), "outcome carries the content fingerprint");
|
||||
|
||||
// Both a waveform AND a loudness row must exist after a successful
|
||||
// PCM decode + EBU R128 analysis.
|
||||
let key = TrackKey {
|
||||
server_id: String::new(),
|
||||
server_id: "server-a".to_string(),
|
||||
track_id: "wav-track".to_string(),
|
||||
md5_16kb: md5_first_16kb(&wav),
|
||||
};
|
||||
@@ -979,25 +991,22 @@ mod tests {
|
||||
track_id: "scoped-track".to_string(),
|
||||
md5_16kb: md5.clone(),
|
||||
};
|
||||
let legacy = TrackKey {
|
||||
server_id: String::new(),
|
||||
assert!(cache.get_waveform(&scoped).unwrap().is_some(), "row lands under server scope");
|
||||
let other = TrackKey {
|
||||
server_id: "server-y".to_string(),
|
||||
track_id: "scoped-track".to_string(),
|
||||
md5_16kb: md5,
|
||||
};
|
||||
assert!(cache.get_waveform(&scoped).unwrap().is_some(), "row lands under server scope");
|
||||
assert!(
|
||||
cache.get_waveform(&legacy).unwrap().is_none(),
|
||||
"nothing written under the legacy '' scope"
|
||||
);
|
||||
assert!(cache.get_waveform(&other).unwrap().is_none(), "row stays under the exact server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_from_bytes_into_cache_returns_skipped_on_second_call() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
|
||||
let (first, _) = seed_from_bytes_into_cache(&cache, "", "wav-track-2", &wav).unwrap();
|
||||
let (first, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav).unwrap();
|
||||
assert_eq!(first, SeedFromBytesOutcome::Upserted);
|
||||
let (second, _) = seed_from_bytes_into_cache(&cache, "", "wav-track-2", &wav).unwrap();
|
||||
let (second, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav).unwrap();
|
||||
assert_eq!(
|
||||
second,
|
||||
SeedFromBytesOutcome::SkippedWaveformCacheHit,
|
||||
@@ -1011,11 +1020,11 @@ mod tests {
|
||||
// Garbage bytes — Symphonia probe fails, the pipeline falls back to
|
||||
// `derive_waveform_bins` (no loudness row gets cached).
|
||||
let bytes = vec![0xAAu8; 8 * 1024];
|
||||
let (outcome, _) = seed_from_bytes_into_cache(&cache, "", "garbage", &bytes).unwrap();
|
||||
let (outcome, _) = seed_from_bytes_into_cache(&cache, "server-a", "garbage", &bytes).unwrap();
|
||||
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
|
||||
|
||||
let key = TrackKey {
|
||||
server_id: String::new(),
|
||||
server_id: "server-a".to_string(),
|
||||
track_id: "garbage".to_string(),
|
||||
md5_16kb: md5_first_16kb(&bytes),
|
||||
};
|
||||
@@ -1026,4 +1035,204 @@ mod tests {
|
||||
"byte-envelope fallback must not cache loudness"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_duration_from_bytes_reports_duration_for_wav() {
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 2.0), 44_100);
|
||||
let duration = audio_duration_from_bytes(&wav).expect("duration must be available");
|
||||
assert!(
|
||||
(1.8..=2.2).contains(&duration),
|
||||
"expected ~2s duration, got {duration}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_duration_from_bytes_returns_none_for_garbage() {
|
||||
assert!(audio_duration_from_bytes(b"not audio").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_mono_pcm_limited_decodes_and_respects_limit() {
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(48_000, 2.0), 48_000);
|
||||
let (full_pcm, sr_full) = decode_mono_pcm_limited(&wav, None).expect("full decode");
|
||||
assert_eq!(sr_full, 48_000.0);
|
||||
assert!(
|
||||
full_pcm.len() >= 95_000,
|
||||
"2 seconds at 48kHz should decode close to 96k samples"
|
||||
);
|
||||
|
||||
let (limited_pcm, sr_limited) =
|
||||
decode_mono_pcm_limited(&wav, Some(0.25)).expect("limited decode");
|
||||
assert_eq!(sr_limited, 48_000.0);
|
||||
assert!(
|
||||
(11_500..=12_500).contains(&limited_pcm.len()),
|
||||
"0.25 seconds at 48kHz should decode ~12k samples, got {}",
|
||||
limited_pcm.len()
|
||||
);
|
||||
assert!(limited_pcm.len() < full_pcm.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_mono_pcm_limited_rejects_empty_buffer() {
|
||||
let err = decode_mono_pcm_limited(&[], Some(1.0)).unwrap_err();
|
||||
assert!(err.contains("empty audio buffer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_mono_pcm_window_decodes_center_slice() {
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 2.0), 44_100);
|
||||
let (window_pcm, sr) = decode_mono_pcm_window(&wav, 0.75, 0.5).expect("window decode");
|
||||
assert_eq!(sr, 44_100.0);
|
||||
assert!(
|
||||
(20_000..=24_000).contains(&window_pcm.len()),
|
||||
"0.5 seconds at 44.1kHz should decode ~22k samples, got {}",
|
||||
window_pcm.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_mono_pcm_window_rejects_invalid_bytes() {
|
||||
let err = decode_mono_pcm_window(b"not-audio", 0.0, 1.0).unwrap_err();
|
||||
assert!(
|
||||
err.contains("failed to open audio decode session"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_scan_pcm_supports_waveform_only_mode_without_loudness() {
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
|
||||
let (frames, hint) = count_mono_frames_from_audio_bytes(&wav).expect("frame counting");
|
||||
let scanned = decode_scan_pcm(&wav, 64, frames, hint, None).expect("scan must succeed");
|
||||
assert_eq!(scanned.bins.len(), 128);
|
||||
assert!(scanned.loudness.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_scan_pcm_with_loudness_target_returns_loudness_tuple() {
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
|
||||
let (frames, hint) = count_mono_frames_from_audio_bytes(&wav).expect("frame counting");
|
||||
let scanned = decode_scan_pcm(&wav, 64, frames, hint, Some(-14.0)).expect("scan must succeed");
|
||||
assert_eq!(scanned.bins.len(), 128);
|
||||
let (integrated_lufs, true_peak, recommended_gain_db, target_lufs) =
|
||||
scanned.loudness.expect("loudness tuple must be present");
|
||||
assert!(integrated_lufs.is_finite());
|
||||
assert!(true_peak.is_finite());
|
||||
assert!((-24.0..=24.0).contains(&recommended_gain_db));
|
||||
assert_eq!(target_lufs, -14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_scan_pcm_returns_none_for_non_audio_input() {
|
||||
assert!(decode_scan_pcm(b"nope", 32, 10, None, Some(-14.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_from_bytes_reanalyzes_when_waveform_exists_without_loudness() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
|
||||
let md5 = md5_first_16kb(&wav);
|
||||
let key = TrackKey {
|
||||
server_id: "server-a".to_string(),
|
||||
track_id: "track-reseed".to_string(),
|
||||
md5_16kb: md5,
|
||||
};
|
||||
cache.touch_track_status(&key, "ready").unwrap();
|
||||
cache
|
||||
.upsert_waveform(
|
||||
&key,
|
||||
&WaveformEntry {
|
||||
bins: vec![8u8; 1000],
|
||||
bin_count: 500,
|
||||
is_partial: false,
|
||||
known_until_sec: 0.0,
|
||||
duration_sec: 0.0,
|
||||
updated_at: now_unix_ts(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!cache.loudness_row_exists_for_key(&key).unwrap());
|
||||
|
||||
let (outcome, _) =
|
||||
seed_from_bytes_into_cache(&cache, "server-a", "track-reseed", &wav).unwrap();
|
||||
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
|
||||
assert!(cache.loudness_row_exists_for_key(&key).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_pcm_window_handles_negative_and_non_finite_durations() {
|
||||
let neg = analysis_pcm_window(-42.0, 60.0);
|
||||
assert_eq!(neg.start_sec, 0.0);
|
||||
assert_eq!(neg.duration_sec, 60.0);
|
||||
|
||||
let inf = analysis_pcm_window(f64::INFINITY, 60.0);
|
||||
assert_eq!(inf.start_sec, 0.0);
|
||||
assert!(!inf.duration_sec.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_mono_pcm_window_rejects_empty_buffer() {
|
||||
let err = decode_mono_pcm_window(&[], 0.0, 1.0).unwrap_err();
|
||||
assert!(err.contains("empty audio buffer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_mono_pcm_limited_rejects_invalid_bytes() {
|
||||
let err = decode_mono_pcm_limited(b"not-audio", Some(0.5)).unwrap_err();
|
||||
assert!(err.contains("failed to open audio decode session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_mono_pcm_limited_ignores_non_positive_or_non_finite_cap() {
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
|
||||
let (full_a, _) = decode_mono_pcm_limited(&wav, None).unwrap();
|
||||
let (full_b, _) = decode_mono_pcm_limited(&wav, Some(0.0)).unwrap();
|
||||
let (full_c, _) = decode_mono_pcm_limited(&wav, Some(f64::NAN)).unwrap();
|
||||
assert_eq!(full_a.len(), full_b.len());
|
||||
assert_eq!(full_a.len(), full_c.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_scan_pcm_returns_none_when_no_frames_decoded() {
|
||||
let wav = build_mono_pcm16_wav(&[], 44_100);
|
||||
assert!(analyze_loudness_and_waveform(&wav, -14.0, 64).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_scan_pcm_ignores_oversized_timeline_hint() {
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
|
||||
let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav).expect("frame counting");
|
||||
let scanned = decode_scan_pcm(&wav, 64, frames, Some(frames * 10), None).unwrap();
|
||||
assert_eq!(scanned.bins.len(), 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_from_bytes_execute_returns_no_cache_without_registered_state() {
|
||||
let app = tauri::test::mock_app();
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.25), 44_100);
|
||||
let handle = app.handle().clone();
|
||||
let (outcome, timings) = seed_from_bytes_execute(&handle, "s", "t", &wav)
|
||||
.expect("seed execute should return a graceful skip");
|
||||
assert_eq!(outcome, SeedFromBytesOutcome::SkippedNoAnalysisCache);
|
||||
assert_eq!(timings.seed_ms, 0);
|
||||
assert_eq!(timings.bpm_ms, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_from_bytes_execute_runs_with_registered_cache() {
|
||||
let app = tauri::test::mock_app();
|
||||
app.manage(AnalysisCache::open_in_memory());
|
||||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.5), 44_100);
|
||||
let handle = app.handle().clone();
|
||||
|
||||
let (first, timings_first) =
|
||||
seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav).unwrap();
|
||||
assert_eq!(first, SeedFromBytesOutcome::Upserted);
|
||||
assert!(timings_first.seed_ms <= 30_000);
|
||||
|
||||
let (second, timings_second) =
|
||||
seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav).unwrap();
|
||||
assert_eq!(second, SeedFromBytesOutcome::SkippedWaveformCacheHit);
|
||||
assert!(timings_second.seed_ms <= 30_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,6 @@ pub use compute::{
|
||||
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, LoudnessEntry, TrackKey, WaveformEntry};
|
||||
pub use store::{
|
||||
AnalysisCache, AnalysisDeleteServerReport, LoudnessEntry, TrackKey, WaveformEntry,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
//! Per-track analysis timing events for the Performance Probe overlay.
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AnalysisSeedTimings {
|
||||
pub seed_ms: u64,
|
||||
pub bpm_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisTrackPerfPayload {
|
||||
pub track_id: String,
|
||||
pub fetch_ms: u64,
|
||||
pub seed_ms: u64,
|
||||
pub bpm_ms: u64,
|
||||
pub total_ms: u64,
|
||||
}
|
||||
|
||||
pub fn emit_analysis_track_perf(
|
||||
app: &AppHandle,
|
||||
track_id: &str,
|
||||
fetch_ms: u64,
|
||||
seed_ms: u64,
|
||||
bpm_ms: u64,
|
||||
) {
|
||||
let total_ms = fetch_ms.saturating_add(seed_ms).saturating_add(bpm_ms);
|
||||
if total_ms == 0 {
|
||||
return;
|
||||
}
|
||||
let _ = app.emit(
|
||||
"analysis:track-perf",
|
||||
AnalysisTrackPerfPayload {
|
||||
track_id: track_id.to_string(),
|
||||
fetch_ms,
|
||||
seed_ms,
|
||||
bpm_ms,
|
||||
total_ms,
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,10 @@ use psysonic_core::ports::PlaybackQueryHandle;
|
||||
|
||||
use crate::analysis_cache;
|
||||
use crate::analysis_runtime::{
|
||||
analysis_backfill_is_current_track, analysis_backfill_shared, prune_analysis_queues,
|
||||
track_analysis_needs_work, AnalysisBackfillEnqueueKind,
|
||||
analysis_backfill_queue_stats, analysis_backfill_resolve_priority, analysis_backfill_shared,
|
||||
analysis_pipeline_queue_stats, prune_analysis_queues, track_analysis_needs_work,
|
||||
AnalysisBackfillEnqueueKind,
|
||||
AnalysisBackfillPriority, PlaybackPriorityHints,
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -49,11 +51,35 @@ pub struct LoudnessCachePayload {
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisDeleteServerReportDto {
|
||||
pub analysis_tracks: u64,
|
||||
pub waveforms: u64,
|
||||
pub loudness: u64,
|
||||
}
|
||||
|
||||
impl From<analysis_cache::AnalysisDeleteServerReport> for AnalysisDeleteServerReportDto {
|
||||
fn from(value: analysis_cache::AnalysisDeleteServerReport) -> Self {
|
||||
Self {
|
||||
analysis_tracks: value.analysis_tracks,
|
||||
waveforms: value.waveforms,
|
||||
loudness: value.loudness,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisServerKeyMigrationDto {
|
||||
pub legacy_id: String,
|
||||
pub index_key: String,
|
||||
}
|
||||
|
||||
/// AppHandle-free helper: looks up a waveform by exact `(server_id, track_id,
|
||||
/// md5_16kb)` key, falling back to the legacy `''` scope and re-tagging it onto
|
||||
/// `server_id` on a hit (best-effort). Converts the `WaveformEntry` into the
|
||||
/// JSON-serialisable `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`]
|
||||
/// so it can be tested with `AnalysisCache::open_in_memory()` and direct upserts.
|
||||
/// md5_16kb)` key. Converts the `WaveformEntry` into the JSON-serialisable
|
||||
/// `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`] so it can be
|
||||
/// tested with `AnalysisCache::open_in_memory()` and direct upserts.
|
||||
pub fn get_waveform_payload(
|
||||
cache: &analysis_cache::AnalysisCache,
|
||||
server_id: &str,
|
||||
@@ -65,26 +91,13 @@ pub fn get_waveform_payload(
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5_16kb.to_string(),
|
||||
};
|
||||
if let Some(e) = cache.get_waveform(&exact)? {
|
||||
return Ok(Some(WaveformCachePayload::from(e)));
|
||||
}
|
||||
if !server_id.is_empty() {
|
||||
let legacy = analysis_cache::TrackKey {
|
||||
server_id: String::new(),
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5_16kb.to_string(),
|
||||
};
|
||||
if let Some(e) = cache.get_waveform(&legacy)? {
|
||||
let _ = cache.relabel_legacy_to_server(server_id, track_id);
|
||||
return Ok(Some(WaveformCachePayload::from(e)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
Ok(cache
|
||||
.get_waveform(&exact)?
|
||||
.map(WaveformCachePayload::from))
|
||||
}
|
||||
|
||||
/// AppHandle-free helper: looks up the latest waveform for `(server_id, track_id)`
|
||||
/// across all id variants (bare ↔ `stream:` prefix) with legacy fallback + lazy
|
||||
/// re-tag. See [`get_waveform_payload`].
|
||||
/// across all id variants (bare ↔ `stream:` prefix). See [`get_waveform_payload`].
|
||||
pub fn get_waveform_payload_for_track(
|
||||
cache: &analysis_cache::AnalysisCache,
|
||||
server_id: &str,
|
||||
@@ -96,7 +109,7 @@ pub fn get_waveform_payload_for_track(
|
||||
}
|
||||
|
||||
/// AppHandle-free helper: looks up the latest loudness row for `(server_id,
|
||||
/// track_id)` (legacy fallback + lazy re-tag) and recomputes `recommended_gain_db`
|
||||
/// track_id)` and recomputes `recommended_gain_db`
|
||||
/// against the optional requested target (clamped to [-30, -8]). When
|
||||
/// `target_lufs` is `None`, the cached row's own target is used.
|
||||
pub fn get_loudness_payload_for_track(
|
||||
@@ -202,18 +215,69 @@ pub fn analysis_delete_all_waveforms(
|
||||
cache.delete_all_waveforms()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_delete_all_for_server(
|
||||
server_id: String,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<AnalysisDeleteServerReportDto, String> {
|
||||
if server_id.trim().is_empty() {
|
||||
return Err("server_id required".to_string());
|
||||
}
|
||||
let report = cache.delete_all_for_server(&server_id)?;
|
||||
Ok(report.into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_migrate_server_index_keys(
|
||||
mappings: Vec<AnalysisServerKeyMigrationDto>,
|
||||
_cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<(), String> {
|
||||
for mapping in mappings {
|
||||
let _ = (mapping.legacy_id, mapping.index_key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_enqueue_seed_from_url(
|
||||
track_id: String,
|
||||
url: String,
|
||||
force: Option<bool>,
|
||||
server_id: Option<String>,
|
||||
priority: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if track_id.trim().is_empty() || url.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let server_id = server_id.unwrap_or_default();
|
||||
let server_id = if let Ok(parsed) = reqwest::Url::parse(&url) {
|
||||
if parsed.scheme() == "http" || parsed.scheme() == "https" {
|
||||
let host = parsed.host_str().unwrap_or_default();
|
||||
let mut base_path = parsed.path().to_string();
|
||||
if let Some(idx) = base_path.find("/rest") {
|
||||
base_path.truncate(idx);
|
||||
}
|
||||
while base_path.ends_with('/') {
|
||||
base_path.pop();
|
||||
}
|
||||
if host.is_empty() {
|
||||
server_id.unwrap_or_default()
|
||||
} else {
|
||||
let mut base = host.to_string();
|
||||
if let Some(port) = parsed.port() {
|
||||
base.push_str(&format!(":{port}"));
|
||||
}
|
||||
if !base_path.is_empty() {
|
||||
base.push_str(&base_path);
|
||||
}
|
||||
base
|
||||
}
|
||||
} else {
|
||||
server_id.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
server_id.unwrap_or_default()
|
||||
};
|
||||
let force = force.unwrap_or(false);
|
||||
if !force {
|
||||
if let Some(playback) = app.try_state::<PlaybackQueryHandle>() {
|
||||
@@ -251,29 +315,32 @@ pub fn analysis_enqueue_seed_from_url(
|
||||
}
|
||||
}
|
||||
let tid_log = track_id.clone();
|
||||
let high_priority = analysis_backfill_is_current_track(&app, &track_id);
|
||||
let explicit = AnalysisBackfillPriority::from_optional_str(priority.as_deref());
|
||||
let resolved =
|
||||
analysis_backfill_resolve_priority(&app, &server_id, &track_id, explicit);
|
||||
let shared = analysis_backfill_shared(&app);
|
||||
let kind = {
|
||||
let mut st = shared
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
|
||||
st.enqueue(server_id, track_id, url, high_priority)
|
||||
st.enqueue(server_id, track_id, url, resolved)
|
||||
};
|
||||
match kind {
|
||||
AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => {
|
||||
AnalysisBackfillEnqueueKind::NewLow
|
||||
| AnalysisBackfillEnqueueKind::NewMiddle
|
||||
| AnalysisBackfillEnqueueKind::NewHigh => {
|
||||
shared.ping_worker();
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill enqueued: track_id={} position={}",
|
||||
"[analysis] backfill enqueued: track_id={} priority={resolved:?}",
|
||||
tid_log,
|
||||
if high_priority { "front" } else { "back" }
|
||||
);
|
||||
}
|
||||
AnalysisBackfillEnqueueKind::ReorderedFront => {
|
||||
AnalysisBackfillEnqueueKind::ReorderedHigher => {
|
||||
shared.ping_worker();
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill bumped to front (current track) track_id={}",
|
||||
tid_log
|
||||
"[analysis] backfill bumped tier track_id={} priority={resolved:?}",
|
||||
tid_log,
|
||||
);
|
||||
}
|
||||
AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {}
|
||||
@@ -281,6 +348,55 @@ pub fn analysis_enqueue_seed_from_url(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPriorityHintDto {
|
||||
pub server_id: String,
|
||||
pub track_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_set_playback_priority_hints(
|
||||
middle_track_refs: Vec<AnalysisPriorityHintDto>,
|
||||
hints: tauri::State<'_, PlaybackPriorityHints>,
|
||||
) -> Result<(), String> {
|
||||
let pairs = middle_track_refs
|
||||
.into_iter()
|
||||
.map(|r| (r.server_id, r.track_id));
|
||||
hints.set_middle_track_ids(pairs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisBackfillQueueStatsDto {
|
||||
pub queued: usize,
|
||||
pub in_progress_count: usize,
|
||||
pub in_progress_track_id: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> {
|
||||
crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_pipeline_queue_stats() -> Result<crate::analysis_runtime::AnalysisPipelineQueueStatsDto, String> {
|
||||
Ok(analysis_pipeline_queue_stats())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsDto, String> {
|
||||
let (queued, in_progress_count, in_progress_track_id) =
|
||||
analysis_backfill_queue_stats();
|
||||
Ok(AnalysisBackfillQueueStatsDto {
|
||||
queued,
|
||||
in_progress_count,
|
||||
in_progress_track_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPrunePendingResult {
|
||||
@@ -296,6 +412,7 @@ pub struct AnalysisPrunePendingResult {
|
||||
#[tauri::command]
|
||||
pub fn analysis_prune_pending_to_track_ids(
|
||||
track_ids: Vec<String>,
|
||||
server_id: String,
|
||||
) -> Result<AnalysisPrunePendingResult, String> {
|
||||
let mut normalized: Vec<String> = Vec::with_capacity(track_ids.len());
|
||||
let mut seen = HashSet::new();
|
||||
@@ -310,8 +427,10 @@ pub fn analysis_prune_pending_to_track_ids(
|
||||
}
|
||||
let keep_track_ids: HashSet<&str> = normalized.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let server_id = server_id.trim().to_string();
|
||||
let server_filter = if server_id.is_empty() { None } else { Some(server_id.as_str()) };
|
||||
let (http_removed, cpu_removed_jobs, cpu_removed_waiters) =
|
||||
prune_analysis_queues(&keep_track_ids)?;
|
||||
prune_analysis_queues(&keep_track_ids, server_filter)?;
|
||||
|
||||
if http_removed > 0 || cpu_removed_jobs > 0 {
|
||||
crate::app_deprintln!(
|
||||
@@ -340,7 +459,7 @@ mod tests {
|
||||
|
||||
fn key(track_id: &str, md5: &str) -> TrackKey {
|
||||
TrackKey {
|
||||
server_id: String::new(),
|
||||
server_id: "server-a".to_string(),
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5.to_string(),
|
||||
}
|
||||
@@ -386,7 +505,7 @@ mod tests {
|
||||
#[test]
|
||||
fn get_waveform_payload_returns_none_for_unknown_key() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let payload = get_waveform_payload(&cache, "", "missing", "deadbeef").unwrap();
|
||||
let payload = get_waveform_payload(&cache, "server-a", "missing", "deadbeef").unwrap();
|
||||
assert!(payload.is_none());
|
||||
}
|
||||
|
||||
@@ -395,7 +514,7 @@ mod tests {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let bins: Vec<u8> = (0..8u8).collect();
|
||||
upsert_waveform(&cache, "abc", "deadbeef", bins.clone());
|
||||
let payload = get_waveform_payload(&cache, "", "abc", "deadbeef")
|
||||
let payload = get_waveform_payload(&cache, "server-a", "abc", "deadbeef")
|
||||
.unwrap()
|
||||
.expect("payload exists");
|
||||
assert_eq!(payload.bins, bins);
|
||||
@@ -411,8 +530,8 @@ mod tests {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_waveform(&cache, "abc", "aaaa", vec![0u8; 8]);
|
||||
upsert_waveform(&cache, "abc", "bbbb", vec![0xFFu8; 8]);
|
||||
let p1 = get_waveform_payload(&cache, "", "abc", "aaaa").unwrap().unwrap();
|
||||
let p2 = get_waveform_payload(&cache, "", "abc", "bbbb").unwrap().unwrap();
|
||||
let p1 = get_waveform_payload(&cache, "server-a", "abc", "aaaa").unwrap().unwrap();
|
||||
let p2 = get_waveform_payload(&cache, "server-a", "abc", "bbbb").unwrap().unwrap();
|
||||
assert_ne!(p1.bins, p2.bins);
|
||||
}
|
||||
|
||||
@@ -424,7 +543,7 @@ mod tests {
|
||||
// matching is the whole point of get_latest_waveform_for_track.
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_waveform(&cache, "stream:abc", "deadbeef", vec![1u8; 8]);
|
||||
let payload = get_waveform_payload_for_track(&cache, "", "abc")
|
||||
let payload = get_waveform_payload_for_track(&cache, "server-a", "abc")
|
||||
.unwrap()
|
||||
.expect("bare-id lookup must hit the stream-prefixed row");
|
||||
assert_eq!(payload.bin_count, 4);
|
||||
@@ -433,28 +552,7 @@ mod tests {
|
||||
#[test]
|
||||
fn get_waveform_for_track_returns_none_for_unknown_track() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
assert!(get_waveform_payload_for_track(&cache, "", "phantom").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_waveform_payload_exact_key_falls_back_to_legacy_and_retags() {
|
||||
// A pre-002 row lives under server_id=''. The exact-key read for a real
|
||||
// server must find it via fallback and re-tag it under the server scope.
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_waveform(&cache, "abc", "deadbeef", vec![7u8; 8]); // legacy '' row
|
||||
|
||||
let payload = get_waveform_payload(&cache, "server-a", "abc", "deadbeef")
|
||||
.unwrap()
|
||||
.expect("legacy fallback must return the blob");
|
||||
assert_eq!(payload.bins, vec![7u8; 8]);
|
||||
|
||||
// Re-tag side effect: the server-scoped exact key now resolves on its own.
|
||||
let scoped = TrackKey {
|
||||
server_id: "server-a".to_string(),
|
||||
track_id: "abc".to_string(),
|
||||
md5_16kb: "deadbeef".to_string(),
|
||||
};
|
||||
assert!(cache.get_waveform(&scoped).unwrap().is_some());
|
||||
assert!(get_waveform_payload_for_track(&cache, "server-a", "phantom").unwrap().is_none());
|
||||
}
|
||||
|
||||
// ── get_loudness_payload_for_track ────────────────────────────────────────
|
||||
@@ -465,7 +563,7 @@ mod tests {
|
||||
upsert_loudness(&cache, "abc", "deadbeef", -14.0);
|
||||
// Cached row: integrated -14, target -14 → gain 0. Request target -10 →
|
||||
// recommended gain = -10 - (-14) = +4 dB (capped by true-peak guard).
|
||||
let payload = get_loudness_payload_for_track(&cache, "", "abc", Some(-10.0))
|
||||
let payload = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(-10.0))
|
||||
.unwrap()
|
||||
.expect("loudness row exists");
|
||||
assert_eq!(payload.target_lufs, -10.0);
|
||||
@@ -480,7 +578,7 @@ mod tests {
|
||||
fn get_loudness_for_track_uses_cached_target_when_request_is_none() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_loudness(&cache, "abc", "deadbeef", -16.0);
|
||||
let payload = get_loudness_payload_for_track(&cache, "", "abc", None)
|
||||
let payload = get_loudness_payload_for_track(&cache, "server-a", "abc", None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(payload.target_lufs, -16.0);
|
||||
@@ -491,11 +589,11 @@ mod tests {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_loudness(&cache, "abc", "deadbeef", -14.0);
|
||||
// Out-of-range target gets clamped to [-30, -8].
|
||||
let too_high = get_loudness_payload_for_track(&cache, "", "abc", Some(0.0))
|
||||
let too_high = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(0.0))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(too_high.target_lufs, -8.0);
|
||||
let too_low = get_loudness_payload_for_track(&cache, "", "abc", Some(-100.0))
|
||||
let too_low = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(-100.0))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(too_low.target_lufs, -30.0);
|
||||
@@ -504,7 +602,7 @@ mod tests {
|
||||
#[test]
|
||||
fn get_loudness_for_track_returns_none_for_unknown_track() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
assert!(get_loudness_payload_for_track(&cache, "", "phantom", None)
|
||||
assert!(get_loudness_payload_for_track(&cache, "server-a", "phantom", None)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! - `analysis_runtime` — backfill queue, CPU-seed queue, queue snapshot loop
|
||||
|
||||
pub mod analysis_cache;
|
||||
pub mod analysis_perf;
|
||||
pub mod analysis_runtime;
|
||||
pub mod commands;
|
||||
pub mod track_analysis_plan;
|
||||
|
||||
@@ -5,7 +5,7 @@ use psysonic_core::track_enrichment::{
|
||||
TrackEnrichmentFacts, TrackEnrichmentIntFact, TrackEnrichmentOutcome, TrackEnrichmentPort,
|
||||
TrackEnrichmentPlan, TrackEnrichmentRealFact,
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
|
||||
use crate::analysis_cache::{
|
||||
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_window, md5_first_16kb,
|
||||
@@ -20,7 +20,7 @@ pub struct EnrichmentUpdatedPayload {
|
||||
pub server_id: String,
|
||||
}
|
||||
|
||||
fn emit_enrichment_updated(app: &AppHandle, server_id: &str, track_id: &str) {
|
||||
fn emit_enrichment_updated<R: Runtime>(app: &AppHandle<R>, server_id: &str, track_id: &str) {
|
||||
let _ = app.emit(
|
||||
"analysis:enrichment-updated",
|
||||
EnrichmentUpdatedPayload {
|
||||
@@ -30,8 +30,8 @@ fn emit_enrichment_updated(app: &AppHandle, server_id: &str, track_id: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
pub fn run_track_enrichment_if_needed(
|
||||
app: &AppHandle,
|
||||
pub fn run_track_enrichment_if_needed<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
|
||||
@@ -9,8 +9,11 @@ use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority;
|
||||
|
||||
use crate::engine::{analysis_track_id_is_current_playback, AudioEngine};
|
||||
use crate::helpers::{analysis_cache_track_id, current_playback_server_id_str};
|
||||
use url::Url;
|
||||
use crate::state::ChainedInfo;
|
||||
use crate::stream::{LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES, TRACK_STREAM_PROMOTE_MAX_BYTES};
|
||||
|
||||
@@ -38,41 +41,82 @@ pub(crate) fn resolve_analysis_server_id(
|
||||
explicit: Option<&str>,
|
||||
engine: Option<&AudioEngine>,
|
||||
) -> String {
|
||||
if let Some(engine) = engine {
|
||||
if let Some(url) = engine
|
||||
.current_playback_url
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| (*g).clone())
|
||||
{
|
||||
if let Some(derived) = server_id_from_playback_url(&url) {
|
||||
return derived;
|
||||
}
|
||||
}
|
||||
}
|
||||
explicit
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
engine
|
||||
.map(current_playback_server_id_str)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_else(|| engine.map(current_playback_server_id_str).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn resolve_high_priority(
|
||||
fn server_id_from_playback_url(url_raw: &str) -> Option<String> {
|
||||
if url_raw.starts_with("psysonic-local://") {
|
||||
return None;
|
||||
}
|
||||
let parsed = Url::parse(url_raw).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
let mut base_path = parsed.path().to_string();
|
||||
if let Some(idx) = base_path.find("/rest") {
|
||||
base_path.truncate(idx);
|
||||
}
|
||||
while base_path.ends_with('/') {
|
||||
base_path.pop();
|
||||
}
|
||||
let mut base = host.to_string();
|
||||
if let Some(port) = parsed.port() {
|
||||
base.push_str(&format!(":{port}"));
|
||||
}
|
||||
if !base_path.is_empty() {
|
||||
base.push_str(&base_path);
|
||||
}
|
||||
Some(base)
|
||||
}
|
||||
|
||||
fn resolve_analysis_priority(
|
||||
app: &AppHandle,
|
||||
engine: Option<&AudioEngine>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
explicit: Option<bool>,
|
||||
) -> bool {
|
||||
explicit.unwrap_or_else(|| {
|
||||
psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id)
|
||||
|| engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id))
|
||||
})
|
||||
explicit: Option<AnalysisBackfillPriority>,
|
||||
) -> AnalysisBackfillPriority {
|
||||
if let Some(priority) = explicit {
|
||||
return priority;
|
||||
}
|
||||
if psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id)
|
||||
|| engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id))
|
||||
{
|
||||
return AnalysisBackfillPriority::High;
|
||||
}
|
||||
psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority(
|
||||
app,
|
||||
server_id,
|
||||
track_id,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve `(server_id, high_priority)` when the caller has live engine state.
|
||||
/// Resolve `(server_id, priority)` when the caller has live engine state.
|
||||
pub(crate) fn prepare_playback_analysis(
|
||||
app: &AppHandle,
|
||||
engine: &AudioEngine,
|
||||
explicit_server_id: Option<&str>,
|
||||
track_id: &str,
|
||||
high_priority: Option<bool>,
|
||||
) -> (String, bool) {
|
||||
(
|
||||
resolve_analysis_server_id(explicit_server_id, Some(engine)),
|
||||
resolve_high_priority(app, Some(engine), track_id, high_priority),
|
||||
)
|
||||
priority: Option<AnalysisBackfillPriority>,
|
||||
) -> (String, AnalysisBackfillPriority) {
|
||||
let sid = resolve_analysis_server_id(explicit_server_id, Some(engine));
|
||||
let resolved = resolve_analysis_priority(app, Some(engine), &sid, track_id, priority);
|
||||
(sid, resolved)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_server_id_for_app(
|
||||
@@ -83,13 +127,14 @@ pub(crate) fn resolve_server_id_for_app(
|
||||
resolve_analysis_server_id(explicit, engine.as_deref())
|
||||
}
|
||||
|
||||
pub(crate) fn high_priority_for_app(
|
||||
pub(crate) fn analysis_priority_for_app(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
explicit: Option<bool>,
|
||||
) -> bool {
|
||||
explicit: Option<AnalysisBackfillPriority>,
|
||||
) -> AnalysisBackfillPriority {
|
||||
let engine = app.try_state::<AudioEngine>();
|
||||
resolve_high_priority(app, engine.as_deref(), track_id, explicit)
|
||||
resolve_analysis_priority(app, engine.as_deref(), server_id, track_id, explicit)
|
||||
}
|
||||
|
||||
/// Gapless boundary: chained track became audible — run unified analysis if needed.
|
||||
@@ -102,12 +147,12 @@ pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedI
|
||||
return;
|
||||
};
|
||||
let engine = app.state::<AudioEngine>();
|
||||
let (sid, high) = prepare_playback_analysis(
|
||||
let (sid, priority) = prepare_playback_analysis(
|
||||
app,
|
||||
&engine,
|
||||
info.server_id.as_deref(),
|
||||
&track_id,
|
||||
Some(true),
|
||||
Some(AnalysisBackfillPriority::High),
|
||||
);
|
||||
let bytes = (*info.raw_bytes).clone();
|
||||
spawn_track_analysis_bytes(
|
||||
@@ -116,7 +161,7 @@ pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedI
|
||||
sid,
|
||||
track_id,
|
||||
bytes,
|
||||
high,
|
||||
priority,
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -128,7 +173,7 @@ pub(crate) async fn dispatch_track_analysis_bytes(
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: Vec<u8>,
|
||||
high_priority: bool,
|
||||
priority: AnalysisBackfillPriority,
|
||||
) -> Result<(), String> {
|
||||
let track_id = track_id.trim();
|
||||
if track_id.is_empty() {
|
||||
@@ -146,7 +191,7 @@ pub(crate) async fn dispatch_track_analysis_bytes(
|
||||
return Ok(());
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} high={high_priority}",
|
||||
"[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} priority={priority:?}",
|
||||
if server_id.is_empty() { "''" } else { server_id },
|
||||
bytes.len() as f64 / (1024.0 * 1024.0),
|
||||
);
|
||||
@@ -155,7 +200,7 @@ pub(crate) async fn dispatch_track_analysis_bytes(
|
||||
server_id,
|
||||
track_id,
|
||||
&bytes,
|
||||
high_priority,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
@@ -168,7 +213,7 @@ pub(crate) fn spawn_track_analysis_bytes(
|
||||
server_id: String,
|
||||
track_id: String,
|
||||
bytes: Vec<u8>,
|
||||
high_priority: bool,
|
||||
priority: AnalysisBackfillPriority,
|
||||
generation_guard: Option<(u64, Arc<AtomicU64>)>,
|
||||
) {
|
||||
if track_id.trim().is_empty() || bytes.is_empty() {
|
||||
@@ -186,7 +231,7 @@ pub(crate) fn spawn_track_analysis_bytes(
|
||||
&server_id,
|
||||
&track_id,
|
||||
bytes,
|
||||
high_priority,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -203,7 +248,7 @@ pub(crate) fn spawn_track_analysis_file(
|
||||
server_id: String,
|
||||
track_id: String,
|
||||
file_path: PathBuf,
|
||||
high_priority: bool,
|
||||
priority: AnalysisBackfillPriority,
|
||||
generation_guard: Option<(u64, Arc<AtomicU64>)>,
|
||||
) {
|
||||
if track_id.trim().is_empty() {
|
||||
@@ -230,7 +275,7 @@ pub(crate) fn spawn_track_analysis_file(
|
||||
&server_id,
|
||||
&track_id,
|
||||
bytes,
|
||||
high_priority,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -548,12 +548,12 @@ pub async fn audio_chain_preload(
|
||||
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
|
||||
let (sid, high) = crate::analysis_dispatch::prepare_playback_analysis(
|
||||
let (sid, priority) = crate::analysis_dispatch::prepare_playback_analysis(
|
||||
&app,
|
||||
&state,
|
||||
analysis_server_id,
|
||||
&track_id,
|
||||
Some(false),
|
||||
Some(psysonic_analysis::analysis_runtime::AnalysisBackfillPriority::Middle),
|
||||
);
|
||||
let bytes = (*raw_bytes).clone();
|
||||
crate::analysis_dispatch::spawn_track_analysis_bytes(
|
||||
@@ -562,7 +562,7 @@ pub async fn audio_chain_preload(
|
||||
sid,
|
||||
track_id,
|
||||
bytes,
|
||||
high,
|
||||
priority,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::time::Duration;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority;
|
||||
|
||||
use super::analysis_dispatch::{
|
||||
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
@@ -36,13 +38,12 @@ async fn seed_preload_analysis_bytes(
|
||||
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
|
||||
return;
|
||||
};
|
||||
let (sid, high) = prepare_playback_analysis(
|
||||
let (sid, priority) = prepare_playback_analysis(
|
||||
app,
|
||||
state,
|
||||
server_id,
|
||||
&track_id,
|
||||
// Next-track prefetch — never steal CPU from the audible track.
|
||||
Some(false),
|
||||
Some(AnalysisBackfillPriority::Middle),
|
||||
);
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
app,
|
||||
@@ -50,7 +51,7 @@ async fn seed_preload_analysis_bytes(
|
||||
&sid,
|
||||
&track_id,
|
||||
data.to_vec(),
|
||||
high,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -69,12 +70,12 @@ fn seed_preload_analysis_file(
|
||||
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
|
||||
return;
|
||||
};
|
||||
let (sid, high) = prepare_playback_analysis(
|
||||
let (sid, priority) = prepare_playback_analysis(
|
||||
app,
|
||||
state,
|
||||
server_id,
|
||||
&track_id,
|
||||
Some(false),
|
||||
Some(AnalysisBackfillPriority::Middle),
|
||||
);
|
||||
crate::app_deprintln!(
|
||||
"[stream] audio_preload: local file analysis track_id={} path={}",
|
||||
@@ -87,7 +88,7 @@ fn seed_preload_analysis_file(
|
||||
sid,
|
||||
track_id,
|
||||
file_path,
|
||||
high,
|
||||
priority,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ use super::{
|
||||
TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
};
|
||||
use crate::analysis_dispatch::{
|
||||
dispatch_track_analysis_bytes, high_priority_for_app, resolve_server_id_for_app,
|
||||
dispatch_track_analysis_bytes, analysis_priority_for_app, resolve_server_id_for_app,
|
||||
spawn_track_analysis_file, TrackAnalysisOrigin,
|
||||
};
|
||||
use crate::helpers::{install_stream_completed_spill, write_stream_spill_file};
|
||||
@@ -647,14 +647,14 @@ pub(crate) async fn ranged_download_task(
|
||||
}
|
||||
if let Some(track_id) = cache_track_id {
|
||||
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
|
||||
let high = high_priority_for_app(&app, &track_id, None);
|
||||
let priority = analysis_priority_for_app(&app, &sid, &track_id, None);
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
&sid,
|
||||
&track_id,
|
||||
data.clone(),
|
||||
high,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -691,14 +691,14 @@ pub(crate) async fn ranged_download_task(
|
||||
}
|
||||
install_stream_completed_spill(&spill_cache_slot, url, path.clone());
|
||||
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
|
||||
let high = high_priority_for_app(&app, &track_id, None);
|
||||
let priority = analysis_priority_for_app(&app, &sid, &track_id, None);
|
||||
spawn_track_analysis_file(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::StreamSpillFile,
|
||||
sid,
|
||||
track_id,
|
||||
path,
|
||||
high,
|
||||
priority,
|
||||
Some((gen, gen_arc.clone())),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,14 +171,14 @@ pub(crate) async fn track_download_task(
|
||||
&app,
|
||||
server_id.as_deref(),
|
||||
);
|
||||
let high = crate::analysis_dispatch::high_priority_for_app(&app, &track_id, None);
|
||||
let priority = crate::analysis_dispatch::analysis_priority_for_app(&app, &sid, &track_id, None);
|
||||
if let Err(e) = crate::analysis_dispatch::dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
crate::analysis_dispatch::TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
&sid,
|
||||
&track_id,
|
||||
capture.clone(),
|
||||
high,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -109,3 +109,28 @@ impl AnalysisReadinessQuery {
|
||||
(self.query)(server_id, track_id, md5_16kb)
|
||||
}
|
||||
}
|
||||
|
||||
type NeedsWorkFn = Arc<dyn Fn(&str, &str) -> Result<bool, String> + Send + Sync + 'static>;
|
||||
|
||||
/// Library→analysis plan probe: does `(server_id, track_id)` still need waveform,
|
||||
/// loudness, or enrichment work? Wired in the shell crate so `psysonic-library`
|
||||
/// can batch-scan without depending on `psysonic-analysis`.
|
||||
#[derive(Clone)]
|
||||
pub struct TrackAnalysisNeedsWorkQuery {
|
||||
query: NeedsWorkFn,
|
||||
}
|
||||
|
||||
impl TrackAnalysisNeedsWorkQuery {
|
||||
pub fn new<F>(query: F) -> Self
|
||||
where
|
||||
F: Fn(&str, &str) -> Result<bool, String> + Send + Sync + 'static,
|
||||
{
|
||||
Self {
|
||||
query: Arc::new(query),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_work(&self, server_id: &str, track_id: &str) -> Result<bool, String> {
|
||||
(self.query)(server_id, track_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Advanced analytics strategy — batch-select library tracks that still need
|
||||
//! waveform / loudness / enrichment work (spec: Settings → Library).
|
||||
|
||||
use psysonic_core::ports::TrackAnalysisNeedsWorkQuery;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::repos::TrackRepository;
|
||||
use crate::runtime::LibraryRuntime;
|
||||
|
||||
const SCAN_CHUNK: usize = 500;
|
||||
const MAX_SCAN_IDS_PER_CALL: usize = 10_000;
|
||||
const DEFAULT_BATCH: u32 = 20;
|
||||
const MAX_BATCH: u32 = 50;
|
||||
const PROGRESS_SCAN_CHUNK: usize = 1000;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryAnalysisBackfillBatchDto {
|
||||
pub track_ids: Vec<String>,
|
||||
pub next_cursor: Option<String>,
|
||||
pub exhausted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryAnalysisProgressDto {
|
||||
pub total_tracks: i64,
|
||||
pub pending_tracks: i64,
|
||||
pub done_tracks: i64,
|
||||
}
|
||||
|
||||
enum ScanMode {
|
||||
Candidates,
|
||||
Full,
|
||||
}
|
||||
|
||||
pub fn collect_analysis_backfill_batch(
|
||||
app: &AppHandle,
|
||||
runtime: &LibraryRuntime,
|
||||
server_id: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<LibraryAnalysisBackfillBatchDto, String> {
|
||||
let want = limit.unwrap_or(DEFAULT_BATCH).min(MAX_BATCH) as usize;
|
||||
let needs_work = app
|
||||
.try_state::<TrackAnalysisNeedsWorkQuery>()
|
||||
.ok_or_else(|| "TrackAnalysisNeedsWorkQuery not registered".to_string())?;
|
||||
|
||||
let repo = TrackRepository::new(&runtime.store);
|
||||
let mut found = Vec::with_capacity(want);
|
||||
let mut after = cursor.map(str::to_string);
|
||||
let mut mode = ScanMode::Candidates;
|
||||
let mut scanned = 0usize;
|
||||
|
||||
while found.len() < want && scanned < MAX_SCAN_IDS_PER_CALL {
|
||||
let page = match mode {
|
||||
ScanMode::Candidates => {
|
||||
repo.list_analysis_candidate_ids_after(server_id, after.as_deref(), SCAN_CHUNK)?
|
||||
}
|
||||
ScanMode::Full => repo.list_track_ids_after(server_id, after.as_deref(), SCAN_CHUNK)?,
|
||||
};
|
||||
|
||||
if page.is_empty() {
|
||||
match mode {
|
||||
ScanMode::Candidates => {
|
||||
mode = ScanMode::Full;
|
||||
continue;
|
||||
}
|
||||
ScanMode::Full => {
|
||||
return Ok(LibraryAnalysisBackfillBatchDto {
|
||||
track_ids: found,
|
||||
next_cursor: after,
|
||||
exhausted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let page_len = page.len();
|
||||
for id in page {
|
||||
scanned += 1;
|
||||
after = Some(id.clone());
|
||||
if needs_work.needs_work(server_id, &id)? {
|
||||
found.push(id);
|
||||
if found.len() >= want {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if scanned >= MAX_SCAN_IDS_PER_CALL {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if found.len() >= want || scanned >= MAX_SCAN_IDS_PER_CALL {
|
||||
break;
|
||||
}
|
||||
|
||||
if page_len < SCAN_CHUNK {
|
||||
match mode {
|
||||
ScanMode::Candidates => {
|
||||
mode = ScanMode::Full;
|
||||
}
|
||||
ScanMode::Full => {
|
||||
return Ok(LibraryAnalysisBackfillBatchDto {
|
||||
track_ids: found,
|
||||
next_cursor: after,
|
||||
exhausted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LibraryAnalysisBackfillBatchDto {
|
||||
track_ids: found,
|
||||
next_cursor: after,
|
||||
exhausted: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn collect_analysis_progress(
|
||||
app: &AppHandle,
|
||||
runtime: &LibraryRuntime,
|
||||
server_id: &str,
|
||||
) -> Result<LibraryAnalysisProgressDto, String> {
|
||||
let needs_work = app
|
||||
.try_state::<TrackAnalysisNeedsWorkQuery>()
|
||||
.ok_or_else(|| "TrackAnalysisNeedsWorkQuery not registered".to_string())?;
|
||||
|
||||
let repo = TrackRepository::new(&runtime.store);
|
||||
let total = repo.count_live_tracks(server_id)?;
|
||||
if total <= 0 {
|
||||
return Ok(LibraryAnalysisProgressDto {
|
||||
total_tracks: 0,
|
||||
pending_tracks: 0,
|
||||
done_tracks: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let mut pending: i64 = 0;
|
||||
let mut after: Option<String> = None;
|
||||
loop {
|
||||
let page = repo.list_track_ids_after(
|
||||
server_id,
|
||||
after.as_deref(),
|
||||
PROGRESS_SCAN_CHUNK,
|
||||
)?;
|
||||
if page.is_empty() {
|
||||
break;
|
||||
}
|
||||
for id in page {
|
||||
after = Some(id.clone());
|
||||
if needs_work.needs_work(server_id, &id)? {
|
||||
pending += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let done = total.saturating_sub(pending);
|
||||
Ok(LibraryAnalysisProgressDto {
|
||||
total_tracks: total,
|
||||
pending_tracks: pending,
|
||||
done_tracks: done,
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use psysonic_integration::navidrome::navidrome_token;
|
||||
use psysonic_integration::subsonic::SubsonicClient;
|
||||
|
||||
use crate::advanced_search;
|
||||
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto};
|
||||
use crate::cross_server;
|
||||
use crate::dto::{
|
||||
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
|
||||
@@ -41,6 +42,81 @@ use crate::sync::tombstone::should_auto_reconcile;
|
||||
|
||||
/// Cap for `library_get_tracks_batch` per spec §7.1 ("max 100 refs/call").
|
||||
const TRACKS_BATCH_LIMIT: usize = 100;
|
||||
const ANALYSIS_PROGRESS_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryServerKeyMigrationDto {
|
||||
pub legacy_id: String,
|
||||
pub index_key: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_analysis_backfill_batch(
|
||||
app: AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<LibraryAnalysisBackfillBatchDto, String> {
|
||||
analysis_backfill::collect_analysis_backfill_batch(
|
||||
&app,
|
||||
&runtime,
|
||||
server_id.trim(),
|
||||
cursor.as_deref().filter(|s| !s.is_empty()),
|
||||
limit,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_analysis_progress(
|
||||
app: AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
) -> Result<LibraryAnalysisProgressDto, String> {
|
||||
let server_id = server_id.trim().to_string();
|
||||
if server_id.is_empty() {
|
||||
return Ok(LibraryAnalysisProgressDto {
|
||||
total_tracks: 0,
|
||||
pending_tracks: 0,
|
||||
done_tracks: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let cached = runtime.analysis_progress_snapshot(&server_id);
|
||||
if let Some(entry) = cached.as_ref() {
|
||||
if entry.updated_at.elapsed() <= ANALYSIS_PROGRESS_CACHE_TTL {
|
||||
return Ok(entry.value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.mark_analysis_progress_in_flight(&server_id) {
|
||||
let app_handle = app.clone();
|
||||
let server_id_clone = server_id.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let Some(runtime) = app_handle.try_state::<LibraryRuntime>() else {
|
||||
return;
|
||||
};
|
||||
let progress = analysis_backfill::collect_analysis_progress(
|
||||
&app_handle,
|
||||
&runtime,
|
||||
server_id_clone.trim(),
|
||||
);
|
||||
match progress {
|
||||
Ok(value) => runtime.set_analysis_progress(&server_id_clone, value),
|
||||
Err(_) => runtime.clear_analysis_progress_in_flight(&server_id_clone),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(cached
|
||||
.map(|entry| entry.value)
|
||||
.unwrap_or(LibraryAnalysisProgressDto {
|
||||
total_tracks: 0,
|
||||
pending_tracks: 0,
|
||||
done_tracks: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_get_status(
|
||||
@@ -228,8 +304,8 @@ pub async fn library_get_track(
|
||||
.lyrics_cached(&server_id, &track_id, now)
|
||||
.unwrap_or(false);
|
||||
// waveform/loudness readiness is gated on a known content_hash (md5_16kb,
|
||||
// populated by E2) and probed via the analysis-readiness port — exact key
|
||||
// then legacy '' fallback, no re-tag. Absent port or hash ⇒ not ready.
|
||||
// populated by E2) and probed via the analysis-readiness port. Absent
|
||||
// port or hash ⇒ not ready.
|
||||
let (waveform_ready, loudness_ready) =
|
||||
match row.content_hash.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(md5) => app
|
||||
@@ -758,6 +834,9 @@ async fn library_sync_start_inner(
|
||||
&format!("sync task panicked: {join_err}"),
|
||||
),
|
||||
};
|
||||
if let Some(runtime) = app_for_emit.try_state::<LibraryRuntime>() {
|
||||
let _ = runtime.store.checkpoint_wal("sync.checkpoint");
|
||||
}
|
||||
let _ = app_for_emit.emit(LibrarySyncProgressPayload::IDLE_EVENT_NAME, &outcome);
|
||||
|
||||
// Clear the slot only if it still names us — sync_start may
|
||||
@@ -1117,6 +1196,17 @@ pub fn library_purge_server(
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_migrate_server_index_keys(
|
||||
_runtime: State<'_, LibraryRuntime>,
|
||||
mappings: Vec<LibraryServerKeyMigrationDto>,
|
||||
) -> Result<(), String> {
|
||||
for mapping in mappings {
|
||||
let _ = (mapping.legacy_id, mapping.index_key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_delete_server_data(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
pub(crate) mod bulk_ingest;
|
||||
pub mod advanced_search;
|
||||
mod advanced_search_mood;
|
||||
pub mod analysis_backfill;
|
||||
pub mod canonical;
|
||||
pub mod commands;
|
||||
pub mod cross_server;
|
||||
|
||||
@@ -616,7 +616,10 @@ mod tests {
|
||||
use std::time::Instant;
|
||||
|
||||
let path: PathBuf = std::env::var("HOME")
|
||||
.map(|h| PathBuf::from(h).join(".local/share/dev.psysonic.player/library.sqlite"))
|
||||
.map(|h| {
|
||||
PathBuf::from(h)
|
||||
.join(".local/share/dev.psysonic.player/databases/library/library.sqlite")
|
||||
})
|
||||
.expect("HOME");
|
||||
if !path.exists() {
|
||||
eprintln!("skip: no db at {}", path.display());
|
||||
|
||||
@@ -270,6 +270,74 @@ impl<'a> TrackRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Keyset page of track ids for cursor-based library scans (`id ASC`).
|
||||
pub fn list_track_ids_after(
|
||||
&self,
|
||||
server_id: &str,
|
||||
after_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if limit == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let limit = i64::try_from(limit).map_err(|e| e.to_string())?;
|
||||
self.store.with_read_conn(|conn| {
|
||||
let sql = "SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 \
|
||||
AND (?2 IS NULL OR id > ?2) \
|
||||
ORDER BY id ASC LIMIT ?3";
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map(params![server_id, after_id, limit], |row| row.get(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()
|
||||
})
|
||||
}
|
||||
|
||||
/// Cheap SQL prefilter: tracks that never received a playback hash and/or
|
||||
/// lack an oximedia BPM fact. Full analysis gaps are confirmed per id via
|
||||
/// [`TrackAnalysisNeedsWorkQuery`] in the shell crate.
|
||||
pub fn list_analysis_candidate_ids_after(
|
||||
&self,
|
||||
server_id: &str,
|
||||
after_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if limit == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let limit = i64::try_from(limit).map_err(|e| e.to_string())?;
|
||||
self.store.with_read_conn(|conn| {
|
||||
let sql = "SELECT t.id FROM track t \
|
||||
WHERE t.server_id = ?1 AND t.deleted = 0 \
|
||||
AND (?2 IS NULL OR t.id > ?2) \
|
||||
AND ( \
|
||||
t.content_hash IS NULL \
|
||||
OR NOT EXISTS ( \
|
||||
SELECT 1 FROM track_fact f \
|
||||
WHERE f.server_id = t.server_id \
|
||||
AND f.track_id = t.id \
|
||||
AND f.fact_kind = 'bpm' \
|
||||
AND f.source_kind = 'analysis' \
|
||||
) \
|
||||
) \
|
||||
ORDER BY t.id ASC LIMIT ?3";
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map(params![server_id, after_id, limit], |row| row.get(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()
|
||||
})
|
||||
}
|
||||
|
||||
/// Count non-deleted tracks for a server (analysis progress baseline).
|
||||
pub fn count_live_tracks(&self, server_id: &str) -> Result<i64, String> {
|
||||
self.store.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE server_id = ?1 AND deleted = 0",
|
||||
params![server_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Batch upsert with optional §6.9 id-remap detection. When
|
||||
/// `unstable_track_ids` is `true`, each incoming row is checked
|
||||
/// against the existing `track` table for a collision via
|
||||
@@ -1151,4 +1219,41 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_track_ids_after_pages_in_id_order() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = TrackRepository::new(&store);
|
||||
for id in ["a1", "b2", "c3"] {
|
||||
let mut r = row("s1", id, id);
|
||||
r.content_hash = None;
|
||||
repo.upsert_batch(&[r]).unwrap();
|
||||
}
|
||||
let first = repo.list_track_ids_after("s1", None, 2).unwrap();
|
||||
assert_eq!(first, vec!["a1", "b2"]);
|
||||
let second = repo.list_track_ids_after("s1", Some("b2"), 2).unwrap();
|
||||
assert_eq!(second, vec!["c3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_analysis_candidate_ids_skips_tracks_with_bpm_fact() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = TrackRepository::new(&store);
|
||||
let mut needs = row("s1", "needs", "Needs");
|
||||
needs.content_hash = None;
|
||||
repo.upsert_batch(&[needs, row("s1", "done", "Done")]).unwrap();
|
||||
store
|
||||
.with_conn_mut("misc", |c| {
|
||||
c.execute(
|
||||
"INSERT INTO track_fact (server_id, track_id, fact_kind, source_kind, source_id, confidence, fetched_at) \
|
||||
VALUES ('s1', 'done', 'bpm', 'analysis', 'oximedia-60s-center', 1.0, 1)",
|
||||
[],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let ids = repo
|
||||
.list_analysis_candidate_ids_after("s1", None, 10)
|
||||
.unwrap();
|
||||
assert_eq!(ids, vec!["needs"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,21 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::analysis_backfill::LibraryAnalysisProgressDto;
|
||||
use crate::store::LibraryStore;
|
||||
use crate::sync::bandwidth::PlaybackHint;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnalysisProgressCacheEntry {
|
||||
pub value: LibraryAnalysisProgressDto,
|
||||
pub updated_at: Instant,
|
||||
pub in_flight: bool,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -67,6 +76,8 @@ pub struct LibraryRuntime {
|
||||
/// Latest `library_live_search` epoch from the UI — stale commands
|
||||
/// skip FTS when a newer keystroke generation was registered.
|
||||
live_search_epoch: AtomicU64,
|
||||
/// Cached analysis progress snapshots keyed by server id.
|
||||
analysis_progress_cache: Mutex<HashMap<String, AnalysisProgressCacheEntry>>,
|
||||
}
|
||||
|
||||
impl LibraryRuntime {
|
||||
@@ -78,6 +89,7 @@ impl LibraryRuntime {
|
||||
current_job: Mutex::new(None),
|
||||
scheduler_cancel: Arc::new(AtomicBool::new(false)),
|
||||
live_search_epoch: AtomicU64::new(0),
|
||||
analysis_progress_cache: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +174,71 @@ impl LibraryRuntime {
|
||||
*h = hint;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analysis_progress_snapshot(
|
||||
&self,
|
||||
server_id: &str,
|
||||
) -> Option<AnalysisProgressCacheEntry> {
|
||||
self.analysis_progress_cache
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|cache| cache.get(server_id).cloned())
|
||||
}
|
||||
|
||||
pub fn mark_analysis_progress_in_flight(&self, server_id: &str) -> bool {
|
||||
if let Ok(mut cache) = self.analysis_progress_cache.lock() {
|
||||
match cache.get_mut(server_id) {
|
||||
Some(entry) => {
|
||||
if entry.in_flight {
|
||||
return false;
|
||||
}
|
||||
entry.in_flight = true;
|
||||
return true;
|
||||
}
|
||||
None => {
|
||||
cache.insert(
|
||||
server_id.to_string(),
|
||||
AnalysisProgressCacheEntry {
|
||||
value: LibraryAnalysisProgressDto {
|
||||
total_tracks: 0,
|
||||
pending_tracks: 0,
|
||||
done_tracks: 0,
|
||||
},
|
||||
updated_at: Instant::now() - Duration::from_secs(60),
|
||||
in_flight: true,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
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(),
|
||||
AnalysisProgressCacheEntry {
|
||||
value,
|
||||
updated_at: Instant::now(),
|
||||
in_flight: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_analysis_progress_in_flight(&self, server_id: &str) {
|
||||
if let Ok(mut cache) = self.analysis_progress_cache.lock() {
|
||||
if let Some(entry) = cache.get_mut(server_id) {
|
||||
entry.in_flight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{fs, io};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
@@ -89,6 +90,7 @@ impl LibraryStore {
|
||||
let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?;
|
||||
configure_write_connection(&write_conn).map_err(|e| e.to_string())?;
|
||||
run_migrations(&write_conn).map_err(|e| e.to_string())?;
|
||||
checkpoint_wal_conn(&write_conn, "open").map_err(|e| e.to_string())?;
|
||||
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
configure_read_connection(&read_conn).map_err(|e| e.to_string())?;
|
||||
@@ -179,6 +181,108 @@ impl LibraryStore {
|
||||
log_write_op(op, lock_wait_ms as u128, exec_ms as u128);
|
||||
Ok((out, WriteOpTiming { lock_wait_ms, exec_ms }))
|
||||
}
|
||||
|
||||
pub(crate) fn checkpoint_wal(&self, op: &'static str) -> Result<(), String> {
|
||||
self.with_conn_mut(op, |conn| {
|
||||
checkpoint_wal_conn(conn, op)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically switch the active sqlite file while replacing long-lived
|
||||
/// write/read connections under the same locks so no command can keep
|
||||
/// writing to the old inode after the swap.
|
||||
pub fn swap_database_file(
|
||||
&self,
|
||||
active_path: &Path,
|
||||
destination_path: &Path,
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
if !destination_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut write_conn = self
|
||||
.write_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store write lock poisoned".to_string())?;
|
||||
let mut read_conn = self
|
||||
.read_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store read lock poisoned".to_string())?;
|
||||
|
||||
let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
let old_write = std::mem::replace(&mut *write_conn, write_tmp);
|
||||
let old_read = std::mem::replace(&mut *read_conn, read_tmp);
|
||||
drop(old_write);
|
||||
drop(old_read);
|
||||
|
||||
let backup = active_path.with_file_name(format!(
|
||||
"{}.backup-pre-indexkey",
|
||||
active_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("library.sqlite")
|
||||
));
|
||||
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 reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
|
||||
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
|
||||
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
|
||||
*write_conn = reopened_write;
|
||||
*read_conn = reopened_read;
|
||||
Ok(Some(backup))
|
||||
}
|
||||
|
||||
pub fn restore_database_backup(&self, backup_path: &Path, active_path: &Path) -> Result<(), String> {
|
||||
let mut write_conn = self
|
||||
.write_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store write lock poisoned".to_string())?;
|
||||
let mut read_conn = self
|
||||
.read_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store read lock poisoned".to_string())?;
|
||||
|
||||
let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
let old_write = std::mem::replace(&mut *write_conn, write_tmp);
|
||||
let old_read = std::mem::replace(&mut *read_conn, read_tmp);
|
||||
drop(old_write);
|
||||
drop(old_read);
|
||||
|
||||
if active_path.exists() {
|
||||
remove_db_with_sidecars(active_path)?;
|
||||
}
|
||||
if backup_path.exists() {
|
||||
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 reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
|
||||
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
|
||||
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
|
||||
*write_conn = reopened_write;
|
||||
*read_conn = reopened_read;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Timing split returned to ingest progress (DevTools / terminal).
|
||||
@@ -206,7 +310,91 @@ fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) {
|
||||
|
||||
fn library_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
Ok(base.join("library.sqlite"))
|
||||
let db_dir = base.join("databases").join("library");
|
||||
let db_path = db_dir.join("library.sqlite");
|
||||
let legacy = base.join("library.sqlite");
|
||||
if let Some(parent) = db_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if db_path.exists() {
|
||||
cleanup_legacy_db_if_present(&legacy, &db_path)?;
|
||||
return Ok(db_path);
|
||||
}
|
||||
|
||||
if legacy.exists() {
|
||||
migrate_db_file(&legacy, &db_path).map_err(|e| e.to_string())?;
|
||||
migrate_db_sidecar(&legacy, &db_path, "-wal").map_err(|e| e.to_string())?;
|
||||
migrate_db_sidecar(&legacy, &db_path, "-shm").map_err(|e| e.to_string())?;
|
||||
}
|
||||
cleanup_legacy_db_if_present(&legacy, &db_path)?;
|
||||
|
||||
Ok(db_path)
|
||||
}
|
||||
|
||||
fn cleanup_legacy_db_if_present(legacy_path: &Path, active_path: &Path) -> Result<(), String> {
|
||||
if legacy_path == active_path {
|
||||
return Ok(());
|
||||
}
|
||||
remove_db_with_sidecars(legacy_path)
|
||||
}
|
||||
|
||||
fn migrate_db_file(from: &Path, to: &Path) -> io::Result<()> {
|
||||
if let Some(parent) = to.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
match fs::rename(from, to) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => {
|
||||
fs::copy(from, to)?;
|
||||
fs::remove_file(from)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_db_sidecar(from: &Path, to: &Path, suffix: &str) -> io::Result<()> {
|
||||
let from_path = PathBuf::from(format!("{}{}", from.display(), suffix));
|
||||
if !from_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let to_path = PathBuf::from(format!("{}{}", to.display(), suffix));
|
||||
if let Some(parent) = to_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
match fs::rename(&from_path, &to_path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => {
|
||||
fs::copy(&from_path, &to_path)?;
|
||||
fs::remove_file(&from_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_sidecar(from_base: &Path, to_base: &Path, suffix: &str) -> Result<(), String> {
|
||||
let from = PathBuf::from(format!("{}{}", from_base.display(), suffix));
|
||||
if !from.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let to = PathBuf::from(format!("{}{}", to_base.display(), suffix));
|
||||
if let Some(parent) = to.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::rename(from, to).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn remove_db_with_sidecars(path: &Path) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let sidecar = PathBuf::from(format!("{}{}", path.display(), suffix));
|
||||
if sidecar.exists() {
|
||||
fs::remove_file(sidecar).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_write_connection(conn: &Connection) -> rusqlite::Result<()> {
|
||||
@@ -226,6 +414,19 @@ fn configure_read_connection(conn: &Connection) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checkpoint_wal_conn(conn: &Connection, op: &str) -> rusqlite::Result<()> {
|
||||
let (busy, log, checkpointed): (i32, i32, i32) =
|
||||
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})?;
|
||||
if busy != 0 {
|
||||
crate::app_eprintln!(
|
||||
"[library-db] wal checkpoint busy op={op} busy={busy} log={log} checkpointed={checkpointed}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_migrations(conn: &Connection) -> rusqlite::Result<MigrationOutcome> {
|
||||
run_migrations_with(
|
||||
conn,
|
||||
|
||||
+26
-8
@@ -1,4 +1,4 @@
|
||||
use psysonic_analysis::analysis_runtime::enqueue_track_analysis;
|
||||
use psysonic_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority};
|
||||
use psysonic_audio as audio;
|
||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||
|
||||
@@ -43,7 +43,14 @@ pub async fn download_track_hot_cache(
|
||||
let sid = server_id.clone();
|
||||
let fp = file_path.clone();
|
||||
tokio::spawn(async move {
|
||||
enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await;
|
||||
enqueue_analysis_seed_from_file(
|
||||
&app_seed,
|
||||
&sid,
|
||||
&tid,
|
||||
&fp,
|
||||
Some(AnalysisBackfillPriority::Middle),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
return Ok(HotCacheDownloadResult {
|
||||
path: path_str,
|
||||
@@ -83,7 +90,14 @@ pub async fn download_track_hot_cache(
|
||||
let sid = server_id.clone();
|
||||
let fp = file_path.clone();
|
||||
tokio::spawn(async move {
|
||||
enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await;
|
||||
enqueue_analysis_seed_from_file(
|
||||
&app_seed,
|
||||
&sid,
|
||||
&tid,
|
||||
&fp,
|
||||
Some(AnalysisBackfillPriority::Middle),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let size = tokio::fs::metadata(&file_path)
|
||||
@@ -139,7 +153,7 @@ pub async fn promote_stream_cache_to_hot_cache(
|
||||
let sid = server_id.clone();
|
||||
let fp = file_path.clone();
|
||||
tokio::spawn(async move {
|
||||
enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await;
|
||||
enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp, None).await;
|
||||
});
|
||||
return Ok(Some(HotCacheDownloadResult { path: path_str, size }));
|
||||
}
|
||||
@@ -154,9 +168,13 @@ pub async fn promote_stream_cache_to_hot_cache(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let high =
|
||||
psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(&app, &track_id);
|
||||
let _ = enqueue_track_analysis(&app, &server_id, &track_id, &bytes, high).await;
|
||||
let priority = psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority(
|
||||
&app,
|
||||
&server_id,
|
||||
&track_id,
|
||||
None,
|
||||
);
|
||||
let _ = enqueue_track_analysis(&app, &server_id, &track_id, &bytes, priority).await;
|
||||
|
||||
let size = tokio::fs::metadata(&file_path)
|
||||
.await
|
||||
@@ -184,7 +202,7 @@ pub async fn promote_stream_cache_to_hot_cache(
|
||||
let sid = server_id.clone();
|
||||
let fp = file_path.clone();
|
||||
tokio::spawn(async move {
|
||||
enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp).await;
|
||||
enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp, None).await;
|
||||
});
|
||||
let size = tokio::fs::metadata(&file_path)
|
||||
.await
|
||||
|
||||
+6
-4
@@ -4,7 +4,8 @@ use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
|
||||
use psysonic_analysis::analysis_runtime::{
|
||||
analysis_backfill_is_current_track, enqueue_track_analysis_from_file,
|
||||
analysis_backfill_resolve_priority, enqueue_track_analysis_from_file,
|
||||
AnalysisBackfillPriority,
|
||||
};
|
||||
use crate::{offline_cancel_flags, DownloadSemaphore};
|
||||
|
||||
@@ -17,9 +18,10 @@ pub async fn enqueue_analysis_seed_from_file(
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
file_path: &std::path::Path,
|
||||
explicit_priority: Option<AnalysisBackfillPriority>,
|
||||
) {
|
||||
let high = analysis_backfill_is_current_track(app, track_id);
|
||||
let _ = enqueue_track_analysis_from_file(app, server_id, track_id, file_path, high).await;
|
||||
let priority = analysis_backfill_resolve_priority(app, server_id, track_id, explicit_priority);
|
||||
let _ = enqueue_track_analysis_from_file(app, server_id, track_id, file_path, priority).await;
|
||||
}
|
||||
|
||||
/// AppHandle-free download primitive: ensures `cache_dir` exists, returns
|
||||
@@ -137,7 +139,7 @@ pub async fn download_track_offline(
|
||||
)
|
||||
.await?;
|
||||
|
||||
enqueue_analysis_seed_from_file(&app, &server_id, &track_id, &final_path).await;
|
||||
enqueue_analysis_seed_from_file(&app, &server_id, &track_id, &final_path, None).await;
|
||||
|
||||
Ok(path_str)
|
||||
}
|
||||
|
||||
@@ -218,6 +218,8 @@ pub fn run() {
|
||||
app.manage(handle);
|
||||
}
|
||||
|
||||
app.manage(psysonic_analysis::analysis_runtime::PlaybackPriorityHints::default());
|
||||
|
||||
// ── Content-hash sink (analysis → library E2 back-edge) ───────
|
||||
// After a seed the analysis pipeline records the playback-derived
|
||||
// md5_16kb as `track.content_hash` so id-remap can rebind a track
|
||||
@@ -274,6 +276,22 @@ pub fn run() {
|
||||
app.manage(query);
|
||||
}
|
||||
|
||||
// ── Analysis needs-work probe (library → analysis batch scan) ──
|
||||
{
|
||||
use psysonic_core::ports::TrackAnalysisNeedsWorkQuery;
|
||||
let app_for_needs_work = app.handle().clone();
|
||||
let needs_work = TrackAnalysisNeedsWorkQuery::new(
|
||||
move |server_id: &str, track_id: &str| {
|
||||
psysonic_analysis::analysis_runtime::track_analysis_needs_work(
|
||||
&app_for_needs_work,
|
||||
server_id,
|
||||
track_id,
|
||||
)
|
||||
},
|
||||
);
|
||||
app.manage(needs_work);
|
||||
}
|
||||
|
||||
// ── Track enrichment port (analysis → library facts) ───────────
|
||||
{
|
||||
use psysonic_core::track_enrichment::{TrackEnrichmentPlan, TrackEnrichmentPort};
|
||||
@@ -545,6 +563,12 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
backup_export_library_db,
|
||||
backup_import_library_db,
|
||||
backup_export_full,
|
||||
backup_import_full,
|
||||
migration_inspect,
|
||||
migration_run,
|
||||
psysonic_syncfs::sync::batch::calculate_sync_payload,
|
||||
exit_app,
|
||||
cli_publish_player_snapshot,
|
||||
@@ -636,7 +660,13 @@ pub fn run() {
|
||||
psysonic_analysis::commands::analysis_delete_loudness_for_track,
|
||||
psysonic_analysis::commands::analysis_delete_waveform_for_track,
|
||||
psysonic_analysis::commands::analysis_delete_all_waveforms,
|
||||
psysonic_analysis::commands::analysis_delete_all_for_server,
|
||||
psysonic_analysis::commands::analysis_migrate_server_index_keys,
|
||||
psysonic_analysis::commands::analysis_enqueue_seed_from_url,
|
||||
psysonic_analysis::commands::analysis_set_playback_priority_hints,
|
||||
psysonic_analysis::commands::analysis_set_pipeline_parallelism,
|
||||
psysonic_analysis::commands::analysis_get_pipeline_queue_stats,
|
||||
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_search,
|
||||
@@ -649,6 +679,7 @@ pub fn run() {
|
||||
psysonic_library::commands::library_get_artifact,
|
||||
psysonic_library::commands::library_get_facts,
|
||||
psysonic_library::commands::library_get_offline_path,
|
||||
psysonic_library::commands::library_analysis_progress,
|
||||
psysonic_library::commands::library_sync_bind_session,
|
||||
psysonic_library::commands::library_sync_clear_session,
|
||||
psysonic_library::commands::library_set_playback_hint,
|
||||
@@ -666,7 +697,9 @@ pub fn run() {
|
||||
psysonic_library::commands::library_get_player_stats_year_bounds,
|
||||
psysonic_library::commands::library_get_player_stats_recent_days,
|
||||
psysonic_library::commands::library_purge_server,
|
||||
psysonic_library::commands::library_migrate_server_index_keys,
|
||||
psysonic_library::commands::library_delete_server_data,
|
||||
psysonic_library::commands::library_analysis_backfill_batch,
|
||||
psysonic_syncfs::cache::offline::download_track_offline,
|
||||
psysonic_syncfs::cache::offline::cancel_offline_downloads,
|
||||
psysonic_syncfs::cache::offline::clear_offline_cancel,
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use zip::write::FileOptions;
|
||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||
|
||||
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;
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct FullBackupPayload {
|
||||
version: u64,
|
||||
app_version: String,
|
||||
stores: Value,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn backup_export_library_db(
|
||||
app: AppHandle,
|
||||
destination_path: String,
|
||||
) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
backup_export_library_db_blocking(&app, destination_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_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())?;
|
||||
}
|
||||
let source_library = library_db_path(app)?;
|
||||
let source_analysis = analysis_db_path(app)?;
|
||||
if !source_library.exists() {
|
||||
return Err("library database does not exist".to_string());
|
||||
}
|
||||
if !source_analysis.exists() {
|
||||
return Err("analysis database does not exist".to_string());
|
||||
}
|
||||
remove_if_exists(&destination)?;
|
||||
|
||||
let snapshot_library_tmp = source_library.with_file_name("library-export.sqlite");
|
||||
let snapshot_analysis_tmp = source_analysis.with_file_name("audio-analysis-export.sqlite");
|
||||
remove_db_with_sidecars(&snapshot_library_tmp)?;
|
||||
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,
|
||||
);
|
||||
remove_db_with_sidecars(&snapshot_library_tmp).ok();
|
||||
remove_db_with_sidecars(&snapshot_analysis_tmp).ok();
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn backup_import_library_db_blocking(app: &AppHandle, source_path: String) -> Result<(), String> {
|
||||
let source = PathBuf::from(source_path);
|
||||
if !source.exists() {
|
||||
return Err("backup file not found".to_string());
|
||||
}
|
||||
|
||||
let active_library = library_db_path(app)?;
|
||||
let active_analysis = analysis_db_path(app)?;
|
||||
let import_library_tmp = active_library.with_file_name("library-import.sqlite");
|
||||
let import_analysis_tmp = active_analysis.with_file_name("audio-analysis-import.sqlite");
|
||||
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_sqlite_file(&import_library_tmp)?;
|
||||
validate_sqlite_file(&import_analysis_tmp)?;
|
||||
import_databases_from_sqlite(app, &import_library_tmp, &import_analysis_tmp)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn backup_export_full(
|
||||
app: AppHandle,
|
||||
destination_path: String,
|
||||
stores: Value,
|
||||
app_version: String,
|
||||
) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
backup_export_full_blocking(&app, destination_path, stores, app_version)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn backup_export_full_blocking(
|
||||
app: &AppHandle,
|
||||
destination_path: String,
|
||||
stores: Value,
|
||||
app_version: String,
|
||||
) -> Result<(), String> {
|
||||
if !stores.is_object() {
|
||||
return Err("stores payload must be an object".to_string());
|
||||
}
|
||||
let destination = PathBuf::from(destination_path);
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
remove_if_exists(&destination)?;
|
||||
|
||||
let source_library = library_db_path(app)?;
|
||||
let source_analysis = analysis_db_path(app)?;
|
||||
if !source_library.exists() {
|
||||
return Err("library database does not exist".to_string());
|
||||
}
|
||||
if !source_analysis.exists() {
|
||||
return Err("analysis database does not exist".to_string());
|
||||
}
|
||||
let snapshot_library_tmp = source_library.with_file_name("library-export.sqlite");
|
||||
let snapshot_analysis_tmp = source_analysis.with_file_name("audio-analysis-export.sqlite");
|
||||
remove_db_with_sidecars(&snapshot_library_tmp)?;
|
||||
remove_db_with_sidecars(&snapshot_analysis_tmp)?;
|
||||
vacuum_copy(&source_library, &snapshot_library_tmp)?;
|
||||
vacuum_copy(&source_analysis, &snapshot_analysis_tmp)?;
|
||||
|
||||
let payload = FullBackupPayload {
|
||||
version: FULL_ARCHIVE_VERSION,
|
||||
app_version,
|
||||
stores,
|
||||
};
|
||||
let result = write_full_archive(
|
||||
&snapshot_library_tmp,
|
||||
&snapshot_analysis_tmp,
|
||||
&destination,
|
||||
&payload,
|
||||
);
|
||||
remove_db_with_sidecars(&snapshot_library_tmp).ok();
|
||||
remove_db_with_sidecars(&snapshot_analysis_tmp).ok();
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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())?
|
||||
}
|
||||
|
||||
fn backup_import_full_blocking(app: &AppHandle, source_path: String) -> Result<Value, String> {
|
||||
let source = PathBuf::from(source_path);
|
||||
if !source.exists() {
|
||||
return Err("backup file not found".to_string());
|
||||
}
|
||||
|
||||
let active_library = library_db_path(app)?;
|
||||
let active_analysis = analysis_db_path(app)?;
|
||||
let import_library_tmp = active_library.with_file_name("library-import.sqlite");
|
||||
let import_analysis_tmp = active_analysis.with_file_name("audio-analysis-import.sqlite");
|
||||
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_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();
|
||||
remove_db_with_sidecars(&import_analysis_tmp).ok();
|
||||
return Err("backup payload stores must be an object".to_string());
|
||||
}
|
||||
|
||||
import_databases_from_sqlite(app, &import_library_tmp, &import_analysis_tmp)?;
|
||||
Ok(stores)
|
||||
}
|
||||
|
||||
fn library_db_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let dir = base.join("databases").join("library");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir.join("library.sqlite"))
|
||||
}
|
||||
|
||||
fn analysis_db_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let dir = base.join("databases").join("analysis");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir.join("audio-analysis.sqlite"))
|
||||
}
|
||||
|
||||
fn vacuum_copy(source: &Path, destination: &Path) -> Result<(), String> {
|
||||
let conn = Connection::open_with_flags(source, OpenFlags::SQLITE_OPEN_READ_WRITE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let escaped = destination.to_string_lossy().replace('\'', "''");
|
||||
let sql = format!("VACUUM INTO '{escaped}';");
|
||||
conn.execute_batch(&sql).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn write_databases_archive(
|
||||
library_snapshot: &Path,
|
||||
analysis_snapshot: &Path,
|
||||
destination_archive: &Path,
|
||||
) -> Result<(), String> {
|
||||
let file = fs::File::create(destination_archive).map_err(|e| e.to_string())?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = FileOptions::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())?;
|
||||
io::copy(&mut src, &mut zip).map_err(|e| e.to_string())?;
|
||||
zip.start_file(ANALYSIS_ARCHIVE_ENTRY, options)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut analysis_src = fs::File::open(analysis_snapshot).map_err(|e| e.to_string())?;
|
||||
io::copy(&mut analysis_src, &mut zip).map_err(|e| e.to_string())?;
|
||||
zip.finish().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_full_archive(
|
||||
library_snapshot: &Path,
|
||||
analysis_snapshot: &Path,
|
||||
destination_archive: &Path,
|
||||
payload: &FullBackupPayload,
|
||||
) -> Result<(), String> {
|
||||
let file = fs::File::create(destination_archive).map_err(|e| e.to_string())?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = FileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
|
||||
zip.start_file(FULL_ARCHIVE_SETTINGS_ENTRY, options)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let settings = serde_json::to_vec_pretty(payload).map_err(|e| e.to_string())?;
|
||||
zip.write_all(&settings).map_err(|e| e.to_string())?;
|
||||
|
||||
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())?;
|
||||
io::copy(&mut src, &mut zip).map_err(|e| e.to_string())?;
|
||||
|
||||
zip.start_file(ANALYSIS_ARCHIVE_ENTRY, options)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut analysis_src = fs::File::open(analysis_snapshot).map_err(|e| e.to_string())?;
|
||||
io::copy(&mut analysis_src, &mut zip).map_err(|e| e.to_string())?;
|
||||
zip.finish().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_databases_archive(
|
||||
source_archive: &Path,
|
||||
library_destination_sqlite: &Path,
|
||||
analysis_destination_sqlite: &Path,
|
||||
) -> Result<(), String> {
|
||||
let file = fs::File::open(source_archive).map_err(|e| e.to_string())?;
|
||||
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
let library_target_index = archive
|
||||
.file_names()
|
||||
.enumerate()
|
||||
.find_map(|(i, name)| (name == LIBRARY_ARCHIVE_ENTRY).then_some(i))
|
||||
.ok_or_else(|| "archive does not contain library.sqlite".to_string())?;
|
||||
let analysis_target_index = archive
|
||||
.file_names()
|
||||
.enumerate()
|
||||
.find_map(|(i, name)| (name == ANALYSIS_ARCHIVE_ENTRY).then_some(i))
|
||||
.ok_or_else(|| "archive does not contain audio-analysis.sqlite".to_string())?;
|
||||
|
||||
if let Some(parent) = library_destination_sqlite.parent() {
|
||||
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 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())?;
|
||||
}
|
||||
|
||||
if let Some(parent) = analysis_destination_sqlite.parent() {
|
||||
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())?;
|
||||
io::copy(&mut analysis_entry, &mut analysis_out).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_full_archive(
|
||||
source_archive: &Path,
|
||||
library_destination_sqlite: &Path,
|
||||
analysis_destination_sqlite: &Path,
|
||||
) -> Result<FullBackupPayload, String> {
|
||||
let file = fs::File::open(source_archive).map_err(|e| e.to_string())?;
|
||||
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
|
||||
let payload = {
|
||||
let mut entry = archive
|
||||
.by_name(FULL_ARCHIVE_SETTINGS_ENTRY)
|
||||
.map_err(|_| "archive does not contain settings.json".to_string())?;
|
||||
let mut buf = Vec::new();
|
||||
io::copy(&mut entry, &mut buf).map_err(|e| e.to_string())?;
|
||||
serde_json::from_slice::<FullBackupPayload>(&buf).map_err(|e| e.to_string())?
|
||||
};
|
||||
if payload.version != FULL_ARCHIVE_VERSION {
|
||||
return Err("unsupported full backup version".to_string());
|
||||
}
|
||||
|
||||
extract_databases_archive(
|
||||
source_archive,
|
||||
library_destination_sqlite,
|
||||
analysis_destination_sqlite,
|
||||
)?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn validate_sqlite_file(path: &Path) -> Result<(), String> {
|
||||
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let integrity: String = conn
|
||||
.query_row("PRAGMA integrity_check;", [], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if integrity != "ok" {
|
||||
return Err("backup file integrity check failed".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 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(
|
||||
app: &AppHandle,
|
||||
import_library_tmp: &Path,
|
||||
import_analysis_tmp: &Path,
|
||||
) -> Result<(), String> {
|
||||
let active_path = library_db_path(app)?;
|
||||
let analysis_active_path = analysis_db_path(app)?;
|
||||
let Some(runtime) = app.try_state::<psysonic_library::LibraryRuntime>() else {
|
||||
remove_db_with_sidecars(import_library_tmp).ok();
|
||||
remove_db_with_sidecars(import_analysis_tmp).ok();
|
||||
return Err("library runtime unavailable".to_string());
|
||||
};
|
||||
let Some(cache) = app.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>() else {
|
||||
remove_db_with_sidecars(import_library_tmp).ok();
|
||||
remove_db_with_sidecars(import_analysis_tmp).ok();
|
||||
return Err("analysis runtime unavailable".to_string());
|
||||
};
|
||||
|
||||
let library_backup = runtime
|
||||
.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) {
|
||||
Ok(Some(backup)) => backup,
|
||||
Ok(None) => {
|
||||
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) => {
|
||||
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(err);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
let _ = remove_db_with_sidecars(import_library_tmp);
|
||||
let _ = remove_db_with_sidecars(import_analysis_tmp);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let library_bak_path = active_path.with_file_name("library.sqlite.import.bak");
|
||||
remove_db_with_sidecars(&library_bak_path).ok();
|
||||
if library_backup.exists() {
|
||||
fs::rename(&library_backup, &library_bak_path).map_err(|e| e.to_string())?;
|
||||
move_sidecar(&library_backup, &library_bak_path, "-wal")?;
|
||||
move_sidecar(&library_backup, &library_bak_path, "-shm")?;
|
||||
}
|
||||
|
||||
let analysis_bak_path = analysis_active_path.with_file_name("audio-analysis.sqlite.import.bak");
|
||||
remove_db_with_sidecars(&analysis_bak_path).ok();
|
||||
if analysis_backup.exists() {
|
||||
fs::rename(&analysis_backup, &analysis_bak_path).map_err(|e| e.to_string())?;
|
||||
move_sidecar(&analysis_backup, &analysis_bak_path, "-wal")?;
|
||||
move_sidecar(&analysis_backup, &analysis_bak_path, "-shm")?;
|
||||
}
|
||||
|
||||
remove_db_with_sidecars(import_library_tmp).ok();
|
||||
remove_db_with_sidecars(import_analysis_tmp).ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_db_with_sidecars(path: &Path) -> Result<(), String> {
|
||||
remove_if_exists(path)?;
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let sidecar = PathBuf::from(format!("{}{}", path.to_string_lossy(), suffix));
|
||||
remove_if_exists(&sidecar)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_sidecar(from_base: &Path, to_base: &Path, suffix: &str) -> Result<(), String> {
|
||||
let from = PathBuf::from(format!("{}{}", from_base.display(), suffix));
|
||||
if !from.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let to = PathBuf::from(format!("{}{}", to_base.display(), suffix));
|
||||
if let Some(parent) = to.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::rename(from, to).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn remove_if_exists(path: &Path) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use tauri::Manager;
|
||||
|
||||
use crate::lib_commands::sync::stop_audio_engine;
|
||||
use crate::runtime_subsonic_wire_user_agent;
|
||||
use crate::analysis_cache;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn greet(name: &str) -> String {
|
||||
@@ -10,6 +11,9 @@ pub(crate) fn greet(name: &str) -> String {
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn exit_app(app_handle: tauri::AppHandle) {
|
||||
if let Some(cache) = app_handle.try_state::<analysis_cache::AnalysisCache>() {
|
||||
let _ = cache.checkpoint_wal("exit");
|
||||
}
|
||||
stop_audio_engine(&app_handle);
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use rusqlite::{params_from_iter, Connection, OpenFlags};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
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: &[&str] = &["analysis_track", "waveform_cache", "loudness_cache"];
|
||||
|
||||
fn migration_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerIndexMapping {
|
||||
pub legacy_id: String,
|
||||
pub index_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MigrationScopeInspect {
|
||||
pub total_legacy_rows: u64,
|
||||
pub skipped_unknown_server_rows: u64,
|
||||
pub tables: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MigrationInspectReport {
|
||||
pub needs_migration: bool,
|
||||
pub has_skipped_unknown_server_rows: bool,
|
||||
pub can_run: bool,
|
||||
pub warnings: Vec<String>,
|
||||
pub unmapped_empty_bucket: bool,
|
||||
pub library: MigrationScopeInspect,
|
||||
pub analysis: MigrationScopeInspect,
|
||||
pub mappings: Vec<ServerIndexMapping>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MigrationProgressEvent {
|
||||
pub stage: String,
|
||||
pub table: String,
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MigrationRunScope {
|
||||
pub imported_rows: u64,
|
||||
pub source_rows: u64,
|
||||
pub skipped_unknown_server_rows: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MigrationRunResult {
|
||||
pub library: MigrationRunScope,
|
||||
pub analysis: MigrationRunScope,
|
||||
pub has_skipped_unknown_server_rows: bool,
|
||||
pub switched: bool,
|
||||
pub backup_removed: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn migration_inspect(
|
||||
app: AppHandle,
|
||||
mappings: Vec<ServerIndexMapping>,
|
||||
) -> Result<MigrationInspectReport, String> {
|
||||
inspect_internal(&app, mappings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn migration_run(
|
||||
app: AppHandle,
|
||||
mappings: Vec<ServerIndexMapping>,
|
||||
) -> Result<MigrationRunResult, String> {
|
||||
let _guard = migration_lock()
|
||||
.lock()
|
||||
.map_err(|_| "migration lock poisoned".to_string())?;
|
||||
run_internal(&app, mappings)
|
||||
}
|
||||
|
||||
fn inspect_internal(
|
||||
app: &AppHandle,
|
||||
mappings: Vec<ServerIndexMapping>,
|
||||
) -> Result<MigrationInspectReport, String> {
|
||||
let normalized = normalize_mappings(mappings);
|
||||
let legacy_ids: Vec<String> = normalized.iter().map(|m| m.legacy_id.clone()).collect();
|
||||
let index_keys: Vec<String> = normalized.iter().map(|m| m.index_key.clone()).collect();
|
||||
let paths = migration_paths(app)?;
|
||||
|
||||
let (library_tables, library_total, library_skipped_unknown_rows) = inspect_tables(
|
||||
&paths.library_active,
|
||||
LIBRARY_TABLES,
|
||||
&legacy_ids,
|
||||
&index_keys,
|
||||
)?;
|
||||
let (analysis_tables, mut analysis_total, analysis_skipped_unknown_rows) = inspect_tables(
|
||||
&paths.analysis_active,
|
||||
ANALYSIS_TABLES,
|
||||
&legacy_ids,
|
||||
&index_keys,
|
||||
)?;
|
||||
let mut analysis_tables = analysis_tables;
|
||||
let mut warnings = Vec::new();
|
||||
let mut unmapped_empty_bucket = false;
|
||||
let mut has_empty_bucket_rows = false;
|
||||
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, "")?;
|
||||
if empty_count > 0 {
|
||||
has_empty_bucket_rows = true;
|
||||
if normalized.len() == 1 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
if normalized.len() > 1 && has_empty_bucket_rows {
|
||||
unmapped_empty_bucket = true;
|
||||
warnings.push("analysis empty server bucket kept for multi-server install".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let needs_migration = library_total > 0 || analysis_total > 0;
|
||||
let can_run = !normalized.is_empty();
|
||||
if needs_migration && !can_run {
|
||||
warnings.push("no server mappings available".to_string());
|
||||
}
|
||||
let has_skipped_unknown_server_rows =
|
||||
library_skipped_unknown_rows > 0 || analysis_skipped_unknown_rows > 0;
|
||||
if has_skipped_unknown_server_rows {
|
||||
warnings.push("rows for removed servers were skipped".to_string());
|
||||
}
|
||||
|
||||
Ok(MigrationInspectReport {
|
||||
needs_migration,
|
||||
has_skipped_unknown_server_rows,
|
||||
can_run,
|
||||
warnings,
|
||||
unmapped_empty_bucket,
|
||||
library: MigrationScopeInspect {
|
||||
total_legacy_rows: library_total,
|
||||
skipped_unknown_server_rows: library_skipped_unknown_rows,
|
||||
tables: library_tables,
|
||||
},
|
||||
analysis: MigrationScopeInspect {
|
||||
total_legacy_rows: analysis_total,
|
||||
skipped_unknown_server_rows: analysis_skipped_unknown_rows,
|
||||
tables: analysis_tables,
|
||||
},
|
||||
mappings: normalized,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_internal(app: &AppHandle, mappings: Vec<ServerIndexMapping>) -> Result<MigrationRunResult, String> {
|
||||
let inspect = inspect_internal(app, mappings)?;
|
||||
if !inspect.needs_migration {
|
||||
return Ok(MigrationRunResult {
|
||||
library: MigrationRunScope {
|
||||
imported_rows: 0,
|
||||
source_rows: 0,
|
||||
skipped_unknown_server_rows: inspect.library.skipped_unknown_server_rows,
|
||||
},
|
||||
analysis: MigrationRunScope {
|
||||
imported_rows: 0,
|
||||
source_rows: 0,
|
||||
skipped_unknown_server_rows: inspect.analysis.skipped_unknown_server_rows,
|
||||
},
|
||||
has_skipped_unknown_server_rows: inspect.has_skipped_unknown_server_rows,
|
||||
switched: false,
|
||||
backup_removed: false,
|
||||
});
|
||||
}
|
||||
if !inspect.can_run {
|
||||
return Err("migration requires at least one server mapping".to_string());
|
||||
}
|
||||
|
||||
let paths = migration_paths(app)?;
|
||||
let mappings = inspect.mappings;
|
||||
let single_mapping = if mappings.len() == 1 {
|
||||
Some(mappings[0].index_key.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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) =
|
||||
run_analysis_import(app, &paths, &mappings, single_mapping.as_deref())?;
|
||||
|
||||
let mut backup_removed = false;
|
||||
let mut library_backup: Option<PathBuf> = None;
|
||||
let mut analysis_backup: Option<PathBuf> = None;
|
||||
|
||||
if paths.library_v2.exists() {
|
||||
if let Some(runtime) = app.try_state::<psysonic_library::LibraryRuntime>() {
|
||||
library_backup = runtime
|
||||
.store
|
||||
.swap_database_file(&paths.library_active, &paths.library_v2)?;
|
||||
} else {
|
||||
library_backup = Some(switch_file(&paths.library_active, &paths.library_v2)?);
|
||||
}
|
||||
}
|
||||
if paths.analysis_v2.exists() {
|
||||
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(&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
|
||||
.store
|
||||
.restore_database_backup(backup, &paths.library_active);
|
||||
} else {
|
||||
let _ = restore_backup(backup, &paths.library_active);
|
||||
}
|
||||
}
|
||||
if let Some(ref backup) = analysis_backup {
|
||||
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);
|
||||
}
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Some(backup) = library_backup {
|
||||
remove_db_with_sidecars(&backup)?;
|
||||
backup_removed = true;
|
||||
}
|
||||
if let Some(backup) = analysis_backup {
|
||||
remove_db_with_sidecars(&backup)?;
|
||||
backup_removed = true;
|
||||
}
|
||||
|
||||
Ok(MigrationRunResult {
|
||||
library: MigrationRunScope {
|
||||
imported_rows: library_imported_rows,
|
||||
source_rows: library_source_rows,
|
||||
skipped_unknown_server_rows: library_skipped_unknown_rows,
|
||||
},
|
||||
analysis: MigrationRunScope {
|
||||
imported_rows: analysis_imported_rows,
|
||||
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,
|
||||
switched,
|
||||
backup_removed,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_library_import(
|
||||
app: &AppHandle,
|
||||
paths: &MigrationPaths,
|
||||
mappings: &[ServerIndexMapping],
|
||||
) -> Result<(u64, u64, u64), String> {
|
||||
if !paths.library_active.exists() {
|
||||
return Ok((0, 0, 0));
|
||||
}
|
||||
remove_db_with_sidecars(&paths.library_v2).ok();
|
||||
vacuum_copy(&paths.library_active, &paths.library_v2)?;
|
||||
|
||||
let source = open_readonly(&paths.library_active)?;
|
||||
let dest = Connection::open(&paths.library_v2).map_err(|e| e.to_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();
|
||||
let total = LIBRARY_TABLES.len() as u64;
|
||||
let mut done = 0_u64;
|
||||
with_foreign_keys_disabled(&dest, || {
|
||||
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)?;
|
||||
}
|
||||
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)?;
|
||||
Ok((source_rows, imported_rows, skipped_unknown_server_rows))
|
||||
}
|
||||
|
||||
fn run_analysis_import(
|
||||
app: &AppHandle,
|
||||
paths: &MigrationPaths,
|
||||
mappings: &[ServerIndexMapping],
|
||||
single_mapping: Option<&str>,
|
||||
) -> Result<(u64, u64, u64), String> {
|
||||
if !paths.analysis_active.exists() {
|
||||
return Ok((0, 0, 0));
|
||||
}
|
||||
remove_db_with_sidecars(&paths.analysis_v2).ok();
|
||||
vacuum_copy(&paths.analysis_active, &paths.analysis_v2)?;
|
||||
|
||||
let source = open_readonly(&paths.analysis_active)?;
|
||||
let dest = Connection::open(&paths.analysis_v2).map_err(|e| e.to_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();
|
||||
let total = ANALYSIS_TABLES.len() as u64;
|
||||
let mut done = 0_u64;
|
||||
with_foreign_keys_disabled(&dest, || {
|
||||
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)?;
|
||||
}
|
||||
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)?;
|
||||
Ok((source_rows, imported_rows, skipped_unknown_server_rows))
|
||||
}
|
||||
|
||||
fn normalize_mappings(mappings: Vec<ServerIndexMapping>) -> Vec<ServerIndexMapping> {
|
||||
let mut out: Vec<ServerIndexMapping> = Vec::new();
|
||||
for mapping in mappings {
|
||||
let legacy_id = mapping.legacy_id.trim().to_string();
|
||||
let index_key = mapping.index_key.trim().to_string();
|
||||
if legacy_id.is_empty() || index_key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = out.iter_mut().find(|v| v.legacy_id == legacy_id) {
|
||||
existing.index_key = index_key;
|
||||
} else {
|
||||
out.push(ServerIndexMapping {
|
||||
legacy_id,
|
||||
index_key,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn inspect_tables(
|
||||
db_path: &Path,
|
||||
tables: &[&str],
|
||||
legacy_ids: &[String],
|
||||
known_index_keys: &[String],
|
||||
) -> Result<(HashMap<String, u64>, u64, u64), String> {
|
||||
let mut counts = HashMap::new();
|
||||
if !db_path.exists() {
|
||||
return Ok((counts, 0, 0));
|
||||
}
|
||||
let conn = open_readonly(db_path)?;
|
||||
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;
|
||||
if count > 0 {
|
||||
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;
|
||||
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: &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 {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: &str, value: &str) -> Result<i64, String> {
|
||||
conn.query_row(
|
||||
&format!("SELECT COUNT(*) FROM {table} WHERE server_id = ?1"),
|
||||
[&value],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn count_unknown_rows(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
known_legacy_ids: &[String],
|
||||
known_index_keys: &[String],
|
||||
) -> Result<i64, String> {
|
||||
let known = known_server_ids(known_legacy_ids, known_index_keys);
|
||||
if known.is_empty() {
|
||||
return conn
|
||||
.query_row(
|
||||
&format!("SELECT COUNT(*) FROM {table} WHERE server_id <> ''"),
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
let placeholders = std::iter::repeat_n("?", known.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
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: &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 {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 {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(())
|
||||
}
|
||||
|
||||
fn known_server_ids(known_legacy_ids: &[String], known_index_keys: &[String]) -> Vec<String> {
|
||||
let mut known: Vec<String> = Vec::new();
|
||||
known.extend(known_legacy_ids.iter().cloned());
|
||||
known.extend(known_index_keys.iter().cloned());
|
||||
known.sort();
|
||||
known.dedup();
|
||||
known
|
||||
}
|
||||
|
||||
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}"), [], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
total = total.saturating_add(rows.max(0) as u64);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn sum_unknown_rows(
|
||||
conn: &Connection,
|
||||
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)?;
|
||||
total = total.saturating_add(rows.max(0) as u64);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn with_foreign_keys_disabled<T>(
|
||||
conn: &Connection,
|
||||
operation: impl FnOnce() -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
conn.execute_batch("PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE;")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let result = operation();
|
||||
match result {
|
||||
Ok(value) => {
|
||||
if let Err(err) = conn
|
||||
.execute_batch("COMMIT; PRAGMA foreign_keys = ON;")
|
||||
.map_err(|e| e.to_string())
|
||||
{
|
||||
let _ = conn.execute_batch("ROLLBACK; PRAGMA foreign_keys = ON;");
|
||||
return Err(err);
|
||||
}
|
||||
ensure_foreign_keys_clean(conn)?;
|
||||
Ok(value)
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = conn.execute_batch("ROLLBACK; PRAGMA foreign_keys = ON;");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_foreign_keys_clean(conn: &Connection) -> Result<(), String> {
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
|
||||
if let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
||||
let table: String = row.get(0).map_err(|e| e.to_string())?;
|
||||
let rowid: i64 = row.get(1).map_err(|e| e.to_string())?;
|
||||
let parent: String = row.get(2).map_err(|e| e.to_string())?;
|
||||
let fkid: i64 = row.get(3).map_err(|e| e.to_string())?;
|
||||
return Err(format!(
|
||||
"foreign key check failed table={table} rowid={rowid} parent={parent} fkid={fkid}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, stage: &str, table: &str, done: u64, total: u64) -> Result<(), String> {
|
||||
app.emit(
|
||||
"migration:progress",
|
||||
MigrationProgressEvent {
|
||||
stage: stage.to_string(),
|
||||
table: table.to_string(),
|
||||
done,
|
||||
total,
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn open_readonly(path: &Path) -> Result<Connection, String> {
|
||||
Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn vacuum_copy(source: &Path, destination: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let conn = Connection::open(source).map_err(|e| e.to_string())?;
|
||||
let sql = format!(
|
||||
"VACUUM INTO '{}';",
|
||||
destination.to_string_lossy().replace('\'', "''")
|
||||
);
|
||||
conn.execute_batch(&sql).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn switch_file(active: &Path, destination: &Path) -> Result<PathBuf, String> {
|
||||
let backup = active.with_file_name(format!(
|
||||
"{}.backup-pre-indexkey",
|
||||
active
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("db.sqlite")
|
||||
));
|
||||
remove_db_with_sidecars(&backup).ok();
|
||||
if active.exists() {
|
||||
fs::rename(active, &backup).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::rename(destination, active).map_err(|e| e.to_string())?;
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
fn restore_backup(backup: &Path, active: &Path) -> Result<(), String> {
|
||||
if active.exists() {
|
||||
fs::remove_file(active).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if backup.exists() {
|
||||
fs::rename(backup, active).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn health_check(library_active: &Path, analysis_active: &Path) -> Result<(), String> {
|
||||
if library_active.exists() {
|
||||
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() {
|
||||
let conn = open_readonly(analysis_active)?;
|
||||
conn.query_row("SELECT COUNT(*) FROM analysis_track", [], |_row| Ok(()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_db_with_sidecars(path: &Path) -> Result<(), String> {
|
||||
remove_if_exists(path)?;
|
||||
let wal = PathBuf::from(format!("{}-wal", path.to_string_lossy()));
|
||||
let shm = PathBuf::from(format!("{}-shm", path.to_string_lossy()));
|
||||
remove_if_exists(&wal)?;
|
||||
remove_if_exists(&shm)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_if_exists(path: &Path) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct MigrationPaths {
|
||||
library_active: PathBuf,
|
||||
library_v2: PathBuf,
|
||||
analysis_active: PathBuf,
|
||||
analysis_v2: PathBuf,
|
||||
}
|
||||
|
||||
fn migration_paths(app: &AppHandle) -> Result<MigrationPaths, String> {
|
||||
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let library_dir = base.join("databases").join("library");
|
||||
let analysis_dir = base.join("databases").join("analysis");
|
||||
fs::create_dir_all(&library_dir).map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&analysis_dir).map_err(|e| e.to_string())?;
|
||||
Ok(MigrationPaths {
|
||||
library_active: library_dir.join("library.sqlite"),
|
||||
library_v2: library_dir.join("library-v2.sqlite"),
|
||||
analysis_active: analysis_dir.join("audio-analysis.sqlite"),
|
||||
analysis_v2: analysis_dir.join("analysis-v2.sqlite"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inspect_reports_skipped_unknown_rows() {
|
||||
let conn = Connection::open_in_memory().expect("in-memory sqlite");
|
||||
conn.execute_batch("CREATE TABLE track (server_id TEXT NOT NULL);")
|
||||
.expect("create table");
|
||||
conn.execute("INSERT INTO track(server_id) VALUES (?1)", ["legacy-a"])
|
||||
.expect("insert known legacy");
|
||||
conn.execute("INSERT INTO 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 unknown = count_unknown_rows(&conn, "track", &known_legacy_ids, &known_index_keys)
|
||||
.expect("unknown count");
|
||||
assert_eq!(unknown, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reports_skipped_unknown_rows_without_failure() {
|
||||
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");
|
||||
|
||||
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_track"], &known_legacy_ids, &known_index_keys)
|
||||
.expect("sum unknown rows");
|
||||
assert_eq!(skipped, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_migration_false_when_only_unknown_rows_present() {
|
||||
let conn = Connection::open_in_memory().expect("in-memory sqlite");
|
||||
conn.execute_batch("CREATE TABLE track (server_id TEXT NOT NULL);")
|
||||
.expect("create table");
|
||||
conn.execute("INSERT INTO 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 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_unknown_rows_removes_only_removed_servers() {
|
||||
let conn = Connection::open_in_memory().expect("in-memory sqlite");
|
||||
conn.execute_batch("CREATE TABLE track (server_id TEXT NOT NULL);")
|
||||
.expect("create table");
|
||||
conn.execute("INSERT INTO track(server_id) VALUES (?1)", ["legacy-a"])
|
||||
.expect("insert legacy");
|
||||
conn.execute("INSERT INTO track(server_id) VALUES (?1)", ["idx-a"])
|
||||
.expect("insert index key");
|
||||
conn.execute("INSERT INTO track(server_id) VALUES (?1)", [""])
|
||||
.expect("insert empty bucket");
|
||||
conn.execute("INSERT INTO track(server_id) VALUES (?1)", ["removed-x"])
|
||||
.expect("insert removed server");
|
||||
|
||||
let known_legacy_ids = vec!["legacy-a".to_string()];
|
||||
let known_index_keys = vec!["idx-a".to_string()];
|
||||
purge_unknown_rows(&conn, "track", &known_legacy_ids, &known_index_keys)
|
||||
.expect("purge unknown rows");
|
||||
|
||||
let remaining: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM track", [], |row| row.get(0))
|
||||
.expect("count remaining");
|
||||
let removed_left: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE server_id = 'removed-x'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count removed server rows");
|
||||
assert_eq!(remaining, 3);
|
||||
assert_eq!(removed_left, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
mod backup;
|
||||
mod cli_bridge;
|
||||
mod core;
|
||||
mod integration;
|
||||
mod migration;
|
||||
mod perf;
|
||||
pub(crate) mod platform;
|
||||
|
||||
// Tauri commands re-exported for the lib.rs invoke_handler.
|
||||
pub(crate) use backup::{
|
||||
backup_export_full, backup_export_library_db, backup_import_full, backup_import_library_db,
|
||||
};
|
||||
pub(crate) use cli_bridge::{
|
||||
cli_publish_library_list, cli_publish_player_snapshot, cli_publish_search_results,
|
||||
cli_publish_server_list,
|
||||
@@ -27,6 +32,7 @@ pub(crate) use integration::{
|
||||
check_dir_accessible, mpris_set_metadata, mpris_set_playback, register_global_shortcut,
|
||||
unregister_global_shortcut,
|
||||
};
|
||||
pub(crate) use migration::{migration_inspect, migration_run};
|
||||
|
||||
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown,
|
||||
// and analysis admin commands now live in their domain crates. invoke_handler!
|
||||
|
||||
Reference in New Issue
Block a user