mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-17 11:12:33 +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
|
||||
|
||||
Reference in New Issue
Block a user