mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
refactor(android): move finalizer container into NativeFinalizerContainer.kt
Move the lossless container conversion and automatic conversion stages, with their file-local helpers (output extension, M4A sniffing, unique output path, preserved container rename), out of NativeDownloadFinalizer into a sibling file of extension functions. Bodies are verbatim except one moved comment line whose em dash is rewritten as a colon. No object member needed wider visibility.
This commit is contained in:
1 parent
92af56eea7
commit
ead04c00b4
2 files changed
+224
-212
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user