mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 12:40:15 +02:00
add cancel button to scanner
This commit is contained in:
+37
@@ -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()
|
||||||
|
|||||||
+65
-40
@@ -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
@@ -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)
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
} 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> {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user