diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 783d2ddd..eb0e350e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -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 -> diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt index 9cb30431..115b7184 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -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", + ) + } } } } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt index e3e67b3c..f0442299 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -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 { + val checkpoint = File(path) + if (!checkpoint.exists()) return mutableMapOf() + val entries = mutableMapOf() + 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>() + // 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 { + 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 } } diff --git a/go_backend/audio_metadata_cover.go b/go_backend/audio_metadata_cover.go index 4af4525d..f9d55659 100644 --- a/go_backend/audio_metadata_cover.go +++ b/go_backend/audio_metadata_cover.go @@ -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) } diff --git a/go_backend/cover.go b/go_backend/cover.go index 7899acb7..d6573365 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -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 diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 5bcc068b..8f92513d 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -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 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({ + '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; } }