diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/NativeDownloadContainerTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/NativeDownloadContainerTest.kt new file mode 100644 index 00000000..c8332c59 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/NativeDownloadContainerTest.kt @@ -0,0 +1,58 @@ +package com.zarz.spotiflac + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NativeDownloadContainerTest { + @Test + fun misnamedMp4IsTaggedAndPublishedAsM4aWithoutOverwritingExistingAudio() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val root = File(context.cacheDir, "container-test-${System.nanoTime()}").apply { mkdirs() } + try { + val input = File(root, "track.flac") + val existing = File(root, "track.m4a").apply { writeText("existing download") } + val fixture = NativeDownloadFinalizer.runFFmpegArguments(arrayOf( + "-v", "error", "-f", "lavfi", "-i", "sine=frequency=997:sample_rate=44100", + "-t", "0.2", "-c:a", "aac", "-f", "mp4", input.path, + )) + assertTrue(fixture.second, fixture.first) + assertTrue(isMP4ContainerFile(input.path)) + val request = JSONObject() + .put("contract_version", 1).put("item_id", "example-track") + .put("service", "example-provider").put("track_name", "Container test") + .put("artist_name", "Example artist").put("album_name", "Example album") + .put("quality", "LOSSLESS").put("storage_mode", "app") + .put("output_ext", ".flac").put("embed_metadata", true) + val result = NativeDownloadFinalizer.finalize( + context, "example-track", request.toString(), "{}", + JSONObject().put("success", true).put("file_path", input.path) + .put("file_name", input.name), + "{\"save_download_history\":false}", + ) + assertTrue(result.toString(), result.getBoolean("success")) + assertTrue(result.getBoolean("native_finalized")) + val output = File(result.getString("file_path")) + assertEquals("m4a", output.extension) + assertTrue(output.length() > 0) + assertTrue(isMP4ContainerFile(output.path)) + assertFalse(input.exists()) + assertEquals("existing download", existing.readText()) + val probe = NativeDownloadFinalizer.runFFmpegArguments(arrayOf( + "-hide_banner", "-i", output.path, "-map", "0:a:0", "-f", "null", "-", + )) + assertTrue(probe.second, probe.first) + assertTrue(probe.second, probe.second.contains("Container test")) + assertTrue(probe.second, probe.second.contains("Audio: aac")) + } finally { + root.deleteRecursively() + } + } +} 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 6d8e09a0..a10845c3 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -760,6 +760,7 @@ object NativeDownloadFinalizer { if (!forceContainerConversion && requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return val mayNeedContainerConversion = forceContainerConversion || looksLikeM4a(state.filePath, state.fileName) || + isMP4ContainerFile(state.filePath) || state.filePath.startsWith("content://") if (!mayNeedContainerConversion) return @@ -768,6 +769,7 @@ object NativeDownloadFinalizer { 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) @@ -794,6 +796,7 @@ object NativeDownloadFinalizer { if (!promoteStagedConversion(stagedOutput, output)) { throw IllegalStateException("failed to publish native FLAC output") } + createdOutput = true output } embedBasicMetadata(context, nativeFlacOutput, input, "flac") @@ -811,6 +814,7 @@ object NativeDownloadFinalizer { if (!promoteStagedConversion(stagedOutput, output)) { throw IllegalStateException("failed to publish container conversion output") } + createdOutput = true // Keep metadata failures before adoption so the source survives // and the unsuccessful output is removed by the local cleanup. embedBasicMetadata(context, output, input, "flac") @@ -819,7 +823,7 @@ object NativeDownloadFinalizer { } finally { if (!adoptedOutput) { File(stagedOutput).delete() - File(output).delete() + if (createdOutput && output != localInput) File(output).delete() } if (deleteLocalInput) File(localInput).delete() } @@ -846,14 +850,7 @@ object NativeDownloadFinalizer { codec == "opus" -> ".opus" else -> return } - val renamed = File( - currentFile.parentFile, - currentFile.name.dropLast(".flac".length) + newExt, - ) - if (renamed.exists() && !renamed.delete()) { - Log.w(TAG, "Cannot adopt container extension; ${renamed.name} already exists") - return - } + val renamed = File(uniqueAutoConversionOutputPath(currentFile.path, newExt)) if (!currentFile.renameTo(renamed)) { Log.w(TAG, "Failed to rename preserved container to ${renamed.name}") return diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt index d686065b..24077a0c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt @@ -35,7 +35,7 @@ import kotlin.math.pow // FFmpeg execution, probing, and container helpers for NativeDownloadFinalizer. -internal fun NativeDownloadFinalizer.isMP4ContainerFile(path: String): Boolean { +internal fun isMP4ContainerFile(path: String): Boolean { return try { File(path).inputStream().use { stream -> val header = ByteArray(12)