feat: detect FLAC/ALAC/EAC3/AC3/AC4 codecs inside MP4 containers

GetM4AQuality now recognizes fLaC, alac, ec-3, ac-3, and ac-4 sample entries and parses the MP4 FLACSpecificBox so library entries carry the real codec rather than the container extension. The AudioQuality struct exposes Codec and Bitrate fields (with an estimator for compressed streams), and ReadFileMetadata publishes format + audio_codec so Flutter and Kotlin can make format decisions based on the actual stream.

Downstream: library_scan labels M4A-family items as flac/alac/eac3/ac3/ac4/m4a, zeroes the bitrate for lossless formats, and the filter UI + quality badges use the codec-derived format instead of only the file extension. Scans and SAF importers also accept .mp4 and .aac file extensions. New unit tests cover codec name mapping and MP4 FLACSpecificBox decoding.
This commit is contained in:
zarzet
2026-05-10 22:14:47 +07:00
parent b4031936a0
commit d664d46ca4
10 changed files with 434 additions and 46 deletions
@@ -772,6 +772,7 @@ class MainActivity: FlutterFragmentActivity() {
return when {
name.endsWith(".m4a") -> ".m4a"
name.endsWith(".mp4") -> ".mp4"
name.endsWith(".aac") -> ".aac"
name.endsWith(".mp3") -> ".mp3"
name.endsWith(".opus") -> ".opus"
name.endsWith(".flac") -> ".flac"
@@ -783,6 +784,10 @@ class MainActivity: FlutterFragmentActivity() {
private fun extFromMimeType(mime: String?): String {
return when (mime) {
"audio/mp4" -> ".m4a"
"audio/aac" -> ".aac"
"audio/eac3" -> ".m4a"
"audio/ac3" -> ".m4a"
"audio/ac4" -> ".m4a"
"audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus"
"audio/flac" -> ".flac"
@@ -1063,7 +1068,7 @@ class MainActivity: FlutterFragmentActivity() {
}
private val cueSiblingAudioExtensions = listOf(
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
)
private fun getSafChildFileLookup(
@@ -1135,7 +1140,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
@@ -1435,7 +1440,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
@@ -3475,7 +3480,7 @@ class MainActivity: FlutterFragmentActivity() {
} catch (_: Exception) { "" }
val cueBaseName = cueName.substringBeforeLast('.')
if (cueBaseName.isNotBlank()) {
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
@@ -96,6 +96,7 @@ object NativeDownloadFinalizer {
".ogg",
".wav",
".aac",
".mp4",
)
private data class FinalizeInput(
@@ -113,6 +114,7 @@ object NativeDownloadFinalizer {
var bitDepth: Int?,
var sampleRate: Int?,
var bitrateKbps: Int? = null,
var audioCodec: String? = null,
var pendingExternalLrc: String? = null,
var pendingExternalLrcFileName: String? = null,
)
@@ -215,6 +217,7 @@ object NativeDownloadFinalizer {
result.put("file_path", state.filePath)
if (state.fileName.isNotBlank()) result.put("file_name", state.fileName)
if (state.quality.isNotBlank()) result.put("quality", state.quality)
result.put("native_finalized", true)
result.put("history_written", true)
result.put("history_item", historyToJson(history))
@@ -652,6 +655,17 @@ object NativeDownloadFinalizer {
val bitDepth = optPositiveInt(metadata, "bit_depth")
val sampleRate = optPositiveInt(metadata, "sample_rate")
val probedCodec = normalizeAudioCodec(
metadata.optString("audio_codec", "").ifBlank {
metadata.optString("codec", "").ifBlank {
metadata.optString("format", "")
}
}
)
if (probedCodec != null) {
state.audioCodec = probedCodec
result.put("audio_codec", probedCodec)
}
if (bitDepth != null) {
state.bitDepth = bitDepth
result.put("actual_bit_depth", bitDepth)
@@ -673,6 +687,7 @@ object NativeDownloadFinalizer {
bitDepth = state.bitDepth,
sampleRate = state.sampleRate,
bitrateKbps = state.bitrateKbps,
audioCodec = state.audioCodec,
storedQuality = state.quality,
)
if (displayQuality != null) {
@@ -710,12 +725,16 @@ object NativeDownloadFinalizer {
bitDepth: Int?,
sampleRate: Int?,
bitrateKbps: Int?,
audioCodec: String? = null,
storedQuality: String?,
): String? {
val format = audioFormatForPath(filePath, fileName)
val format = audioFormatForCodec(audioCodec) ?: audioFormatForPath(filePath, fileName)
if (format == "OPUS" ||
format == "MP3" ||
format == "AAC" ||
format == "EAC3" ||
format == "AC3" ||
format == "AC4" ||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
) {
return if (bitrateKbps != null && bitrateKbps > 0) {
@@ -734,6 +753,34 @@ object NativeDownloadFinalizer {
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
}
private fun audioFormatForCodec(codec: String?): String? {
return when (normalizeAudioCodec(codec)) {
"flac" -> "FLAC"
"alac" -> "ALAC"
"aac" -> "AAC"
"eac3" -> "EAC3"
"ac3" -> "AC3"
"ac4" -> "AC4"
"mp3" -> "MP3"
"opus" -> "OPUS"
else -> null
}
}
private fun normalizeAudioCodec(codec: String?): String? {
val normalized = normalizeOptional(codec)
?.lowercase(Locale.ROOT)
?.replace('-', '_')
?: return null
return when (normalized) {
"mp4a" -> "aac"
"ec_3" -> "eac3"
"ac_3" -> "ac3"
"ac_4" -> "ac4"
else -> normalized
}
}
private fun audioFormatForPath(filePath: String, fileName: String): String? {
for (candidate in listOf(filePath, fileName)) {
val lower = candidate.trim().lowercase(Locale.ROOT)
@@ -1165,7 +1212,7 @@ object NativeDownloadFinalizer {
return when (normalizeExt(File(path).extension)) {
".mp3" -> "mp3"
".opus", ".ogg" -> "opus"
".m4a", ".mp4" -> "m4a"
".m4a", ".mp4", ".aac" -> "m4a"
else -> "flac"
}
}