mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-03 16:50:40 +02:00
perf(library): resume and parallelize SAF indexing
This commit is contained in:
@@ -95,6 +95,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
@Volatile internal var safScanActive = false
|
||||
/** Tri-state: null = untested, true = works, false = fails (Samsung SELinux). */
|
||||
@Volatile internal var procSelfFdReadable: Boolean? = null
|
||||
@Volatile internal var procSelfFdFallbacks: Int = 0
|
||||
internal val procSelfFdStateLock = Any()
|
||||
private val safTreeLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { activityResult ->
|
||||
|
||||
@@ -263,29 +263,36 @@ internal fun MainActivity.readAudioMetadataFromUri(
|
||||
val obj = JSONObject(metadataJson)
|
||||
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
|
||||
if (!obj.has("error") && !filenameFallback) {
|
||||
procSelfFdReadable = true
|
||||
synchronized(procSelfFdStateLock) {
|
||||
procSelfFdReadable = true
|
||||
procSelfFdFallbacks = 0
|
||||
}
|
||||
return obj
|
||||
}
|
||||
// Go could not read real metadata from the fd path –
|
||||
// remember so we skip the attempt for remaining files.
|
||||
if (procSelfFdReadable == null) {
|
||||
procSelfFdReadable = false
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct /proc/self/fd read not usable on this device, " +
|
||||
"using temp-file fallback for remaining files",
|
||||
)
|
||||
// One filename fallback should not disable descriptors for all files.
|
||||
synchronized(procSelfFdStateLock) {
|
||||
procSelfFdFallbacks++
|
||||
if (procSelfFdFallbacks >= 3 && procSelfFdReadable == null) {
|
||||
procSelfFdReadable = false
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct /proc/self/fd read not usable for this provider, " +
|
||||
"using temp-file fallback",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (procSelfFdReadable == null) {
|
||||
procSelfFdReadable = false
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct /proc/self/fd read not usable on this device, " +
|
||||
"using temp-file fallback for remaining files",
|
||||
)
|
||||
synchronized(procSelfFdStateLock) {
|
||||
if (procSelfFdReadable == null) {
|
||||
procSelfFdReadable = false
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct /proc/self/fd read not usable on this device, " +
|
||||
"using temp-file fallback for remaining files",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,10 @@ import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.RandomAccessFile
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
// SAF library-scan subsystem: tree walking, incremental diff, CUE resolution,
|
||||
// and scan-progress state shared with the progress stream in MainActivity.
|
||||
@@ -55,7 +57,10 @@ internal fun MainActivity.resetSafScanProgress() {
|
||||
safScanProgress = MainActivity.SafScanProgress()
|
||||
}
|
||||
// Allow re-probing /proc/self/fd readability on every new scan session.
|
||||
procSelfFdReadable = null
|
||||
synchronized(procSelfFdStateLock) {
|
||||
procSelfFdReadable = null
|
||||
procSelfFdFallbacks = 0
|
||||
}
|
||||
}
|
||||
|
||||
internal fun MainActivity.updateSafScanProgress(block: (MainActivity.SafScanProgress) -> Unit) {
|
||||
@@ -412,6 +417,59 @@ internal fun MainActivity.inspectSafFiles(requestsJson: String): String {
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun safScanCheckpointPath(outputPath: String): String = "$outputPath.state"
|
||||
|
||||
private fun loadSafScanCheckpoint(path: String): MutableMap<String, Long> {
|
||||
val checkpoint = File(path)
|
||||
if (!checkpoint.exists()) return mutableMapOf()
|
||||
val entries = mutableMapOf<String, Long>()
|
||||
try {
|
||||
checkpoint.forEachLine(Charsets.UTF_8) { line ->
|
||||
val separator = line.indexOf('\t')
|
||||
if (separator <= 0) return@forEachLine
|
||||
val uri = line.substring(0, separator)
|
||||
val modified = line.substring(separator + 1).toLongOrNull() ?: return@forEachLine
|
||||
entries[uri] = modified
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "SAF scan: failed reading checkpoint: ${e.message}")
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun countSafScanRows(path: String): Int {
|
||||
val output = File(path)
|
||||
if (!output.exists()) return 0
|
||||
return try {
|
||||
output.useLines(Charsets.UTF_8) { lines -> lines.count { it.isNotBlank() } }
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "SAF scan: failed counting existing rows: ${e.message}")
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun repairSafScanOutput(path: String) {
|
||||
val output = File(path)
|
||||
if (!output.exists()) return
|
||||
try {
|
||||
RandomAccessFile(output, "rw").use { file ->
|
||||
var position = file.length() - 1
|
||||
while (position >= 0) {
|
||||
file.seek(position)
|
||||
if (file.readByte().toInt() == '\n'.code) {
|
||||
file.setLength(position + 1)
|
||||
return
|
||||
}
|
||||
position--
|
||||
}
|
||||
// No complete line survived the interruption.
|
||||
file.setLength(0)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "SAF scan: failed repairing output: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extract the audio filename referenced by a CUE sheet file.
|
||||
@@ -568,13 +626,14 @@ internal fun MainActivity.scanSafTree(
|
||||
fun emptyResult(): Any {
|
||||
if (ndjsonOutputPath == null) return "[]"
|
||||
File(ndjsonOutputPath).writeText("", Charsets.UTF_8)
|
||||
try { File(safScanCheckpointPath(ndjsonOutputPath)).delete() } catch (_: Exception) {}
|
||||
return mapOf("path" to ndjsonOutputPath, "count" to 0)
|
||||
}
|
||||
|
||||
fun cancelledResult(): Any {
|
||||
updateSafScanProgress { it.isComplete = true }
|
||||
if (ndjsonOutputPath == null) return "[]"
|
||||
try { File(ndjsonOutputPath).delete() } catch (_: Exception) {}
|
||||
// Preserve partial output so the next scan can resume.
|
||||
throw java.util.concurrent.CancellationException("SAF library scan cancelled")
|
||||
}
|
||||
|
||||
@@ -675,10 +734,43 @@ internal fun MainActivity.scanSafTree(
|
||||
// its serialized string would otherwise hold the whole payload on the
|
||||
// Java heap several times over.
|
||||
val spill = if (ndjsonOutputPath == null) this.SpillJsonWriter() else null
|
||||
val ndjsonWriter = ndjsonOutputPath?.let {
|
||||
File(it).bufferedWriter(Charsets.UTF_8, 64 * 1024)
|
||||
val outputPath = ndjsonOutputPath
|
||||
val checkpointPath = outputPath?.let(::safScanCheckpointPath)
|
||||
val checkpoint = if (checkpointPath != null) {
|
||||
val outputFile = File(requireNotNull(outputPath))
|
||||
val checkpointFile = File(checkpointPath)
|
||||
outputFile.parentFile?.mkdirs()
|
||||
// Discard stale output that has no checkpoint.
|
||||
if (outputFile.exists() && !checkpointFile.exists()) {
|
||||
try { outputFile.delete() } catch (_: Exception) {}
|
||||
} else if (!outputFile.exists() && checkpointFile.exists()) {
|
||||
try { checkpointFile.delete() } catch (_: Exception) {}
|
||||
}
|
||||
repairSafScanOutput(outputFile.absolutePath)
|
||||
loadSafScanCheckpoint(checkpointPath)
|
||||
} else {
|
||||
mutableMapOf()
|
||||
}
|
||||
var resultCount = 0
|
||||
val checkpointWriter = checkpointPath?.let {
|
||||
FileOutputStream(File(it), true).bufferedWriter(Charsets.UTF_8)
|
||||
}
|
||||
val ndjsonWriter = outputPath?.let {
|
||||
FileOutputStream(File(it), true).bufferedWriter(Charsets.UTF_8)
|
||||
}
|
||||
var resultCount = outputPath?.let(::countSafScanRows) ?: 0
|
||||
fun checkpointed(uri: String, lastModified: Long): Boolean =
|
||||
checkpoint[uri] == lastModified
|
||||
|
||||
fun recordCheckpoint(uri: String, lastModified: Long) {
|
||||
if (checkpointWriter == null || uri.isBlank()) return
|
||||
checkpoint[uri] = lastModified
|
||||
checkpointWriter.write(uri)
|
||||
checkpointWriter.write('\t'.code)
|
||||
checkpointWriter.write(lastModified.toString())
|
||||
checkpointWriter.newLine()
|
||||
checkpointWriter.flush()
|
||||
}
|
||||
|
||||
fun putResult(obj: JSONObject) {
|
||||
if (ndjsonWriter != null) {
|
||||
ndjsonWriter.write(obj.toString())
|
||||
@@ -791,6 +883,15 @@ internal fun MainActivity.scanSafTree(
|
||||
}
|
||||
}
|
||||
|
||||
data class SafAudioScanOutcome(
|
||||
val uri: String,
|
||||
val name: String,
|
||||
val lastModified: Long,
|
||||
val metadata: JSONObject?,
|
||||
)
|
||||
|
||||
val pendingAudio = mutableListOf<Triple<DocumentFile, String, Long>>()
|
||||
// Skip resumable and CUE entries before parallel reads.
|
||||
for ((doc, _) in audioFiles) {
|
||||
if (safScanCancel) {
|
||||
ndjsonWriter?.close()
|
||||
@@ -798,7 +899,14 @@ internal fun MainActivity.scanSafTree(
|
||||
return cancelledResult()
|
||||
}
|
||||
|
||||
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
||||
val stableUri = doc.uri.toString()
|
||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||
if (checkpointed(stableUri, lastModified) ||
|
||||
cueReferencedAudioUris.contains(stableUri)
|
||||
) {
|
||||
if (!checkpointed(stableUri, lastModified)) {
|
||||
recordCheckpoint(stableUri, lastModified)
|
||||
}
|
||||
scanned++
|
||||
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
|
||||
updateSafScanProgress {
|
||||
@@ -807,43 +915,89 @@ internal fun MainActivity.scanSafTree(
|
||||
}
|
||||
continue
|
||||
}
|
||||
pendingAudio.add(Triple(doc, stableUri, lastModified))
|
||||
}
|
||||
|
||||
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
|
||||
updateSafScanProgress {
|
||||
it.currentFile = name
|
||||
}
|
||||
// Bound parallelism to limit SAF full-copy memory and I/O.
|
||||
val workerCount = 2
|
||||
val executor = Executors.newFixedThreadPool(workerCount)
|
||||
try {
|
||||
for (batch in pendingAudio.chunked(workerCount * 2)) {
|
||||
if (safScanCancel) {
|
||||
ndjsonWriter?.close()
|
||||
spill?.abandon()
|
||||
return cancelledResult()
|
||||
}
|
||||
|
||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||
val stableUri = doc.uri.toString()
|
||||
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified)
|
||||
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", lastModified)
|
||||
putResult(metadataObj)
|
||||
} catch (_: Exception) {
|
||||
errors++
|
||||
val futures = batch.map { (doc, stableUri, lastModified) ->
|
||||
executor.submit<SafAudioScanOutcome> {
|
||||
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
|
||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified)
|
||||
val metadata = try {
|
||||
readAudioMetadataFromUri(
|
||||
doc.uri,
|
||||
name,
|
||||
fallbackExt,
|
||||
coverCacheKey,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"SAF scan: metadata read failed for $stableUri: ${e.message}",
|
||||
)
|
||||
null
|
||||
}
|
||||
SafAudioScanOutcome(stableUri, name, lastModified, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
for (future in futures) {
|
||||
if (safScanCancel) {
|
||||
futures.forEach { it.cancel(true) }
|
||||
ndjsonWriter?.close()
|
||||
spill?.abandon()
|
||||
return cancelledResult()
|
||||
}
|
||||
|
||||
val outcome = try {
|
||||
future.get()
|
||||
} catch (e: Exception) {
|
||||
errors++
|
||||
null
|
||||
}
|
||||
if (outcome != null) {
|
||||
updateSafScanProgress { it.currentFile = outcome.name }
|
||||
val metadataObj = outcome.metadata
|
||||
if (metadataObj == null) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanned++
|
||||
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
|
||||
updateSafScanProgress {
|
||||
it.scannedFiles = scanned
|
||||
it.errorCount = errors
|
||||
it.progressPct = pct
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
updateSafScanProgress {
|
||||
@@ -853,16 +1007,16 @@ internal fun MainActivity.scanSafTree(
|
||||
|
||||
if (ndjsonWriter != null) {
|
||||
ndjsonWriter.close()
|
||||
return mapOf("path" to ndjsonOutputPath, "count" to resultCount)
|
||||
checkpointWriter?.close()
|
||||
return mapOf("path" to outputPath, "count" to resultCount)
|
||||
}
|
||||
spill!!.raw(if (resultCount == 0) "[]" else "]")
|
||||
return spill.result()
|
||||
} catch (e: Exception) {
|
||||
try { ndjsonWriter?.close() } catch (_: Exception) {}
|
||||
try { checkpointWriter?.close() } catch (_: Exception) {}
|
||||
spill?.abandon()
|
||||
if (ndjsonOutputPath != null) {
|
||||
try { File(ndjsonOutputPath).delete() } catch (_: Exception) {}
|
||||
}
|
||||
// Preserve partial output for resume.
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +373,15 @@ func saveLibraryCoverDataToCache(cacheDir, cacheKey string, imageData []byte, mi
|
||||
if len(imageData) == 0 {
|
||||
return "", fmt.Errorf("cover data is empty")
|
||||
}
|
||||
// Cap cached Library artwork while preserving unsupported formats.
|
||||
if resized, changed, err := resizeCoverForEmbedding(imageData, libraryCoverMaxDimension); err == nil {
|
||||
if changed {
|
||||
imageData = resized
|
||||
mimeType = embeddedCoverMIME(imageData)
|
||||
}
|
||||
} else {
|
||||
GoLog("[LibraryScan] Cover resize skipped for %s: %v\n", cacheKey, err)
|
||||
}
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create cache dir: %w", err)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ func downloadCoverToMemory(coverURL string) ([]byte, error) {
|
||||
|
||||
const (
|
||||
embeddedCoverJPEGQuality = 88
|
||||
// Bound Library cover cache dimensions.
|
||||
libraryCoverMaxDimension = 800
|
||||
maxCoverDownloadBytes = 24 * 1024 * 1024
|
||||
// Decoding arbitrary provider artwork allocates roughly four bytes per
|
||||
// pixel. Refuse pathological images before Decode so a malicious extension
|
||||
|
||||
@@ -50,6 +50,11 @@ class LibraryScanNDJSONFile {
|
||||
try {
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (_) {}
|
||||
// Remove the resumable scan checkpoint after ingestion.
|
||||
try {
|
||||
final checkpoint = File('${file.path}.state');
|
||||
if (await checkpoint.exists()) await checkpoint.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2120,10 +2125,21 @@ class PlatformBridge {
|
||||
required Map<String, dynamic> arguments,
|
||||
bool Function()? isCancelled,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
// Stable support-directory path lets native SAF scans resume after process death.
|
||||
final scanDir = await getApplicationSupportDirectory();
|
||||
await scanDir.create(recursive: true);
|
||||
final identity = jsonEncode(<String, dynamic>{
|
||||
'method': method,
|
||||
'arguments': arguments,
|
||||
});
|
||||
var hash = 0x811c9dc5;
|
||||
for (final byte in utf8.encode(identity)) {
|
||||
hash ^= byte;
|
||||
hash = (hash * 0x01000193) & 0xffffffff;
|
||||
}
|
||||
final output = File(
|
||||
'${tempDir.path}${Platform.pathSeparator}'
|
||||
'library_scan_${DateTime.now().microsecondsSinceEpoch}.ndjson',
|
||||
'${scanDir.path}${Platform.pathSeparator}'
|
||||
'library_scan_${hash.toRadixString(16).padLeft(8, '0')}.ndjson',
|
||||
);
|
||||
try {
|
||||
if (isCancelled?.call() == true) {
|
||||
@@ -2158,9 +2174,7 @@ class PlatformBridge {
|
||||
}
|
||||
return LibraryScanNDJSONFile(file: file, expectedCount: count);
|
||||
} catch (_) {
|
||||
try {
|
||||
if (await output.exists()) await output.delete();
|
||||
} catch (_) {}
|
||||
// Keep partial output; native SAF scan resumes from its sidecar.
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user