mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user