From 150d28a9681905beb4a6422a1bb47f1f04f6bc57 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 20 Jun 2026 22:58:32 +0200 Subject: [PATCH] fix(analysis): make library analysis cancellable + cooperative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #286 reports that the post-scan "Auto-analyse tracks" run freezes a mid-tier Windows laptop after ~1500 of 4002 tracks. The log confirms the analyzer was mid-run when the freeze hit (1445 `using xing header for duration` lines from Symphonia, each marking a full-file MP3 decode). The shape of the bug: `run_analyze_library` was a tight loop of `spawn_blocking(analyze_file)` + sqlx upsert, with no yield, no sleep, no cancellation gate. On a 4000-track library that pins at least one core at 100 % for 30+ minutes, leaves zero idle slices for the UI / Defender's per-file open scan / Windows scheduler. The user had no way to stop it short of force-killing the app. This commit adds three coupled primitives that solve all three sub- problems (no cancel, no breathing room, no double-start guard): ## Cancellation - `ANALYSIS_CANCEL: AtomicBool` mirrors the existing `PREFETCH_CANCEL` flag in `crate::commands::lyrics`. Reset to `false` at the start of every run so a stale `true` from a prior cancellation doesn't short-circuit the next call before track 0. - `ANALYSIS_RUNNING: AtomicBool` swap-on-entry double-start guard. Without it, a user click on "Analyze library" while the post-scan `maybe_auto_analyze` hook is still in flight races on the same `INSERT ... ON CONFLICT(track_id)` against `track_analysis` — both runs would redo every Symphonia decode for the same set of pending tracks (free 2x CPU saturation). The second caller now bails with `cancelled = true`. - `RunningGuard: Drop` clears `ANALYSIS_RUNNING` on every exit path — early return, sqlx Err propagation, future `?` insertions, even a decode panic — so the flag can't get stuck and brick auto-analyze for the rest of the session. - New `#[tauri::command] cancel_library_analysis() -> bool` flips the cancel flag and returns whether a run was actually in flight (so the UI can show a confirmation toast). Idempotent. ## Cooperative scheduling Every loop iteration now ends with: tokio::task::yield_now().await; tokio::time::sleep(ANALYSIS_PER_TRACK_PAUSE).await; // 25 ms The yield gives other tokio tasks (UI events, sync drain, playback commands) a turn on the reactor between decodes. The 25 ms sleep hands the OS scheduler back enough time to interleave Defender, Spotlight, mpd, etc. between two CPU-bound decodes. On a 4000-track library that adds ~100 s of idle to wall-clock — about a 2 % overhead vs the freeze risk it prevents. ## Cancellation gate placement The cancel check sits at the TOP of the loop, BEFORE the decode and the spawn_blocking. A user click that lands between two decodes is honoured without burning one extra ~8-second symphonia run. The post-loop summary carries `cancelled: bool` so the frontend can render "Cancelled at X / Y" instead of pretending the run finished. ## Frontend - `cancelLibraryAnalysis(): Promise` in `src/lib/tauri/analysis.ts`. `LibraryAnalysisSummary` gains `cancelled: boolean`. - `SettingsView` swaps the Analyze button out for a Stop button while `isAnalyzingLib === true`. The Stop button stays clickable even though the worker loop only exits at the next track boundary — the click resolves instantly but the running flag stays set until `analyzeLibrary`'s awaited promise unblocks, so there's no tug-of-war between the optimistic UI flip and the actual worker state. - New `settings.analyze.cancel` i18n key ("Stop analysis" / "Arrêter l'analyse" / etc.) across the 16 supported locales + the legacy `kr` alias. Brand-style consistency with the existing `settings.analyze.action` ("Analyze") label. ## What this commit does NOT address - The slow first scan (3 min 25 s for 4002 tracks vs 19-42 s in other players) is a separate structural cost — WaveFlow hashes every file with BLAKE3 + extracts cover art + writes per-track rows, which other players don't all do. Defer to its own perf pass. - The auto-analyze flag defaulting to ON in onboarding is unchanged here — the user explicitly opted in via the wizard toggle in the reported case. Defer the onboarding UX rewording to a follow-up. 84 desktop unit tests + workspace `cargo check` clean. `bun run typecheck` clean. --- src-tauri/crates/app/src/commands/analysis.rs | 138 ++++++++++++++++++ src-tauri/crates/app/src/lib.rs | 1 + src/components/views/SettingsView.tsx | 62 ++++++-- src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/en.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/hi.json | 1 + src/i18n/locales/id.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/ko.json | 1 + src/i18n/locales/nl.json | 1 + src/i18n/locales/pt-BR.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/tr.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-TW.json | 1 + src/lib/tauri/analysis.ts | 27 ++++ 21 files changed, 232 insertions(+), 13 deletions(-) diff --git a/src-tauri/crates/app/src/commands/analysis.rs b/src-tauri/crates/app/src/commands/analysis.rs index 18f4ce2c..38cd5290 100644 --- a/src-tauri/crates/app/src/commands/analysis.rs +++ b/src-tauri/crates/app/src/commands/analysis.rs @@ -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; @@ -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 @@ -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 @@ -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 { + // 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 @@ -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 { @@ -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 { @@ -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", @@ -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. diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index 10db36b6..b39fc581 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -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, diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index 459ae93d..b45ffa9f 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -21,6 +21,7 @@ import { EyeOff, MousePointerClick, Sparkles, + Square, Bell, Gamepad2, FileText, @@ -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<{ @@ -2792,19 +2811,36 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { - + {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). + + ) : ( + + )} {/* Auto-analyze toggle: when on, every scan that adds new tracks fires the analyzer in the diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 134e88bc..ec02a5c1 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1462,6 +1462,7 @@ "title": "تحليل المكتبة", "subtitle": "حساب BPM والجهارة والذروة للمقاطع غير المحللة", "action": "تحليل", + "cancel": "إيقاف التحليل", "autoTitle": "التحليل التلقائي", "autoSubtitle": "تشغيل التحليل في الخلفية بعد كل فحص يضيف مقاطع جديدة", "failed_one": "{{count}} فشل", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 10103a7f..81fca3db 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3abd0ef4..d6f52834 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 73329724..4b9e352f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 53d1d244..ebd3eee1 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 244d92c3..19845f85 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1470,6 +1470,7 @@ "title": "लाइब्रेरी का विश्लेषण करें", "subtitle": "उन ट्रैकों के लिए जिनका विश्लेषण नहीं किया गया है, BPM, लाउडनेस और पीक स्तर की गणना करें।", "action": "विश्लेषण करें", + "cancel": "विश्लेषण रोकें", "autoTitle": "स्वचालित विश्लेषण", "autoSubtitle": "नए ट्रैक जोड़ने वाले प्रत्येक स्कैन के बाद पृष्ठभूमि में विश्लेषण चलाएँ।", "failed_one": "{{count}} विफलता", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 828aeae7..520436de 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -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", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index fa424492..b6070fd9 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9ff32b55..d97ff51c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1433,6 +1433,7 @@ "title": "ライブラリを分析する", "subtitle": "未分析の楽曲について、BPM、音量、ピーク値を算出する", "action": "分析する", + "cancel": "分析を停止", "autoTitle": "自動分析", "autoSubtitle": "新しいトラックが追加されるスキャンが終了するたびに、バックグラウンドで分析を実行する", "failed_one": "{{count}}件失敗", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index bfbc617a..05bef641 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1470,6 +1470,7 @@ "title": "라이브러리 분석하기", "subtitle": "분석되지 않은 트랙에 대한 BPM, 음량 및 피크 값 계산", "action": "분석", + "cancel": "분석 중지", "autoTitle": "자동 분석", "autoSubtitle": "새 트랙이 추가되는 각 스캔 후 백그라운드에서 분석 실행", "failed_one": "{{count}}건 실패", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 0e16f205..01095dd2 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -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", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 8d4ffe61..8536e513 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -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", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index ef851038..c59c2322 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index bd25551b..8156402c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1499,6 +1499,7 @@ "title": "Проанализировать библиотеку", "subtitle": "Рассчитать BPM, громкость и пик для непроанализированных треков", "action": "Анализ", + "cancel": "Остановить анализ", "autoTitle": "Автоматический анализ", "autoSubtitle": "Запускать анализ в фоне после каждого сканирования, которое добавляет новые треки", "failed_one": "{{count}} ошибка", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index e5d19ac2..708e9f25 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1458,6 +1458,7 @@ "title": "Kütüphaneyi analiz et", "subtitle": "Analiz edilmemiş parçalar için BPM, ses yüksekliği ve tepe hesapla", "action": "Analiz et", + "cancel": "Analizi durdur", "autoTitle": "Otomatik analiz", "autoSubtitle": "Yeni parça ekleyen her taramadan sonra arka planda analiz çalıştır", "failed_one": "{{count}} hata", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c3b2a79d..9f8afb3a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1433,6 +1433,7 @@ "title": "分析音乐库", "subtitle": "计算未分析曲目的 BPM、响度和峰值", "action": "分析", + "cancel": "停止分析", "autoTitle": "自动分析", "autoSubtitle": "每次扫描并添加新曲目后,在后台运行分析", "failed_one": "{{count}} 个失败", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 6e6aecc2..bdd95643 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1433,6 +1433,7 @@ "title": "分析音樂庫", "subtitle": "計算未經分析曲目的 BPM、響度及峰值", "action": "分析", + "cancel": "停止分析", "autoTitle": "自動分析", "autoSubtitle": "每次掃描並新增曲目後,在背景執行分析", "failed_one": "{{count}} 失敗", diff --git a/src/lib/tauri/analysis.ts b/src/lib/tauri/analysis.ts index a124ea30..95c2f7ee 100644 --- a/src/lib/tauri/analysis.ts +++ b/src/lib/tauri/analysis.ts @@ -31,16 +31,43 @@ export interface LibraryAnalysisSummary { processed: number; failed: number; skipped: number; + /** + * `true` when the run exited early because the user clicked the + * "Stop" button (or any other call site triggered + * `cancelLibraryAnalysis`). UI uses this to render "Cancelled at + * X / Y" instead of pretending the run completed. Also `true` + * when a second `analyzeLibrary` call was rejected because one + * was already in flight. + */ + cancelled: boolean; } /** * Sweep the library for tracks lacking an analysis row and process * them sequentially. Emits `analysis:progress` along the way. + * + * Cooperative + cancellable since 1.5.1 (issue #286): the worker + * yields and sleeps 25 ms between tracks to keep the CPU available + * for the UI and OS background services, and honours + * `cancelLibraryAnalysis` at every iteration. Without these + * primitives a 4000-track first-time analysis would peg a laptop's + * CPU for 30+ minutes with no escape. */ export function analyzeLibrary(): Promise { return invoke("analyze_library"); } +/** + * Signal the in-flight library analyzer to stop at the next track + * boundary. Resolves with `true` when a run was actually in flight + * (so the UI can show a confirmation toast), `false` when nothing + * was running — clicking "Stop" twice or before "Start" is a + * no-op, not an error. + */ +export function cancelLibraryAnalysis(): Promise { + return invoke("cancel_library_analysis"); +} + /** * Read the per-profile auto-analyze flag. When `true`, every scan * that adds new tracks fires the analyzer in the background so the