perf(library): overlap SAF listings and metadata reads

SD cards, USB drives and network shares pay a provider round trip per query. Folder listings are now prefetched four at a time while the walk stays serial, so order, deduplication and error counts are unchanged. Full and incremental scans share a six-worker in-order read pool; incremental scans were fully serial.

Changed files reuse the listing's name and timestamp, CUE copies skip the name and MIME queries, and directories holding CUE sheets are not listed twice.
This commit is contained in:
zarzet committed 2026-10-01 19:06:25 +07:00
1 parent 6e6d9dd00e
commit d65f3a1cc2
2 files changed
+260 -148

No files matched your search

@@ -122,19 +122,27 @@ internal fun MainActivity.extFromMimeType(mime: String?): String {
} }
} }
internal fun MainActivity.copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? { /** [displayName] skips a provider name query when the caller already listed it. */
internal fun MainActivity.copyUriToTemp(
uri: Uri,
fallbackExt: String? = null,
displayName: String? = null,
): String? {
var tempFile: File? = null var tempFile: File? = null
var success = false var success = false
try { try {
val mime = try { contentResolver.getType(uri) } catch (_: Exception) { null }
val nameHint = ( val nameHint = (
try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null } displayName
?: try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
?: uri.lastPathSegment ?: uri.lastPathSegment
?: "" ?: ""
).lowercase(Locale.ROOT) ).lowercase(Locale.ROOT)
val extFromName = extFromFileName(nameHint) val extFromName = extFromFileName(nameHint)
val extFromMime = extFromMimeType(mime) // The MIME type only matters when the name has no extension.
val extFromMime = if (extFromName.isNotBlank()) "" else extFromMimeType(
try { contentResolver.getType(uri) } catch (_: Exception) { null },
)
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "") val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "")
val suffix = ext.ifBlank { ".tmp" } val suffix = ext.ifBlank { ".tmp" }
tempFile = coreBackend.createTemporaryMediaFile(this, "saf_", suffix) tempFile = coreBackend.createTemporaryMediaFile(this, "saf_", suffix)
@@ -40,8 +40,12 @@ import java.io.IOException
import java.io.RandomAccessFile import java.io.RandomAccessFile
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Locale import java.util.Locale
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.Future import java.util.concurrent.Future
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
// SAF library-scan subsystem: tree walking, incremental diff, CUE resolution, // SAF library-scan subsystem: tree walking, incremental diff, CUE resolution,
// and scan-progress state shared with the progress stream in MainActivity. // and scan-progress state shared with the progress stream in MainActivity.
@@ -558,18 +562,124 @@ internal fun MainActivity.getSafChildFileLookup(
cache: MutableMap<String, Map<String, DocumentFile>>, cache: MutableMap<String, Map<String, DocumentFile>>,
): Map<String, DocumentFile> { ): Map<String, DocumentFile> {
val dirKey = dir.uri.toString() val dirKey = dir.uri.toString()
return cache.getOrPut(dirKey) { return cache.getOrPut(dirKey) { safChildFileLookup(listSafChildrenOrThrow(dir)) }
buildMap { }
for (child in listSafChildrenOrThrow(dir)) {
if (child.isDirectory) continue private fun safChildFileLookup(children: List<SafChildEntry>): Map<String, DocumentFile> =
val childName = child.name?.trim().orEmpty() buildMap {
if (childName.isBlank()) continue for (child in children) {
put(childName.lowercase(Locale.ROOT), child.doc) if (child.isDirectory) continue
} val childName = child.name?.trim().orEmpty()
} if (childName.isBlank()) continue
put(childName.lowercase(Locale.ROOT), child.doc)
} }
} }
/** Keeps a traversal listing for CUE sibling lookup so the directory is not
* queried again; on network providers every listing is a round trip. */
private fun rememberCueDirectoryListing(
dir: DocumentFile,
children: List<SafChildEntry>,
cache: MutableMap<String, Map<String, DocumentFile>>,
) {
if (children.any { !it.isDirectory && it.name?.endsWith(".cue", ignoreCase = true) == true }) {
cache[dir.uri.toString()] = safChildFileLookup(children)
}
}
// Provider round trips dominate on SD cards, USB drives and network shares.
private const val SAF_LIST_CONCURRENCY = 4
private const val SAF_READ_WORKERS = 6
/**
* Prefetches the listings of directories already waiting in a breadth-first
* queue. The caller still dequeues, deduplicates and records errors serially,
* so traversal order and results match a one-directory-at-a-time walk.
* Idle daemon threads exit on their own, so abandoned walks leak nothing.
*/
private class SafTreeLister(private val context: Context) {
private val executor = ThreadPoolExecutor(
SAF_LIST_CONCURRENCY,
SAF_LIST_CONCURRENCY,
1,
TimeUnit.SECONDS,
LinkedBlockingQueue(),
) { runnable -> Thread(runnable, "saf-scan-list").apply { isDaemon = true } }
.apply { allowCoreThreadTimeOut(true) }
private val prefetched = mutableMapOf<String, Future<Result<List<SafChildEntry>>>>()
private fun submit(dir: DocumentFile) = executor.submit<Result<List<SafChildEntry>>> {
try {
Result.success(context.listSafChildrenOrThrow(dir))
} catch (e: Exception) {
Result.failure(e)
}
}
fun list(
dir: DocumentFile,
queue: Collection<Pair<DocumentFile, String>>,
visited: Set<String>,
): Result<List<SafChildEntry>> {
val uri = dir.uri.toString()
val current = prefetched.remove(uri) ?: submit(dir)
for ((next, _) in queue) {
if (prefetched.size >= SAF_LIST_CONCURRENCY * 2) break
val nextUri = next.uri.toString()
if (nextUri == uri || nextUri in visited || nextUri in prefetched) continue
prefetched[nextUri] = submit(next)
}
return try {
current.get()
} catch (e: ExecutionException) {
throw e.cause ?: e
}
}
}
/**
* Runs [task] on a bounded pool and passes each result to [consume] in input
* order. A sliding window keeps workers busy past one slow file. Returns
* false when cancelled.
*/
private fun <T, R> runSafReadsInOrder(
items: List<T>,
cancelled: () -> Boolean,
task: (T) -> R,
consume: (T, Result<R>) -> Unit,
): Boolean {
if (items.isEmpty()) return true
val workers = minOf(SAF_READ_WORKERS, items.size)
val executor = Executors.newFixedThreadPool(workers)
try {
val window = workers * 4
val inFlight = ArrayDeque<Pair<T, Future<R>>>(window)
var next = 0
while (next < items.size || inFlight.isNotEmpty()) {
if (cancelled()) {
inFlight.forEach { it.second.cancel(true) }
return false
}
while (inFlight.size < window && next < items.size) {
val item = items[next++]
inFlight.addLast(item to executor.submit<R> { task(item) })
}
val (item, future) = inFlight.removeFirst()
val result = try {
Result.success(future.get())
} catch (e: ExecutionException) {
Result.failure(e.cause ?: e)
} catch (e: Exception) {
Result.failure(e)
}
consume(item, result)
}
return true
} finally {
executor.shutdownNow()
}
}
internal fun MainActivity.resolveCueAudioSibling( internal fun MainActivity.resolveCueAudioSibling(
parentDir: DocumentFile, parentDir: DocumentFile,
cueName: String, cueName: String,
@@ -779,6 +889,7 @@ internal fun MainActivity.scanSafTree(
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque() val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "") queue.add(root to "")
val lister = SafTreeLister(this)
while (queue.isNotEmpty()) { while (queue.isNotEmpty()) {
if (safScanCancel) { if (safScanCancel) {
@@ -791,17 +902,18 @@ internal fun MainActivity.scanSafTree(
continue continue
} }
val children = try { val listing = lister.list(dir, queue, visitedDirUris)
listSafChildrenOrThrow(dir) val children = listing.getOrNull()
} catch (e: Exception) { if (children == null) {
traversalErrors++ traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors } updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w( android.util.Log.w(
"SpotiFLAC", "SpotiFLAC",
"SAF scan: failed listing directory $dirUri: ${e.message}", "SAF scan: failed listing directory $dirUri: ${listing.exceptionOrNull()?.message}",
) )
continue continue
} }
rememberCueDirectoryListing(dir, children, safChildLookupCache)
for (child in children) { for (child in children) {
if (safScanCancel) { if (safScanCancel) {
@@ -936,7 +1048,7 @@ internal fun MainActivity.scanSafTree(
var tempCuePath: String? = null var tempCuePath: String? = null
var tempAudioPath: String? = null var tempAudioPath: String? = null
try { try {
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") tempCuePath = copyUriToTemp(cueDoc.uri, ".cue", cueName)
if (tempCuePath == null) { if (tempCuePath == null) {
errors++ errors++
android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}") android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}")
@@ -1060,11 +1172,12 @@ internal fun MainActivity.scanSafTree(
pendingAudio.add(audio) pendingAudio.add(audio)
} }
// Bound parallelism to limit SAF full-copy memory and I/O. // Descriptor reads stream from the provider without copying, so the
val workerCount = 2 // pool is bounded by provider round trips rather than copy memory.
val executor = Executors.newFixedThreadPool(workerCount) val completed = runSafReadsInOrder(
fun submitAudio(audio: SafAudioEntry): Future<SafAudioScanOutcome> = pendingAudio,
executor.submit<SafAudioScanOutcome> { cancelled = { safScanCancel },
task = { audio ->
val doc = audio.doc val doc = audio.doc
val stableUri = doc.uri.toString() val stableUri = doc.uri.toString()
val name = audio.name val name = audio.name
@@ -1087,62 +1200,43 @@ internal fun MainActivity.scanSafTree(
null null
} }
SafAudioScanOutcome(stableUri, name, lastModified, metadata) SafAudioScanOutcome(stableUri, name, lastModified, metadata)
} },
try { ) { _, result ->
// A sliding window keeps both workers busy past one slow file, val outcome = result.getOrNull()
// unlike fixed batches that waited for their slowest entry. At most if (outcome == null) {
// workerCount * 2 reads are queued or running, and rows are still errors++
// consumed in input order. } else {
val window = workerCount * 2 updateSafScanProgress { it.currentFile = outcome.name }
val inFlight = ArrayDeque<Future<SafAudioScanOutcome>>(window) val metadataObj = outcome.metadata
var nextAudio = 0 if (metadataObj == null) {
while (nextAudio < pendingAudio.size || inFlight.isNotEmpty()) {
if (safScanCancel) {
inFlight.forEach { it.cancel(true) }
ndjsonWriter?.close()
spill?.abandon()
return cancelledResult()
}
while (inFlight.size < window && nextAudio < pendingAudio.size) {
inFlight.addLast(submitAudio(pendingAudio[nextAudio++]))
}
val outcome = try {
inFlight.removeFirst().get()
} catch (e: Exception) {
errors++ errors++
null } else {
} try {
if (outcome != null) { metadataObj.put("id", buildStableLibraryId(outcome.uri))
updateSafScanProgress { it.currentFile = outcome.name } metadataObj.put("filePath", outcome.uri)
val metadataObj = outcome.metadata metadataObj.put("fileModTime", outcome.lastModified)
if (metadataObj == null) { putResult(metadataObj)
// Flush before recording the checkpoint to avoid losing the row.
ndjsonWriter?.flush()
recordCheckpoint(outcome.uri, outcome.lastModified)
} catch (_: Exception) {
errors++ errors++
} else {
try {
metadataObj.put("id", buildStableLibraryId(outcome.uri))
metadataObj.put("filePath", outcome.uri)
metadataObj.put("fileModTime", outcome.lastModified)
putResult(metadataObj)
// Flush before recording the checkpoint to avoid losing the row.
ndjsonWriter?.flush()
recordCheckpoint(outcome.uri, outcome.lastModified)
} catch (_: Exception) {
errors++
}
} }
} }
scanned++
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
updateSafScanProgress {
it.scannedFiles = scanned
it.errorCount = errors
it.progressPct = pct
}
} }
} finally {
executor.shutdownNow() scanned++
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
updateSafScanProgress {
it.scannedFiles = scanned
it.errorCount = errors
it.progressPct = pct
}
}
if (!completed) {
ndjsonWriter?.close()
spill?.abandon()
return cancelledResult()
} }
updateSafScanProgress { updateSafScanProgress {
@@ -1201,9 +1295,18 @@ internal fun MainActivity.scanSafTreeIncremental(
} }
val supportedAudioExt = libraryScanAudioExtensions val supportedAudioExt = libraryScanAudioExtensions
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // Names and timestamps come from the directory listing; querying each
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>() // document again costs a provider round trip per changed file.
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>() data class ChangedAudio(val doc: DocumentFile, val name: String, val lastModified: Long)
data class CueEntry(
val doc: DocumentFile,
val parentDir: DocumentFile,
val name: String,
val lastModified: Long,
)
val audioFiles = mutableListOf<ChangedAudio>()
val cueFilesToScan = mutableListOf<CueEntry>()
val unchangedCueFiles = mutableListOf<CueEntry>()
val currentUris = mutableSetOf<String>() val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>() val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>() val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
@@ -1220,6 +1323,7 @@ internal fun MainActivity.scanSafTreeIncremental(
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque() val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "") queue.add(root to "")
val lister = SafTreeLister(this)
while (queue.isNotEmpty()) { while (queue.isNotEmpty()) {
if (safScanCancel) { if (safScanCancel) {
@@ -1239,17 +1343,18 @@ internal fun MainActivity.scanSafTreeIncremental(
continue continue
} }
val children = try { val listing = lister.list(dir, queue, visitedDirUris)
listSafChildrenOrThrow(dir) val children = listing.getOrNull()
} catch (e: Exception) { if (children == null) {
traversalErrors++ traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors } updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w( android.util.Log.w(
"SpotiFLAC", "SpotiFLAC",
"SAF incremental scan: failed listing directory $dirUri: ${e.message}", "SAF incremental scan: failed listing directory $dirUri: ${listing.exceptionOrNull()?.message}",
) )
continue continue
} }
rememberCueDirectoryListing(dir, children, safChildLookupCache)
for (child in children) { for (child in children) {
if (safScanCancel) { if (safScanCancel) {
@@ -1286,19 +1391,19 @@ internal fun MainActivity.scanSafTreeIncremental(
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] } val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
if (existingModified != null && existingModified == lastModified) { if (existingModified != null && existingModified == lastModified) {
unchangedCueFiles.add(child.doc to dir) unchangedCueFiles.add(CueEntry(child.doc, dir, name, lastModified))
for (vp in virtualPaths) { for (vp in virtualPaths) {
currentUris.add(vp) currentUris.add(vp)
} }
} else { } else {
cueFilesToScan.add(Triple(child.doc, dir, lastModified)) cueFilesToScan.add(CueEntry(child.doc, dir, name, lastModified))
} }
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
val existingModified = existingFiles[uriStr] val existingModified = existingFiles[uriStr]
val lastModified = child.lastModified val lastModified = child.lastModified
if (existingModified == null || existingModified != lastModified) { if (existingModified == null || existingModified != lastModified) {
audioFiles.add(Triple(child.doc, path, lastModified)) audioFiles.add(ChangedAudio(child.doc, name, lastModified))
} }
} }
} }
@@ -1355,7 +1460,7 @@ internal fun MainActivity.scanSafTreeIncremental(
val cueReferencedAudioUris = mutableSetOf<String>() val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) { for ((cueDoc, parentDir, cueName, cueLastModified) in cueFilesToScan) {
if (safScanCancel) { if (safScanCancel) {
updateSafScanProgress { it.isComplete = true } updateSafScanProgress { it.isComplete = true }
spill.abandon() spill.abandon()
@@ -1368,13 +1473,12 @@ internal fun MainActivity.scanSafTreeIncremental(
return result.toString() return result.toString()
} }
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress { it.currentFile = cueName } updateSafScanProgress { it.currentFile = cueName }
var tempCuePath: String? = null var tempCuePath: String? = null
var tempAudioPath: String? = null var tempAudioPath: String? = null
try { try {
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") tempCuePath = copyUriToTemp(cueDoc.uri, ".cue", cueName)
if (tempCuePath == null) { if (tempCuePath == null) {
errors++ errors++
android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}") android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}")
@@ -1465,13 +1569,12 @@ internal fun MainActivity.scanSafTreeIncremental(
} }
} }
for ((cueDoc, parentDir) in unchangedCueFiles) { for ((cueDoc, parentDir, cueName) in unchangedCueFiles) {
var tempCue: String? = null var tempCue: String? = null
try { try {
tempCue = copyUriToTemp(cueDoc.uri, ".cue") tempCue = copyUriToTemp(cueDoc.uri, ".cue", cueName)
if (tempCue != null) { if (tempCue != null) {
val audioFileName = extractCueAudioFileName(tempCue) val audioFileName = extractCueAudioFileName(tempCue)
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
val audioDoc = resolveCueAudioSibling( val audioDoc = resolveCueAudioSibling(
parentDir = parentDir, parentDir = parentDir,
cueName = cueName, cueName = cueName,
@@ -1489,65 +1592,19 @@ internal fun MainActivity.scanSafTreeIncremental(
} }
} }
for ((doc, _, lastModified) in audioFiles) { fun cancelledIncrementalResult(): String {
if (safScanCancel) { updateSafScanProgress { it.isComplete = true }
updateSafScanProgress { it.isComplete = true } spill.abandon()
spill.abandon() val result = JSONObject()
val result = JSONObject() result.put("files", JSONArray())
result.put("files", JSONArray()) result.put("removedUris", JSONArray())
result.put("removedUris", JSONArray()) result.put("skippedCount", skippedCount)
result.put("skippedCount", skippedCount) result.put("totalFiles", totalFiles)
result.put("totalFiles", totalFiles) result.put("cancelled", true)
result.put("cancelled", true) return result.toString()
return result.toString() }
}
if (cueReferencedAudioUris.contains(doc.uri.toString())) { fun reportProcessed() {
scanned++
val processed = skippedCount + scanned
val pct = if (totalFiles > 0) {
processed.toDouble() / totalFiles.toDouble() * 100.0
} else {
100.0
}
updateSafScanProgress {
it.scannedFiles = processed
it.progressPct = pct
}
continue
}
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress {
it.currentFile = name
}
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
val stableUri = doc.uri.toString()
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, safeLastModified)
val metadataObj = readAudioMetadataFromUri(
doc.uri,
name,
fallbackExt,
coverCacheKey,
)
if (metadataObj == null) {
errors++
} else {
try {
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", safeLastModified)
metadataObj.put("lastModified", safeLastModified)
putFile(metadataObj)
} catch (_: Exception) {
errors++
}
}
scanned++
val processed = skippedCount + scanned val processed = skippedCount + scanned
val pct = if (totalFiles > 0) { val pct = if (totalFiles > 0) {
processed.toDouble() / totalFiles.toDouble() * 100.0 processed.toDouble() / totalFiles.toDouble() * 100.0
@@ -1561,6 +1618,53 @@ internal fun MainActivity.scanSafTreeIncremental(
} }
} }
val pendingAudio = mutableListOf<ChangedAudio>()
for (audio in audioFiles) {
if (safScanCancel) return cancelledIncrementalResult()
if (cueReferencedAudioUris.contains(audio.doc.uri.toString())) {
scanned++
reportProcessed()
} else {
pendingAudio.add(audio)
}
}
val completed = runSafReadsInOrder(
pendingAudio,
cancelled = { safScanCancel },
task = { audio ->
val ext = audio.name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
readAudioMetadataFromUri(
audio.doc.uri,
audio.name,
fallbackExt,
buildLibraryCoverCacheKey(audio.doc.uri.toString(), audio.lastModified),
)
},
) { audio, result ->
updateSafScanProgress { it.currentFile = audio.name }
// A failed read aborts the incremental scan, as it did when serial.
val metadataObj = result.getOrThrow()
if (metadataObj == null) {
errors++
} else {
try {
val stableUri = audio.doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", audio.lastModified)
metadataObj.put("lastModified", audio.lastModified)
putFile(metadataObj)
} catch (_: Exception) {
errors++
}
}
scanned++
reportProcessed()
}
if (!completed) return cancelledIncrementalResult()
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) } val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
updateSafScanProgress { updateSafScanProgress {