From 0bde96e3210e54a8e14c620622d1e55334d231cf Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:10:31 -0400 Subject: [PATCH] add cancel button to scanner --- .../data/RoomLibraryRepositoryTest.kt | 37 ++++++ .../AstraLibraryScannerModule.kt | 105 +++++++++++------- .../data/AstraLibraryRepository.kt | 36 ++++++ .../data/CatalogReadModelBuilder.kt | 2 + modules/astra-library-scanner/index.ts | 3 + package.json | 1 + scripts/run-release-tests.mjs | 1 + src/components/library/ScanProgress.tsx | 50 ++++++++- src/components/onboarding/OnboardingFlow.tsx | 31 +++++- src/library/scanCancellation.test.mts | 84 ++++++++++++++ src/library/scanCancellation.ts | 60 ++++++++++ src/library/scanner.ts | 73 ++++++++---- src/stores/libraryStore.ts | 45 ++++++-- 13 files changed, 452 insertions(+), 76 deletions(-) create mode 100644 src/library/scanCancellation.test.mts create mode 100644 src/library/scanCancellation.ts diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt index f7e8996..4ff447d 100644 --- a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt @@ -127,6 +127,43 @@ class RoomLibraryRepositoryTest { assertNull(dao.getGeneration("active")) } + @Test + fun cancelledScanDiscardsStagingAndRestoresFolderWithoutPublishing() = runBlocking { + publish("active", listOf(track("active", 1, "Last known good"))) + val catalogDao = catalog.catalogDao() + val userDao = user.userDao() + val revision = catalogDao.getRevision() + val scannedAt = 1234L + val folderId = userDao.insertFolder( + FolderEntity( + treeUri = "content://music", + displayName = "Music", + addedAt = 1, + lastScannedAt = scannedAt, + lastScanStatus = "ready", + ), + ) + + catalogDao.insertGeneration(ScanGenerationEntity("cancelled", "local:1", "staging", 2)) + catalogDao.putTracks(listOf(track("cancelled", 2, "Half written scan"))) + userDao.updateFolderScanState(folderId, scannedAt, "scanning", null) + + catalogDao.deleteGenerationTracks("cancelled") + catalogDao.deleteGeneration("cancelled") + userDao.updateFolderScanState(folderId, scannedAt, "ready", null) + + assertNull(catalogDao.getGeneration("cancelled")) + assertEquals(revision, catalogDao.getRevision()) + assertEquals( + listOf("Last known good"), + catalogDao.getTitlePage(null, "", 10).map { it.title }, + ) + val restored = userDao.getFolder(folderId) + assertEquals(scannedAt, restored?.lastScannedAt) + assertEquals("ready", restored?.lastScanStatus) + assertNull(restored?.lastScanError) + } + @Test fun userMutationsAndVirtualQueueAreAtomicAndDurable() = runBlocking { val dao = user.userDao() diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt index 0591f70..2d4a219 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt @@ -41,6 +41,7 @@ import expo.modules.kotlin.records.Record import expo.modules.astralibraryscanner.data.AstraLibraryRepository import expo.modules.astralibraryscanner.data.LocalAudioFile import expo.modules.astralibraryscanner.data.LocalAudioMetadata +import expo.modules.astralibraryscanner.data.ScanCancelledException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -111,6 +112,11 @@ class AstraLibraryScannerModule : Module() { // decode can be cancelled too. private val activeAnalyses = ConcurrentHashMap() + // Scans are serialized by the repository, but register before acquiring that lock so + // cancelScan also stops a queued scan. A set keeps the native boundary robust if a + // caller ever bypasses the JS single-scan guard. + private val activeScans = ConcurrentHashMap.newKeySet() + override fun definition() = ModuleDefinition { Name("AstraLibraryScanner") @@ -134,49 +140,62 @@ class AstraLibraryScannerModule : Module() { mode: String, extensions: List, -> - withContext(Dispatchers.IO) { - val repository = AstraLibraryRepository.get(requireContext()) - repository.withUserRecovery { scanLocalFolder( - folderId = folderId.toLong(), - full = mode == "full", - discover = { treeUri -> - val listing = listAudioFiles(treeUri, extensions) - @Suppress("UNCHECKED_CAST") - val files = listing["files"] as? List> ?: emptyList() - @Suppress("UNCHECKED_CAST") - val covers = listing["covers"] as? Map ?: emptyMap() - files.mapNotNull { file -> - val uri = file["uri"] as? String ?: return@mapNotNull null - val parentUri = file["parentUri"] as? String ?: "" - LocalAudioFile( - uri = uri, - name = file["name"] as? String ?: uri.substringAfterLast('/'), - size = (file["size"] as? Number)?.toLong(), - lastModified = (file["lastModified"] as? Number)?.toLong() ?: 0L, - mimeType = file["mimeType"] as? String, - parentUri = parentUri, - coverUri = covers[parentUri], + val cancelFlag = AtomicBoolean(false) + activeScans.add(cancelFlag) + try { + withContext(Dispatchers.IO) { + val repository = AstraLibraryRepository.get(requireContext()) + repository.withUserRecovery { scanLocalFolder( + folderId = folderId.toLong(), + full = mode == "full", + discover = { treeUri -> + val listing = listAudioFiles(treeUri, extensions, cancelFlag) + @Suppress("UNCHECKED_CAST") + val files = listing["files"] as? List> ?: emptyList() + @Suppress("UNCHECKED_CAST") + val covers = listing["covers"] as? Map ?: emptyMap() + files.mapNotNull { file -> + val uri = file["uri"] as? String ?: return@mapNotNull null + val parentUri = file["parentUri"] as? String ?: "" + LocalAudioFile( + uri = uri, + name = file["name"] as? String ?: uri.substringAfterLast('/'), + size = (file["size"] as? Number)?.toLong(), + lastModified = (file["lastModified"] as? Number)?.toLong() ?: 0L, + mimeType = file["mimeType"] as? String, + parentUri = parentUri, + coverUri = covers[parentUri], + ) + } + }, + extract = { file -> + extractOne(file.uri, file.coverUri).toLocalAudioMetadata() + }, + onProgress = { phase, processed, total, folderName -> + sendEvent( + "onScanProgress", + mapOf( + "phase" to phase, + "processed" to processed, + "total" to total, + "folderName" to folderName, + ), ) - } - }, - extract = { file -> - extractOne(file.uri, file.coverUri).toLocalAudioMetadata() - }, - onProgress = { phase, processed, total, folderName -> - sendEvent( - "onScanProgress", - mapOf( - "phase" to phase, - "processed" to processed, - "total" to total, - "folderName" to folderName, - ), - ) - }, - ).toMap() } + }, + isCancelled = cancelFlag::get, + ).toMap() } + } + } finally { + activeScans.remove(cancelFlag) } } + // Request cooperative cancellation for every active or queued library scan. + // Each scan unwinds at a safe checkpoint and discards its staging generation. + Function("cancelScan") { + activeScans.forEach { it.set(true) } + } + // ONE whole-file PCM decode producing waveform peaks and (when withLoudness) gated // integrated loudness + sample peak. Both need every sample, so they ride the same // pass — running them separately meant decoding each track twice. Heavy, so cap @@ -494,7 +513,11 @@ class AstraLibraryScannerModule : Module() { private val coverBaseNames = listOf("cover", "folder", "front", "albumart") private val coverExtensions = setOf("jpg", "jpeg", "png", "webp") - private fun listAudioFiles(treeUri: String, extensions: List): Map { + private fun listAudioFiles( + treeUri: String, + extensions: List, + cancelFlag: AtomicBoolean? = null, + ): Map { coverHashMemo.clear() val resolver = requireContext().contentResolver @@ -517,6 +540,7 @@ class AstraLibraryScannerModule : Module() { queue.add(DocumentsContract.getTreeDocumentId(tree)) while (queue.isNotEmpty()) { + if (cancelFlag?.get() == true) throw ScanCancelledException() val dirDocId = queue.removeFirst() val parentUri = DocumentsContract.buildDocumentUriUsingTree(tree, dirDocId).toString() val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree, dirDocId) @@ -525,6 +549,7 @@ class AstraLibraryScannerModule : Module() { ?: continue // directory disappeared mid-walk; skip it cursor.use { while (it.moveToNext()) { + if (cancelFlag?.get() == true) throw ScanCancelledException() val docId = it.getString(0) ?: continue val name = it.getString(1) ?: continue val mime = it.getString(2) ?: "" diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt index 4b407a9..d4f3464 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt @@ -36,12 +36,17 @@ private const val MOBILE_SESSION_ID = "mobile" private const val ACTIVE_PLAYBACK_CONTEXT_ID = "active-context" class StaleRevisionException : IllegalStateException("STALE_REVISION") +internal class ScanCancelledException : IllegalStateException("SCAN_CANCELLED") internal fun boundedPlaybackWindowStart(start: Long, total: Long): Long? { val normalized = start.coerceAtLeast(0) return normalized.takeIf { it < total } } +private fun throwIfScanCancelled(isCancelled: () -> Boolean) { + if (isCancelled()) throw ScanCancelledException() +} + private data class RemoteSyncHandle( val syncId: String, val sourceKey: String, @@ -355,6 +360,7 @@ class AstraLibraryRepository private constructor( discover: suspend (String) -> List, extract: suspend (LocalAudioFile) -> LocalAudioMetadata, onProgress: (phase: String, processed: Int, total: Int, folderName: String) -> Unit, + isCancelled: () -> Boolean = { false }, ): NativeScanResult { initialize() return catalogWriterMutex.withLock { @@ -387,8 +393,10 @@ class AstraLibraryRepository private constructor( updateOperationalStatus(LibraryStatus.SCANNING) try { + throwIfScanCancelled(isCancelled) onProgress("discovering", 0, 0, folder.displayName) val files = discover(folder.treeUri) + throwIfScanCancelled(isCancelled) onProgress("discovering", files.size, files.size, folder.displayName) val existing = dao.getActiveTrackEntitiesForSource(sourceKey) @@ -401,9 +409,11 @@ class AstraLibraryRepository private constructor( var processed = 0 for (batch in files.chunked(24)) { + throwIfScanCancelled(isCancelled) val rows = coroutineScope { batch.map { file -> async(Dispatchers.IO) { + throwIfScanCancelled(isCancelled) val old = existingByPath[file.uri] val unchanged = !full && old != null && @@ -422,6 +432,7 @@ class AstraLibraryRepository private constructor( ) to false } else { val metadata = extract(file) + throwIfScanCancelled(isCancelled) if (!metadata.ok) { if (old != null) { old.copy(id = 0, generationId = generationId, sourceKey = sourceKey) to true @@ -442,6 +453,7 @@ class AstraLibraryRepository private constructor( } }.awaitAll() } + throwIfScanCancelled(isCancelled) val insertRows = ArrayList(rows.size) for ((row, failed) in rows) { if (failed) errors += 1 @@ -454,6 +466,7 @@ class AstraLibraryRepository private constructor( onProgress("extracting", processed, files.size, folder.displayName) } + throwIfScanCancelled(isCancelled) val prospective = dao.getProspectiveTracks(sourceKey, generationId) val nextRevision = dao.getRevision() + 1 onProgress("indexing", prospective.size, prospective.size, folder.displayName) @@ -464,6 +477,9 @@ class AstraLibraryRepository private constructor( userDao.getFolders().associateBy(FolderEntity::id), ) } + // Publishing is one Room transaction. Honour cancellation immediately before + // it starts; once inside, let it finish so the active catalog stays coherent. + throwIfScanCancelled(isCancelled) val revision = dao.publishGeneration( sourceKey = sourceKey, generationId = generationId, @@ -488,6 +504,26 @@ class AstraLibraryRepository private constructor( total = files.size, revision = revision, ) + } catch (_: ScanCancelledException) { + dao.deleteGenerationTracks(generationId) + dao.deleteGeneration(generationId) + userDao.updateFolderScanState( + folderId, + folder.lastScannedAt, + folder.lastScanStatus, + folder.lastScanError, + ) + refreshReadyStatus() + scheduleSnapshot() + NativeScanResult( + added = 0, + updated = 0, + removed = 0, + errors = 0, + total = 0, + revision = dao.getRevision(), + cancelled = true, + ) } catch (error: Throwable) { runCatching { dao.deleteGenerationTracks(generationId) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt index 02bd9ad..66ab8e9 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt @@ -57,6 +57,7 @@ data class NativeScanResult( val errors: Int, val total: Int, val revision: Long, + val cancelled: Boolean = false, ) { fun toMap(): Map = mapOf( "added" to added, @@ -65,6 +66,7 @@ data class NativeScanResult( "errors" to errors, "total" to total, "catalogRevision" to revision.toString(), + "cancelled" to cancelled, ) } diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 6416a94..7f84dfc 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -87,6 +87,7 @@ export interface NativeScanResult { errors: number; total: number; catalogRevision: string; + cancelled: boolean; } /** @@ -135,6 +136,8 @@ declare class AstraLibraryScannerModuleType extends NativeModule; + /** Cooperatively stop every active or queued library scan at its next safe checkpoint. */ + cancelScan(): void; /** * ONE whole-file PCM decode producing `bins` RMS waveform peaks and, when * `withLoudness`, gated integrated LUFS + sample peak. Both analyses need every diff --git a/package.json b/package.json index d9a5148..841e8f3 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts", "test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts", "test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts", + "test:library-scan": "node --experimental-strip-types --test src/library/scanCancellation.test.mts", "test:release-config": "node --experimental-strip-types --test plugins/withAstraAndroidRelease.test.mjs scripts/release/android-release.test.mjs src/release/buildInfo.test.mts", "test:release": "node scripts/run-release-tests.mjs", "release:validate": "node scripts/release/android-release.mjs validate github && node scripts/release/android-release.mjs validate google-play", diff --git a/scripts/run-release-tests.mjs b/scripts/run-release-tests.mjs index 1b611c0..9900eb6 100644 --- a/scripts/run-release-tests.mjs +++ b/scripts/run-release-tests.mjs @@ -23,6 +23,7 @@ const TEST_SCRIPTS = [ 'test:haptics', 'test:home-greeting', 'test:session', + 'test:library-scan', ]; for (const script of TEST_SCRIPTS) { diff --git a/src/components/library/ScanProgress.tsx b/src/components/library/ScanProgress.tsx index 6c6a7dc..f230b3d 100644 --- a/src/components/library/ScanProgress.tsx +++ b/src/components/library/ScanProgress.tsx @@ -1,6 +1,7 @@ -import { View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Text } from '@/components/Text'; import { spacing } from '@/theme'; +import { useRipple } from '@/theme/ripple'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useLibraryStore } from '@/stores/libraryStore'; @@ -8,8 +9,11 @@ import { useLibraryStore } from '@/stores/libraryStore'; export function ScanProgress() { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); const isScanning = useLibraryStore((s) => s.isScanning); + const isCancelling = useLibraryStore((s) => s.isCancelling); const progress = useLibraryStore((s) => s.scanProgress); + const cancelScan = useLibraryStore((s) => s.cancelScan); if (!isScanning) return null; @@ -29,9 +33,33 @@ export function ScanProgress() { return ( - - {label} - + + + {label} + + + + {isCancelling ? 'Cancelling…' : 'Cancel'} + + + ({ gap: spacing.xs, marginBottom: spacing.md, }, + labelRow: { + minHeight: 32, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + label: { + flex: 1, + }, + cancelButton: { + minHeight: 32, + justifyContent: 'center', + paddingHorizontal: spacing.xs, + }, track: { height: 2, backgroundColor: colors.glassBorder, diff --git a/src/components/onboarding/OnboardingFlow.tsx b/src/components/onboarding/OnboardingFlow.tsx index 6026ec0..18c0968 100644 --- a/src/components/onboarding/OnboardingFlow.tsx +++ b/src/components/onboarding/OnboardingFlow.tsx @@ -368,8 +368,11 @@ function StepHeader({ function ScanBanner() { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); const isScanning = useLibraryStore((s) => s.isScanning); + const isCancelling = useLibraryStore((s) => s.isCancelling); const progress = useLibraryStore((s) => s.scanProgress); + const cancelScan = useLibraryStore((s) => s.cancelScan); if (!isScanning) return null; const detail = (progress.phase === 'extracting' || progress.phase === 'analyzing') && progress.total > 0 @@ -388,8 +391,27 @@ function ScanBanner() { numberOfLines={1} style={styles.scanBannerText} > - Scanning your library{detail ? ` · ${detail}` : '…'} + {isCancelling + ? 'Cancelling library scan…' + : `Scanning your library${detail ? ` · ${detail}` : '…'}`} + + + {isCancelling ? 'Cancelling…' : 'Cancel'} + + ); } @@ -431,7 +453,12 @@ const useStyles = createThemedStyles((colors) => ({ backgroundColor: colors.glassBg, }, scanBannerText: { - maxWidth: 240, + flexShrink: 1, + }, + scanBannerCancel: { + minHeight: 32, + justifyContent: 'center', + paddingHorizontal: spacing.xs, }, scroll: { flex: 1, diff --git a/src/library/scanCancellation.test.mts b/src/library/scanCancellation.test.mts new file mode 100644 index 0000000..385b5a6 --- /dev/null +++ b/src/library/scanCancellation.test.mts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createScanCancellationController, + runCancellableFolderScans, + type ScanResult, +} from './scanCancellation.ts'; + +const completed = (added: number): ScanResult => ({ + added, + updated: 0, + removed: 0, + errors: 0, + cancelled: false, +}); + +test('cancellation controller is immediate and idempotent', () => { + const controller = createScanCancellationController(); + assert.equal(controller.signal.cancelled, false); + + controller.cancel(); + controller.cancel(); + + assert.equal(controller.signal.cancelled, true); +}); + +test('cancelling between folders keeps completed results and starts no later folder', async () => { + const controller = createScanCancellationController(); + const started: number[] = []; + + const result = await runCancellableFolderScans( + [1, 2, 3], + controller.signal, + () => true, + async (folder) => { + started.push(folder); + if (folder === 1) controller.cancel(); + return completed(folder); + } + ); + + assert.deepEqual(started, [1]); + assert.deepEqual(result, { ...completed(1), cancelled: true }); +}); + +test('a native cancellation discards the active result but keeps earlier folders', async () => { + const started: number[] = []; + + const result = await runCancellableFolderScans( + [1, 2, 3], + undefined, + () => true, + async (folder) => { + started.push(folder); + return folder === 2 + ? { ...completed(0), cancelled: true } + : completed(folder); + } + ); + + assert.deepEqual(started, [1, 2]); + assert.deepEqual(result, { ...completed(1), cancelled: true }); +}); + +test('unavailable folders are skipped without affecting totals', async () => { + const started: number[] = []; + + const result = await runCancellableFolderScans( + [ + { id: 1, available: true }, + { id: 2, available: false }, + { id: 3, available: true }, + ], + undefined, + (folder) => folder.available, + async (folder) => { + started.push(folder.id); + return completed(folder.id); + } + ); + + assert.deepEqual(started, [1, 3]); + assert.deepEqual(result, completed(4)); +}); diff --git a/src/library/scanCancellation.ts b/src/library/scanCancellation.ts new file mode 100644 index 0000000..a461385 --- /dev/null +++ b/src/library/scanCancellation.ts @@ -0,0 +1,60 @@ +export interface ScanResult { + added: number; + updated: number; + removed: number; + errors: number; + cancelled: boolean; +} + +export interface ScanCancellationSignal { + readonly cancelled: boolean; +} + +export interface ScanCancellationController { + readonly signal: ScanCancellationSignal; + cancel: () => void; +} + +export function createScanCancellationController(): ScanCancellationController { + let cancelled = false; + return { + signal: { + get cancelled() { + return cancelled; + }, + }, + cancel: () => { + cancelled = true; + }, + }; +} + +export async function runCancellableFolderScans( + folders: readonly T[], + cancellation: ScanCancellationSignal | undefined, + isAvailable: (folder: T) => boolean, + scan: (folder: T) => Promise +): Promise { + const total: ScanResult = { + added: 0, + updated: 0, + removed: 0, + errors: 0, + cancelled: false, + }; + + for (const folder of folders) { + if (!isAvailable(folder)) continue; + if (cancellation?.cancelled) return { ...total, cancelled: true }; + + const result = await scan(folder); + if (result.cancelled) return { ...total, cancelled: true }; + + total.added += result.added; + total.updated += result.updated; + total.removed += result.removed; + total.errors += result.errors; + } + + return total; +} diff --git a/src/library/scanner.ts b/src/library/scanner.ts index c8d6187..65d66aa 100644 --- a/src/library/scanner.ts +++ b/src/library/scanner.ts @@ -6,6 +6,18 @@ import { } from '../../modules/astra-library-scanner'; import type { LibraryFolder } from '@/types/library'; import { AUDIO_EXTENSIONS } from './audioExtensions'; +import { + runCancellableFolderScans, + type ScanCancellationSignal, + type ScanResult, +} from './scanCancellation'; + +export { + createScanCancellationController, + type ScanCancellationController, + type ScanCancellationSignal, + type ScanResult, +} from './scanCancellation'; export interface ScanProgress { phase: 'discovering' | 'extracting' | 'analyzing'; @@ -18,13 +30,6 @@ export interface ScanCallbacks { onProgress?: (progress: ScanProgress) => void; } -export interface ScanResult { - added: number; - updated: number; - removed: number; - errors: number; -} - type NativeFolder = LibraryFolder & { track_count: number; scan_status?: string; @@ -44,29 +49,50 @@ function scanResult(result: NativeScanResult): ScanResult { updated: result.updated, removed: result.removed, errors: result.errors, + cancelled: result.cancelled === true, }; } +function cancelledScanResult(): ScanResult { + return { added: 0, updated: 0, removed: 0, errors: 0, cancelled: true }; +} + +export function cancelActiveScan(): void { + const scanner = AstraLibraryScanner as typeof AstraLibraryScanner & { + cancelScan?: () => void; + }; + scanner.cancelScan?.(); +} + export async function loadFolders(): Promise { await AstraLibraryData.initialize(); return (await AstraLibraryData.listFolders()) as unknown as NativeFolder[]; } -export async function addFolderViaPicker(callbacks?: ScanCallbacks): Promise { +export async function addFolderViaPicker( + callbacks?: ScanCallbacks, + cancellation?: ScanCancellationSignal +): Promise { const permission = await StorageAccessFramework.requestDirectoryPermissionsAsync(); if (!permission.granted) return null; + if (cancellation?.cancelled) return cancelledScanResult(); const treeUri = permission.directoryUri; await AstraLibraryScanner.takePersistableUriPermission(treeUri); const folder = await AstraLibraryData.registerFolder(treeUri, displayNameFromTreeUri(treeUri)); - return scanFolder(folder as unknown as LibraryFolder, { callbacks }); + return scanFolder(folder as unknown as LibraryFolder, { callbacks, cancellation }); } export async function scanFolder( folder: Omit & { available?: boolean }, - opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {} + opts: { + mode?: 'incremental' | 'full'; + callbacks?: ScanCallbacks; + cancellation?: ScanCancellationSignal; + } = {} ): Promise { - const { mode = 'incremental', callbacks } = opts; + const { mode = 'incremental', callbacks, cancellation } = opts; + if (cancellation?.cancelled) return cancelledScanResult(); const subscription = AstraLibraryScanner.addListener('onScanProgress', (event) => { const total = event.total ?? event.found ?? 0; callbacks?.onProgress?.({ @@ -78,26 +104,27 @@ export async function scanFolder( }); try { const result = await AstraLibraryScanner.scanFolderNative(folder.id, mode, AUDIO_EXTENSIONS); - return scanResult(result); + const mapped = scanResult(result); + return cancellation?.cancelled ? { ...mapped, cancelled: true } : mapped; } finally { subscription.remove(); } } export async function rescanAll( - opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {} + opts: { + mode?: 'incremental' | 'full'; + callbacks?: ScanCallbacks; + cancellation?: ScanCancellationSignal; + } = {} ): Promise { const folders = await loadFolders(); - const total: ScanResult = { added: 0, updated: 0, removed: 0, errors: 0 }; - for (const folder of folders) { - if (!folder.available) continue; - const result = await scanFolder(folder, opts); - total.added += result.added; - total.updated += result.updated; - total.removed += result.removed; - total.errors += result.errors; - } - return total; + return runCancellableFolderScans( + folders, + opts.cancellation, + (folder) => folder.available, + (folder) => scanFolder(folder, opts) + ); } export async function removeFolder(folder: Pick): Promise { diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index c50ec74..3b2687f 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -7,9 +7,12 @@ import { import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library'; import { addFolderViaPicker, + cancelActiveScan, + createScanCancellationController, loadFolders, removeFolder as scannerRemoveFolder, rescanAll, + type ScanCancellationSignal, type ScanProgress, type ScanResult, } from '@/library/scanner'; @@ -79,6 +82,7 @@ interface LibraryStore { artistSort: ArtistSort; includeCollabArtists: boolean; isScanning: boolean; + isCancelling: boolean; scanProgress: ScanProgressState; scanError: string | null; trackNextCursor: string | null; @@ -118,10 +122,12 @@ interface LibraryStore { removeFolder: (folderId: number) => Promise; rescan: () => Promise; rebuildLocalIndex: () => Promise; + cancelScan: () => void; } let initPromise: Promise | null = null; let nativeSubscriptionsInstalled = false; +let activeScanCancellation: ReturnType | null = null; // These grow without a cap on purpose. A sliding window that dropped items off the // head shrank the content height mid-scroll, which read as the list flinging itself @@ -353,18 +359,30 @@ export const useLibraryStore = create((set, get) => { set({ sectionAnchors: anchors }); }; - const runScan = async (scan: () => Promise) => { + const runScan = async (scan: (cancellation: ScanCancellationSignal) => Promise) => { if (get().isScanning) return; - set({ isScanning: true, scanError: null, scanProgress: { ...IDLE_PROGRESS } }); + const cancellation = createScanCancellationController(); + activeScanCancellation = cancellation; + set({ + isScanning: true, + isCancelling: false, + scanError: null, + scanProgress: { ...IDLE_PROGRESS }, + }); try { - await scan(); + await scan(cancellation.signal); } catch (error) { set({ scanError: error instanceof Error ? error.message : String(error) }); } finally { try { await get().refresh(); } finally { - set({ isScanning: false, scanProgress: { ...IDLE_PROGRESS } }); + if (activeScanCancellation === cancellation) activeScanCancellation = null; + set({ + isScanning: false, + isCancelling: false, + scanProgress: { ...IDLE_PROGRESS }, + }); endScanService(); } } @@ -388,6 +406,7 @@ export const useLibraryStore = create((set, get) => { artistSort: 'name', includeCollabArtists: false, isScanning: false, + isCancelling: false, scanProgress: { ...IDLE_PROGRESS }, scanError: null, trackNextCursor: null, @@ -943,7 +962,8 @@ export const useLibraryStore = create((set, get) => { void resetSectionAnchors(); }, - addFolder: () => runScan(() => addFolderViaPicker({ onProgress })), + addFolder: () => + runScan((cancellation) => addFolderViaPicker({ onProgress }, cancellation)), removeFolder: async (folderId) => { const folder = get().folders.find((entry) => entry.id === folderId); @@ -952,8 +972,19 @@ export const useLibraryStore = create((set, get) => { await get().refresh(); }, - rescan: () => runScan(() => rescanAll({ callbacks: { onProgress } })), + rescan: () => + runScan((cancellation) => rescanAll({ callbacks: { onProgress }, cancellation })), - rebuildLocalIndex: () => runScan(() => rescanAll({ mode: 'full', callbacks: { onProgress } })), + rebuildLocalIndex: () => + runScan((cancellation) => + rescanAll({ mode: 'full', callbacks: { onProgress }, cancellation }) + ), + + cancelScan: () => { + if (!get().isScanning || get().isCancelling || !activeScanCancellation) return; + set({ isCancelling: true }); + activeScanCancellation.cancel(); + cancelActiveScan(); + }, }; });