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:
7 files changed
+644
-5
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 -----------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user