Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions src-tauri/crates/app/src/commands/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
//! events the UI can wire to a progress bar.

use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use chrono::Utc;
use serde::Serialize;
Expand All @@ -29,6 +31,45 @@ use crate::{

const AUTO_ANALYZE_KEY: &str = "audio.auto_analyze";

/// Mutual-exclusion + cancellation primitives for the library-wide
/// analyzer. Mirrors the `PREFETCH_RUNNING` / `PREFETCH_CANCEL` pair
/// in [`crate::commands::lyrics`] — the run is process-wide (only one
/// active profile at a time), so a bare static atomic pair is enough;
/// no need to thread tokens through `AppState`.
///
/// `ANALYSIS_RUNNING` guards against double-start. The user is the
/// one likely to trigger this by clicking the Library "Run analysis"
/// button while the post-scan `maybe_auto_analyze` hook is already in
/// flight — without the guard both runs would race on the same
/// `INSERT … ON CONFLICT` against `track_analysis`, each redoing the
/// same expensive Symphonia decode (issue #286 made this concrete:
/// 1500+ tracks decoded twice would saturate even a beefy laptop).
///
/// `ANALYSIS_CANCEL` is the cooperative stop flag. The loop checks it
/// at every iteration and exits early; the post-loop summary carries
/// `cancelled = true` so the frontend can render "Cancelled at X / Y"
/// instead of pretending the run completed.
static ANALYSIS_RUNNING: AtomicBool = AtomicBool::new(false);
static ANALYSIS_CANCEL: AtomicBool = AtomicBool::new(false);

/// Sleep duration injected between two consecutive `analyze_file`
/// calls in [`run_analyze_library`]. The analyzer is CPU-bound on
/// the Symphonia decode (full audio decode of every sample); on a
/// weak / mid-tier laptop a back-to-back run with zero pauses keeps
/// at least one core pinned at 100% for the entire library, which
/// triggers Windows' thermal throttling and — per issue #286 — can
/// actually freeze the machine when the OS scheduler stops finding
/// idle slices to service the UI and Defender's per-file open scan.
///
/// 25 ms is short enough that a 4000-track library only adds 100 s
/// of pure idle time (~2 % of the analysis wall-clock on a typical
/// 8 ms-per-decode-second track) but long enough to give the OS
/// scheduler room to interleave UI, audio playback, and background
/// services. Combined with `tokio::task::yield_now` it also lets
/// any concurrently-running Tauri command (other than another
/// analyze) progress between tracks.
const ANALYSIS_PER_TRACK_PAUSE: Duration = Duration::from_millis(25);

/// Row shape returned by `get_track_analysis` and `analyze_track`.
/// Mirrors the columns of `track_analysis` but exposes the fields
/// the UI cares about — `analyzed_at` so a stale-warning ribbon can
Expand Down Expand Up @@ -58,6 +99,13 @@ pub struct LibraryAnalysisSummary {
pub processed: u32,
pub failed: u32,
pub skipped: u32,
/// `true` when the loop exited because the user clicked the
/// `cancel_library_analysis` command (or any other call site set
/// the cancel flag) — the UI uses this to render "Cancelled at
/// X / Y" instead of pretending the run completed. Also `true`
/// when [`ANALYSIS_RUNNING`] was already set on entry, since the
/// second caller never actually ran.
pub cancelled: bool,
}

/// Look up the cached analysis for a track. Returns `None` when the
Expand Down Expand Up @@ -171,10 +219,47 @@ pub async fn analyze_library(
/// Inner worker shared by the user-triggered command and the
/// auto-analyze hook fired after a scan. Takes the pool directly so
/// the caller can decide whether to spawn or await.
///
/// **Cancellation**: the loop checks [`ANALYSIS_CANCEL`] at the top
/// of each iteration and exits early with `cancelled = true` in the
/// summary. The frontend hooks the `cancel_library_analysis` command
/// to a "Stop" button so the user can recover from a run that's
/// pegging their CPU (issue #286 — pre-fix, a 4000-track library
/// would lock a mid-tier laptop for 30+ minutes with no escape).
///
/// **Throttling**: every iteration ends with a `yield_now` + 25 ms
/// sleep ([`ANALYSIS_PER_TRACK_PAUSE`]). The yield gives other tokio
/// tasks (UI events, playback commands, sync drain) a chance to
/// progress between decodes; the sleep gives the OS scheduler room
/// to interleave Windows / macOS / Linux services like Defender's
/// per-file open scan. Adds ~25 ms per track to wall-clock time, so
/// a 4000-track run pays 100 s of pure idle — a 2 % overhead vs the
/// freeze risk it prevents.
pub async fn run_analyze_library(
app: &AppHandle,
pool: &SqlitePool,
) -> AppResult<LibraryAnalysisSummary> {
// Double-start guard. `swap` atomically takes the slot if it was
// free; if `prev == true` another call is already in flight and
// we bail with a cancelled summary so the caller's progress UI
// doesn't get stuck spinning.
if ANALYSIS_RUNNING.swap(true, Ordering::SeqCst) {
tracing::info!("analyze_library called while another run is in flight; ignoring");
return Ok(LibraryAnalysisSummary {
processed: 0,
failed: 0,
skipped: 0,
cancelled: true,
});
}
// Reset the cancel flag at the START of every run — a stale
// `true` from a prior cancellation would otherwise short-circuit
// the new run before the first track. The RAII guard at the end
// clears `ANALYSIS_RUNNING` on every exit path (early returns +
// panics) so the flag can't get stuck `true`.
ANALYSIS_CANCEL.store(false, Ordering::SeqCst);
let _guard = RunningGuard;

let pending: Vec<(i64, String)> = sqlx::query_as(
"SELECT t.id, t.file_path
FROM track t
Expand All @@ -188,8 +273,18 @@ pub async fn run_analyze_library(
let total = pending.len() as u32;
let mut processed = 0u32;
let mut failed = 0u32;
let mut cancelled = false;

for (track_id, file_path) in pending {
// Cancellation gate at the TOP of the loop so a user click
// that lands between two decodes is honoured without burning
// one extra ~8-second decode.
if ANALYSIS_CANCEL.load(Ordering::Relaxed) {
cancelled = true;
tracing::info!(processed, total, "library analysis cancelled by user");
break;
}

let _ = app.emit(
"analysis:progress",
LibraryAnalysisProgress {
Expand Down Expand Up @@ -240,6 +335,16 @@ pub async fn run_analyze_library(
}
}
processed += 1;

// Cooperative scheduling pair: yield to give other tokio
// tasks (UI events, drain ticks, playback commands) a turn
// on the reactor, then sleep to free the OS scheduler so a
// background-priority service (Defender, Spotlight, etc.)
// can run between two CPU-bound decodes. See the doc on
// `ANALYSIS_PER_TRACK_PAUSE` for the cost / benefit
// breakdown.
tokio::task::yield_now().await;
tokio::time::sleep(ANALYSIS_PER_TRACK_PAUSE).await;
}

let summary = LibraryAnalysisSummary {
Expand All @@ -249,6 +354,7 @@ pub async fn run_analyze_library(
// excludes already-analyzed rows. Reserved for a future
// "skip if older than N days" option.
skipped: 0,
cancelled,
};
let _ = app.emit(
"analysis:progress",
Expand All @@ -262,6 +368,38 @@ pub async fn run_analyze_library(
Ok(summary)
}

/// RAII guard that clears [`ANALYSIS_RUNNING`] on drop. Lets every
/// exit path of `run_analyze_library` — early return on cancel, the
/// SQL error path, a future `?` on a new query, even a panic during
/// decode — reset the running flag without explicit cleanup. Without
/// this a single error from `sqlx::query_as` would leave the static
/// `true` forever and brick auto-analyze for the rest of the
/// session.
struct RunningGuard;

impl Drop for RunningGuard {
fn drop(&mut self) {
ANALYSIS_RUNNING.store(false, Ordering::SeqCst);
}
}

/// Signal the in-flight library analyzer to stop at the next track
/// boundary. Returns `true` when a run was actually in flight (so
/// the frontend can show a confirmation toast); `false` is a no-op
/// when nothing was running — clicking "Stop" twice or before
/// "Start" shouldn't surface as an error.
///
/// Idempotent: setting the cancel flag while it's already `true` is
/// fine; the worker loop clears it at the start of the next run.
#[tauri::command]
pub fn cancel_library_analysis() -> bool {
let was_running = ANALYSIS_RUNNING.load(Ordering::Relaxed);
if was_running {
ANALYSIS_CANCEL.store(true, Ordering::SeqCst);
}
was_running
}

/// Read the per-profile auto-analyze flag. `true` when the user has
/// opted in to running the analyzer in the background after each
/// scan; defaults to `false` so the first scan stays fast and free.
Expand Down
1 change: 1 addition & 0 deletions src-tauri/crates/app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,7 @@ pub fn run() {
commands::duplicates::delete_tracks,
commands::analysis::analyze_track,
commands::analysis::analyze_library,
commands::analysis::cancel_library_analysis,
commands::analysis::get_track_analysis,
commands::analysis::get_auto_analyze,
commands::analysis::set_auto_analyze,
Expand Down
62 changes: 49 additions & 13 deletions src/components/views/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
EyeOff,
MousePointerClick,
Sparkles,
Square,
Bell,
Gamepad2,
FileText,
Expand Down Expand Up @@ -807,6 +808,24 @@ export function SettingsView({ onNavigate }: SettingsViewProps) {
}
};

const handleCancelAnalyzeLibrary = async () => {
try {
const { cancelLibraryAnalysis } = await import(
"../../lib/tauri/analysis"
);
await cancelLibraryAnalysis();
// We deliberately DON'T flip `isAnalyzingLib` here — the
// backend exits at the next track boundary, and `analyzeLibrary`
// is still awaiting that exit. Its `finally` block clears the
// running flag once the awaited promise resolves with the
// cancelled summary, so the UI stays consistent with the
// worker's actual state without a tug-of-war between two
// sources of truth.
} catch (err) {
console.error("[SettingsView] cancel analyze library failed", err);
}
};

// Cover batch fetch
const [isFetchingCovers, setIsFetchingCovers] = useState(false);
const [coverProgress, setCoverProgress] = useState<{
Expand Down Expand Up @@ -2792,19 +2811,36 @@ export function SettingsView({ onNavigate }: SettingsViewProps) {
</div>
</div>
</div>
<button
type="button"
onClick={handleAnalyzeLibrary}
disabled={isAnalyzingLib || libraries.length === 0}
className="flex items-center space-x-2 px-4 py-2 rounded-xl border border-zinc-200 bg-white text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Sparkles
size={14}
aria-hidden="true"
className={isAnalyzingLib ? "animate-pulse" : ""}
/>
<span>{t("settings.analyze.action")}</span>
</button>
{isAnalyzingLib ? (
// Mid-run: swap the Analyze button out for a Stop
// button so the user can recover from a long-running
// sweep that's saturating their CPU (issue #286).
// The backend `cancel_library_analysis` flips a flag
// the worker loop checks at every track boundary —
// the click resolves quickly but the loop only exits
// after the current decode finishes (single track
// delay is acceptable; aborting mid-decode would
// require restructuring `analyze_file` for cancel
// tokens, deferred).
<button
type="button"
onClick={handleCancelAnalyzeLibrary}
className="flex items-center space-x-2 px-4 py-2 rounded-xl border border-rose-200 bg-rose-50 text-sm font-medium text-rose-700 hover:bg-rose-100 dark:border-rose-900/40 dark:bg-rose-950/30 dark:text-rose-300 dark:hover:bg-rose-950/50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500"
>
<Square size={14} aria-hidden="true" />
<span>{t("settings.analyze.cancel")}</span>
</button>
) : (
<button
type="button"
onClick={handleAnalyzeLibrary}
disabled={libraries.length === 0}
className="flex items-center space-x-2 px-4 py-2 rounded-xl border border-zinc-200 bg-white text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Sparkles size={14} aria-hidden="true" />
<span>{t("settings.analyze.action")}</span>
</button>
)}
</div>
{/* Auto-analyze toggle: when on, every scan that
adds new tracks fires the analyzer in the
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -1462,6 +1462,7 @@
"title": "تحليل المكتبة",
"subtitle": "حساب BPM والجهارة والذروة للمقاطع غير المحللة",
"action": "تحليل",
"cancel": "إيقاف التحليل",
"autoTitle": "التحليل التلقائي",
"autoSubtitle": "تشغيل التحليل في الخلفية بعد كل فحص يضيف مقاطع جديدة",
"failed_one": "{{count}} فشل",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,7 @@
"title": "Bibliothek analysieren",
"subtitle": "BPM, Lautheit und Peak für noch nicht analysierte Titel berechnen",
"action": "Analysieren",
"cancel": "Analyse stoppen",
"autoTitle": "Automatische Analyse",
"autoSubtitle": "Analyse im Hintergrund ausführen, nachdem ein Scan neue Titel hinzugefügt hat",
"failed_one": "{{count}} Fehler",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,7 @@
"title": "Analyse the library",
"subtitle": "Compute BPM, loudness and peak for non-analysed tracks",
"action": "Analyse",
"cancel": "Stop analysis",
"autoTitle": "Automatic analysis",
"autoSubtitle": "Run analysis in the background after each scan that adds new tracks",
"failed_one": "{{count}} failure",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,7 @@
"title": "Analizar la biblioteca",
"subtitle": "Calcular BPM, sonoridad y pico de las pistas no analizadas",
"action": "Analizar",
"cancel": "Detener análisis",
"autoTitle": "Análisis automático",
"autoSubtitle": "Lanzar el análisis en segundo plano tras cada escaneo que añada pistas nuevas",
"failed_one": "{{count}} fallo",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,7 @@
"title": "Analyser la bibliothèque",
"subtitle": "Calculer le BPM, le Loudness et la crête pour les titres non analysés",
"action": "Analyser",
"cancel": "Arrêter l'analyse",
"autoTitle": "Analyse automatique",
"autoSubtitle": "Lancer l'analyse en arrière-plan après chaque scan qui ajoute de nouveaux titres",
"failed_one": "{{count}} échec",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,7 @@
"title": "लाइब्रेरी का विश्लेषण करें",
"subtitle": "उन ट्रैकों के लिए जिनका विश्लेषण नहीं किया गया है, BPM, लाउडनेस और पीक स्तर की गणना करें।",
"action": "विश्लेषण करें",
"cancel": "विश्लेषण रोकें",
"autoTitle": "स्वचालित विश्लेषण",
"autoSubtitle": "नए ट्रैक जोड़ने वाले प्रत्येक स्कैन के बाद पृष्ठभूमि में विश्लेषण चलाएँ।",
"failed_one": "{{count}} विफलता",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,7 @@
"title": "Analisis pustaka",
"subtitle": "Hitung BPM, kelantangan, dan puncak untuk lagu yang belum dianalisis",
"action": "Analisis",
"cancel": "Hentikan analisis",
"autoTitle": "Analisis otomatis",
"autoSubtitle": "Jalankan analisis di latar belakang setelah setiap pemindaian yang menambahkan lagu baru",
"failed_one": "{{count}} kegagalan",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,7 @@
"title": "Analizza la libreria",
"subtitle": "Calcola BPM, loudness e picco per i brani non ancora analizzati",
"action": "Analizza",
"cancel": "Interrompi analisi",
"autoTitle": "Analisi automatica",
"autoSubtitle": "Esegui l'analisi in background dopo ogni scansione che aggiunge nuovi brani",
"failed_one": "{{count}} errore",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,7 @@
"title": "ライブラリを分析する",
"subtitle": "未分析の楽曲について、BPM、音量、ピーク値を算出する",
"action": "分析する",
"cancel": "分析を停止",
"autoTitle": "自動分析",
"autoSubtitle": "新しいトラックが追加されるスキャンが終了するたびに、バックグラウンドで分析を実行する",
"failed_one": "{{count}}件失敗",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,7 @@
"title": "라이브러리 분석하기",
"subtitle": "분석되지 않은 트랙에 대한 BPM, 음량 및 피크 값 계산",
"action": "분석",
"cancel": "분석 중지",
"autoTitle": "자동 분석",
"autoSubtitle": "새 트랙이 추가되는 각 스캔 후 백그라운드에서 분석 실행",
"failed_one": "{{count}}건 실패",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,7 @@
"title": "Bibliotheek analyseren",
"subtitle": "Bereken BPM, loudness en piek voor niet-geanalyseerde nummers",
"action": "Analyseren",
"cancel": "Analyse stoppen",
"autoTitle": "Automatische analyse",
"autoSubtitle": "Voer de analyse op de achtergrond uit na elke scan die nieuwe nummers toevoegt",
"failed_one": "{{count}} fout",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,7 @@
"title": "Analisar a biblioteca",
"subtitle": "Calcula BPM, loudness e pico para faixas ainda não analisadas",
"action": "Analisar",
"cancel": "Parar análise",
"autoTitle": "Análise automática",
"autoSubtitle": "Executa uma análise em segundo plano após cada escaneamento que adicione novas faixas",
"failed_one": "{{count}} falha",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,7 @@
"title": "Analisar a biblioteca",
"subtitle": "Calcular BPM, loudness e pico para faixas ainda não analisadas",
"action": "Analisar",
"cancel": "Parar análise",
"autoTitle": "Análise automática",
"autoSubtitle": "Executar a análise em segundo plano após cada análise que adicione novas faixas",
"failed_one": "{{count}} falha",
Expand Down
Loading