mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 13:09:46 +02:00
add cancel button to scanner
This commit is contained in:
+37
@@ -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()
|
||||
|
||||
+65
-40
@@ -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<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 {
|
||||
Name("AstraLibraryScanner")
|
||||
|
||||
@@ -134,49 +140,62 @@ class AstraLibraryScannerModule : Module() {
|
||||
mode: String,
|
||||
extensions: List<String>,
|
||||
->
|
||||
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<Map<String, Any?>> ?: emptyList()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val covers = listing["covers"] as? Map<String, String> ?: 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<Map<String, Any?>> ?: emptyList()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val covers = listing["covers"] as? Map<String, String> ?: 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<String>): Map<String, Any> {
|
||||
private fun listAudioFiles(
|
||||
treeUri: String,
|
||||
extensions: List<String>,
|
||||
cancelFlag: AtomicBoolean? = null,
|
||||
): Map<String, Any> {
|
||||
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) ?: ""
|
||||
|
||||
+36
@@ -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<LocalAudioFile>,
|
||||
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<TrackEntity>(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)
|
||||
|
||||
+2
@@ -57,6 +57,7 @@ data class NativeScanResult(
|
||||
val errors: Int,
|
||||
val total: Int,
|
||||
val revision: Long,
|
||||
val cancelled: Boolean = false,
|
||||
) {
|
||||
fun toMap(): Map<String, Any> = mapOf(
|
||||
"added" to added,
|
||||
@@ -65,6 +66,7 @@ data class NativeScanResult(
|
||||
"errors" to errors,
|
||||
"total" to total,
|
||||
"catalogRevision" to revision.toString(),
|
||||
"cancelled" to cancelled,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface NativeScanResult {
|
||||
errors: number;
|
||||
total: number;
|
||||
catalogRevision: string;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,6 +136,8 @@ declare class AstraLibraryScannerModuleType extends NativeModule<AstraLibrarySca
|
||||
mode: 'incremental' | 'full',
|
||||
extensions: string[]
|
||||
): 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
|
||||
* `withLoudness`, gated integrated LUFS + sample peak. Both analyses need every
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -23,6 +23,7 @@ const TEST_SCRIPTS = [
|
||||
'test:haptics',
|
||||
'test:home-greeting',
|
||||
'test:session',
|
||||
'test:library-scan',
|
||||
];
|
||||
|
||||
for (const script of TEST_SCRIPTS) {
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={styles.labelRow}>
|
||||
<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={[
|
||||
@@ -50,6 +78,20 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
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,
|
||||
|
||||
@@ -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}` : '…'}`}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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
@@ -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<NativeFolder[]> {
|
||||
await AstraLibraryData.initialize();
|
||||
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();
|
||||
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<LibraryFolder, 'available'> & { available?: boolean },
|
||||
opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {}
|
||||
opts: {
|
||||
mode?: 'incremental' | 'full';
|
||||
callbacks?: ScanCallbacks;
|
||||
cancellation?: ScanCancellationSignal;
|
||||
} = {}
|
||||
): 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 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<ScanResult> {
|
||||
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<LibraryFolder, 'id'>): Promise<void> {
|
||||
|
||||
@@ -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<void>;
|
||||
rescan: () => Promise<void>;
|
||||
rebuildLocalIndex: () => Promise<void>;
|
||||
cancelScan: () => void;
|
||||
}
|
||||
|
||||
let initPromise: Promise<void> | null = null;
|
||||
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
|
||||
// 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 });
|
||||
};
|
||||
|
||||
const runScan = async (scan: () => Promise<ScanResult | null>) => {
|
||||
const runScan = async (scan: (cancellation: ScanCancellationSignal) => Promise<ScanResult | null>) => {
|
||||
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<LibraryStore>((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<LibraryStore>((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<LibraryStore>((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();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user