mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
fix(analysis): native library backfill coordinator for advanced strategy (#881)
* feat(analysis): native library backfill coordinator for advanced strategy Move advanced analytics scheduling from the webview loop into a Rust background worker (configure + spawn_blocking batch/enqueue), matching cover backfill. Scan hash+BPM gap tracks instead of the full library; suppress low-priority analysis UI events; limit loudness refresh IPC to the playback window. * fix(analysis): flat backfill configure IPC and restore probe track-perf Use flattened Tauri args like cover backfill so release/prod invoke works; keep emitting analysis:track-perf for library low-priority work so Performance Probe tpm/last-track stats update while waveform/enrichment UI events stay suppressed. * chore(analysis): fix clippy and drop duplicate TS backfill policy Allow too_many_arguments on library_analysis_backfill_configure for CI; remove unused frontend batch API and TS watermark helpers now owned in Rust; clarify analysis_emits_ui_events comment for low-priority track-perf. * docs: CHANGELOG and credits for PR #881 native analysis backfill
This commit is contained in:
@@ -47,6 +47,7 @@ pub fn seed_from_bytes_execute<R: Runtime>(
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
notify_ui: bool,
|
||||
) -> Result<(SeedFromBytesOutcome, AnalysisSeedTimings), String> {
|
||||
let seed_started = Instant::now();
|
||||
let Some(cache) = app.try_state::<AnalysisCache>() else {
|
||||
@@ -84,6 +85,7 @@ pub fn seed_from_bytes_execute<R: Runtime>(
|
||||
server_id,
|
||||
track_id,
|
||||
bytes,
|
||||
notify_ui,
|
||||
);
|
||||
if matches!(enrichment_outcome, TrackEnrichmentOutcome::Failed) {
|
||||
let key = TrackKey {
|
||||
@@ -1233,7 +1235,7 @@ mod tests {
|
||||
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)
|
||||
let (outcome, timings) = seed_from_bytes_execute(&handle, "s", "t", &wav, true)
|
||||
.expect("seed execute should return a graceful skip");
|
||||
assert_eq!(outcome, SeedFromBytesOutcome::SkippedNoAnalysisCache);
|
||||
assert_eq!(timings.seed_ms, 0);
|
||||
@@ -1248,12 +1250,12 @@ mod tests {
|
||||
let handle = app.handle().clone();
|
||||
|
||||
let (first, timings_first) =
|
||||
seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav).unwrap();
|
||||
seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav, true).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();
|
||||
seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav, true).unwrap();
|
||||
assert_eq!(second, SeedFromBytesOutcome::SkippedWaveformCacheHit);
|
||||
assert!(timings_second.seed_ms <= 30_000);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use psysonic_core::ports::PlaybackQueryHandle;
|
||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
|
||||
|
||||
@@ -436,7 +437,14 @@ async fn enqueue_track_analysis_with_fetch(
|
||||
content_hash
|
||||
);
|
||||
let bpm_started = std::time::Instant::now();
|
||||
let outcome = run_track_enrichment_from_bytes(app, server_id, track_id, bytes).await;
|
||||
let outcome = run_track_enrichment_from_bytes(
|
||||
app,
|
||||
server_id,
|
||||
track_id,
|
||||
bytes,
|
||||
analysis_emits_ui_events(priority),
|
||||
)
|
||||
.await;
|
||||
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
|
||||
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
||||
let key = analysis_cache::TrackKey {
|
||||
@@ -464,6 +472,7 @@ pub async fn run_track_enrichment_from_bytes(
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
notify_ui: bool,
|
||||
) -> TrackEnrichmentOutcome {
|
||||
if server_id.is_empty() {
|
||||
return TrackEnrichmentOutcome::SkippedNoServer;
|
||||
@@ -473,7 +482,7 @@ pub async fn run_track_enrichment_from_bytes(
|
||||
let tid = track_id.to_string();
|
||||
let data = bytes.to_vec();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
crate::track_enrichment::run_track_enrichment_if_needed(&app, &sid, &tid, &data)
|
||||
crate::track_enrichment::run_track_enrichment_if_needed(&app, &sid, &tid, &data, notify_ui)
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -795,6 +804,118 @@ pub fn analysis_backfill_resolve_priority(
|
||||
AnalysisBackfillPriority::Low
|
||||
}
|
||||
|
||||
/// Library backfill uses `Low` — skip waveform / enrichment refresh IPC (`analysis:track-perf` still emits for probes).
|
||||
pub fn analysis_emits_ui_events(priority: AnalysisBackfillPriority) -> bool {
|
||||
!matches!(priority, AnalysisBackfillPriority::Low)
|
||||
}
|
||||
|
||||
/// Enqueue HTTP download + analysis seed (native coordinator + optional UI invoke).
|
||||
pub fn enqueue_seed_from_url(
|
||||
app: &tauri::AppHandle,
|
||||
track_id: &str,
|
||||
url: &str,
|
||||
server_id_hint: Option<&str>,
|
||||
explicit_priority: Option<AnalysisBackfillPriority>,
|
||||
force: bool,
|
||||
) -> Result<(), String> {
|
||||
if track_id.trim().is_empty() || url.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
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_hint.unwrap_or("").to_string()
|
||||
} 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_hint.unwrap_or("").to_string()
|
||||
}
|
||||
} else {
|
||||
server_id_hint.unwrap_or("").to_string()
|
||||
};
|
||||
if !force {
|
||||
if let Some(playback) = app.try_state::<PlaybackQueryHandle>() {
|
||||
if playback.ranged_loudness_backfill_should_defer(track_id) {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill skip track_id={} reason=ranged_playback_will_seed",
|
||||
track_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !force {
|
||||
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
||||
if cache.cpu_seed_redundant_for_track(&server_id, track_id)? {
|
||||
if server_id.is_empty() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill skip (no server scope): {}",
|
||||
track_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if !track_analysis_needs_work(app, &server_id, track_id)? {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill skip (analysis complete): {}",
|
||||
track_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill enqueue (analysis pending) track_id={}",
|
||||
track_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let tid_log = track_id.to_string();
|
||||
let resolved = analysis_backfill_resolve_priority(app, &server_id, track_id, explicit_priority);
|
||||
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.to_string(), url.to_string(), resolved)
|
||||
};
|
||||
match kind {
|
||||
AnalysisBackfillEnqueueKind::NewLow
|
||||
| AnalysisBackfillEnqueueKind::NewMiddle
|
||||
| AnalysisBackfillEnqueueKind::NewHigh => {
|
||||
shared.ping_worker();
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill enqueued: track_id={} priority={resolved:?}",
|
||||
tid_log,
|
||||
);
|
||||
}
|
||||
AnalysisBackfillEnqueueKind::ReorderedHigher => {
|
||||
shared.ping_worker();
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill bumped tier track_id={} priority={resolved:?}",
|
||||
tid_log,
|
||||
);
|
||||
}
|
||||
AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Full-track waveform + loudness: CPU seed queue (parallel decode workers) ─
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1131,12 +1252,19 @@ async fn spawn_cpu_seed_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisCpuSe
|
||||
let app_for_decode = app.clone();
|
||||
let app_for_events = app.clone();
|
||||
let shared = shared.clone();
|
||||
let notify_ui = analysis_emits_ui_events(job.priority);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let sid = job.server_id.clone();
|
||||
let tid = job.track_id.clone();
|
||||
let bytes = job.bytes;
|
||||
let seed_result = tokio::task::spawn_blocking(move || {
|
||||
analysis_cache::seed_from_bytes_execute(&app_for_decode, &sid, &tid, &bytes)
|
||||
analysis_cache::seed_from_bytes_execute(
|
||||
&app_for_decode,
|
||||
&sid,
|
||||
&tid,
|
||||
&bytes,
|
||||
notify_ui,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(format!("cpu-seed spawn_blocking: {e}")));
|
||||
@@ -1181,7 +1309,7 @@ async fn spawn_cpu_seed_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisCpuSe
|
||||
tid_log,
|
||||
ok
|
||||
);
|
||||
if ok {
|
||||
if ok && notify_ui {
|
||||
let _ = app_for_events.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload {
|
||||
|
||||
@@ -5,16 +5,10 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use psysonic_core::ports::PlaybackQueryHandle;
|
||||
|
||||
use crate::analysis_cache;
|
||||
use crate::analysis_runtime::{
|
||||
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,
|
||||
analysis_backfill_queue_stats, analysis_pipeline_queue_stats, enqueue_seed_from_url,
|
||||
prune_analysis_queues, AnalysisBackfillPriority, PlaybackPriorityHints,
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -313,105 +307,15 @@ pub fn analysis_enqueue_seed_from_url(
|
||||
priority: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if track_id.trim().is_empty() || url.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
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>() {
|
||||
if playback.ranged_loudness_backfill_should_defer(&track_id) {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill skip track_id={} reason=ranged_playback_will_seed",
|
||||
track_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !force {
|
||||
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
||||
if cache.cpu_seed_redundant_for_track(&server_id, &track_id)? {
|
||||
if server_id.is_empty() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill skip (no server scope): {}",
|
||||
track_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if !track_analysis_needs_work(&app, &server_id, &track_id)? {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill skip (analysis complete): {}",
|
||||
track_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill enqueue (analysis pending) track_id={}",
|
||||
track_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let tid_log = track_id.clone();
|
||||
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, resolved)
|
||||
};
|
||||
match kind {
|
||||
AnalysisBackfillEnqueueKind::NewLow
|
||||
| AnalysisBackfillEnqueueKind::NewMiddle
|
||||
| AnalysisBackfillEnqueueKind::NewHigh => {
|
||||
shared.ping_worker();
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill enqueued: track_id={} priority={resolved:?}",
|
||||
tid_log,
|
||||
);
|
||||
}
|
||||
AnalysisBackfillEnqueueKind::ReorderedHigher => {
|
||||
shared.ping_worker();
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill bumped tier track_id={} priority={resolved:?}",
|
||||
tid_log,
|
||||
);
|
||||
}
|
||||
AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {}
|
||||
}
|
||||
Ok(())
|
||||
enqueue_seed_from_url(
|
||||
&app,
|
||||
&track_id,
|
||||
&url,
|
||||
server_id.as_deref(),
|
||||
explicit,
|
||||
force.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
|
||||
@@ -35,6 +35,7 @@ pub fn run_track_enrichment_if_needed<R: Runtime>(
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
notify_ui: bool,
|
||||
) -> TrackEnrichmentOutcome {
|
||||
if server_id.is_empty() {
|
||||
return TrackEnrichmentOutcome::SkippedNoServer;
|
||||
@@ -56,7 +57,9 @@ pub fn run_track_enrichment_if_needed<R: Runtime>(
|
||||
server_id,
|
||||
content_hash
|
||||
);
|
||||
emit_enrichment_updated(app, server_id, track_id);
|
||||
if notify_ui {
|
||||
emit_enrichment_updated(app, server_id, track_id);
|
||||
}
|
||||
TrackEnrichmentOutcome::Applied
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod stream_url;
|
||||
pub mod types;
|
||||
|
||||
pub use auth::SubsonicCredentials;
|
||||
pub use client::{
|
||||
fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID,
|
||||
};
|
||||
pub use stream_url::{build_stream_view_url, rest_base_from_url};
|
||||
pub use error::SubsonicError;
|
||||
pub use types::{
|
||||
Album, AlbumSummary, ArtistIndex, ArtistRef, IndexBucket, ScanStatus, SearchResult, ServerInfo,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Subsonic `stream.view` URLs for native library analysis backfill.
|
||||
|
||||
use url::Url;
|
||||
|
||||
use super::auth::SubsonicCredentials;
|
||||
use super::client::{SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID};
|
||||
|
||||
/// `{origin}/rest` — mirrors frontend `restBaseFromUrl`.
|
||||
pub fn rest_base_from_url(server_url: &str) -> String {
|
||||
let trimmed = server_url.trim().trim_end_matches('/');
|
||||
let base = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
};
|
||||
format!("{base}/rest")
|
||||
}
|
||||
|
||||
/// Authenticated `stream.view` URL for a library track id.
|
||||
pub fn build_stream_view_url(
|
||||
server_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
track_id: &str,
|
||||
) -> String {
|
||||
let creds = SubsonicCredentials::from_password(username, password);
|
||||
let base = rest_base_from_url(server_url);
|
||||
let mut url =
|
||||
Url::parse(&format!("{base}/stream.view")).unwrap_or_else(|_| Url::parse("http://invalid/rest/stream.view").unwrap());
|
||||
{
|
||||
let mut q = url.query_pairs_mut();
|
||||
q.append_pair("id", track_id);
|
||||
q.append_pair("u", &creds.username);
|
||||
q.append_pair("t", &creds.token);
|
||||
q.append_pair("s", &creds.salt);
|
||||
q.append_pair("v", SUBSONIC_API_VERSION);
|
||||
q.append_pair("c", SUBSONIC_CLIENT_ID);
|
||||
q.append_pair("f", "json");
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stream_url_contains_track_and_auth_params() {
|
||||
let url = build_stream_view_url(
|
||||
"https://music.example",
|
||||
"alice",
|
||||
"secret",
|
||||
"tr-42",
|
||||
);
|
||||
assert!(url.contains("stream.view"));
|
||||
assert!(url.contains("id=tr-42"));
|
||||
assert!(url.contains("u=alice"));
|
||||
assert!(url.contains("&t="));
|
||||
assert!(url.contains("&s="));
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,12 @@ pub struct LibraryAnalysisProgressDto {
|
||||
|
||||
enum ScanMode {
|
||||
Candidates,
|
||||
Full,
|
||||
/// Tracks with hash + BPM that may still need waveform/LUFS/enrichment gaps.
|
||||
HashBpmGaps,
|
||||
}
|
||||
|
||||
/// Candidate SQL skips tracks that already have `content_hash` and an analysis BPM
|
||||
/// fact. Those rows can still need waveform/LUFS — the full-table pass must start
|
||||
/// from the first id, not from the last candidate cursor.
|
||||
fn begin_full_library_scan() -> (ScanMode, Option<String>) {
|
||||
(ScanMode::Full, None)
|
||||
fn begin_hash_bpm_gap_scan() -> (ScanMode, Option<String>) {
|
||||
(ScanMode::HashBpmGaps, None)
|
||||
}
|
||||
|
||||
pub fn collect_analysis_backfill_batch(
|
||||
@@ -64,16 +62,18 @@ pub fn collect_analysis_backfill_batch(
|
||||
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)?,
|
||||
ScanMode::HashBpmGaps => {
|
||||
repo.list_analysis_hash_bpm_ids_after(server_id, after.as_deref(), SCAN_CHUNK)?
|
||||
}
|
||||
};
|
||||
|
||||
if page.is_empty() {
|
||||
match mode {
|
||||
ScanMode::Candidates => {
|
||||
(mode, after) = begin_full_library_scan();
|
||||
(mode, after) = begin_hash_bpm_gap_scan();
|
||||
continue;
|
||||
}
|
||||
ScanMode::Full => {
|
||||
ScanMode::HashBpmGaps => {
|
||||
return Ok(LibraryAnalysisBackfillBatchDto {
|
||||
track_ids: found,
|
||||
next_cursor: after,
|
||||
@@ -105,9 +105,9 @@ pub fn collect_analysis_backfill_batch(
|
||||
if page_len < SCAN_CHUNK {
|
||||
match mode {
|
||||
ScanMode::Candidates => {
|
||||
(mode, after) = begin_full_library_scan();
|
||||
(mode, after) = begin_hash_bpm_gap_scan();
|
||||
}
|
||||
ScanMode::Full => {
|
||||
ScanMode::HashBpmGaps => {
|
||||
return Ok(LibraryAnalysisBackfillBatchDto {
|
||||
track_ids: found,
|
||||
next_cursor: after,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Library analysis backfill queue watermark — native coordinator (spec: Settings → Library).
|
||||
|
||||
pub const LIBRARY_BACKLOG_DEPTH_MULTIPLIER: u32 = 3;
|
||||
pub const LIBRARY_BACKLOG_MIN: u32 = 8;
|
||||
pub const LIBRARY_BACKLOG_MAX: u32 = 240;
|
||||
pub const LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE: u32 = 20;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct PipelineBacklogCounts {
|
||||
pub http_queued: u32,
|
||||
pub http_download_active: u32,
|
||||
pub cpu_queued: u32,
|
||||
pub cpu_decode_active: u32,
|
||||
}
|
||||
|
||||
impl PipelineBacklogCounts {
|
||||
pub fn total(self) -> u32 {
|
||||
self.http_queued
|
||||
.saturating_add(self.http_download_active)
|
||||
.saturating_add(self.cpu_queued)
|
||||
.saturating_add(self.cpu_decode_active)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_library_backfill_target_depth(workers: u32) -> u32 {
|
||||
let w = workers.max(1);
|
||||
(w * LIBRARY_BACKLOG_DEPTH_MULTIPLIER).clamp(LIBRARY_BACKLOG_MIN, LIBRARY_BACKLOG_MAX)
|
||||
}
|
||||
|
||||
pub fn library_backfill_needs_top_up(counts: PipelineBacklogCounts, workers: u32) -> bool {
|
||||
counts.total() < compute_library_backfill_target_depth(workers)
|
||||
}
|
||||
|
||||
pub fn library_backfill_top_up_limit(counts: PipelineBacklogCounts, workers: u32) -> u32 {
|
||||
let target = compute_library_backfill_target_depth(workers);
|
||||
let deficit = target.saturating_sub(counts.total());
|
||||
if deficit == 0 {
|
||||
return 0;
|
||||
}
|
||||
deficit.min(LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_depth_uses_floor_and_cap() {
|
||||
assert_eq!(compute_library_backfill_target_depth(1), 8);
|
||||
assert_eq!(compute_library_backfill_target_depth(4), 12);
|
||||
assert_eq!(compute_library_backfill_target_depth(100), 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_up_when_backlog_below_target() {
|
||||
let counts = PipelineBacklogCounts {
|
||||
http_queued: 2,
|
||||
http_download_active: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(library_backfill_needs_top_up(counts, 8));
|
||||
assert_eq!(library_backfill_top_up_limit(counts, 8), 20);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub mod album_compilation_filter;
|
||||
pub mod browse_support;
|
||||
mod advanced_search_mood;
|
||||
pub mod analysis_backfill;
|
||||
pub mod analysis_backfill_policy;
|
||||
pub mod library_readiness;
|
||||
pub mod artist_lossless_browse;
|
||||
pub mod cover_backfill;
|
||||
pub mod cover_resolve;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! When the local library index is safe for background analysis backfill.
|
||||
|
||||
use crate::dto::local_tracks_max_updated_ms;
|
||||
use crate::repos::{sync_state::SyncStateRepository, TrackRepository};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
/// Mirrors frontend `libraryStatusIsReady` / `libraryIsReady` (spec §9.3).
|
||||
pub fn library_server_is_ready(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
|
||||
let repo = SyncStateRepository::new(store);
|
||||
let phase = repo
|
||||
.get_sync_phase(server_id, "")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let Some(phase) = phase else {
|
||||
return Ok(has_any_local_tracks(store, server_id));
|
||||
};
|
||||
|
||||
if phase == "ready" {
|
||||
return Ok(true);
|
||||
}
|
||||
if phase == "initial_sync" {
|
||||
let local = repo
|
||||
.get_local_track_count(server_id, "")
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or(0);
|
||||
let server = repo
|
||||
.get_server_track_count(server_id, "")
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or(0);
|
||||
if server > 0 && (local as f64 / server as f64) >= 0.95 {
|
||||
return Ok(true);
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
if phase == "idle" {
|
||||
if has_any_local_tracks(store, server_id) {
|
||||
return Ok(true);
|
||||
}
|
||||
if repo
|
||||
.has_last_full_sync_at(server_id, "")
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
if local_tracks_max_updated_ms(store, server_id)?
|
||||
.is_some_and(|ms| ms > 0)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let local = repo
|
||||
.get_local_track_count(server_id, "")
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or(0);
|
||||
return Ok(local > 0);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn has_any_local_tracks(store: &LibraryStore, server_id: &str) -> bool {
|
||||
TrackRepository::new(store)
|
||||
.count_live_tracks(server_id)
|
||||
.map(|n| n > 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -292,6 +292,37 @@ impl<'a> TrackRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
|
||||
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
|
||||
pub fn list_analysis_hash_bpm_ids_after(
|
||||
&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 NOT NULL \
|
||||
AND 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>>>()
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user