add cancel button to scanner

This commit is contained in:
Boof2015
2026-07-25 17:10:31 -04:00
parent 80c27b039f
commit 0bde96e321
13 changed files with 452 additions and 76 deletions
@@ -127,6 +127,43 @@ class RoomLibraryRepositoryTest {
assertNull(dao.getGeneration("active")) 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 @Test
fun userMutationsAndVirtualQueueAreAtomicAndDurable() = runBlocking { fun userMutationsAndVirtualQueueAreAtomicAndDurable() = runBlocking {
val dao = user.userDao() val dao = user.userDao()
@@ -41,6 +41,7 @@ import expo.modules.kotlin.records.Record
import expo.modules.astralibraryscanner.data.AstraLibraryRepository import expo.modules.astralibraryscanner.data.AstraLibraryRepository
import expo.modules.astralibraryscanner.data.LocalAudioFile import expo.modules.astralibraryscanner.data.LocalAudioFile
import expo.modules.astralibraryscanner.data.LocalAudioMetadata import expo.modules.astralibraryscanner.data.LocalAudioMetadata
import expo.modules.astralibraryscanner.data.ScanCancelledException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -111,6 +112,11 @@ class AstraLibraryScannerModule : Module() {
// decode can be cancelled too. // decode can be cancelled too.
private val activeAnalyses = ConcurrentHashMap<String, AtomicBoolean>() private val activeAnalyses = ConcurrentHashMap<String, AtomicBoolean>()
// 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<AtomicBoolean>()
override fun definition() = ModuleDefinition { override fun definition() = ModuleDefinition {
Name("AstraLibraryScanner") Name("AstraLibraryScanner")
@@ -134,49 +140,62 @@ class AstraLibraryScannerModule : Module() {
mode: String, mode: String,
extensions: List<String>, extensions: List<String>,
-> ->
withContext(Dispatchers.IO) { val cancelFlag = AtomicBoolean(false)
val repository = AstraLibraryRepository.get(requireContext()) activeScans.add(cancelFlag)
repository.withUserRecovery { scanLocalFolder( try {
folderId = folderId.toLong(), withContext(Dispatchers.IO) {
full = mode == "full", val repository = AstraLibraryRepository.get(requireContext())
discover = { treeUri -> repository.withUserRecovery { scanLocalFolder(
val listing = listAudioFiles(treeUri, extensions) folderId = folderId.toLong(),
@Suppress("UNCHECKED_CAST") full = mode == "full",
val files = listing["files"] as? List<Map<String, Any?>> ?: emptyList() discover = { treeUri ->
@Suppress("UNCHECKED_CAST") val listing = listAudioFiles(treeUri, extensions, cancelFlag)
val covers = listing["covers"] as? Map<String, String> ?: emptyMap() @Suppress("UNCHECKED_CAST")
files.mapNotNull { file -> val files = listing["files"] as? List<Map<String, Any?>> ?: emptyList()
val uri = file["uri"] as? String ?: return@mapNotNull null @Suppress("UNCHECKED_CAST")
val parentUri = file["parentUri"] as? String ?: "" val covers = listing["covers"] as? Map<String, String> ?: emptyMap()
LocalAudioFile( files.mapNotNull { file ->
uri = uri, val uri = file["uri"] as? String ?: return@mapNotNull null
name = file["name"] as? String ?: uri.substringAfterLast('/'), val parentUri = file["parentUri"] as? String ?: ""
size = (file["size"] as? Number)?.toLong(), LocalAudioFile(
lastModified = (file["lastModified"] as? Number)?.toLong() ?: 0L, uri = uri,
mimeType = file["mimeType"] as? String, name = file["name"] as? String ?: uri.substringAfterLast('/'),
parentUri = parentUri, size = (file["size"] as? Number)?.toLong(),
coverUri = covers[parentUri], 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,
),
) )
} },
}, isCancelled = cancelFlag::get,
extract = { file -> ).toMap() }
extractOne(file.uri, file.coverUri).toLocalAudioMetadata() }
}, } finally {
onProgress = { phase, processed, total, folderName -> activeScans.remove(cancelFlag)
sendEvent(
"onScanProgress",
mapOf(
"phase" to phase,
"processed" to processed,
"total" to total,
"folderName" to folderName,
),
)
},
).toMap() }
} }
} }
// 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 // 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 // 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 // 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 coverBaseNames = listOf("cover", "folder", "front", "albumart")
private val coverExtensions = setOf("jpg", "jpeg", "png", "webp") private val coverExtensions = setOf("jpg", "jpeg", "png", "webp")
private fun listAudioFiles(treeUri: String, extensions: List<String>): Map<String, Any> { private fun listAudioFiles(
treeUri: String,
extensions: List<String>,
cancelFlag: AtomicBoolean? = null,
): Map<String, Any> {
coverHashMemo.clear() coverHashMemo.clear()
val resolver = requireContext().contentResolver val resolver = requireContext().contentResolver
@@ -517,6 +540,7 @@ class AstraLibraryScannerModule : Module() {
queue.add(DocumentsContract.getTreeDocumentId(tree)) queue.add(DocumentsContract.getTreeDocumentId(tree))
while (queue.isNotEmpty()) { while (queue.isNotEmpty()) {
if (cancelFlag?.get() == true) throw ScanCancelledException()
val dirDocId = queue.removeFirst() val dirDocId = queue.removeFirst()
val parentUri = DocumentsContract.buildDocumentUriUsingTree(tree, dirDocId).toString() val parentUri = DocumentsContract.buildDocumentUriUsingTree(tree, dirDocId).toString()
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree, dirDocId) val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree, dirDocId)
@@ -525,6 +549,7 @@ class AstraLibraryScannerModule : Module() {
?: continue // directory disappeared mid-walk; skip it ?: continue // directory disappeared mid-walk; skip it
cursor.use { cursor.use {
while (it.moveToNext()) { while (it.moveToNext()) {
if (cancelFlag?.get() == true) throw ScanCancelledException()
val docId = it.getString(0) ?: continue val docId = it.getString(0) ?: continue
val name = it.getString(1) ?: continue val name = it.getString(1) ?: continue
val mime = it.getString(2) ?: "" val mime = it.getString(2) ?: ""
@@ -36,12 +36,17 @@ private const val MOBILE_SESSION_ID = "mobile"
private const val ACTIVE_PLAYBACK_CONTEXT_ID = "active-context" private const val ACTIVE_PLAYBACK_CONTEXT_ID = "active-context"
class StaleRevisionException : IllegalStateException("STALE_REVISION") class StaleRevisionException : IllegalStateException("STALE_REVISION")
internal class ScanCancelledException : IllegalStateException("SCAN_CANCELLED")
internal fun boundedPlaybackWindowStart(start: Long, total: Long): Long? { internal fun boundedPlaybackWindowStart(start: Long, total: Long): Long? {
val normalized = start.coerceAtLeast(0) val normalized = start.coerceAtLeast(0)
return normalized.takeIf { it < total } return normalized.takeIf { it < total }
} }
private fun throwIfScanCancelled(isCancelled: () -> Boolean) {
if (isCancelled()) throw ScanCancelledException()
}
private data class RemoteSyncHandle( private data class RemoteSyncHandle(
val syncId: String, val syncId: String,
val sourceKey: String, val sourceKey: String,
@@ -355,6 +360,7 @@ class AstraLibraryRepository private constructor(
discover: suspend (String) -> List<LocalAudioFile>, discover: suspend (String) -> List<LocalAudioFile>,
extract: suspend (LocalAudioFile) -> LocalAudioMetadata, extract: suspend (LocalAudioFile) -> LocalAudioMetadata,
onProgress: (phase: String, processed: Int, total: Int, folderName: String) -> Unit, onProgress: (phase: String, processed: Int, total: Int, folderName: String) -> Unit,
isCancelled: () -> Boolean = { false },
): NativeScanResult { ): NativeScanResult {
initialize() initialize()
return catalogWriterMutex.withLock { return catalogWriterMutex.withLock {
@@ -387,8 +393,10 @@ class AstraLibraryRepository private constructor(
updateOperationalStatus(LibraryStatus.SCANNING) updateOperationalStatus(LibraryStatus.SCANNING)
try { try {
throwIfScanCancelled(isCancelled)
onProgress("discovering", 0, 0, folder.displayName) onProgress("discovering", 0, 0, folder.displayName)
val files = discover(folder.treeUri) val files = discover(folder.treeUri)
throwIfScanCancelled(isCancelled)
onProgress("discovering", files.size, files.size, folder.displayName) onProgress("discovering", files.size, files.size, folder.displayName)
val existing = dao.getActiveTrackEntitiesForSource(sourceKey) val existing = dao.getActiveTrackEntitiesForSource(sourceKey)
@@ -401,9 +409,11 @@ class AstraLibraryRepository private constructor(
var processed = 0 var processed = 0
for (batch in files.chunked(24)) { for (batch in files.chunked(24)) {
throwIfScanCancelled(isCancelled)
val rows = coroutineScope { val rows = coroutineScope {
batch.map { file -> batch.map { file ->
async(Dispatchers.IO) { async(Dispatchers.IO) {
throwIfScanCancelled(isCancelled)
val old = existingByPath[file.uri] val old = existingByPath[file.uri]
val unchanged = !full && val unchanged = !full &&
old != null && old != null &&
@@ -422,6 +432,7 @@ class AstraLibraryRepository private constructor(
) to false ) to false
} else { } else {
val metadata = extract(file) val metadata = extract(file)
throwIfScanCancelled(isCancelled)
if (!metadata.ok) { if (!metadata.ok) {
if (old != null) { if (old != null) {
old.copy(id = 0, generationId = generationId, sourceKey = sourceKey) to true old.copy(id = 0, generationId = generationId, sourceKey = sourceKey) to true
@@ -442,6 +453,7 @@ class AstraLibraryRepository private constructor(
} }
}.awaitAll() }.awaitAll()
} }
throwIfScanCancelled(isCancelled)
val insertRows = ArrayList<TrackEntity>(rows.size) val insertRows = ArrayList<TrackEntity>(rows.size)
for ((row, failed) in rows) { for ((row, failed) in rows) {
if (failed) errors += 1 if (failed) errors += 1
@@ -454,6 +466,7 @@ class AstraLibraryRepository private constructor(
onProgress("extracting", processed, files.size, folder.displayName) onProgress("extracting", processed, files.size, folder.displayName)
} }
throwIfScanCancelled(isCancelled)
val prospective = dao.getProspectiveTracks(sourceKey, generationId) val prospective = dao.getProspectiveTracks(sourceKey, generationId)
val nextRevision = dao.getRevision() + 1 val nextRevision = dao.getRevision() + 1
onProgress("indexing", prospective.size, prospective.size, folder.displayName) onProgress("indexing", prospective.size, prospective.size, folder.displayName)
@@ -464,6 +477,9 @@ class AstraLibraryRepository private constructor(
userDao.getFolders().associateBy(FolderEntity::id), 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( val revision = dao.publishGeneration(
sourceKey = sourceKey, sourceKey = sourceKey,
generationId = generationId, generationId = generationId,
@@ -488,6 +504,26 @@ class AstraLibraryRepository private constructor(
total = files.size, total = files.size,
revision = revision, 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) { } catch (error: Throwable) {
runCatching { runCatching {
dao.deleteGenerationTracks(generationId) dao.deleteGenerationTracks(generationId)
@@ -57,6 +57,7 @@ data class NativeScanResult(
val errors: Int, val errors: Int,
val total: Int, val total: Int,
val revision: Long, val revision: Long,
val cancelled: Boolean = false,
) { ) {
fun toMap(): Map<String, Any> = mapOf( fun toMap(): Map<String, Any> = mapOf(
"added" to added, "added" to added,
@@ -65,6 +66,7 @@ data class NativeScanResult(
"errors" to errors, "errors" to errors,
"total" to total, "total" to total,
"catalogRevision" to revision.toString(), "catalogRevision" to revision.toString(),
"cancelled" to cancelled,
) )
} }
+3
View File
@@ -87,6 +87,7 @@ export interface NativeScanResult {
errors: number; errors: number;
total: number; total: number;
catalogRevision: string; catalogRevision: string;
cancelled: boolean;
} }
/** /**
@@ -135,6 +136,8 @@ declare class AstraLibraryScannerModuleType extends NativeModule<AstraLibrarySca
mode: 'incremental' | 'full', mode: 'incremental' | 'full',
extensions: string[] extensions: string[]
): Promise<NativeScanResult>; ): Promise<NativeScanResult>;
/** 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 * ONE whole-file PCM decode producing `bins` RMS waveform peaks and, when
* `withLoudness`, gated integrated LUFS + sample peak. Both analyses need every * `withLoudness`, gated integrated LUFS + sample peak. Both analyses need every
+1
View File
@@ -87,6 +87,7 @@
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts", "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: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: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-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", "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", "release:validate": "node scripts/release/android-release.mjs validate github && node scripts/release/android-release.mjs validate google-play",
+1
View File
@@ -23,6 +23,7 @@ const TEST_SCRIPTS = [
'test:haptics', 'test:haptics',
'test:home-greeting', 'test:home-greeting',
'test:session', 'test:session',
'test:library-scan',
]; ];
for (const script of TEST_SCRIPTS) { for (const script of TEST_SCRIPTS) {
+46 -4
View File
@@ -1,6 +1,7 @@
import { View } from 'react-native'; import { Pressable, View } from 'react-native';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { useRipple } from '@/theme/ripple';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
@@ -8,8 +9,11 @@ import { useLibraryStore } from '@/stores/libraryStore';
export function ScanProgress() { export function ScanProgress() {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const isScanning = useLibraryStore((s) => s.isScanning); const isScanning = useLibraryStore((s) => s.isScanning);
const isCancelling = useLibraryStore((s) => s.isCancelling);
const progress = useLibraryStore((s) => s.scanProgress); const progress = useLibraryStore((s) => s.scanProgress);
const cancelScan = useLibraryStore((s) => s.cancelScan);
if (!isScanning) return null; if (!isScanning) return null;
@@ -29,9 +33,33 @@ export function ScanProgress() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}> <View style={styles.labelRow}>
{label} <Text
</Text> variant="caption"
color={colors.textSecondary}
numberOfLines={1}
style={styles.label}
>
{label}
</Text>
<Pressable
android_ripple={ripple.bounded}
disabled={isCancelling}
onPress={cancelScan}
accessibilityRole="button"
accessibilityLabel={isCancelling ? 'Cancelling library scan' : 'Cancel library scan'}
accessibilityState={{ disabled: isCancelling, busy: isCancelling }}
hitSlop={6}
style={styles.cancelButton}
>
<Text
variant="caption"
color={isCancelling ? colors.textTertiary : colors.warning}
>
{isCancelling ? 'Cancelling…' : 'Cancel'}
</Text>
</Pressable>
</View>
<View style={styles.track}> <View style={styles.track}>
<View <View
style={[ style={[
@@ -50,6 +78,20 @@ const useStyles = createThemedStyles((colors) => ({
gap: spacing.xs, gap: spacing.xs,
marginBottom: spacing.md, 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: { track: {
height: 2, height: 2,
backgroundColor: colors.glassBorder, backgroundColor: colors.glassBorder,
+29 -2
View File
@@ -368,8 +368,11 @@ function StepHeader({
function ScanBanner() { function ScanBanner() {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const isScanning = useLibraryStore((s) => s.isScanning); const isScanning = useLibraryStore((s) => s.isScanning);
const isCancelling = useLibraryStore((s) => s.isCancelling);
const progress = useLibraryStore((s) => s.scanProgress); const progress = useLibraryStore((s) => s.scanProgress);
const cancelScan = useLibraryStore((s) => s.cancelScan);
if (!isScanning) return null; if (!isScanning) return null;
const detail = const detail =
(progress.phase === 'extracting' || progress.phase === 'analyzing') && progress.total > 0 (progress.phase === 'extracting' || progress.phase === 'analyzing') && progress.total > 0
@@ -388,8 +391,27 @@ function ScanBanner() {
numberOfLines={1} numberOfLines={1}
style={styles.scanBannerText} style={styles.scanBannerText}
> >
Scanning your library{detail ? ` · ${detail}` : '…'} {isCancelling
? 'Cancelling library scan…'
: `Scanning your library${detail ? ` · ${detail}` : '…'}`}
</Text> </Text>
<Pressable
android_ripple={ripple.bounded}
disabled={isCancelling}
onPress={cancelScan}
accessibilityRole="button"
accessibilityLabel={isCancelling ? 'Cancelling library scan' : 'Cancel library scan'}
accessibilityState={{ disabled: isCancelling, busy: isCancelling }}
hitSlop={6}
style={styles.scanBannerCancel}
>
<Text
variant="caption"
color={isCancelling ? colors.textTertiary : colors.warning}
>
{isCancelling ? 'Cancelling…' : 'Cancel'}
</Text>
</Pressable>
</Animated.View> </Animated.View>
); );
} }
@@ -431,7 +453,12 @@ const useStyles = createThemedStyles((colors) => ({
backgroundColor: colors.glassBg, backgroundColor: colors.glassBg,
}, },
scanBannerText: { scanBannerText: {
maxWidth: 240, flexShrink: 1,
},
scanBannerCancel: {
minHeight: 32,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
}, },
scroll: { scroll: {
flex: 1, flex: 1,
+84
View File
@@ -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));
});
+60
View File
@@ -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<T>(
folders: readonly T[],
cancellation: ScanCancellationSignal | undefined,
isAvailable: (folder: T) => boolean,
scan: (folder: T) => Promise<ScanResult>
): Promise<ScanResult> {
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;
}
+50 -23
View File
@@ -6,6 +6,18 @@ import {
} from '../../modules/astra-library-scanner'; } from '../../modules/astra-library-scanner';
import type { LibraryFolder } from '@/types/library'; import type { LibraryFolder } from '@/types/library';
import { AUDIO_EXTENSIONS } from './audioExtensions'; 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 { export interface ScanProgress {
phase: 'discovering' | 'extracting' | 'analyzing'; phase: 'discovering' | 'extracting' | 'analyzing';
@@ -18,13 +30,6 @@ export interface ScanCallbacks {
onProgress?: (progress: ScanProgress) => void; onProgress?: (progress: ScanProgress) => void;
} }
export interface ScanResult {
added: number;
updated: number;
removed: number;
errors: number;
}
type NativeFolder = LibraryFolder & { type NativeFolder = LibraryFolder & {
track_count: number; track_count: number;
scan_status?: string; scan_status?: string;
@@ -44,29 +49,50 @@ function scanResult(result: NativeScanResult): ScanResult {
updated: result.updated, updated: result.updated,
removed: result.removed, removed: result.removed,
errors: result.errors, 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<NativeFolder[]> { export async function loadFolders(): Promise<NativeFolder[]> {
await AstraLibraryData.initialize(); await AstraLibraryData.initialize();
return (await AstraLibraryData.listFolders()) as unknown as NativeFolder[]; return (await AstraLibraryData.listFolders()) as unknown as NativeFolder[];
} }
export async function addFolderViaPicker(callbacks?: ScanCallbacks): Promise<ScanResult | null> { export async function addFolderViaPicker(
callbacks?: ScanCallbacks,
cancellation?: ScanCancellationSignal
): Promise<ScanResult | null> {
const permission = await StorageAccessFramework.requestDirectoryPermissionsAsync(); const permission = await StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!permission.granted) return null; if (!permission.granted) return null;
if (cancellation?.cancelled) return cancelledScanResult();
const treeUri = permission.directoryUri; const treeUri = permission.directoryUri;
await AstraLibraryScanner.takePersistableUriPermission(treeUri); await AstraLibraryScanner.takePersistableUriPermission(treeUri);
const folder = await AstraLibraryData.registerFolder(treeUri, displayNameFromTreeUri(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( export async function scanFolder(
folder: Omit<LibraryFolder, 'available'> & { available?: boolean }, folder: Omit<LibraryFolder, 'available'> & { available?: boolean },
opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {} opts: {
mode?: 'incremental' | 'full';
callbacks?: ScanCallbacks;
cancellation?: ScanCancellationSignal;
} = {}
): Promise<ScanResult> { ): Promise<ScanResult> {
const { mode = 'incremental', callbacks } = opts; const { mode = 'incremental', callbacks, cancellation } = opts;
if (cancellation?.cancelled) return cancelledScanResult();
const subscription = AstraLibraryScanner.addListener('onScanProgress', (event) => { const subscription = AstraLibraryScanner.addListener('onScanProgress', (event) => {
const total = event.total ?? event.found ?? 0; const total = event.total ?? event.found ?? 0;
callbacks?.onProgress?.({ callbacks?.onProgress?.({
@@ -78,26 +104,27 @@ export async function scanFolder(
}); });
try { try {
const result = await AstraLibraryScanner.scanFolderNative(folder.id, mode, AUDIO_EXTENSIONS); 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 { } finally {
subscription.remove(); subscription.remove();
} }
} }
export async function rescanAll( export async function rescanAll(
opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {} opts: {
mode?: 'incremental' | 'full';
callbacks?: ScanCallbacks;
cancellation?: ScanCancellationSignal;
} = {}
): Promise<ScanResult> { ): Promise<ScanResult> {
const folders = await loadFolders(); const folders = await loadFolders();
const total: ScanResult = { added: 0, updated: 0, removed: 0, errors: 0 }; return runCancellableFolderScans(
for (const folder of folders) { folders,
if (!folder.available) continue; opts.cancellation,
const result = await scanFolder(folder, opts); (folder) => folder.available,
total.added += result.added; (folder) => scanFolder(folder, opts)
total.updated += result.updated; );
total.removed += result.removed;
total.errors += result.errors;
}
return total;
} }
export async function removeFolder(folder: Pick<LibraryFolder, 'id'>): Promise<void> { export async function removeFolder(folder: Pick<LibraryFolder, 'id'>): Promise<void> {
+38 -7
View File
@@ -7,9 +7,12 @@ import {
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library'; import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
import { import {
addFolderViaPicker, addFolderViaPicker,
cancelActiveScan,
createScanCancellationController,
loadFolders, loadFolders,
removeFolder as scannerRemoveFolder, removeFolder as scannerRemoveFolder,
rescanAll, rescanAll,
type ScanCancellationSignal,
type ScanProgress, type ScanProgress,
type ScanResult, type ScanResult,
} from '@/library/scanner'; } from '@/library/scanner';
@@ -79,6 +82,7 @@ interface LibraryStore {
artistSort: ArtistSort; artistSort: ArtistSort;
includeCollabArtists: boolean; includeCollabArtists: boolean;
isScanning: boolean; isScanning: boolean;
isCancelling: boolean;
scanProgress: ScanProgressState; scanProgress: ScanProgressState;
scanError: string | null; scanError: string | null;
trackNextCursor: string | null; trackNextCursor: string | null;
@@ -118,10 +122,12 @@ interface LibraryStore {
removeFolder: (folderId: number) => Promise<void>; removeFolder: (folderId: number) => Promise<void>;
rescan: () => Promise<void>; rescan: () => Promise<void>;
rebuildLocalIndex: () => Promise<void>; rebuildLocalIndex: () => Promise<void>;
cancelScan: () => void;
} }
let initPromise: Promise<void> | null = null; let initPromise: Promise<void> | null = null;
let nativeSubscriptionsInstalled = false; let nativeSubscriptionsInstalled = false;
let activeScanCancellation: ReturnType<typeof createScanCancellationController> | null = null;
// These grow without a cap on purpose. A sliding window that dropped items off the // 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 // head shrank the content height mid-scroll, which read as the list flinging itself
@@ -353,18 +359,30 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
set({ sectionAnchors: anchors }); set({ sectionAnchors: anchors });
}; };
const runScan = async (scan: () => Promise<ScanResult | null>) => { const runScan = async (scan: (cancellation: ScanCancellationSignal) => Promise<ScanResult | null>) => {
if (get().isScanning) return; 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 { try {
await scan(); await scan(cancellation.signal);
} catch (error) { } catch (error) {
set({ scanError: error instanceof Error ? error.message : String(error) }); set({ scanError: error instanceof Error ? error.message : String(error) });
} finally { } finally {
try { try {
await get().refresh(); await get().refresh();
} finally { } finally {
set({ isScanning: false, scanProgress: { ...IDLE_PROGRESS } }); if (activeScanCancellation === cancellation) activeScanCancellation = null;
set({
isScanning: false,
isCancelling: false,
scanProgress: { ...IDLE_PROGRESS },
});
endScanService(); endScanService();
} }
} }
@@ -388,6 +406,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
artistSort: 'name', artistSort: 'name',
includeCollabArtists: false, includeCollabArtists: false,
isScanning: false, isScanning: false,
isCancelling: false,
scanProgress: { ...IDLE_PROGRESS }, scanProgress: { ...IDLE_PROGRESS },
scanError: null, scanError: null,
trackNextCursor: null, trackNextCursor: null,
@@ -943,7 +962,8 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
void resetSectionAnchors(); void resetSectionAnchors();
}, },
addFolder: () => runScan(() => addFolderViaPicker({ onProgress })), addFolder: () =>
runScan((cancellation) => addFolderViaPicker({ onProgress }, cancellation)),
removeFolder: async (folderId) => { removeFolder: async (folderId) => {
const folder = get().folders.find((entry) => entry.id === folderId); const folder = get().folders.find((entry) => entry.id === folderId);
@@ -952,8 +972,19 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
await get().refresh(); 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();
},
}; };
}); });