diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 094b795c..84d61265 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -35,17 +35,17 @@ import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.pow object NativeDownloadFinalizer { - private const val TAG = "NativeFinalizer" + internal const val TAG = "NativeFinalizer" const val NATIVE_WORKER_CONTRACT_VERSION = 1 // Native finalizer owns background-safe history writes while Flutter may be suspended. // Keep this schema contract in sync with Dart HistoryDatabase before bumping either side. const val HISTORY_SCHEMA_VERSION = 10 - private val activeFFmpegSessionIds = mutableSetOf() - private val nativeFFmpegSessionIds = mutableSetOf() - private val activeFFmpegSessionLock = Any() - private val ffmpegCompleteCallbackLock = Any() - private var forwardedFFmpegCompleteCallback: FFmpegSessionCompleteCallback? = null - private val nativeFilteringFFmpegCompleteCallback = FFmpegSessionCompleteCallback { session -> + internal val activeFFmpegSessionIds = mutableSetOf() + internal val nativeFFmpegSessionIds = mutableSetOf() + internal val activeFFmpegSessionLock = Any() + internal val ffmpegCompleteCallbackLock = Any() + internal var forwardedFFmpegCompleteCallback: FFmpegSessionCompleteCallback? = null + internal val nativeFilteringFFmpegCompleteCallback = FFmpegSessionCompleteCallback { session -> val isNativeSession = synchronized(activeFFmpegSessionLock) { nativeFFmpegSessionIds.contains(session.sessionId) } @@ -112,7 +112,7 @@ object NativeDownloadFinalizer { ".mp4", ) - private data class FinalizeInput( + internal data class FinalizeInput( val itemId: String, val request: JSONObject, val item: JSONObject, @@ -120,7 +120,7 @@ object NativeDownloadFinalizer { val result: JSONObject, ) - private data class FinalizeState( + internal data class FinalizeState( var filePath: String, var fileName: String, var quality: String, @@ -132,7 +132,7 @@ object NativeDownloadFinalizer { var pendingExternalLrcFileName: String? = null, ) - private data class ReplayGainScan( + internal data class ReplayGainScan( val trackGain: String, val trackPeak: String, val integratedLufs: Double, @@ -304,7 +304,7 @@ object NativeDownloadFinalizer { return result } - private fun checkCancelled(shouldCancel: () -> Boolean) { + internal fun checkCancelled(shouldCancel: () -> Boolean) { if (shouldCancel()) { throw CancellationException("Native finalization cancelled") } @@ -710,22 +710,6 @@ object NativeDownloadFinalizer { state.audioCodec = normalizeAudioCodec(codec) } - private fun isMP4ContainerFile(path: String): Boolean { - return try { - File(path).inputStream().use { stream -> - val header = ByteArray(12) - val read = stream.read(header) - read >= 8 && - header[4] == 'f'.code.toByte() && - header[5] == 't'.code.toByte() && - header[6] == 'y'.code.toByte() && - header[7] == 'p'.code.toByte() - } - } catch (_: Exception) { - false - } - } - private fun finalizeMetadata(context: Context, input: FinalizeInput, state: FinalizeState) { if (!input.request.optBoolean("embed_metadata", false)) return if (!state.filePath.startsWith("content://")) { @@ -900,168 +884,6 @@ object NativeDownloadFinalizer { lowerName.endsWith(".ogg") } - private fun qualityVariantFilenameLabel(state: FinalizeState): String? { - return NativeFinalizationPolicy.qualityVariantFilenameLabel( - measuredQuality = state.quality, - bitDepth = state.bitDepth, - sampleRate = state.sampleRate, - bitrateKbps = state.bitrateKbps, - audioCodec = state.audioCodec, - ) - } - - private fun finalizeQualityVariantFilename( - context: Context, - input: FinalizeInput, - state: FinalizeState, - ) { - if (!input.request.optBoolean("allow_quality_variant", false)) return - val stagingLabel = input.request.optString("quality_variant", "").trim() - val qualityLabel = qualityVariantFilenameLabel(state) - if (qualityLabel == null) { - Log.w(TAG, "Keeping temporary quality label because final audio specifications are unavailable") - return - } - - val preferredName = applyQualityVariantFilenameLabel( - fileName = state.fileName, - stagingLabel = stagingLabel, - qualityLabel = qualityLabel, - ) - if (preferredName == state.fileName) return - input.result.put("quality_variant_file_name", preferredName) - if (isDeferredSafPublish(input)) { - state.fileName = preferredName - return - } - - if (state.filePath.startsWith("content://")) { - val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return - try { - val writeResult = SafDownloadHandler.writeFileToSafUnique( - context = context, - treeUriStr = input.request.optString("saf_tree_uri", ""), - relativeDir = input.request.optString("saf_relative_dir", ""), - fileName = preferredName, - mimeType = mimeTypeForExt(File(preferredName).extension), - srcPath = tempPath, - preservedSuffix = qualityLabel, - ) ?: return - SafDownloadHandler.deleteContentUri(context, state.filePath) - state.filePath = writeResult.uri - state.fileName = writeResult.fileName - } finally { - File(tempPath).delete() - } - } else { - val source = File(state.filePath) - val target = uniqueLocalFile(source.parentFile, preferredName) - if (!source.renameTo(target)) { - Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}") - return - } - state.filePath = target.absolutePath - state.fileName = target.name - } - - input.result.put("file_path", state.filePath) - input.result.put("file_name", state.fileName) - input.result.optJSONObject("replaygain")?.let { replayGain -> - replayGain.put("file_path", state.filePath) - replayGain.put("file_name", state.fileName) - } - } - - private fun uniqueLocalFile(parent: File?, preferredName: String): File { - val directory = parent ?: return File(preferredName) - var candidate = File(directory, preferredName) - if (!candidate.exists()) return candidate - val dotIndex = preferredName.lastIndexOf('.') - val hasExtension = dotIndex > 0 - val stem = if (hasExtension) preferredName.substring(0, dotIndex) else preferredName - val extension = if (hasExtension) preferredName.substring(dotIndex) else "" - var counter = 2 - while (candidate.exists()) { - candidate = File(directory, "$stem ($counter)$extension") - counter++ - } - return candidate - } - - private fun writeExternalLrc(context: Context, input: FinalizeInput, state: FinalizeState) { - if (!input.request.optBoolean("embed_metadata", false) || !input.request.optBoolean("embed_lyrics", false)) return - val lyricsMode = input.request.optString("lyrics_mode", "") - if (lyricsMode != "external" && lyricsMode != "both") return - val lrc = resolveLyricsLrc(input) - if (lrc.isBlank() || lrc == "[instrumental:true]") return - val audioFileName = if (isDeferredSafRequest(input)) { - desiredFileName(input, state, File(state.filePath).extension) - } else { - state.fileName - } - val baseName = audioFileName.replace(Regex("\\.[^.]+$"), "") - if (isDeferredSafRequest(input)) { - state.pendingExternalLrc = lrc - state.pendingExternalLrcFileName = "$baseName.lrc" - return - } - if (state.filePath.startsWith("content://")) { - val treeUri = input.request.optString("saf_tree_uri", "") - val relativeDir = input.request.optString("saf_relative_dir", "") - val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc") - temp.writeText(lrc) - try { - SafDownloadHandler.writeFileToSaf( - context = context, - treeUriStr = treeUri, - relativeDir = relativeDir, - fileName = "$baseName.lrc", - mimeType = "application/octet-stream", - srcPath = temp.absolutePath, - ) - } finally { - temp.delete() - } - } else { - val target = File(File(state.filePath).parentFile, "$baseName.lrc") - target.writeText(lrc) - } - } - - private fun resolveLyricsLrc(input: FinalizeInput): String { - val existing = input.result.optString("lyrics_lrc", "").trim() - if (existing.isNotEmpty()) return existing - - val spotifyId = trackString(input, "id", input.request.optString("spotify_id", "")) - val trackName = trackString(input, "name", input.request.optString("track_name", "")) - val artistName = trackString(input, "artistName", input.request.optString("artist_name", "")) - if (trackName.isBlank() || artistName.isBlank()) return "" - - return try { - val fetched = Gobackend.getLyricsLRC( - spotifyId, - trackName, - artistName, - "", - lyricsDurationMs(input), - ).trim() - if (fetched.isNotEmpty()) { - input.result.put("lyrics_lrc", fetched) - } - fetched - } catch (_: Exception) { - "" - } - } - - private fun lyricsDurationMs(input: FinalizeInput): Long { - val requestDuration = input.request.optLong("duration_ms", 0L) - val trackDuration = trackInt(input, "duration", 0).toLong() - val duration = if (requestDuration > 0L) requestDuration else trackDuration - if (duration <= 0L) return 0L - return if (duration > 10000L) duration else duration * 1000L - } - private fun runPostProcessing( context: Context, input: FinalizeInput, @@ -1144,13 +966,13 @@ object NativeDownloadFinalizer { } } - private fun materializeForFFmpeg(context: Context, input: FinalizeInput, state: FinalizeState): String { + internal fun materializeForFFmpeg(context: Context, input: FinalizeInput, state: FinalizeState): String { if (!state.filePath.startsWith("content://")) return state.filePath return SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: throw IllegalStateException("failed to copy SAF file") } - private fun replaceStatePath( + internal fun replaceStatePath( context: Context, input: FinalizeInput, state: FinalizeState, @@ -1181,508 +1003,6 @@ object NativeDownloadFinalizer { if (deleteOld && oldPath != localOutput) File(oldPath).delete() } - private fun embedBasicMetadata(context: Context, path: String, input: FinalizeInput, format: String) { - if (!input.request.optBoolean("embed_metadata", false)) return - val title = resultString(input, "title").ifBlank { - trackString(input, "name", requestString(input, "track_name")) - } - val artist = resultString(input, "artist").ifBlank { - trackString(input, "artistName", requestString(input, "artist_name")) - } - val album = resultString(input, "album").ifBlank { - trackString(input, "albumName", requestString(input, "album_name")) - } - val albumArtist = resultString(input, "album_artist").ifBlank { - trackString(input, "albumArtist", requestString(input, "album_artist")) - } - val date = resultString(input, "release_date").ifBlank { - resultString(input, "date").ifBlank { - trackString(input, "releaseDate", requestString(input, "release_date")) - } - } - val trackNumberValue = positiveOrNull(input.result.optInt("track_number", 0), trackInt(input, "trackNumber", input.request.optInt("track_number", 0))) ?: 0 - val totalTracksValue = positiveOrNull(input.result.optInt("total_tracks", 0), trackInt(input, "totalTracks", input.request.optInt("total_tracks", 0))) ?: 0 - val discNumberValue = positiveOrNull(input.result.optInt("disc_number", 0), trackInt(input, "discNumber", input.request.optInt("disc_number", 0))) ?: 0 - val totalDiscsValue = positiveOrNull(input.result.optInt("total_discs", 0), trackInt(input, "totalDiscs", input.request.optInt("total_discs", 0))) ?: 0 - val trackNumber = formatIndexTag(trackNumberValue, totalTracksValue) - val discNumber = formatIndexTag(discNumberValue, totalDiscsValue) - val isrc = resultString(input, "isrc").ifBlank { - trackString(input, "isrc", requestString(input, "isrc")) - } - val composer = resultString(input, "composer").ifBlank { - trackString(input, "composer", requestString(input, "composer")) - } - val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") } - val label = resultString(input, "label").ifBlank { requestString(input, "label") } - val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") } - val lyricsMode = input.request.optString("lyrics_mode", "embed") - val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) && - (lyricsMode == "embed" || lyricsMode == "both") - val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else "" - val shouldEmbedLyrics = shouldResolveLyrics && - lyrics.isNotBlank() && - lyrics != "[instrumental:true]" - // FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the - // tag block atomically without an ffmpeg remux (which drops foreign - // frames and rewrites the whole container). The Go side answers - // method=ffmpeg when it cannot handle the file natively. - if (format == "flac" || format == "mp3" || format == "opus" || format == "m4a") { - val nativeCover = downloadCoverForMetadata(context, input) - val handledNatively = try { - val fields = JSONObject() - .put("title", title) - .put("artist", artist) - .put("album", album) - .put("album_artist", albumArtist) - .put("date", date) - .put("isrc", isrc) - .put("composer", composer) - .put("genre", genre) - .put("label", label) - .put("copyright", copyright) - if (trackNumberValue > 0) fields.put("track_number", trackNumberValue.toString()) - if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString()) - if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString()) - if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString()) - if (nativeCover != null) fields.put("cover_path", nativeCover.absolutePath) - if (shouldEmbedLyrics) { - fields.put("lyrics", lyrics) - fields.put("unsyncedlyrics", lyrics) - } - val response = Gobackend.editFileMetadata(path, fields.toString()) - val method = try { - JSONObject(response).optString("method", "") - } catch (_: Exception) { - "" - } - method != "ffmpeg" - } catch (e: Exception) { - if (format == "flac") throw e - Log.w(TAG, "Native tag embed failed for $format: ${e.message}; falling back to ffmpeg") - false - } finally { - nativeCover?.delete() - } - if (handledNatively) return - } - - val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" } - val inputFile = File(path) - // ".partial" keeps the temp invisible to library scans while FFmpeg - // still infers the muxer from the real trailing extension. - val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged.partial$ext") - val isM4a = format == "m4a" - val isOpus = format == "opus" - val coverFile = if (isM4a || isOpus) downloadCoverForMetadata(context, input) else null - val labelKey = if (isM4a) "organization" else "label" - val metadataPairs = mutableListOf( - "title" to title, - "artist" to artist, - "album" to album, - "album_artist" to albumArtist, - "date" to date, - "track" to trackNumber, - "disc" to discNumber, - "isrc" to isrc, - "composer" to composer, - "genre" to genre, - labelKey to label, - "copyright" to copyright, - "lyrics" to if (shouldEmbedLyrics) lyrics else "", - "unsyncedlyrics" to if (shouldEmbedLyrics) lyrics else "", - ) - if (isOpus && coverFile != null) { - createMetadataBlockPicture(coverFile)?.let { - metadataPairs.add("METADATA_BLOCK_PICTURE" to it) - } - } - val metadataArgs = metadataPairs - .filter { it.second.isNotBlank() && it.second != "0" } - .joinToString(" ") { "-metadata ${it.first}=${q(it.second)}" } - if (metadataArgs.isBlank() && coverFile == null) return - val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else "" - var adoptedTemp = false - var originalDeleted = false - - fun buildEmbedCommand(forceMov: Boolean): String { - return if (isM4a && coverFile != null) { - "-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " + - "-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " + - "-disposition:v:0 attached_pic " + - "-metadata:s:v ${q("title=Album cover")} " + - "-metadata:s:v ${q("comment=Cover (front)")} " + - "$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y" - } else { - val movFlag = if (forceMov) "-f mov " else "" - "-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y" - } - } - - try { - var result = runFFmpeg(buildEmbedCommand(false)) - // MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4). - if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) { - temp.delete() - result = runFFmpeg(buildEmbedCommand(true)) - } - if (result.first && temp.exists()) { - fsyncQuietly(temp) - // Rename directly over the original: a process kill between a - // delete-first and the rename would lose the file entirely. - adoptedTemp = temp.renameTo(inputFile) - if (!adoptedTemp && inputFile.delete()) { - originalDeleted = true - adoptedTemp = temp.renameTo(inputFile) - } - } - } finally { - if (!adoptedTemp && !originalDeleted) { - temp.delete() - } - coverFile?.delete() - } - } - - /** - * Best-effort fsync so a file's bytes are durable before it is renamed - * over another file; fsync on a fresh handle flushes the page cache pages - * written earlier by ffmpeg in this process. - */ - private fun fsyncQuietly(file: File) { - try { - RandomAccessFile(file, "rw").use { it.fd.sync() } - } catch (_: Exception) { - } - } - - private fun createMetadataBlockPicture(coverFile: File): String? { - return try { - if (!coverFile.exists() || coverFile.length() <= 0L) return null - val imageData = coverFile.readBytes() - if (imageData.isEmpty()) return null - val mimeType = detectCoverMimeType(coverFile, imageData) - val mimeBytes = mimeType.toByteArray(Charsets.UTF_8) - val descriptionBytes = ByteArray(0) - val blockSize = 4 + 4 + mimeBytes.size + 4 + descriptionBytes.size + 4 + 4 + 4 + 4 + 4 + imageData.size - val buffer = ByteBuffer.allocate(blockSize) - buffer.putInt(3) - buffer.putInt(mimeBytes.size) - buffer.put(mimeBytes) - buffer.putInt(descriptionBytes.size) - buffer.put(descriptionBytes) - buffer.putInt(0) - buffer.putInt(0) - buffer.putInt(0) - buffer.putInt(0) - buffer.putInt(imageData.size) - buffer.put(imageData) - Base64.encodeToString(buffer.array(), Base64.NO_WRAP) - } catch (e: Exception) { - Log.w(TAG, "Failed to create Opus cover picture block: ${e.message}") - null - } - } - - private fun detectCoverMimeType(coverFile: File, imageData: ByteArray): String { - val ext = coverFile.extension.lowercase(Locale.ROOT) - if (ext == "png") return "image/png" - if (ext == "jpg" || ext == "jpeg") return "image/jpeg" - if (imageData.size >= 8 && - imageData[0] == 0x89.toByte() && - imageData[1] == 0x50.toByte() && - imageData[2] == 0x4E.toByte() && - imageData[3] == 0x47.toByte() - ) { - return "image/png" - } - return "image/jpeg" - } - - private fun downloadCoverForMetadata(context: Context, input: FinalizeInput): File? { - val coverUrl = metadataCoverUrl(input).ifBlank { resultString(input, "cover_url") } - if (coverUrl.isBlank()) return null - - val safeItemId = input.itemId.ifBlank { "item" }.replace(Regex("[^A-Za-z0-9._-]"), "_") - val output = File.createTempFile("native_cover_${safeItemId}_", ".jpg", context.cacheDir) - return try { - Gobackend.downloadCoverToFile( - coverUrl, - output.absolutePath, - input.request.optBoolean("embed_max_quality_cover", true) - ) - if (output.exists() && output.length() > 0L) { - output - } else { - output.delete() - null - } - } catch (e: Exception) { - Log.w(TAG, "Failed to download metadata cover: ${e.message}") - output.delete() - null - } - } - - private fun formatForPath(path: String): String { - return when (normalizeExt(File(path).extension)) { - ".mp3" -> "mp3" - ".opus", ".ogg" -> "opus" - ".m4a", ".mp4", ".aac" -> "m4a" - else -> "flac" - } - } - - private fun scanReplayGain(path: String, shouldCancel: () -> Boolean = { false }): ReplayGainScan? { - val command = "-hide_banner -nostats -i ${q(path)} -filter_complex ebur128=peak=true:framelog=quiet -f null -" - val result = runFFmpeg(command, shouldCancel) - val output = result.second - val integrated = Regex("I:\\s+(-?\\d+\\.?\\d*)\\s+LUFS") - .findAll(output) - .lastOrNull() - ?.groupValues - ?.getOrNull(1) - ?.toDoubleOrNull() ?: return null - val truePeak = Regex("Peak:\\s+(-?\\d+\\.?\\d*)\\s+dBFS") - .findAll(output) - .mapNotNull { it.groupValues.getOrNull(1)?.toDoubleOrNull() } - .maxOrNull() - val gain = -18.0 - integrated - val peak = if (truePeak != null) 10.0.pow(truePeak / 20.0) else 1.0 - return ReplayGainScan( - trackGain = "${if (gain >= 0) "+" else ""}${"%.2f".format(Locale.US, gain)} dB", - trackPeak = "%.6f".format(Locale.US, peak), - integratedLufs = integrated, - truePeakLinear = peak, - ) - } - - private fun runFFmpeg(command: String, shouldCancel: () -> Boolean = { false }): Pair { - checkCancelled(shouldCancel) - installNativeFFmpegCallbackFilter() - val latch = CountDownLatch(1) - var completedSession: FFmpegSession? = null - val session = FFmpegSession.create( - FFmpegKitConfig.parseArguments(command), - { finishedSession -> - completedSession = finishedSession - latch.countDown() - }, - null, - null, - LogRedirectionStrategy.NEVER_PRINT_LOGS, - ) - val sessionId = session.sessionId - synchronized(activeFFmpegSessionLock) { - activeFFmpegSessionIds.add(sessionId) - nativeFFmpegSessionIds.add(sessionId) - } - FFmpegKitConfig.asyncFFmpegExecute(session) - try { - var cancelRequested = false - while (!latch.await(200, TimeUnit.MILLISECONDS)) { - if (shouldCancel()) { - cancelRequested = true - try { - FFmpegKit.cancel(sessionId) - } catch (_: Exception) { - } - break - } - } - if (cancelRequested) { - latch.await(5, TimeUnit.SECONDS) - throw CancellationException("Native FFmpeg session cancelled") - } - val finalSession = completedSession ?: session - val output = finalSession.getAllLogsAsString(1000) ?: "" - checkCancelled(shouldCancel) - return ReturnCode.isSuccess(finalSession.returnCode) to output - } finally { - synchronized(activeFFmpegSessionLock) { - activeFFmpegSessionIds.remove(sessionId) - } - } - } - - private fun installNativeFFmpegCallbackFilter() { - synchronized(ffmpegCompleteCallbackLock) { - val current = FFmpegKitConfig.getFFmpegSessionCompleteCallback() - if (current !== nativeFilteringFFmpegCompleteCallback) { - forwardedFFmpegCompleteCallback = current - FFmpegKitConfig.enableFFmpegSessionCompleteCallback(nativeFilteringFFmpegCompleteCallback) - } - } - } - - private fun withFFmpegCommandPump( - shouldCancel: () -> Boolean = { false }, - block: () -> String, - ): String { - val running = AtomicBoolean(true) - val handled = mutableSetOf() - val pump = Thread { - while (running.get()) { - try { - val raw = Gobackend.getAllPendingFFmpegCommandsJSON() - val commands = org.json.JSONArray(raw) - for (index in 0 until commands.length()) { - val command = commands.optJSONObject(index) ?: continue - val id = command.optString("command_id", "") - val commandLine = command.optString("command", "") - if (id.isBlank() || commandLine.isBlank() || handled.contains(id)) { - continue - } - handled.add(id) - // Every claimed command must get a result delivered to - // the Go side, even on failure or cancellation: the - // backend blocks until one arrives and never retries a - // claimed id, so bailing out here would strand the - // gomobile call the main thread is sitting in forever. - val result = try { - if (shouldCancel()) { - Pair(false, "cancelled") - } else { - runFFmpeg(commandLine, shouldCancel) - } - } catch (e: Exception) { - Pair(false, e.message ?: "FFmpeg execution failed") - } - try { - Gobackend.setFFmpegCommandResultByID( - id, - result.first, - result.second, - if (result.first) "" else result.second, - ) - } catch (e: Exception) { - Log.w(TAG, "Failed to deliver FFmpeg result for $id: ${e.message}") - } - } - } catch (_: Exception) { - } - try { - Thread.sleep(100) - } catch (_: InterruptedException) { - // Keep pumping until `running` flips: on cancel the Go call - // may still be waiting for a result for an in-flight - // command, and it is delivered as failed above. - } - } - } - pump.isDaemon = true - pump.start() - return try { - block() - } finally { - running.set(false) - pump.interrupt() - } - } - - /** - * Staged sibling name for conversion outputs: "song.flac" -> "song.partial.flac". - * The ".partial" shape is ignored by library scans and duplicate checks, - * while the real trailing extension still lets FFmpeg infer the muxer. A - * process kill mid-conversion therefore never leaves a partial file under a - * real audio name in the user's music folder. - */ - private fun stagedConversionPath(finalPath: String): String { - val file = File(finalPath) - val ext = file.extension - val name = if (ext.isBlank()) "${file.name}.partial" else "${file.nameWithoutExtension}.partial.$ext" - return File(file.parentFile, name).absolutePath - } - - private fun promoteStagedConversion(stagedPath: String, finalPath: String): Boolean { - val staged = File(stagedPath) - fsyncQuietly(staged) - val final = File(finalPath) - if (staged.renameTo(final)) return true - return final.delete() && staged.renameTo(final) - } - - private fun buildOutputPath(inputPath: String, extension: String): String { - val ext = normalizeExt(extension).ifBlank { ".tmp" } - val file = File(inputPath) - val base = file.nameWithoutExtension.ifBlank { "track" } - val candidate = File(file.parentFile, "$base$ext").absolutePath - if (candidate != inputPath) return candidate - return File(file.parentFile, "${base}_converted$ext").absolutePath - } - - private fun desiredFileName(input: FinalizeInput, state: FinalizeState, extension: String): String { - val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } } - val rawName = input.result.optString("quality_variant_file_name", "") - .ifBlank { input.request.optString("saf_file_name", "") } - .ifBlank { state.fileName } - .ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" } - val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc") - var base = rawName.trim() - val lower = base.lowercase(Locale.ROOT) - for (knownExt in knownExts) { - if (lower.endsWith(knownExt)) { - base = base.dropLast(knownExt.length) - break - } - } - base = base - .replace("/", " ") - .replace(Regex("[\\\\:*?\"<>|]"), " ") - .trim() - .trim('.', ' ') - .ifBlank { "track" } - return "$base$ext" - } - - private fun shouldForceContainerConversion(input: FinalizeInput, state: FinalizeState): Boolean { - if (input.result.optBoolean("requires_container_conversion", false)) return true - if (input.request.optBoolean("requires_container_conversion", false)) return true - return false - } - - private fun probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String { - val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel) - val output = result.second - val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output) - return match?.groupValues?.getOrNull(1) - ?.trim() - ?.lowercase(Locale.ROOT) - ?.replace('-', '_') - .orEmpty() - } - - /** - * Returns true when the file on [path] starts with the native FLAC magic - * bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside - * an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC - * tag writers require the raw fLaC header, so we must detect that mismatch - * before skipping the container conversion step. - */ - private fun isNativeFlacFile(path: String): Boolean { - return try { - RandomAccessFile(path, "r").use { raf -> - if (raf.length() < 4L) return false - val header = ByteArray(4) - raf.readFully(header) - header[0] == 0x66.toByte() && // 'f' - header[1] == 0x4C.toByte() && // 'L' - header[2] == 0x61.toByte() && // 'a' - header[3] == 0x43.toByte() // 'C' - } - } catch (e: Exception) { - Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}") - false - } - } - - private fun requestedDecryptionOutputExt(input: FinalizeInput): String { - val descriptor = input.result.optJSONObject("decryption") - return normalizeExt( - descriptor?.optString("output_extension", "") - ?.ifBlank { input.result.optString("output_extension", "") } - ) - } - private fun validateRequestContract(request: JSONObject) { val version = request.optInt("contract_version", -1) if (version != NATIVE_WORKER_CONTRACT_VERSION) { @@ -1700,132 +1020,6 @@ object NativeDownloadFinalizer { } } - private fun promoteStagedSafOutputIfNeeded( - context: Context, - input: FinalizeInput, - state: FinalizeState, - ) { - if (!state.filePath.startsWith("content://")) return - if (!input.result.optBoolean("saf_staged_output", false)) return - val stagedName = input.result.optString("saf_staged_file_name", "").trim() - if (stagedName.isNotEmpty() && state.fileName != stagedName) return - - val localInput = materializeForFFmpeg(context, input, state) - try { - replaceStatePath(context, input, state, localInput, deleteOld = true) - } finally { - File(localInput).delete() - } - } - - private fun isDeferredSafPublish(input: FinalizeInput): Boolean { - return input.request.optBoolean("defer_saf_publish", false) && - input.result.optBoolean("saf_deferred_publish", false) - } - - private fun isDeferredSafRequest(input: FinalizeInput): Boolean { - return input.request.optString("storage_mode", "") == "saf" && - input.request.optBoolean("defer_saf_publish", false) - } - - private fun publishDeferredSafOutput( - context: Context, - input: FinalizeInput, - state: FinalizeState, - ) { - if (!isDeferredSafPublish(input)) return - if (state.filePath.startsWith("content://")) return - - val outputFile = File(state.filePath) - if (!outputFile.exists() || outputFile.length() <= 0L) { - throw IllegalStateException("deferred SAF output missing or empty") - } - - val finalName = desiredFileName(input, state, outputFile.extension) - val treeUri = input.result.optString("saf_tree_uri", "") - .ifBlank { input.request.optString("saf_tree_uri", "") } - val relativeDir = input.result.optString("saf_relative_dir", "") - .ifBlank { input.request.optString("saf_relative_dir", "") } - val mimeType = mimeTypeForExt(outputFile.extension) - val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false) - val uniqueWrite = if (preserveQualityVariant) { - SafDownloadHandler.writeFileToSafUnique( - context = context, - treeUriStr = treeUri, - relativeDir = relativeDir, - fileName = finalName, - mimeType = mimeType, - srcPath = outputFile.absolutePath, - preservedSuffix = qualityVariantFilenameLabel(state).orEmpty(), - ) - } else { - null - } - val newUri = uniqueWrite?.uri ?: if (!preserveQualityVariant) { - SafDownloadHandler.writeFileToSaf( - context = context, - treeUriStr = treeUri, - relativeDir = relativeDir, - fileName = finalName, - mimeType = mimeType, - srcPath = outputFile.absolutePath, - ) - } else { - null - } ?: throw IllegalStateException("failed to publish deferred SAF output") - val publishedName = uniqueWrite?.fileName ?: finalName - - Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}") - outputFile.delete() - state.filePath = newUri - state.fileName = publishedName - input.result.put("file_path", newUri) - input.result.put("file_name", publishedName) - input.result.optJSONObject("replaygain")?.let { replayGain -> - replayGain.put("file_path", newUri) - replayGain.put("file_name", publishedName) - } - if (state.pendingExternalLrc != null) { - state.pendingExternalLrcFileName = "${publishedName.replace(Regex("\\.[^.]+$"), "")}.lrc" - } - input.result.put("saf_deferred_published", true) - publishPendingDeferredExternalLrc(context, input, state) - } - - private fun publishPendingDeferredExternalLrc( - context: Context, - input: FinalizeInput, - state: FinalizeState, - ) { - val lrc = state.pendingExternalLrc ?: return - val fileName = state.pendingExternalLrcFileName ?: return - val treeUri = input.result.optString("saf_tree_uri", "") - .ifBlank { input.request.optString("saf_tree_uri", "") } - val relativeDir = input.result.optString("saf_relative_dir", "") - .ifBlank { input.request.optString("saf_relative_dir", "") } - val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc") - try { - temp.writeText(lrc) - val newUri = SafDownloadHandler.writeFileToSaf( - context = context, - treeUriStr = treeUri, - relativeDir = relativeDir, - fileName = fileName, - mimeType = "application/octet-stream", - srcPath = temp.absolutePath, - ) - if (newUri == null) { - Log.w(TAG, "Failed to publish deferred external LRC: $fileName") - } - } catch (e: Exception) { - Log.w(TAG, "Failed to publish deferred external LRC: ${e.message}") - } finally { - temp.delete() - state.pendingExternalLrc = null - state.pendingExternalLrcFileName = null - } - } - private fun decryptionKeyCandidates(raw: String): List { val candidates = linkedSetOf() fun add(value: String) { @@ -2413,19 +1607,19 @@ object NativeDownloadFinalizer { return json } - private fun trackString(input: FinalizeInput, key: String, fallback: String): String = + internal fun trackString(input: FinalizeInput, key: String, fallback: String): String = cleanMetadataString(input.track.optString(key, "")).ifBlank { cleanMetadataString(fallback) } - private fun requestString(input: FinalizeInput, key: String): String = + internal fun requestString(input: FinalizeInput, key: String): String = cleanMetadataString(input.request.optString(key, "")) - private fun resultString(input: FinalizeInput, key: String): String = + internal fun resultString(input: FinalizeInput, key: String): String = cleanMetadataString(input.result.optString(key, "")) - private fun metadataCoverUrl(input: FinalizeInput): String = + internal fun metadataCoverUrl(input: FinalizeInput): String = trackString(input, "coverUrl", requestString(input, "cover_url")) - private fun trackInt(input: FinalizeInput, key: String, fallback: Int): Int { + internal fun trackInt(input: FinalizeInput, key: String, fallback: Int): Int { val value = input.track.optInt(key, 0) return if (value > 0) value else fallback } @@ -2445,7 +1639,7 @@ object NativeDownloadFinalizer { return if (kbps >= 16) kbps else null } - private fun positiveOrNull(primary: Int, fallback: Int): Int? { + internal fun positiveOrNull(primary: Int, fallback: Int): Int? { val value = if (primary > 0) primary else fallback return if (value > 0) value else null } @@ -2460,5 +1654,5 @@ object NativeDownloadFinalizer { return if (trimmed.equals("null", ignoreCase = true)) "" else trimmed } - private fun q(value: String): String = "\"${value.replace("\"", "\\\"")}\"" + internal fun q(value: String): String = "\"${value.replace("\"", "\\\"")}\"" } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt new file mode 100644 index 00000000..76b89207 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt @@ -0,0 +1,314 @@ +package com.zarz.spotiflac + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.net.Uri +import android.util.Base64 +import android.util.Log +import com.antonkarpenko.ffmpegkit.FFmpegKit +import com.antonkarpenko.ffmpegkit.FFmpegKitConfig +import com.antonkarpenko.ffmpegkit.FFmpegSession +import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback +import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy +import com.antonkarpenko.ffmpegkit.ReturnCode +import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt +import com.zarz.spotiflac.SafDownloadHandler.normalizeExt +import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel +import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality +import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag +import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec +import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec +import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec +import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension +import gobackend.Gobackend +import org.json.JSONObject +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.util.Locale +import java.util.concurrent.CancellationException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.pow + + +// FFmpeg execution, probing, and container helpers for NativeDownloadFinalizer. + +internal fun NativeDownloadFinalizer.isMP4ContainerFile(path: String): Boolean { + return try { + File(path).inputStream().use { stream -> + val header = ByteArray(12) + val read = stream.read(header) + read >= 8 && + header[4] == 'f'.code.toByte() && + header[5] == 't'.code.toByte() && + header[6] == 'y'.code.toByte() && + header[7] == 'p'.code.toByte() + } + } catch (_: Exception) { + false + } +} + +internal fun NativeDownloadFinalizer.formatForPath(path: String): String { + return when (normalizeExt(File(path).extension)) { + ".mp3" -> "mp3" + ".opus", ".ogg" -> "opus" + ".m4a", ".mp4", ".aac" -> "m4a" + else -> "flac" + } +} + +internal fun NativeDownloadFinalizer.scanReplayGain(path: String, shouldCancel: () -> Boolean = { false }): NativeDownloadFinalizer.ReplayGainScan? { + val command = "-hide_banner -nostats -i ${q(path)} -filter_complex ebur128=peak=true:framelog=quiet -f null -" + val result = runFFmpeg(command, shouldCancel) + val output = result.second + val integrated = Regex("I:\\s+(-?\\d+\\.?\\d*)\\s+LUFS") + .findAll(output) + .lastOrNull() + ?.groupValues + ?.getOrNull(1) + ?.toDoubleOrNull() ?: return null + val truePeak = Regex("Peak:\\s+(-?\\d+\\.?\\d*)\\s+dBFS") + .findAll(output) + .mapNotNull { it.groupValues.getOrNull(1)?.toDoubleOrNull() } + .maxOrNull() + val gain = -18.0 - integrated + val peak = if (truePeak != null) 10.0.pow(truePeak / 20.0) else 1.0 + return NativeDownloadFinalizer.ReplayGainScan( + trackGain = "${if (gain >= 0) "+" else ""}${"%.2f".format(Locale.US, gain)} dB", + trackPeak = "%.6f".format(Locale.US, peak), + integratedLufs = integrated, + truePeakLinear = peak, + ) +} + +internal fun NativeDownloadFinalizer.runFFmpeg(command: String, shouldCancel: () -> Boolean = { false }): Pair { + checkCancelled(shouldCancel) + installNativeFFmpegCallbackFilter() + val latch = CountDownLatch(1) + var completedSession: FFmpegSession? = null + val session = FFmpegSession.create( + FFmpegKitConfig.parseArguments(command), + { finishedSession -> + completedSession = finishedSession + latch.countDown() + }, + null, + null, + LogRedirectionStrategy.NEVER_PRINT_LOGS, + ) + val sessionId = session.sessionId + synchronized(activeFFmpegSessionLock) { + activeFFmpegSessionIds.add(sessionId) + nativeFFmpegSessionIds.add(sessionId) + } + FFmpegKitConfig.asyncFFmpegExecute(session) + try { + var cancelRequested = false + while (!latch.await(200, TimeUnit.MILLISECONDS)) { + if (shouldCancel()) { + cancelRequested = true + try { + FFmpegKit.cancel(sessionId) + } catch (_: Exception) { + } + break + } + } + if (cancelRequested) { + latch.await(5, TimeUnit.SECONDS) + throw CancellationException("Native FFmpeg session cancelled") + } + val finalSession = completedSession ?: session + val output = finalSession.getAllLogsAsString(1000) ?: "" + checkCancelled(shouldCancel) + return ReturnCode.isSuccess(finalSession.returnCode) to output + } finally { + synchronized(activeFFmpegSessionLock) { + activeFFmpegSessionIds.remove(sessionId) + } + } +} + +internal fun NativeDownloadFinalizer.installNativeFFmpegCallbackFilter() { + synchronized(ffmpegCompleteCallbackLock) { + val current = FFmpegKitConfig.getFFmpegSessionCompleteCallback() + if (current !== nativeFilteringFFmpegCompleteCallback) { + forwardedFFmpegCompleteCallback = current + FFmpegKitConfig.enableFFmpegSessionCompleteCallback(nativeFilteringFFmpegCompleteCallback) + } + } +} + +internal fun NativeDownloadFinalizer.withFFmpegCommandPump( + shouldCancel: () -> Boolean = { false }, + block: () -> String, +): String { + val running = AtomicBoolean(true) + val handled = mutableSetOf() + val pump = Thread { + while (running.get()) { + try { + val raw = Gobackend.getAllPendingFFmpegCommandsJSON() + val commands = org.json.JSONArray(raw) + for (index in 0 until commands.length()) { + val command = commands.optJSONObject(index) ?: continue + val id = command.optString("command_id", "") + val commandLine = command.optString("command", "") + if (id.isBlank() || commandLine.isBlank() || handled.contains(id)) { + continue + } + handled.add(id) + // Every claimed command must get a result delivered to + // the Go side, even on failure or cancellation: the + // backend blocks until one arrives and never retries a + // claimed id, so bailing out here would strand the + // gomobile call the main thread is sitting in forever. + val result = try { + if (shouldCancel()) { + Pair(false, "cancelled") + } else { + runFFmpeg(commandLine, shouldCancel) + } + } catch (e: Exception) { + Pair(false, e.message ?: "FFmpeg execution failed") + } + try { + Gobackend.setFFmpegCommandResultByID( + id, + result.first, + result.second, + if (result.first) "" else result.second, + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to deliver FFmpeg result for $id: ${e.message}") + } + } + } catch (_: Exception) { + } + try { + Thread.sleep(100) + } catch (_: InterruptedException) { + // Keep pumping until `running` flips: on cancel the Go call + // may still be waiting for a result for an in-flight + // command, and it is delivered as failed above. + } + } + } + pump.isDaemon = true + pump.start() + return try { + block() + } finally { + running.set(false) + pump.interrupt() + } +} + +/** + * Staged sibling name for conversion outputs: "song.flac" -> "song.partial.flac". + * The ".partial" shape is ignored by library scans and duplicate checks, + * while the real trailing extension still lets FFmpeg infer the muxer. A + * process kill mid-conversion therefore never leaves a partial file under a + * real audio name in the user's music folder. + */ +internal fun NativeDownloadFinalizer.stagedConversionPath(finalPath: String): String { + val file = File(finalPath) + val ext = file.extension + val name = if (ext.isBlank()) "${file.name}.partial" else "${file.nameWithoutExtension}.partial.$ext" + return File(file.parentFile, name).absolutePath +} + +internal fun NativeDownloadFinalizer.promoteStagedConversion(stagedPath: String, finalPath: String): Boolean { + val staged = File(stagedPath) + fsyncQuietly(staged) + val final = File(finalPath) + if (staged.renameTo(final)) return true + return final.delete() && staged.renameTo(final) +} + +internal fun NativeDownloadFinalizer.buildOutputPath(inputPath: String, extension: String): String { + val ext = normalizeExt(extension).ifBlank { ".tmp" } + val file = File(inputPath) + val base = file.nameWithoutExtension.ifBlank { "track" } + val candidate = File(file.parentFile, "$base$ext").absolutePath + if (candidate != inputPath) return candidate + return File(file.parentFile, "${base}_converted$ext").absolutePath +} + +internal fun NativeDownloadFinalizer.desiredFileName(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState, extension: String): String { + val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } } + val rawName = input.result.optString("quality_variant_file_name", "") + .ifBlank { input.request.optString("saf_file_name", "") } + .ifBlank { state.fileName } + .ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" } + val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc") + var base = rawName.trim() + val lower = base.lowercase(Locale.ROOT) + for (knownExt in knownExts) { + if (lower.endsWith(knownExt)) { + base = base.dropLast(knownExt.length) + break + } + } + base = base + .replace("/", " ") + .replace(Regex("[\\\\:*?\"<>|]"), " ") + .trim() + .trim('.', ' ') + .ifBlank { "track" } + return "$base$ext" +} + +internal fun NativeDownloadFinalizer.shouldForceContainerConversion(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState): Boolean { + if (input.result.optBoolean("requires_container_conversion", false)) return true + if (input.request.optBoolean("requires_container_conversion", false)) return true + return false +} + +internal fun NativeDownloadFinalizer.probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String { + val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel) + val output = result.second + val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output) + return match?.groupValues?.getOrNull(1) + ?.trim() + ?.lowercase(Locale.ROOT) + ?.replace('-', '_') + .orEmpty() +} + +/** + * Returns true when the file on [path] starts with the native FLAC magic + * bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside + * an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC + * tag writers require the raw fLaC header, so we must detect that mismatch + * before skipping the container conversion step. + */ +internal fun NativeDownloadFinalizer.isNativeFlacFile(path: String): Boolean { + return try { + RandomAccessFile(path, "r").use { raf -> + if (raf.length() < 4L) return false + val header = ByteArray(4) + raf.readFully(header) + header[0] == 0x66.toByte() && // 'f' + header[1] == 0x4C.toByte() && // 'L' + header[2] == 0x61.toByte() && // 'a' + header[3] == 0x43.toByte() // 'C' + } + } catch (e: Exception) { + Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}") + false + } +} + +internal fun NativeDownloadFinalizer.requestedDecryptionOutputExt(input: NativeDownloadFinalizer.FinalizeInput): String { + val descriptor = input.result.optJSONObject("decryption") + return normalizeExt( + descriptor?.optString("output_extension", "") + ?.ifBlank { input.result.optString("output_extension", "") } + ) +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt new file mode 100644 index 00000000..89f40686 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt @@ -0,0 +1,443 @@ +package com.zarz.spotiflac + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.net.Uri +import android.util.Base64 +import android.util.Log +import com.antonkarpenko.ffmpegkit.FFmpegKit +import com.antonkarpenko.ffmpegkit.FFmpegKitConfig +import com.antonkarpenko.ffmpegkit.FFmpegSession +import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback +import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy +import com.antonkarpenko.ffmpegkit.ReturnCode +import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt +import com.zarz.spotiflac.SafDownloadHandler.normalizeExt +import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel +import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality +import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag +import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec +import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec +import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec +import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension +import gobackend.Gobackend +import org.json.JSONObject +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.util.Locale +import java.util.concurrent.CancellationException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.pow + + +// Metadata embedding, cover download, external LRC, and quality-variant +// filename helpers for NativeDownloadFinalizer. + +internal fun NativeDownloadFinalizer.qualityVariantFilenameLabel(state: NativeDownloadFinalizer.FinalizeState): String? { + return NativeFinalizationPolicy.qualityVariantFilenameLabel( + measuredQuality = state.quality, + bitDepth = state.bitDepth, + sampleRate = state.sampleRate, + bitrateKbps = state.bitrateKbps, + audioCodec = state.audioCodec, + ) +} + +internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename( + context: Context, + input: NativeDownloadFinalizer.FinalizeInput, + state: NativeDownloadFinalizer.FinalizeState, +) { + if (!input.request.optBoolean("allow_quality_variant", false)) return + val stagingLabel = input.request.optString("quality_variant", "").trim() + val qualityLabel = qualityVariantFilenameLabel(state) + if (qualityLabel == null) { + Log.w(TAG, "Keeping temporary quality label because final audio specifications are unavailable") + return + } + + val preferredName = applyQualityVariantFilenameLabel( + fileName = state.fileName, + stagingLabel = stagingLabel, + qualityLabel = qualityLabel, + ) + if (preferredName == state.fileName) return + input.result.put("quality_variant_file_name", preferredName) + if (isDeferredSafPublish(input)) { + state.fileName = preferredName + return + } + + if (state.filePath.startsWith("content://")) { + val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return + try { + val writeResult = SafDownloadHandler.writeFileToSafUnique( + context = context, + treeUriStr = input.request.optString("saf_tree_uri", ""), + relativeDir = input.request.optString("saf_relative_dir", ""), + fileName = preferredName, + mimeType = mimeTypeForExt(File(preferredName).extension), + srcPath = tempPath, + preservedSuffix = qualityLabel, + ) ?: return + SafDownloadHandler.deleteContentUri(context, state.filePath) + state.filePath = writeResult.uri + state.fileName = writeResult.fileName + } finally { + File(tempPath).delete() + } + } else { + val source = File(state.filePath) + val target = uniqueLocalFile(source.parentFile, preferredName) + if (!source.renameTo(target)) { + Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}") + return + } + state.filePath = target.absolutePath + state.fileName = target.name + } + + input.result.put("file_path", state.filePath) + input.result.put("file_name", state.fileName) + input.result.optJSONObject("replaygain")?.let { replayGain -> + replayGain.put("file_path", state.filePath) + replayGain.put("file_name", state.fileName) + } +} + +internal fun NativeDownloadFinalizer.uniqueLocalFile(parent: File?, preferredName: String): File { + val directory = parent ?: return File(preferredName) + var candidate = File(directory, preferredName) + if (!candidate.exists()) return candidate + val dotIndex = preferredName.lastIndexOf('.') + val hasExtension = dotIndex > 0 + val stem = if (hasExtension) preferredName.substring(0, dotIndex) else preferredName + val extension = if (hasExtension) preferredName.substring(dotIndex) else "" + var counter = 2 + while (candidate.exists()) { + candidate = File(directory, "$stem ($counter)$extension") + counter++ + } + return candidate +} + +internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState) { + if (!input.request.optBoolean("embed_metadata", false) || !input.request.optBoolean("embed_lyrics", false)) return + val lyricsMode = input.request.optString("lyrics_mode", "") + if (lyricsMode != "external" && lyricsMode != "both") return + val lrc = resolveLyricsLrc(input) + if (lrc.isBlank() || lrc == "[instrumental:true]") return + val audioFileName = if (isDeferredSafRequest(input)) { + desiredFileName(input, state, File(state.filePath).extension) + } else { + state.fileName + } + val baseName = audioFileName.replace(Regex("\\.[^.]+$"), "") + if (isDeferredSafRequest(input)) { + state.pendingExternalLrc = lrc + state.pendingExternalLrcFileName = "$baseName.lrc" + return + } + if (state.filePath.startsWith("content://")) { + val treeUri = input.request.optString("saf_tree_uri", "") + val relativeDir = input.request.optString("saf_relative_dir", "") + val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc") + temp.writeText(lrc) + try { + SafDownloadHandler.writeFileToSaf( + context = context, + treeUriStr = treeUri, + relativeDir = relativeDir, + fileName = "$baseName.lrc", + mimeType = "application/octet-stream", + srcPath = temp.absolutePath, + ) + } finally { + temp.delete() + } + } else { + val target = File(File(state.filePath).parentFile, "$baseName.lrc") + target.writeText(lrc) + } +} + +internal fun NativeDownloadFinalizer.resolveLyricsLrc(input: NativeDownloadFinalizer.FinalizeInput): String { + val existing = input.result.optString("lyrics_lrc", "").trim() + if (existing.isNotEmpty()) return existing + + val spotifyId = trackString(input, "id", input.request.optString("spotify_id", "")) + val trackName = trackString(input, "name", input.request.optString("track_name", "")) + val artistName = trackString(input, "artistName", input.request.optString("artist_name", "")) + if (trackName.isBlank() || artistName.isBlank()) return "" + + return try { + val fetched = Gobackend.getLyricsLRC( + spotifyId, + trackName, + artistName, + "", + lyricsDurationMs(input), + ).trim() + if (fetched.isNotEmpty()) { + input.result.put("lyrics_lrc", fetched) + } + fetched + } catch (_: Exception) { + "" + } +} + +internal fun NativeDownloadFinalizer.lyricsDurationMs(input: NativeDownloadFinalizer.FinalizeInput): Long { + val requestDuration = input.request.optLong("duration_ms", 0L) + val trackDuration = trackInt(input, "duration", 0).toLong() + val duration = if (requestDuration > 0L) requestDuration else trackDuration + if (duration <= 0L) return 0L + return if (duration > 10000L) duration else duration * 1000L +} + +internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path: String, input: NativeDownloadFinalizer.FinalizeInput, format: String) { + if (!input.request.optBoolean("embed_metadata", false)) return + val title = resultString(input, "title").ifBlank { + trackString(input, "name", requestString(input, "track_name")) + } + val artist = resultString(input, "artist").ifBlank { + trackString(input, "artistName", requestString(input, "artist_name")) + } + val album = resultString(input, "album").ifBlank { + trackString(input, "albumName", requestString(input, "album_name")) + } + val albumArtist = resultString(input, "album_artist").ifBlank { + trackString(input, "albumArtist", requestString(input, "album_artist")) + } + val date = resultString(input, "release_date").ifBlank { + resultString(input, "date").ifBlank { + trackString(input, "releaseDate", requestString(input, "release_date")) + } + } + val trackNumberValue = positiveOrNull(input.result.optInt("track_number", 0), trackInt(input, "trackNumber", input.request.optInt("track_number", 0))) ?: 0 + val totalTracksValue = positiveOrNull(input.result.optInt("total_tracks", 0), trackInt(input, "totalTracks", input.request.optInt("total_tracks", 0))) ?: 0 + val discNumberValue = positiveOrNull(input.result.optInt("disc_number", 0), trackInt(input, "discNumber", input.request.optInt("disc_number", 0))) ?: 0 + val totalDiscsValue = positiveOrNull(input.result.optInt("total_discs", 0), trackInt(input, "totalDiscs", input.request.optInt("total_discs", 0))) ?: 0 + val trackNumber = formatIndexTag(trackNumberValue, totalTracksValue) + val discNumber = formatIndexTag(discNumberValue, totalDiscsValue) + val isrc = resultString(input, "isrc").ifBlank { + trackString(input, "isrc", requestString(input, "isrc")) + } + val composer = resultString(input, "composer").ifBlank { + trackString(input, "composer", requestString(input, "composer")) + } + val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") } + val label = resultString(input, "label").ifBlank { requestString(input, "label") } + val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") } + val lyricsMode = input.request.optString("lyrics_mode", "embed") + val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) && + (lyricsMode == "embed" || lyricsMode == "both") + val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else "" + val shouldEmbedLyrics = shouldResolveLyrics && + lyrics.isNotBlank() && + lyrics != "[instrumental:true]" + // FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the + // tag block atomically without an ffmpeg remux (which drops foreign + // frames and rewrites the whole container). The Go side answers + // method=ffmpeg when it cannot handle the file natively. + if (format == "flac" || format == "mp3" || format == "opus" || format == "m4a") { + val nativeCover = downloadCoverForMetadata(context, input) + val handledNatively = try { + val fields = JSONObject() + .put("title", title) + .put("artist", artist) + .put("album", album) + .put("album_artist", albumArtist) + .put("date", date) + .put("isrc", isrc) + .put("composer", composer) + .put("genre", genre) + .put("label", label) + .put("copyright", copyright) + if (trackNumberValue > 0) fields.put("track_number", trackNumberValue.toString()) + if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString()) + if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString()) + if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString()) + if (nativeCover != null) fields.put("cover_path", nativeCover.absolutePath) + if (shouldEmbedLyrics) { + fields.put("lyrics", lyrics) + fields.put("unsyncedlyrics", lyrics) + } + val response = Gobackend.editFileMetadata(path, fields.toString()) + val method = try { + JSONObject(response).optString("method", "") + } catch (_: Exception) { + "" + } + method != "ffmpeg" + } catch (e: Exception) { + if (format == "flac") throw e + Log.w(TAG, "Native tag embed failed for $format: ${e.message}; falling back to ffmpeg") + false + } finally { + nativeCover?.delete() + } + if (handledNatively) return + } + + val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" } + val inputFile = File(path) + // ".partial" keeps the temp invisible to library scans while FFmpeg + // still infers the muxer from the real trailing extension. + val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged.partial$ext") + val isM4a = format == "m4a" + val isOpus = format == "opus" + val coverFile = if (isM4a || isOpus) downloadCoverForMetadata(context, input) else null + val labelKey = if (isM4a) "organization" else "label" + val metadataPairs = mutableListOf( + "title" to title, + "artist" to artist, + "album" to album, + "album_artist" to albumArtist, + "date" to date, + "track" to trackNumber, + "disc" to discNumber, + "isrc" to isrc, + "composer" to composer, + "genre" to genre, + labelKey to label, + "copyright" to copyright, + "lyrics" to if (shouldEmbedLyrics) lyrics else "", + "unsyncedlyrics" to if (shouldEmbedLyrics) lyrics else "", + ) + if (isOpus && coverFile != null) { + createMetadataBlockPicture(coverFile)?.let { + metadataPairs.add("METADATA_BLOCK_PICTURE" to it) + } + } + val metadataArgs = metadataPairs + .filter { it.second.isNotBlank() && it.second != "0" } + .joinToString(" ") { "-metadata ${it.first}=${q(it.second)}" } + if (metadataArgs.isBlank() && coverFile == null) return + val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else "" + var adoptedTemp = false + var originalDeleted = false + + fun buildEmbedCommand(forceMov: Boolean): String { + return if (isM4a && coverFile != null) { + "-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " + + "-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " + + "-disposition:v:0 attached_pic " + + "-metadata:s:v ${q("title=Album cover")} " + + "-metadata:s:v ${q("comment=Cover (front)")} " + + "$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y" + } else { + val movFlag = if (forceMov) "-f mov " else "" + "-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y" + } + } + + try { + var result = runFFmpeg(buildEmbedCommand(false)) + // MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4). + if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) { + temp.delete() + result = runFFmpeg(buildEmbedCommand(true)) + } + if (result.first && temp.exists()) { + fsyncQuietly(temp) + // Rename directly over the original: a process kill between a + // delete-first and the rename would lose the file entirely. + adoptedTemp = temp.renameTo(inputFile) + if (!adoptedTemp && inputFile.delete()) { + originalDeleted = true + adoptedTemp = temp.renameTo(inputFile) + } + } + } finally { + if (!adoptedTemp && !originalDeleted) { + temp.delete() + } + coverFile?.delete() + } +} + +/** + * Best-effort fsync so a file's bytes are durable before it is renamed + * over another file; fsync on a fresh handle flushes the page cache pages + * written earlier by ffmpeg in this process. + */ +internal fun NativeDownloadFinalizer.fsyncQuietly(file: File) { + try { + RandomAccessFile(file, "rw").use { it.fd.sync() } + } catch (_: Exception) { + } +} + +internal fun NativeDownloadFinalizer.createMetadataBlockPicture(coverFile: File): String? { + return try { + if (!coverFile.exists() || coverFile.length() <= 0L) return null + val imageData = coverFile.readBytes() + if (imageData.isEmpty()) return null + val mimeType = detectCoverMimeType(coverFile, imageData) + val mimeBytes = mimeType.toByteArray(Charsets.UTF_8) + val descriptionBytes = ByteArray(0) + val blockSize = 4 + 4 + mimeBytes.size + 4 + descriptionBytes.size + 4 + 4 + 4 + 4 + 4 + imageData.size + val buffer = ByteBuffer.allocate(blockSize) + buffer.putInt(3) + buffer.putInt(mimeBytes.size) + buffer.put(mimeBytes) + buffer.putInt(descriptionBytes.size) + buffer.put(descriptionBytes) + buffer.putInt(0) + buffer.putInt(0) + buffer.putInt(0) + buffer.putInt(0) + buffer.putInt(imageData.size) + buffer.put(imageData) + Base64.encodeToString(buffer.array(), Base64.NO_WRAP) + } catch (e: Exception) { + Log.w(TAG, "Failed to create Opus cover picture block: ${e.message}") + null + } +} + +internal fun NativeDownloadFinalizer.detectCoverMimeType(coverFile: File, imageData: ByteArray): String { + val ext = coverFile.extension.lowercase(Locale.ROOT) + if (ext == "png") return "image/png" + if (ext == "jpg" || ext == "jpeg") return "image/jpeg" + if (imageData.size >= 8 && + imageData[0] == 0x89.toByte() && + imageData[1] == 0x50.toByte() && + imageData[2] == 0x4E.toByte() && + imageData[3] == 0x47.toByte() + ) { + return "image/png" + } + return "image/jpeg" +} + +internal fun NativeDownloadFinalizer.downloadCoverForMetadata(context: Context, input: NativeDownloadFinalizer.FinalizeInput): File? { + val coverUrl = metadataCoverUrl(input).ifBlank { resultString(input, "cover_url") } + if (coverUrl.isBlank()) return null + + val safeItemId = input.itemId.ifBlank { "item" }.replace(Regex("[^A-Za-z0-9._-]"), "_") + val output = File.createTempFile("native_cover_${safeItemId}_", ".jpg", context.cacheDir) + return try { + Gobackend.downloadCoverToFile( + coverUrl, + output.absolutePath, + input.request.optBoolean("embed_max_quality_cover", true) + ) + if (output.exists() && output.length() > 0L) { + output + } else { + output.delete() + null + } + } catch (e: Exception) { + Log.w(TAG, "Failed to download metadata cover: ${e.message}") + output.delete() + null + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt new file mode 100644 index 00000000..03f8e983 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt @@ -0,0 +1,164 @@ +package com.zarz.spotiflac + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.net.Uri +import android.util.Base64 +import android.util.Log +import com.antonkarpenko.ffmpegkit.FFmpegKit +import com.antonkarpenko.ffmpegkit.FFmpegKitConfig +import com.antonkarpenko.ffmpegkit.FFmpegSession +import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback +import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy +import com.antonkarpenko.ffmpegkit.ReturnCode +import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt +import com.zarz.spotiflac.SafDownloadHandler.normalizeExt +import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel +import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality +import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag +import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec +import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec +import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec +import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension +import gobackend.Gobackend +import org.json.JSONObject +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.util.Locale +import java.util.concurrent.CancellationException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.pow + + +// Deferred SAF publish helpers for NativeDownloadFinalizer. + +internal fun NativeDownloadFinalizer.promoteStagedSafOutputIfNeeded( + context: Context, + input: NativeDownloadFinalizer.FinalizeInput, + state: NativeDownloadFinalizer.FinalizeState, +) { + if (!state.filePath.startsWith("content://")) return + if (!input.result.optBoolean("saf_staged_output", false)) return + val stagedName = input.result.optString("saf_staged_file_name", "").trim() + if (stagedName.isNotEmpty() && state.fileName != stagedName) return + + val localInput = materializeForFFmpeg(context, input, state) + try { + replaceStatePath(context, input, state, localInput, deleteOld = true) + } finally { + File(localInput).delete() + } +} + +internal fun NativeDownloadFinalizer.isDeferredSafPublish(input: NativeDownloadFinalizer.FinalizeInput): Boolean { + return input.request.optBoolean("defer_saf_publish", false) && + input.result.optBoolean("saf_deferred_publish", false) +} + +internal fun NativeDownloadFinalizer.isDeferredSafRequest(input: NativeDownloadFinalizer.FinalizeInput): Boolean { + return input.request.optString("storage_mode", "") == "saf" && + input.request.optBoolean("defer_saf_publish", false) +} + +internal fun NativeDownloadFinalizer.publishDeferredSafOutput( + context: Context, + input: NativeDownloadFinalizer.FinalizeInput, + state: NativeDownloadFinalizer.FinalizeState, +) { + if (!isDeferredSafPublish(input)) return + if (state.filePath.startsWith("content://")) return + + val outputFile = File(state.filePath) + if (!outputFile.exists() || outputFile.length() <= 0L) { + throw IllegalStateException("deferred SAF output missing or empty") + } + + val finalName = desiredFileName(input, state, outputFile.extension) + val treeUri = input.result.optString("saf_tree_uri", "") + .ifBlank { input.request.optString("saf_tree_uri", "") } + val relativeDir = input.result.optString("saf_relative_dir", "") + .ifBlank { input.request.optString("saf_relative_dir", "") } + val mimeType = mimeTypeForExt(outputFile.extension) + val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false) + val uniqueWrite = if (preserveQualityVariant) { + SafDownloadHandler.writeFileToSafUnique( + context = context, + treeUriStr = treeUri, + relativeDir = relativeDir, + fileName = finalName, + mimeType = mimeType, + srcPath = outputFile.absolutePath, + preservedSuffix = qualityVariantFilenameLabel(state).orEmpty(), + ) + } else { + null + } + val newUri = uniqueWrite?.uri ?: if (!preserveQualityVariant) { + SafDownloadHandler.writeFileToSaf( + context = context, + treeUriStr = treeUri, + relativeDir = relativeDir, + fileName = finalName, + mimeType = mimeType, + srcPath = outputFile.absolutePath, + ) + } else { + null + } ?: throw IllegalStateException("failed to publish deferred SAF output") + val publishedName = uniqueWrite?.fileName ?: finalName + + Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}") + outputFile.delete() + state.filePath = newUri + state.fileName = publishedName + input.result.put("file_path", newUri) + input.result.put("file_name", publishedName) + input.result.optJSONObject("replaygain")?.let { replayGain -> + replayGain.put("file_path", newUri) + replayGain.put("file_name", publishedName) + } + if (state.pendingExternalLrc != null) { + state.pendingExternalLrcFileName = "${publishedName.replace(Regex("\\.[^.]+$"), "")}.lrc" + } + input.result.put("saf_deferred_published", true) + publishPendingDeferredExternalLrc(context, input, state) +} + +internal fun NativeDownloadFinalizer.publishPendingDeferredExternalLrc( + context: Context, + input: NativeDownloadFinalizer.FinalizeInput, + state: NativeDownloadFinalizer.FinalizeState, +) { + val lrc = state.pendingExternalLrc ?: return + val fileName = state.pendingExternalLrcFileName ?: return + val treeUri = input.result.optString("saf_tree_uri", "") + .ifBlank { input.request.optString("saf_tree_uri", "") } + val relativeDir = input.result.optString("saf_relative_dir", "") + .ifBlank { input.request.optString("saf_relative_dir", "") } + val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc") + try { + temp.writeText(lrc) + val newUri = SafDownloadHandler.writeFileToSaf( + context = context, + treeUriStr = treeUri, + relativeDir = relativeDir, + fileName = fileName, + mimeType = "application/octet-stream", + srcPath = temp.absolutePath, + ) + if (newUri == null) { + Log.w(TAG, "Failed to publish deferred external LRC: $fileName") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to publish deferred external LRC: ${e.message}") + } finally { + temp.delete() + state.pendingExternalLrc = null + state.pendingExternalLrcFileName = null + } +}