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"))
}
@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()
@@ -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,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)
@@ -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,
)
}
+3
View File
@@ -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