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(())
}
+168 -3
View File
@@ -11,12 +11,14 @@
//! kept in a module static; the Slint `LibraryFoldersState.folders` model is
//! the filtered render set derived from it.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use slint::{ComponentHandle, ModelRc, VecModel, Weak};
use crate::{AppWindow, LibFolderEditState, LibraryFolderItem, LibraryFoldersState};
use crate::{
AppWindow, LibFolderEditState, LibraryFolderItem, LibraryFoldersState, LibraryScanState,
};
/// One registered folder + its UI selection state. The authoritative copy;
/// the Slint model is derived (filtered) from this.
@@ -588,3 +590,166 @@ pub fn clear_library(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
pub fn set_filter(weak: Weak<AppWindow>) {
let _ = weak.upgrade_in_event_loop(|w| derive(&w));
}
// ================================ Scan ====================================
/// Cancel token for the running scan (the port's equivalent of Tauri's
/// `LibraryState.scan_cancel`). `stop_scan` sets it; the core loop checks it
/// at every file boundary.
static SCAN_CANCEL: LazyLock<Arc<AtomicBool>> = LazyLock::new(|| Arc::new(AtomicBool::new(false)));
fn basename(path: &str) -> String {
path.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(path)
.to_string()
}
/// ~100ms coalescing for the per-file event stream (a 16K-track scan would
/// otherwise flood the event loop). Terminal/phase events bypass this.
fn throttle_ok(last: &Mutex<std::time::Instant>) -> bool {
let mut g = last.lock().unwrap_or_else(|e| e.into_inner());
if g.elapsed() >= std::time::Duration::from_millis(100) {
*g = std::time::Instant::now();
true
} else {
false
}
}
/// Run a scan (full when `ids` is None, else the given enabled folders) on a
/// blocking thread, pushing throttled progress to `LibraryScanState`. On
/// finish: reload the folder list, reset the browse models so the tabs
/// re-fetch, and toast the outcome.
fn run_scan(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, ids: Option<Vec<i64>>) {
SCAN_CANCEL.store(false, Ordering::SeqCst);
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<LibraryScanState>();
s.set_scanning(true);
s.set_scan_status(1);
s.set_total_files(0);
s.set_processed_files(0);
s.set_progress(0.0);
s.set_current_file("".into());
s.set_error_count(0);
});
let h = handle.clone();
handle.spawn_blocking(move || {
let artwork_cache = crate::library_db::artwork_cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."));
let cancel = SCAN_CANCEL.clone();
let weak_sink = weak.clone();
let last = Mutex::new(std::time::Instant::now());
let sink = move |ev: qbz_library::ScanEvent| {
use qbz_library::ScanEvent::*;
match ev {
Started => {}
TotalsAdded { total } => {
let _ = weak_sink.upgrade_in_event_loop(move |w| {
w.global::<LibraryScanState>().set_total_files(total as i32);
});
}
FileStarted { path } => {
if throttle_ok(&last) {
let base = basename(&path);
let _ = weak_sink.upgrade_in_event_loop(move |w| {
w.global::<LibraryScanState>().set_current_file(base.into());
});
}
}
FileDone { processed, total } => {
if throttle_ok(&last) {
let _ = weak_sink.upgrade_in_event_loop(move |w| {
let s = w.global::<LibraryScanState>();
s.set_processed_files(processed as i32);
s.set_total_files(total as i32);
s.set_progress(if total > 0 {
(processed as f32 / total as f32).min(1.0)
} else {
0.0
});
});
}
}
Cleanup => {
let _ = weak_sink.upgrade_in_event_loop(|w| {
w.global::<LibraryScanState>()
.set_current_file("Cleaning up missing files...".into());
});
}
Finished { status, errors } => {
let st = match status {
qbz_library::ScanStatus::Complete => 2,
qbz_library::ScanStatus::Cancelled => 3,
qbz_library::ScanStatus::Error => 4,
_ => 0,
};
let ec = errors.len() as i32;
let _ = weak_sink.upgrade_in_event_loop(move |w| {
let s = w.global::<LibraryScanState>();
s.set_scanning(false);
s.set_scan_status(st);
s.set_error_count(ec);
s.set_current_file("".into());
if st == 2 {
s.set_progress(1.0);
}
});
match st {
2 if ec > 0 => crate::toast::success_weak(
&weak_sink,
format!("Scan complete ({ec} file(s) skipped)"),
),
2 => crate::toast::success_weak(&weak_sink, "Scan complete"),
3 => crate::toast::success_weak(&weak_sink, "Scan cancelled"),
_ => crate::toast::error_weak(&weak_sink, "Scan failed"),
}
}
}
};
let ids_ref = ids.as_deref();
let _ = crate::library_db::with_db(|db| {
qbz_library::scan_with_progress(db, ids_ref, &artwork_cache, &cancel, &sink)
});
// Post-scan: refresh the folder list (last_scan labels) + reset the
// browse models so the tabs re-fetch the new index on next visit.
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<crate::LocalLibraryState>();
let empty_albums = ModelRc::new(VecModel::from(Vec::<crate::AlbumCardItem>::new()));
let empty_tracks = ModelRc::new(VecModel::from(Vec::<crate::TrackItem>::new()));
s.set_albums(empty_albums.clone());
s.set_folders(empty_albums);
s.set_tracks(empty_tracks);
s.set_artists(ModelRc::new(VecModel::from(Vec::<crate::LocalArtistItem>::new())));
});
load_folders(weak, h);
});
}
/// Scan every enabled folder. Guards on an empty list.
pub fn scan_all(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let empty = folders_lock().is_empty();
if empty {
crate::toast::error_weak(&weak, "Add a folder before scanning");
return;
}
run_scan(weak, handle, None);
}
/// Scan a single folder (from the settings modal). Closes the modal first.
pub fn scan_folder(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, id: i64) {
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LibFolderEditState>().set_open(false);
});
run_scan(weak, handle, Some(vec![id]));
}
/// Request cancellation of the running scan.
pub fn stop_scan() {
SCAN_CANCEL.store(true, Ordering::SeqCst);
}
+21
View File
@@ -4125,6 +4125,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.global::<LibraryManageActions>()
.on_set_filter(move |_q| local_library_settings::set_filter(weak.clone()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<LibraryManageActions>()
.on_scan_all(move || local_library_settings::scan_all(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<LibraryManageActions>()
.on_scan_folder(move |id| {
local_library_settings::scan_folder(weak.clone(), handle.clone(), id as i64)
});
}
{
window
.global::<LibraryManageActions>()
.on_stop_scan(move || local_library_settings::stop_scan());
}
// Local Library — Albums tab controls (search / sort re-query page 1;
// load-more pages on scroll; retry) + the shared AlbumCollectionView's
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, FolderEditState, FolderEditActions, ToastState } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, FolderEditState, FolderEditActions, ToastState } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -9,7 +9,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { LibraryFoldersState, LibraryManageActions } from "../state.slint";
import { LibraryFoldersState, LibraryManageActions, LibraryScanState } from "../state.slint";
import { SettingRow } from "SettingRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
@@ -178,6 +178,7 @@ export component LocalLibrarySettings inherits VerticalLayout {
// Toolbar: scan-all / add / edit (one selected) / remove (any selected).
IconBtn {
icon: @image-url("../assets/icons/refresh-cw.svg");
enabled: !LibraryScanState.scanning;
clicked => { LibraryManageActions.scan-all(); }
}
IconBtn {
@@ -237,6 +238,82 @@ export component LocalLibrarySettings inherits VerticalLayout {
}
}
// --- Scan progress (determinate; only while scanning) ----------------
if LibraryScanState.scanning: Rectangle {
height: scan-box.preferred-height + 24px;
VerticalLayout {
scan-box := VerticalLayout {
padding-top: 14px;
spacing: 8px;
HorizontalLayout {
Text {
text: @tr("Scanning") + ": \{LibraryScanState.processed-files} / \{LibraryScanState.total-files}";
color: Theme.text-secondary;
font-size: Typography.body;
font-weight: Typography.medium;
horizontal-stretch: 1;
vertical-alignment: center;
}
// Stop scan.
Rectangle {
width: stop-row.preferred-width + 22px;
height: 30px;
border-radius: Radius.sm;
border-width: 1px;
border-color: #e0564f;
background: stop-ta.has-hover ? #e0564f22 : transparent;
stop-row := HorizontalLayout {
x: (parent.width - self.width) / 2;
spacing: 6px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 13px;
height: 13px;
tint: #e0564f;
}
}
VerticalLayout {
alignment: center;
Text {
text: @tr("Stop");
color: #e0564f;
font-size: Typography.legal;
font-weight: Typography.semibold;
}
}
}
stop-ta := TouchArea {
mouse-cursor: pointer;
clicked => { LibraryManageActions.stop-scan(); }
}
}
}
// Determinate bar (2 rectangles; ADR-008-clean).
Rectangle {
height: 6px;
border-radius: 3px;
background: Theme.surface-elevated;
clip: true;
Rectangle {
x: 0;
width: parent.width * LibraryScanState.progress;
height: parent.height;
border-radius: 3px;
background: Theme.accent;
}
}
if LibraryScanState.current-file != "": Text {
text: LibraryScanState.current-file;
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
}
}
Rectangle { height: 22px; }
// --- Maintenance -----------------------------------------------------
+12
View File
@@ -558,6 +558,18 @@ export global LibFolderEditState {
in property <string> last-scan-label: ""; // "Never" / date
}
// Library scan progress — a single global poll target (scan is not per-row,
// unlike the offline cache). `progress` is computed Rust-side (total==0 guard).
export global LibraryScanState {
in property <bool> scanning: false; // status == Scanning
in property <int> scan-status: 0; // 0 Idle,1 Scanning,2 Complete,3 Cancelled,4 Error
in property <int> total-files: 0;
in property <int> processed-files: 0;
in property <float> progress: 0.0; // 0..1
in property <string> current-file: ""; // basename, or the cleanup label
in property <int> error-count: 0;
}
export global LibraryManageActions {
callback load(); // (re)load the folder list (panel init)
callback add-folder(); // native dir picker (Rust)