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:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user