mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
8004ec559c
* fix(analysis): persist backfill scan phase and cursor across coordinator ticks Keep HashBpmGaps progress when the candidate SQL page is empty only because id > cursor; store scan phase in the native worker so each tick does not restart from Candidates and rescan the first ~10k ready tracks. * docs: CHANGELOG and credits for PR #882 backfill scan phase fix * fix(analysis): move backfill tests below production code for clippy Clippy items_after_test_module requires all non-test items before mod tests.
243 lines
7.5 KiB
Rust
243 lines
7.5 KiB
Rust
//! 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,
|
|
}
|
|
|
|
/// Persisted across native coordinator ticks (see `library_analysis_backfill` worker).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum AnalysisBackfillScanPhase {
|
|
#[default]
|
|
Candidates,
|
|
/// Tracks with hash + BPM that may still need waveform/LUFS/enrichment gaps.
|
|
HashBpmGaps,
|
|
}
|
|
|
|
enum ScanMode {
|
|
Candidates,
|
|
HashBpmGaps,
|
|
}
|
|
|
|
impl From<AnalysisBackfillScanPhase> for ScanMode {
|
|
fn from(p: AnalysisBackfillScanPhase) -> Self {
|
|
match p {
|
|
AnalysisBackfillScanPhase::Candidates => ScanMode::Candidates,
|
|
AnalysisBackfillScanPhase::HashBpmGaps => ScanMode::HashBpmGaps,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ScanMode> for AnalysisBackfillScanPhase {
|
|
fn from(m: ScanMode) -> Self {
|
|
match m {
|
|
ScanMode::Candidates => AnalysisBackfillScanPhase::Candidates,
|
|
ScanMode::HashBpmGaps => AnalysisBackfillScanPhase::HashBpmGaps,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// End of the cheap candidate SQL pass — start hash/BPM gap scan from the first id.
|
|
fn begin_hash_bpm_gap_scan_from_start() -> (ScanMode, Option<String>) {
|
|
(ScanMode::HashBpmGaps, None)
|
|
}
|
|
|
|
/// Candidate page empty only because `id > cursor`; continue the id walk in hash/BPM phase.
|
|
fn begin_hash_bpm_gap_scan_from_cursor(cursor: String) -> (ScanMode, Option<String>) {
|
|
(ScanMode::HashBpmGaps, Some(cursor))
|
|
}
|
|
|
|
fn advance_after_empty_candidate_page(after: Option<String>) -> (ScanMode, Option<String>) {
|
|
match after {
|
|
None => begin_hash_bpm_gap_scan_from_start(),
|
|
Some(cursor) => begin_hash_bpm_gap_scan_from_cursor(cursor),
|
|
}
|
|
}
|
|
|
|
pub fn collect_analysis_backfill_batch(
|
|
app: &AppHandle,
|
|
runtime: &LibraryRuntime,
|
|
server_id: &str,
|
|
phase: AnalysisBackfillScanPhase,
|
|
cursor: Option<&str>,
|
|
limit: Option<u32>,
|
|
) -> Result<(LibraryAnalysisBackfillBatchDto, AnalysisBackfillScanPhase), 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::from(phase);
|
|
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::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) = advance_after_empty_candidate_page(after);
|
|
continue;
|
|
}
|
|
ScanMode::HashBpmGaps => {
|
|
let dto = LibraryAnalysisBackfillBatchDto {
|
|
track_ids: found,
|
|
next_cursor: after,
|
|
exhausted: true,
|
|
};
|
|
return Ok((dto, AnalysisBackfillScanPhase::HashBpmGaps));
|
|
}
|
|
}
|
|
}
|
|
|
|
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, after) = begin_hash_bpm_gap_scan_from_start();
|
|
}
|
|
ScanMode::HashBpmGaps => {
|
|
let dto = LibraryAnalysisBackfillBatchDto {
|
|
track_ids: found,
|
|
next_cursor: after,
|
|
exhausted: true,
|
|
};
|
|
return Ok((dto, AnalysisBackfillScanPhase::HashBpmGaps));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let dto = LibraryAnalysisBackfillBatchDto {
|
|
track_ids: found,
|
|
next_cursor: after,
|
|
exhausted: false,
|
|
};
|
|
Ok((dto, ScanMode::into(mode)))
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn empty_candidate_page_with_cursor_keeps_id_walk() {
|
|
let (mode, after) = advance_after_empty_candidate_page(Some("track-z".to_string()));
|
|
assert!(matches!(mode, ScanMode::HashBpmGaps));
|
|
assert_eq!(after.as_deref(), Some("track-z"));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_candidate_page_without_cursor_starts_gap_scan_at_beginning() {
|
|
let (mode, after) = advance_after_empty_candidate_page(None);
|
|
assert!(matches!(mode, ScanMode::HashBpmGaps));
|
|
assert!(after.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn finished_candidate_phase_resets_gap_scan_cursor() {
|
|
let (mode, after) = begin_hash_bpm_gap_scan_from_start();
|
|
assert!(matches!(mode, ScanMode::HashBpmGaps));
|
|
assert!(after.is_none());
|
|
}
|
|
}
|