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 e0ec9e7f..b9b4e79c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -428,209 +428,6 @@ object NativeDownloadFinalizer { } } - private fun outputExt(input: FinalizeInput): String { - val safExt = input.request.optString("saf_output_ext", "") - val ext = safExt.ifBlank { input.request.optString("output_ext", "") } - return normalizeExt(ext.ifBlank { ".flac" }) - } - - private fun finalizeAutoConversion( - context: Context, - input: FinalizeInput, - state: FinalizeState, - shouldCancel: () -> Boolean, - ) { - val target = NativeFinalizationPolicy.autoConversionTarget( - enabled = input.request.optBoolean("auto_convert_downloads", false), - format = input.request.optString("auto_convert_format", ""), - bitrate = input.request.optString("auto_convert_bitrate", ""), - ) ?: return - if ( - NativeFinalizationPolicy.autoConversionAlreadySatisfied( - target, - state.audioCodec, - state.bitrateKbps, - ) - ) return - - val localInput = materializeForFFmpeg(context, input, state) - val sourceWasSaf = state.filePath.startsWith("content://") - val sameLocalExtension = !sourceWasSaf && - normalizeExt(File(localInput).extension) == target.extension - val output = if (sameLocalExtension) { - buildOutputPath(localInput, target.extension) - } else { - uniqueAutoConversionOutputPath(localInput, target.extension) - } - val stagedOutput = stagedConversionPath(output) - val bitrate = "${target.bitrateKbps}k" - var adoptedOutput = false - try { - val command = when (target.codec) { - "opus" -> "-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(stagedOutput)} -y" - "aac" -> "-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(stagedOutput)} -y" - else -> "-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(stagedOutput)} -y" - } - val conversion = runFFmpeg(command, shouldCancel) - if (!conversion.first || !File(stagedOutput).exists()) { - throw IllegalStateException("automatic conversion failed: ${conversion.second}") - } - val metadataFormat = if (target.codec == "aac") "m4a" else target.codec - embedBasicMetadata(context, stagedOutput, input, metadataFormat) - if (!promoteStagedConversion(stagedOutput, output)) { - throw IllegalStateException("failed to promote automatic conversion output") - } - - if (sameLocalExtension) { - state.filePath = localInput - state.fileName = File(localInput).name - } else { - replaceStatePath(context, input, state, output, deleteOld = true) - } - adoptedOutput = true - } finally { - if (!adoptedOutput) { - File(stagedOutput).delete() - if (output != localInput) File(output).delete() - } - if (sourceWasSaf) File(localInput).delete() - } - - state.quality = "${if (target.codec == "aac") "AAC" else target.codec.uppercase(Locale.ROOT)} ${target.bitrateKbps}kbps" - state.bitDepth = null - state.sampleRate = null - state.bitrateKbps = target.bitrateKbps - state.audioCodec = target.codec - } - - private fun uniqueAutoConversionOutputPath(inputPath: String, extension: String): String { - val source = File(inputPath) - val requested = File(source.parentFile, "${source.nameWithoutExtension}$extension") - if (!requested.exists()) return requested.absolutePath - for (index in 2..Int.MAX_VALUE) { - val candidate = File( - source.parentFile, - "${source.nameWithoutExtension} ($index)$extension", - ) - if (!candidate.exists()) return candidate.absolutePath - } - throw IllegalStateException("could not allocate automatic conversion output") - } - - private fun finalizeContainerConversion( - context: Context, - input: FinalizeInput, - state: FinalizeState, - shouldCancel: () -> Boolean, - ) { - if (outputExt(input) != ".flac") return - val forceContainerConversion = shouldForceContainerConversion(input, state) - // The decrypted container may still contain FLAC. Only the measured - // stream codec can decide whether an M4A/MP4 should be preserved. - val mayNeedContainerConversion = forceContainerConversion || - looksLikeM4a(state.filePath, state.fileName) || - isMP4ContainerFile(state.filePath) || - state.filePath.startsWith("content://") - if (!mayNeedContainerConversion) return - - val localInput = materializeForFFmpeg(context, input, state) - val deleteLocalInput = state.filePath.startsWith("content://") - val output = buildOutputPath(localInput, ".flac") - val stagedOutput = stagedConversionPath(output) - var adoptedOutput = false - var createdOutput = false - try { - val codec = probePrimaryAudioCodec(localInput, shouldCancel) - val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput) - if (!NativeFinalizationPolicy.shouldAttemptLosslessContainerConversion( - forceContainerConversion, - codec, - ) - ) { - Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}") - // The preserved stream is not FLAC but still carries the - // requested .flac name. Rename to the real container so the - // metadata/ReplayGain writers pick the right format — an - // MP4 stream under a .flac name fails "fLaC head incorrect" - // on every subsequent write and the file is never repaired. - adoptPreservedContainerExtension(state, localInput, codec) - return - } - if (isAlreadyNativeFlac) { - Log.d(TAG, "Native FLAC payload detected; publishing as FLAC and embedding metadata") - val nativeFlacOutput = if (localInput.lowercase(Locale.ROOT).endsWith(".flac")) { - localInput - } else { - File(localInput).copyTo(File(stagedOutput), overwrite = true) - if (!promoteStagedConversion(stagedOutput, output)) { - throw IllegalStateException("failed to publish native FLAC output") - } - createdOutput = true - output - } - embedBasicMetadata(context, nativeFlacOutput, input, "flac") - replaceStatePath(context, input, state, nativeFlacOutput, deleteOld = true) - adoptedOutput = true - return - } - convertToStagedFlac( - input = localInput, - stagedOutput = stagedOutput, - codec = codec, - execute = { arguments -> runFFmpegArguments(arguments, shouldCancel) }, - checkCancelled = { checkCancelled(shouldCancel) }, - ) - // Tag the staged file before replacing a same-suffix source. - embedBasicMetadata(context, stagedOutput, input, "flac") - if (!promoteStagedConversion(stagedOutput, output)) { - throw IllegalStateException("failed to publish container conversion output") - } - createdOutput = true - replaceStatePath(context, input, state, output, deleteOld = true) - adoptedOutput = true - } finally { - if (!adoptedOutput) { - File(stagedOutput).delete() - if (createdOutput && output != localInput) File(output).delete() - } - if (deleteLocalInput) File(localInput).delete() - } - } - - /// Renames a preserved lossy/unknown stream away from its requested .flac - /// name to match its actual container (mirrors the Dart pipeline's - /// post-download rename). Local files only: legacy content:// outputs are - /// left untouched. No-op when the container cannot be identified. - private fun adoptPreservedContainerExtension( - state: FinalizeState, - localInput: String, - codec: String, - ) { - if (state.filePath.startsWith("content://")) return - val currentFile = File(state.filePath) - if (!currentFile.exists()) return - if (!currentFile.name.lowercase(Locale.ROOT).endsWith(".flac")) return - - val newExt = when { - isMP4ContainerFile(localInput) -> - NativeFinalizationPolicy.isoBmffAudioExtension(codec) - codec == "mp3" -> ".mp3" - codec == "opus" -> ".opus" - else -> return - } - val renamed = File(uniqueAutoConversionOutputPath(currentFile.path, newExt)) - if (!currentFile.renameTo(renamed)) { - Log.w(TAG, "Failed to rename preserved container to ${renamed.name}") - return - } - Log.i(TAG, "Preserved container renamed: ${currentFile.name} -> ${renamed.name}") - state.filePath = renamed.absolutePath - if (state.fileName.isNotBlank()) { - state.fileName = renamed.name - } - state.audioCodec = normalizeAudioCodec(codec) - } - private fun finalizeMetadata(context: Context, input: FinalizeInput, state: FinalizeState) { if (!input.request.optBoolean("embed_metadata", false)) return if (!state.filePath.startsWith("content://")) { @@ -999,15 +796,6 @@ object NativeDownloadFinalizer { } } - private fun looksLikeM4a(path: String, fileName: String): Boolean { - val lowerPath = path.lowercase(Locale.ROOT) - val lowerName = fileName.lowercase(Locale.ROOT) - return lowerPath.endsWith(".m4a") || - lowerPath.endsWith(".mp4") || - lowerName.endsWith(".m4a") || - lowerName.endsWith(".mp4") - } - private fun albumKey(input: FinalizeInput): String { val albumId = trackString(input, "albumId", "") if (albumId.isNotBlank()) return "id:$albumId" diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerContainer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerContainer.kt new file mode 100644 index 00000000..3b92aa1e --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerContainer.kt @@ -0,0 +1,224 @@ +package com.zarz.spotiflac + +import android.content.Context +import android.util.Log +import com.zarz.spotiflac.SafDownloadHandler.normalizeExt +import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec +import java.io.File +import java.util.Locale + + +// Lossless container conversion and automatic lossy conversion for +// NativeDownloadFinalizer. + +internal fun NativeDownloadFinalizer.finalizeAutoConversion( + context: Context, + input: NativeDownloadFinalizer.FinalizeInput, + state: NativeDownloadFinalizer.FinalizeState, + shouldCancel: () -> Boolean, +) { + val target = NativeFinalizationPolicy.autoConversionTarget( + enabled = input.request.optBoolean("auto_convert_downloads", false), + format = input.request.optString("auto_convert_format", ""), + bitrate = input.request.optString("auto_convert_bitrate", ""), + ) ?: return + if ( + NativeFinalizationPolicy.autoConversionAlreadySatisfied( + target, + state.audioCodec, + state.bitrateKbps, + ) + ) return + + val localInput = materializeForFFmpeg(context, input, state) + val sourceWasSaf = state.filePath.startsWith("content://") + val sameLocalExtension = !sourceWasSaf && + normalizeExt(File(localInput).extension) == target.extension + val output = if (sameLocalExtension) { + buildOutputPath(localInput, target.extension) + } else { + uniqueAutoConversionOutputPath(localInput, target.extension) + } + val stagedOutput = stagedConversionPath(output) + val bitrate = "${target.bitrateKbps}k" + var adoptedOutput = false + try { + val command = when (target.codec) { + "opus" -> "-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(stagedOutput)} -y" + "aac" -> "-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(stagedOutput)} -y" + else -> "-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(stagedOutput)} -y" + } + val conversion = runFFmpeg(command, shouldCancel) + if (!conversion.first || !File(stagedOutput).exists()) { + throw IllegalStateException("automatic conversion failed: ${conversion.second}") + } + val metadataFormat = if (target.codec == "aac") "m4a" else target.codec + embedBasicMetadata(context, stagedOutput, input, metadataFormat) + if (!promoteStagedConversion(stagedOutput, output)) { + throw IllegalStateException("failed to promote automatic conversion output") + } + + if (sameLocalExtension) { + state.filePath = localInput + state.fileName = File(localInput).name + } else { + replaceStatePath(context, input, state, output, deleteOld = true) + } + adoptedOutput = true + } finally { + if (!adoptedOutput) { + File(stagedOutput).delete() + if (output != localInput) File(output).delete() + } + if (sourceWasSaf) File(localInput).delete() + } + + state.quality = "${if (target.codec == "aac") "AAC" else target.codec.uppercase(Locale.ROOT)} ${target.bitrateKbps}kbps" + state.bitDepth = null + state.sampleRate = null + state.bitrateKbps = target.bitrateKbps + state.audioCodec = target.codec +} + +private fun NativeDownloadFinalizer.uniqueAutoConversionOutputPath(inputPath: String, extension: String): String { + val source = File(inputPath) + val requested = File(source.parentFile, "${source.nameWithoutExtension}$extension") + if (!requested.exists()) return requested.absolutePath + for (index in 2..Int.MAX_VALUE) { + val candidate = File( + source.parentFile, + "${source.nameWithoutExtension} ($index)$extension", + ) + if (!candidate.exists()) return candidate.absolutePath + } + throw IllegalStateException("could not allocate automatic conversion output") +} + +internal fun NativeDownloadFinalizer.finalizeContainerConversion( + context: Context, + input: NativeDownloadFinalizer.FinalizeInput, + state: NativeDownloadFinalizer.FinalizeState, + shouldCancel: () -> Boolean, +) { + if (outputExt(input) != ".flac") return + val forceContainerConversion = shouldForceContainerConversion(input, state) + // The decrypted container may still contain FLAC. Only the measured + // stream codec can decide whether an M4A/MP4 should be preserved. + val mayNeedContainerConversion = forceContainerConversion || + looksLikeM4a(state.filePath, state.fileName) || + isMP4ContainerFile(state.filePath) || + state.filePath.startsWith("content://") + if (!mayNeedContainerConversion) return + + val localInput = materializeForFFmpeg(context, input, state) + val deleteLocalInput = state.filePath.startsWith("content://") + val output = buildOutputPath(localInput, ".flac") + val stagedOutput = stagedConversionPath(output) + var adoptedOutput = false + var createdOutput = false + try { + val codec = probePrimaryAudioCodec(localInput, shouldCancel) + val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput) + if (!NativeFinalizationPolicy.shouldAttemptLosslessContainerConversion( + forceContainerConversion, + codec, + ) + ) { + Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}") + // The preserved stream is not FLAC but still carries the + // requested .flac name. Rename to the real container so the + // metadata/ReplayGain writers pick the right format: an + // MP4 stream under a .flac name fails "fLaC head incorrect" + // on every subsequent write and the file is never repaired. + adoptPreservedContainerExtension(state, localInput, codec) + return + } + if (isAlreadyNativeFlac) { + Log.d(TAG, "Native FLAC payload detected; publishing as FLAC and embedding metadata") + val nativeFlacOutput = if (localInput.lowercase(Locale.ROOT).endsWith(".flac")) { + localInput + } else { + File(localInput).copyTo(File(stagedOutput), overwrite = true) + if (!promoteStagedConversion(stagedOutput, output)) { + throw IllegalStateException("failed to publish native FLAC output") + } + createdOutput = true + output + } + embedBasicMetadata(context, nativeFlacOutput, input, "flac") + replaceStatePath(context, input, state, nativeFlacOutput, deleteOld = true) + adoptedOutput = true + return + } + convertToStagedFlac( + input = localInput, + stagedOutput = stagedOutput, + codec = codec, + execute = { arguments -> runFFmpegArguments(arguments, shouldCancel) }, + checkCancelled = { checkCancelled(shouldCancel) }, + ) + // Tag the staged file before replacing a same-suffix source. + embedBasicMetadata(context, stagedOutput, input, "flac") + if (!promoteStagedConversion(stagedOutput, output)) { + throw IllegalStateException("failed to publish container conversion output") + } + createdOutput = true + replaceStatePath(context, input, state, output, deleteOld = true) + adoptedOutput = true + } finally { + if (!adoptedOutput) { + File(stagedOutput).delete() + if (createdOutput && output != localInput) File(output).delete() + } + if (deleteLocalInput) File(localInput).delete() + } +} + +/// Renames a preserved lossy/unknown stream away from its requested .flac +/// name to match its actual container (mirrors the Dart pipeline's +/// post-download rename). Local files only: legacy content:// outputs are +/// left untouched. No-op when the container cannot be identified. +private fun NativeDownloadFinalizer.adoptPreservedContainerExtension( + state: NativeDownloadFinalizer.FinalizeState, + localInput: String, + codec: String, +) { + if (state.filePath.startsWith("content://")) return + val currentFile = File(state.filePath) + if (!currentFile.exists()) return + if (!currentFile.name.lowercase(Locale.ROOT).endsWith(".flac")) return + + val newExt = when { + isMP4ContainerFile(localInput) -> + NativeFinalizationPolicy.isoBmffAudioExtension(codec) + codec == "mp3" -> ".mp3" + codec == "opus" -> ".opus" + else -> return + } + val renamed = File(uniqueAutoConversionOutputPath(currentFile.path, newExt)) + if (!currentFile.renameTo(renamed)) { + Log.w(TAG, "Failed to rename preserved container to ${renamed.name}") + return + } + Log.i(TAG, "Preserved container renamed: ${currentFile.name} -> ${renamed.name}") + state.filePath = renamed.absolutePath + if (state.fileName.isNotBlank()) { + state.fileName = renamed.name + } + state.audioCodec = normalizeAudioCodec(codec) +} + +private fun NativeDownloadFinalizer.outputExt(input: NativeDownloadFinalizer.FinalizeInput): String { + val safExt = input.request.optString("saf_output_ext", "") + val ext = safExt.ifBlank { input.request.optString("output_ext", "") } + return normalizeExt(ext.ifBlank { ".flac" }) +} + +private fun NativeDownloadFinalizer.looksLikeM4a(path: String, fileName: String): Boolean { + val lowerPath = path.lowercase(Locale.ROOT) + val lowerName = fileName.lowercase(Locale.ROOT) + return lowerPath.endsWith(".m4a") || + lowerPath.endsWith(".mp4") || + lowerName.endsWith(".m4a") || + lowerName.endsWith(".mp4") +}