feat(locallibrary): library scan engine + progress (Slice B)

Lift the Tauri scan-orchestration loop into the core crate and drive it from
Settings > Local Library with live progress + cancel.

Core (qbz-library/src/scan.rs): scan_with_progress(db, folder_ids, artwork_cache,
cancel, on_event) replicates v2_library_scan (full) and v2_library_scan_folder
(single) byte-for-byte: CUE files first, per-album sidecar override, embedded ->
folder artwork with a per-folder cache, insert, and missing-file cleanup (whole
DB for full, folder-prefix for single). Progress is pushed through a ScanEvent
callback (no IPC poll) and a cancel AtomicBool is checked at every file boundary
(no cleanup/scan-time on cancel, Tauri parity). Two verified improvements: the
full scan now stamps each folder's last_scan, and network status is re-detected
for every non-overridden scanned folder.

Slint: LibraryScanState global (single poll target; progress computed Rust-side);
a determinate 2-rect progress bar + counts + current-file + Stop button in the
panel; scan-all disabled while running. Controller run_scan() on spawn_blocking
with a ~100ms-coalesced sink (terminal/phase events bypass throttling), an owned
SCAN_CANCEL token, an outcome toast, and a post-scan reload (folder list +
reset browse models so the tabs re-fetch). scan-all/scan-folder/stop-scan wired.

cc
This commit is contained in:
vicrodh committed 2026-05-29 13:16:21 -06:00
1 parent 3826298f77
commit aa7c320b94
7 files changed
+644 -5

No files matched your search

+2
View File
@@ -35,6 +35,7 @@ mod errors;
mod metadata;
mod models;
mod mount_info;
mod scan;
mod scanner;
mod tag_sidecar;
mod thumbnails;
@@ -49,6 +50,7 @@ pub use errors::LibraryError;
pub use metadata::MetadataExtractor;
pub use models::*;
pub use mount_info::{is_network_path, network_fs_label};
pub use scan::{scan_with_progress, ScanEvent};
pub use scanner::{LibraryScanner, ScanResult};
pub use thumbnails::{
clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size,
+362
View File
@@ -0,0 +1,362 @@
//! Progress-emitting library scan (frontend-agnostic).
//!
//! Lifts the scan-orchestration loop that lived in the Tauri command
//! `v2_library_scan` / `v2_library_scan_folder` into the core crate so any
//! frontend (Slint, TUI) can drive it. The Tauri side polled an
//! `Arc<Mutex<ScanProgress>>` over the IPC boundary; in-process callers get
//! the same information pushed through `on_event` and check `cancel` at every
//! file boundary. The per-file logic (CUE-first, sidecar override, embedded →
//! folder artwork, insert, missing-file cleanup) is replicated exactly.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use crate::{
cue_to_tracks, AlbumTagSidecar, CueParser, LibraryDatabase, LibraryError, LibraryScanner,
LocalTrack, MetadataExtractor, ScanError, ScanStatus,
};
/// One step of a scan, pushed to the caller. The caller maps these onto its
/// own progress surface (and may coalesce the per-file stream).
pub enum ScanEvent {
/// Scan started (status = Scanning, counters reset).
Started,
/// A folder's file count was folded into the running total.
TotalsAdded { total: u32 },
/// A file is about to be processed (caller trims to a basename).
FileStarted { path: String },
/// A file finished (processed/total advanced).
FileDone { processed: u32, total: u32 },
/// Entering the missing-file cleanup phase.
Cleanup,
/// Terminal: Complete / Cancelled / Error, with any per-file errors.
Finished {
status: ScanStatus,
errors: Vec<ScanError>,
},
}
fn normalize_path(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Apply a per-album sidecar override to a scanned track, caching the sidecar
/// read per album group key. Mirrors Tauri's
/// `library_apply_sidecar_override_if_present`.
fn apply_sidecar_override(
track: &mut LocalTrack,
cache: &mut HashMap<String, Option<AlbumTagSidecar>>,
) {
let group_key = track.album_group_key.trim();
if group_key.is_empty() {
return;
}
let cached = cache.entry(group_key.to_string()).or_insert_with(|| {
let album_dir = Path::new(group_key);
if !album_dir.is_dir() {
return None;
}
crate::tag_sidecar::read_album_sidecar(album_dir).unwrap_or(None)
});
if let Some(sidecar) = cached.as_ref() {
crate::tag_sidecar::apply_sidecar_to_track(track, sidecar);
}
}
/// Parse a CUE sheet into its virtual tracks and insert them. Mirrors Tauri's
/// `library_process_cue_file` (the per-track artwork loop already covers what
/// the deprecated `update_album_group_artwork` shortcut did, so it is omitted).
fn process_cue_file(
db: &LibraryDatabase,
cue_path: &Path,
artwork_cache: &Path,
) -> Result<(), String> {
let mut cue = CueParser::parse(cue_path).map_err(|e| e.to_string())?;
let audio_path = normalize_path(Path::new(&cue.audio_file));
if !audio_path.exists() {
return Err(format!("Audio file not found: {}", cue.audio_file));
}
cue.audio_file = audio_path.to_string_lossy().to_string();
let properties = MetadataExtractor::extract_properties(&audio_path).map_err(|e| e.to_string())?;
let format = MetadataExtractor::detect_format(&audio_path);
let mut tracks = cue_to_tracks(&cue, properties.duration_secs, format, &properties);
if let Some(group_key) = tracks
.first()
.map(|t| t.album_group_key.trim().to_string())
.filter(|k| !k.is_empty())
{
let album_dir = Path::new(&group_key);
if album_dir.is_dir() {
if let Ok(Some(sidecar)) = crate::tag_sidecar::read_album_sidecar(album_dir) {
for t in tracks.iter_mut() {
crate::tag_sidecar::apply_sidecar_to_track(t, &sidecar);
}
}
}
}
let mut artwork = MetadataExtractor::extract_artwork(&audio_path, artwork_cache);
if artwork.is_none() {
if let Some(folder_art) =
MetadataExtractor::find_folder_artwork(&audio_path, cue.title.as_deref())
{
artwork =
MetadataExtractor::cache_artwork_file(Path::new(&folder_art), artwork_cache);
}
}
if let Some(p) = artwork.as_ref() {
for t in tracks.iter_mut() {
t.artwork_path = Some(p.clone());
}
}
for track in &tracks {
db.insert_track(track).map_err(|e| e.to_string())?;
}
Ok(())
}
/// Scan the library (or a single folder set) with progress + cancellation.
///
/// `folder_ids = None` scans every ENABLED folder (full scan); `Some(&[id])`
/// scans only those enabled folders (single/per-folder parity). `artwork_cache`
/// is the cache dir for extracted/copied covers. `cancel` is checked at every
/// file boundary; on cancel the scan returns early with `Finished{Cancelled}`
/// and does NOT run cleanup (Tauri parity). `on_event` receives each step.
///
/// Improvements over the Tauri loop (both verified against source):
/// - the full scan updates each folder's `last_scan` on success (Tauri only
/// did this for single-folder scans);
/// - network status is re-detected for every scanned folder that is not
/// user-overridden (Tauri only did this for single-folder scans).
pub fn scan_with_progress(
db: &LibraryDatabase,
folder_ids: Option<&[i64]>,
artwork_cache: &Path,
cancel: &AtomicBool,
on_event: &(dyn Fn(ScanEvent) + Send + Sync),
) -> Result<(), LibraryError> {
let all = db.get_folders_with_metadata()?;
let targets: Vec<crate::LibraryFolder> = match folder_ids {
None => all.into_iter().filter(|f| f.enabled).collect(),
Some(ids) => all
.into_iter()
.filter(|f| f.enabled && ids.contains(&f.id))
.collect(),
};
if targets.is_empty() {
return Err(LibraryError::Other("No library folders to scan".to_string()));
}
let single = folder_ids.is_some();
// Refresh network detection for non-overridden folders being scanned.
for f in &targets {
if f.user_override_network {
continue;
}
let p = Path::new(&f.path);
let is_net = crate::mount_info::is_network_path(p);
if is_net != f.is_network {
let fs = if is_net {
crate::mount_info::network_fs_label(p)
} else {
None
};
let _ = db.update_folder_settings(
f.id,
f.alias.as_deref(),
f.enabled,
is_net,
fs.as_deref(),
false,
);
}
}
on_event(ScanEvent::Started);
let scanner = LibraryScanner::new();
let mut all_errors: Vec<ScanError> = Vec::new();
let mut sidecar_cache: HashMap<String, Option<AlbumTagSidecar>> = HashMap::new();
let mut total: u32 = 0;
let mut processed: u32 = 0;
for folder in &targets {
let scan_result = match scanner.scan_directory(Path::new(&folder.path)) {
Ok(r) => r,
Err(e) => {
all_errors.push(ScanError {
file_path: folder.path.clone(),
error: e.to_string(),
});
continue;
}
};
total += (scan_result.audio_files.len() + scan_result.cue_files.len()) as u32;
on_event(ScanEvent::TotalsAdded { total });
// CUE files first (one file -> several virtual tracks).
for cue_path in &scan_result.cue_files {
if cancel.load(Ordering::Relaxed) {
on_event(ScanEvent::Finished {
status: ScanStatus::Cancelled,
errors: std::mem::take(&mut all_errors),
});
return Ok(());
}
on_event(ScanEvent::FileStarted {
path: cue_path.to_string_lossy().to_string(),
});
if let Err(e) = process_cue_file(db, cue_path, artwork_cache) {
all_errors.push(ScanError {
file_path: cue_path.to_string_lossy().to_string(),
error: e,
});
}
processed += 1;
on_event(ScanEvent::FileDone { processed, total });
}
// Audio files (skipping any referenced by a CUE sheet).
let cue_audio_files: HashSet<String> = scan_result
.cue_files
.iter()
.filter_map(|p| {
CueParser::parse(p).ok().map(|cue| {
normalize_path(Path::new(&cue.audio_file))
.to_string_lossy()
.to_string()
})
})
.collect();
let mut folder_artwork_cache: HashMap<PathBuf, Option<String>> = HashMap::new();
for audio_path in &scan_result.audio_files {
if cancel.load(Ordering::Relaxed) {
on_event(ScanEvent::Finished {
status: ScanStatus::Cancelled,
errors: std::mem::take(&mut all_errors),
});
return Ok(());
}
let canonical = normalize_path(audio_path);
let path_str = canonical.to_string_lossy().to_string();
if cue_audio_files.contains(&path_str) {
processed += 1;
on_event(ScanEvent::FileDone { processed, total });
continue;
}
on_event(ScanEvent::FileStarted {
path: path_str.clone(),
});
match MetadataExtractor::extract(&canonical) {
Ok(mut track) => {
apply_sidecar_override(&mut track, &mut sidecar_cache);
let mut artwork = MetadataExtractor::extract_artwork(&canonical, artwork_cache);
if artwork.is_none() {
let album_hint: Option<String> = if !track.album_group_title.is_empty() {
Some(track.album_group_title.clone())
} else {
Some(track.album.clone())
};
let folder_dir = canonical
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| canonical.clone());
let cached = folder_artwork_cache
.entry(folder_dir)
.or_insert_with(|| {
MetadataExtractor::find_folder_artwork(
&canonical,
album_hint.as_deref(),
)
})
.clone();
if let Some(folder_art) = cached {
artwork = MetadataExtractor::cache_artwork_file(
Path::new(&folder_art),
artwork_cache,
);
}
}
track.artwork_path = artwork;
if let Err(e) = db.insert_track(&track) {
all_errors.push(ScanError {
file_path: path_str,
error: e.to_string(),
});
}
}
Err(e) => all_errors.push(ScanError {
file_path: path_str,
error: e.to_string(),
}),
}
processed += 1;
on_event(ScanEvent::FileDone { processed, total });
}
}
// Cleanup: remove tracks whose files no longer exist. Full scan checks the
// whole DB; single-folder scan only the scanned folders' subtrees.
on_event(ScanEvent::Cleanup);
if let Ok(tracks) = db.get_all_track_paths() {
let missing: Vec<i64> = if single {
let prefixes: Vec<String> = targets
.iter()
.map(|f| {
if f.path.ends_with('/') {
f.path.clone()
} else {
format!("{}/", f.path)
}
})
.collect();
tracks
.iter()
.filter(|(_, p)| {
prefixes.iter().any(|pre| p.starts_with(pre)) && !Path::new(p).exists()
})
.map(|(id, _)| *id)
.collect()
} else {
tracks
.iter()
.filter(|(_, p)| !Path::new(p).exists())
.map(|(id, _)| *id)
.collect()
};
for chunk in missing.chunks(500) {
let _ = db.delete_tracks_by_ids(chunk);
}
}
// Stamp each scanned folder's last_scan (improvement: full scan too).
let now = now_secs();
for f in &targets {
let _ = db.update_folder_scan_time(&f.path, now);
}
on_event(ScanEvent::Finished {
status: ScanStatus::Complete,
errors: all_errors,
});
Ok(())
}