From fe36f089186362660762ba6278f7acb8e076e43b Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:00:42 +0700 Subject: [PATCH] feat(backend): hold library scans in place while paused Path and SAF scans wait at their next file checkpoint while paused and keep their position in memory. Cancel, backend shutdown and released request leases still end a paused scan. --- .../kotlin/com/zarz/spotiflac/CoreBackend.kt | 2 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 18 +++ .../com/zarz/spotiflac/MainActivitySafScan.kt | 32 +++-- .../com/zarz/spotiflac/SelectedCoreBackend.kt | 4 + ios/Runner/AppDelegate.swift | 11 +- ios/Runner/CoreBackend.swift | 16 +++ .../extensions/src/backend/library/scan.rs | 123 +++++++++++++++++- rust_backend/crates/mobile/src/tags.rs | 12 ++ 8 files changed, 206 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt index 11ed6606..1be1b2b2 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt @@ -203,6 +203,8 @@ internal interface CoreBackend { fun scanLibraryFolderIncrementalFromSnapshot(folder: String, snapshot: String): String fun getLibraryScanProgress(): String fun cancelLibraryScan() + fun pauseLibraryScan() + fun resumeLibraryScan() fun parseCueSheet(path: String, audioDirectory: String): String fun parseCueSheetWithResolvedAudio(path: String, audioPath: String): String fun scanCueForLibrary(path: String, audioDirectory: String, virtualPrefix: String, modTime: Long, cacheKey: String): String diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 0769190f..dbcad63e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -101,6 +101,8 @@ class MainActivity: FlutterFragmentActivity() { "scanSafTreeIncrementalFromSnapshot", "getLibraryScanProgress", "cancelLibraryScan", + "pauseLibraryScan", + "resumeLibraryScan", "parseCueSheet", "pickSafTree", "safExists", @@ -167,6 +169,7 @@ class MainActivity: FlutterFragmentActivity() { private val playbackLeaseLock = Any() private val playbackLeases = LinkedHashMap() @Volatile internal var safScanCancel = false + @Volatile internal var safScanPaused = false @Volatile internal var safScanActive = false private val safTreeLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() @@ -2018,10 +2021,25 @@ class MainActivity: FlutterFragmentActivity() { "cancelLibraryScan" -> { withContext(Dispatchers.IO) { safScanCancel = true + safScanPaused = false coreBackend.cancelLibraryScan() } result.success(null) } + "pauseLibraryScan" -> { + withContext(Dispatchers.IO) { + safScanPaused = true + coreBackend.pauseLibraryScan() + } + result.success(null) + } + "resumeLibraryScan" -> { + withContext(Dispatchers.IO) { + safScanPaused = false + coreBackend.resumeLibraryScan() + } + result.success(null) + } "readAudioMetadata" -> { val filePath = call.argument("file_path") ?: "" val response = withContext(Dispatchers.IO) { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt index 0ef31ecf..dc530264 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -590,6 +590,18 @@ private fun rememberCueDirectoryListing( // Provider round trips dominate on SD cards, USB drives and network shares. private const val SAF_LIST_CONCURRENCY = 4 private const val SAF_READ_WORKERS = 6 +private const val SAF_SCAN_PAUSE_POLL_MS = 100L + +/** + * Holds a SAF scan at its checkpoint while paused, keeping its position in + * memory. Returns true when the scan should stop because it was cancelled. + */ +internal fun MainActivity.safScanStopRequested(): Boolean { + while (safScanPaused && !safScanCancel) { + Thread.sleep(SAF_SCAN_PAUSE_POLL_MS) + } + return safScanCancel +} /** * Prefetches the listings of directories already waiting in a breadth-first @@ -892,7 +904,7 @@ internal fun MainActivity.scanSafTree( val lister = SafTreeLister(this) while (queue.isNotEmpty()) { - if (safScanCancel) { + if (safScanStopRequested()) { return cancelledResult() } @@ -916,7 +928,7 @@ internal fun MainActivity.scanSafTree( rememberCueDirectoryListing(dir, children, safChildLookupCache) for (child in children) { - if (safScanCancel) { + if (safScanStopRequested()) { return cancelledResult() } @@ -1036,7 +1048,7 @@ internal fun MainActivity.scanSafTree( val parentDir = cue.parentDir val cueUri = cueDoc.uri.toString() val cueAlreadyIndexed = checkpointed(cueUri, cue.lastModified) - if (safScanCancel) { + if (safScanStopRequested()) { ndjsonWriter?.close() spill?.abandon() return cancelledResult() @@ -1148,7 +1160,7 @@ internal fun MainActivity.scanSafTree( // Skip resumable and CUE entries before parallel reads. for (audio in audioFiles) { val doc = audio.doc - if (safScanCancel) { + if (safScanStopRequested()) { ndjsonWriter?.close() spill?.abandon() return cancelledResult() @@ -1176,7 +1188,7 @@ internal fun MainActivity.scanSafTree( // pool is bounded by provider round trips rather than copy memory. val completed = runSafReadsInOrder( pendingAudio, - cancelled = { safScanCancel }, + cancelled = { safScanStopRequested() }, task = { audio -> val doc = audio.doc val stableUri = doc.uri.toString() @@ -1326,7 +1338,7 @@ internal fun MainActivity.scanSafTreeIncremental( val lister = SafTreeLister(this) while (queue.isNotEmpty()) { - if (safScanCancel) { + if (safScanStopRequested()) { updateSafScanProgress { it.isComplete = true } val result = JSONObject() result.put("files", JSONArray()) @@ -1357,7 +1369,7 @@ internal fun MainActivity.scanSafTreeIncremental( rememberCueDirectoryListing(dir, children, safChildLookupCache) for (child in children) { - if (safScanCancel) { + if (safScanStopRequested()) { updateSafScanProgress { it.isComplete = true } val result = JSONObject() result.put("files", JSONArray()) @@ -1461,7 +1473,7 @@ internal fun MainActivity.scanSafTreeIncremental( val cueReferencedAudioUris = mutableSetOf() for ((cueDoc, parentDir, cueName, cueLastModified) in cueFilesToScan) { - if (safScanCancel) { + if (safScanStopRequested()) { updateSafScanProgress { it.isComplete = true } spill.abandon() val result = JSONObject() @@ -1620,7 +1632,7 @@ internal fun MainActivity.scanSafTreeIncremental( val pendingAudio = mutableListOf() for (audio in audioFiles) { - if (safScanCancel) return cancelledIncrementalResult() + if (safScanStopRequested()) return cancelledIncrementalResult() if (cueReferencedAudioUris.contains(audio.doc.uri.toString())) { scanned++ reportProcessed() @@ -1631,7 +1643,7 @@ internal fun MainActivity.scanSafTreeIncremental( val completed = runSafReadsInOrder( pendingAudio, - cancelled = { safScanCancel }, + cancelled = { safScanStopRequested() }, task = { audio -> val ext = audio.name.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null diff --git a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt index 0c8cd1ee..eed99b12 100644 --- a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt +++ b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt @@ -335,6 +335,10 @@ internal object RustCoreBackend : CoreBackend { override fun cancelLibraryScan() { synchronized(this) { manager }?.cancelLibraryScan() } + override fun pauseLibraryScan() { synchronized(this) { manager }?.pauseLibraryScan() } + + override fun resumeLibraryScan() { synchronized(this) { manager }?.resumeLibraryScan() } + override fun parseCueSheet(path: String, audioDirectory: String): String { val cue = File(path).canonicalFile val audio = if (audioDirectory.isEmpty()) cue.parentFile!! else File(audioDirectory).canonicalFile diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index abffac74..35bb7ccf 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -263,7 +263,8 @@ import UniformTypeIdentifiers "startBackgroundWork", "updateBackgroundWork", "stopBackgroundWork", "pickIosDirectory", "startAccessingIosBookmark", "stopAccessingIosBookmark", "downloadCoverToFile", "releaseMemory", "releaseMemoryUnderPressure", "setLibraryCoverCacheDir", "scanLibraryFolderToNDJSONFile", "scanLibraryFolderIncremental", - "getLibraryScanProgress", "cancelLibraryScan", "parseCueSheet", "extractCoverToFile", + "getLibraryScanProgress", "cancelLibraryScan", "pauseLibraryScan", "resumeLibraryScan", + "parseCueSheet", "extractCoverToFile", "rewriteSplitArtistTags", "writeM4AFreeformTags", "ensureAC4Config", "writeAC4Metadata", "reEnrichFile", "checkHiResAuthenticity"] if call.method == "setScreenAwake" { @@ -616,6 +617,14 @@ import UniformTypeIdentifiers try coreBackend.cancelLibraryScan() return nil + case "pauseLibraryScan": + try coreBackend.pauseLibraryScan() + return nil + + case "resumeLibraryScan": + try coreBackend.resumeLibraryScan() + return nil + case "startAccessingIosBookmark": guard diff --git a/ios/Runner/CoreBackend.swift b/ios/Runner/CoreBackend.swift index d96dccb4..88e7a143 100644 --- a/ios/Runner/CoreBackend.swift +++ b/ios/Runner/CoreBackend.swift @@ -177,6 +177,8 @@ protocol CoreBackend { func scanLibraryFolderIncremental(folder: String, existing: String) throws -> String func getLibraryScanProgress() throws -> String func cancelLibraryScan() throws + func pauseLibraryScan() throws + func resumeLibraryScan() throws func parseCueSheet(path: String, audioDirectory: String) throws -> String func openDownloadDirectory(path: String) throws -> CoreDirectoryScope func createTemporaryMediaFile(prefix: String, suffix: String) throws -> URL @@ -378,6 +380,20 @@ final class RustCoreBackend: CoreBackend { try current?.cancelLibraryScan() } + func pauseLibraryScan() throws { + ownerLock.lock() + let current = manager + ownerLock.unlock() + try current?.pauseLibraryScan() + } + + func resumeLibraryScan() throws { + ownerLock.lock() + let current = manager + ownerLock.unlock() + try current?.resumeLibraryScan() + } + func parseCueSheet(path: String, audioDirectory: String) throws -> String { let cue = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL let audio = audioDirectory.isEmpty ? cue.deletingLastPathComponent() : URL(fileURLWithPath: audioDirectory).resolvingSymlinksInPath().standardizedFileURL diff --git a/rust_backend/crates/extensions/src/backend/library/scan.rs b/rust_backend/crates/extensions/src/backend/library/scan.rs index 2ba88754..ed3c68e5 100644 --- a/rust_backend/crates/extensions/src/backend/library/scan.rs +++ b/rust_backend/crates/extensions/src/backend/library/scan.rs @@ -8,16 +8,23 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io::{BufRead, BufReader, BufWriter, Read, Write}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, TryLockError, mpsc}; +use std::sync::{Arc, Condvar, Mutex, TryLockError, mpsc}; use std::thread; use std::time::Duration; type Check<'a> = &'a (dyn Fn() -> Result<(), String> + Sync); +/// How often a paused scan re-checks backend shutdown and its request lease. +const PAUSE_POLL: Duration = Duration::from_millis(250); + #[derive(Default)] pub(in crate::backend) struct ScanState { current: Mutex>>, owner: Mutex<()>, + // Pausing is scan-wide rather than per run so a pause requested just + // before the native scan starts still holds it at its first checkpoint. + paused: Mutex, + resumed: Condvar, } #[derive(Default)] @@ -72,6 +79,51 @@ impl Backend { { run.cancelled.store(true, Ordering::Release); } + // Wake paused workers so they observe the cancellation. + self.set_library_scan_paused(false); + Ok(()) + } + + /// Holds the scan at its next checkpoint. Workers keep their position in + /// memory, so [`Self::resume_library_scan`] continues with the next file. + pub fn pause_library_scan(&self) -> Result<(), String> { + let _operation = self.enter()?; + self.set_library_scan_paused(true); + Ok(()) + } + + pub fn resume_library_scan(&self) -> Result<(), String> { + let _operation = self.enter()?; + self.set_library_scan_paused(false); + Ok(()) + } + + fn set_library_scan_paused(&self, paused: bool) { + *self + .library_scan + .paused + .lock() + .expect("library scan pause lock") = paused; + self.library_scan.resumed.notify_all(); + } + + fn wait_while_library_scan_paused(&self, run: &Run, check: Check<'_>) -> Result<(), String> { + let state = &self.library_scan; + let mut paused = state.paused.lock().expect("library scan pause lock"); + while *paused && !run.cancelled.load(Ordering::Acquire) { + paused = state + .resumed + .wait_timeout(paused, PAUSE_POLL) + .expect("library scan pause lock") + .0; + if *paused { + drop(paused); + // Shutdown and released request leases still end a paused scan. + self.check()?; + check()?; + paused = state.paused.lock().expect("library scan pause lock"); + } + } Ok(()) } @@ -321,6 +373,9 @@ impl Backend { previous.cancelled.store(true, Ordering::Release); } let check = || { + // Wait before taking the failure lock so paused workers do not + // serialize on it. + self.wait_while_library_scan_paused(&run, check)?; let mut failure = run.failure.lock().expect("scan failure lock"); if let Some(error) = failure.as_ref() { return Err(error.clone()); @@ -683,4 +738,70 @@ mod tests { "native media path must not contain symlinks or special files" ); } + + fn paused_library(root: &Path) -> (Backend, String) { + let library = fs::canonicalize(root).unwrap().join("library"); + fs::create_dir_all(&library).unwrap(); + for index in 0..3 { + fs::write(library.join(format!("{index}.mp3")), b"not audio").unwrap(); + } + let backend = backend(root, &library); + backend.pause_library_scan().unwrap(); + (backend, library.to_string_lossy().into_owned()) + } + + fn wait_for_run(backend: &Backend) { + while backend + .library_scan + .current + .lock() + .expect("library scan lock") + .is_none() + { + thread::sleep(Duration::from_millis(5)); + } + } + + #[test] + fn paused_scan_holds_its_position_until_resumed() { + let root = tempfile::tempdir().unwrap(); + let (backend, folder) = paused_library(root.path()); + + thread::scope(|scope| { + let scan = + scope.spawn(|| backend.scan_library_folder_incremental(&folder, "{}", &|| Ok(()))); + wait_for_run(&backend); + thread::sleep(Duration::from_millis(300)); + assert!(!scan.is_finished()); + let progress = backend.get_library_scan_progress().unwrap(); + assert_eq!(progress["scanned_files"], 0); + assert_eq!(progress["is_complete"], false); + + backend.resume_library_scan().unwrap(); + let result = scan.join().unwrap().unwrap(); + assert_eq!(result["totalFiles"], 3); + }); + let progress = backend.get_library_scan_progress().unwrap(); + assert_eq!(progress["scanned_files"], 3); + assert_eq!(progress["is_complete"], true); + } + + #[test] + fn cancel_releases_a_paused_scan() { + let root = tempfile::tempdir().unwrap(); + let (backend, folder) = paused_library(root.path()); + + thread::scope(|scope| { + let scan = + scope.spawn(|| backend.scan_library_folder_incremental(&folder, "{}", &|| Ok(()))); + wait_for_run(&backend); + backend.cancel_library_scan().unwrap(); + assert_eq!(scan.join().unwrap().unwrap_err(), "scan cancelled"); + }); + // Cancelling also clears the pause, so the next scan is not held. + let result = backend + .scan_library_folder_incremental(&folder, "{}", &|| Ok(())) + .unwrap(); + assert_eq!(result["totalFiles"], 3); + } } diff --git a/rust_backend/crates/mobile/src/tags.rs b/rust_backend/crates/mobile/src/tags.rs index ccd5aa9a..d72a9f1a 100644 --- a/rust_backend/crates/mobile/src/tags.rs +++ b/rust_backend/crates/mobile/src/tags.rs @@ -145,6 +145,18 @@ impl ExtensionManager { .map_err(ExtensionManagerError::Operation) } + pub fn pause_library_scan(&self) -> Result<(), ExtensionManagerError> { + self.inner + .pause_library_scan() + .map_err(ExtensionManagerError::Operation) + } + + pub fn resume_library_scan(&self) -> Result<(), ExtensionManagerError> { + self.inner + .resume_library_scan() + .map_err(ExtensionManagerError::Operation) + } + pub fn rewrite_split_artist_tags( &self, path: String,