From d664d46ca4ca1212afd0cb7da4c74bd7903485f0 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 10 May 2026 22:14:47 +0700 Subject: [PATCH] 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. --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 13 +- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 51 +++++- go_backend/exports.go | 25 ++- go_backend/library_scan.go | 47 +++++- go_backend/metadata.go | 159 +++++++++++++++--- go_backend/metadata_m4a_quality_test.go | 50 ++++++ lib/providers/download_queue_provider.dart | 27 ++- lib/screens/queue_tab.dart | 27 ++- lib/screens/track_metadata_screen.dart | 65 ++++++- lib/services/library_database.dart | 16 +- 10 files changed, 434 insertions(+), 46 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c771463b..c37abeda 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -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>() val cueFiles = mutableListOf>() val visitedDirUris = mutableSetOf() @@ -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>() val cueFilesToScan = mutableListOf>() val unchangedCueFiles = mutableListOf>() @@ -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 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 b64527c0..54822771 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -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" } } diff --git a/go_backend/exports.go b/go_backend/exports.go index 9d03afc8..cecdf6c1 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1101,7 +1101,7 @@ func CleanupConnections() { func ReadFileMetadata(filePath string) (string, error) { lower := strings.ToLower(filePath) isFlac := strings.HasSuffix(lower, ".flac") - isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") + isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac") isMp3 := strings.HasSuffix(lower, ".mp3") isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") isApe := strings.HasSuffix(lower, ".ape") @@ -1126,9 +1126,13 @@ func ReadFileMetadata(filePath string) (string, error) { "composer": "", "comment": "", "duration": 0, + "format": "", + "audio_codec": "", } if isFlac { + result["format"] = "flac" + result["audio_codec"] = "flac" metadata, err := ReadMetadata(filePath) if err != nil { // File may have wrong extension (e.g. opus saved as .flac). @@ -1161,6 +1165,8 @@ func ReadFileMetadata(filePath string) (string, error) { result["bitrate"] = quality.Bitrate / 1000 } } + result["format"] = "opus" + result["audio_codec"] = "opus" } else { return "", fmt.Errorf("failed to read metadata: %w", err) } @@ -1190,12 +1196,16 @@ func ReadFileMetadata(filePath string) (string, error) { if qualityErr == nil { result["bit_depth"] = quality.BitDepth result["sample_rate"] = quality.SampleRate + if quality.Codec != "" { + result["audio_codec"] = quality.Codec + } if quality.SampleRate > 0 && quality.TotalSamples > 0 { result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) } } } } else if isM4A { + result["format"] = "m4a" meta, err := ReadM4ATags(filePath) if err == nil && meta != nil { result["title"] = meta.Title @@ -1227,8 +1237,17 @@ func ReadFileMetadata(filePath string) (string, error) { result["bit_depth"] = quality.BitDepth result["sample_rate"] = quality.SampleRate result["duration"] = quality.Duration + result["audio_codec"] = quality.Codec + if format := libraryFormatForM4ACodec(quality.Codec); format != "" { + result["format"] = format + } + if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) { + result["bitrate"] = quality.Bitrate + } } } else if isMp3 { + result["format"] = "mp3" + result["audio_codec"] = "mp3" meta, err := ReadID3Tags(filePath) if err == nil && meta != nil { result["title"] = meta.Title @@ -1265,6 +1284,8 @@ func ReadFileMetadata(filePath string) (string, error) { } } } else if isOgg { + result["format"] = "opus" + result["audio_codec"] = "opus" meta, err := ReadOggVorbisComments(filePath) if err == nil && meta != nil { result["title"] = meta.Title @@ -1300,6 +1321,8 @@ func ReadFileMetadata(filePath string) (string, error) { } } } else if isApe || isWv || isMpc { + result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".") + result["audio_codec"] = result["format"] // APE, WavPack, Musepack: read APEv2 tags apeTag, apeErr := ReadAPETags(filePath) if apeErr == nil && apeTag != nil { diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index d15e9a4f..91aaf4b0 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -68,6 +68,8 @@ var ( var supportedAudioFormats = map[string]bool{ ".flac": true, ".m4a": true, + ".mp4": true, + ".aac": true, ".mp3": true, ".opus": true, ".ogg": true, @@ -330,7 +332,7 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ switch ext { case ".flac": return scanFLACFile(filePath, result, displayNameHint) - case ".m4a": + case ".m4a", ".mp4", ".aac": return scanM4AFile(filePath, result, displayNameHint) case ".mp3": return scanMP3File(filePath, result, displayNameHint) @@ -410,7 +412,6 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str metadata, err := ReadM4ATags(filePath) if err != nil { GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err) - return scanFromFilename(filePath, displayNameHint, result) } if metadata != nil { @@ -437,12 +438,54 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str if err == nil { result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate + result.Duration = quality.Duration + if quality.Bitrate > 0 { + result.Bitrate = quality.Bitrate + } + if format := libraryFormatForM4ACodec(quality.Codec); format != "" { + result.Format = format + if isLosslessLibraryFormat(format) { + result.Bitrate = 0 + } + } + } + + if metadata == nil { + return scanFromFilename(filePath, displayNameHint, result) } applyDefaultLibraryMetadata(filePath, displayNameHint, result) return result, nil } +func libraryFormatForM4ACodec(codec string) string { + switch strings.ToLower(strings.TrimSpace(codec)) { + case "flac": + return "flac" + case "alac": + return "alac" + case "eac3", "ec-3": + return "eac3" + case "ac3", "ac-3": + return "ac3" + case "ac4", "ac-4": + return "ac4" + case "aac", "mp4a": + return "m4a" + default: + return "" + } +} + +func isLosslessLibraryFormat(format string) bool { + switch strings.ToLower(strings.TrimSpace(format)) { + case "flac", "alac": + return true + default: + return false + } +} + func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) { metadata, err := ReadID3Tags(filePath) if err != nil { diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 3db29102..09c955de 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -872,7 +872,7 @@ func ExtractLyrics(filePath string) (string, error) { return extractLyricsFromSidecarLRC(filePath) } - if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") { + if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac") { lyrics, err := extractLyricsFromM4A(filePath) if err == nil && strings.TrimSpace(lyrics) != "" { return lyrics, nil @@ -1582,6 +1582,8 @@ type AudioQuality struct { SampleRate int `json:"sample_rate"` TotalSamples int64 `json:"total_samples"` Duration int `json:"duration"` + Bitrate int `json:"bitrate,omitempty"` // kbps, estimated for compressed MP4-family streams + Codec string `json:"codec,omitempty"` } func GetAudioQuality(filePath string) (AudioQuality, error) { @@ -1632,6 +1634,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) { SampleRate: sampleRate, TotalSamples: totalSamples, Duration: duration, + Codec: "flac", }, nil } @@ -1696,6 +1699,7 @@ func GetM4AQuality(filePath string) (AudioQuality, error) { // [28:32] samplerate (16.16 fixed-point) sampleRate := int(buf[28])<<8 | int(buf[29]) bitDepth := 0 + codec := normalizeM4AAudioCodec(atomType) if atomType == "alac" { bitDepth = int(buf[22])<<8 | int(buf[23]) @@ -1707,9 +1711,55 @@ func GetM4AQuality(filePath string) (AudioQuality, error) { sampleRate = alacSampleRate } } + } else if atomType == "fLaC" { + bitDepth = int(buf[22])<<8 | int(buf[23]) + if flacBitDepth, flacSampleRate, flacTotalSamples, ok := readMP4FLACSpecificConfig(f, sampleOffset, fileSize); ok { + if flacBitDepth > 0 { + bitDepth = flacBitDepth + } + if flacSampleRate > 0 { + sampleRate = flacSampleRate + } + if flacTotalSamples > 0 && sampleRate > 0 && duration <= 0 { + duration = int(flacTotalSamples / int64(sampleRate)) + } + } } - return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate, Duration: duration}, nil + bitrate := estimateAudioBitrateKbps(fileSize, duration) + return AudioQuality{ + BitDepth: bitDepth, + SampleRate: sampleRate, + Duration: duration, + Bitrate: bitrate, + Codec: codec, + }, nil +} + +func normalizeM4AAudioCodec(atomType string) string { + switch atomType { + case "mp4a": + return "aac" + case "alac": + return "alac" + case "fLaC": + return "flac" + case "ec-3": + return "eac3" + case "ac-3": + return "ac3" + case "ac-4": + return "ac4" + default: + return strings.TrimSpace(atomType) + } +} + +func estimateAudioBitrateKbps(fileSize int64, durationSeconds int) int { + if fileSize <= 0 || durationSeconds <= 0 { + return 0 + } + return int(math.Round(float64(fileSize*8) / float64(durationSeconds) / 1000.0)) } func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int { @@ -1785,6 +1835,79 @@ func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, return parseALACSpecificConfig(payload) } +func readMP4FLACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, int64, bool) { + if sampleOffset < 4 { + return 0, 0, 0, false + } + + sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize) + if err != nil { + return 0, 0, 0, false + } + + childStart := sampleOffset + 32 + childEnd := sampleEntryHeader.offset + sampleEntryHeader.size + if childStart >= childEnd { + return 0, 0, 0, false + } + + configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "dfLa", fileSize) + if err != nil || !found { + return 0, 0, 0, false + } + + payloadSize := configHeader.size - configHeader.headerSize + if payloadSize <= 0 { + return 0, 0, 0, false + } + + payload := make([]byte, payloadSize) + if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil { + return 0, 0, 0, false + } + + return parseMP4FLACSpecificConfig(payload) +} + +func parseMP4FLACSpecificConfig(payload []byte) (int, int, int64, bool) { + if len(payload) >= 4 && string(payload[:4]) == "fLaC" { + payload = payload[4:] + } else if len(payload) >= 4 { + // FLACSpecificBox starts with a full-box version/flags field. + payload = payload[4:] + } + + for len(payload) >= 4 { + blockType := payload[0] & 0x7F + blockLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3]) + if blockLen < 0 || len(payload) < 4+blockLen { + return 0, 0, 0, false + } + block := payload[4 : 4+blockLen] + if blockType == 0 && len(block) >= 34 { + bitDepth, sampleRate, totalSamples := parseFLACStreamInfoQuality(block[:34]) + return bitDepth, sampleRate, totalSamples, bitDepth > 0 || sampleRate > 0 + } + payload = payload[4+blockLen:] + } + + return 0, 0, 0, false +} + +func parseFLACStreamInfoQuality(streamInfo []byte) (int, int, int64) { + if len(streamInfo) < 18 { + return 0, 0, 0 + } + sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4) + bitsPerSample := (((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4)) + 1 + totalSamples := int64(streamInfo[13]&0x0F)<<32 | + int64(streamInfo[14])<<24 | + int64(streamInfo[15])<<16 | + int64(streamInfo[16])<<8 | + int64(streamInfo[17]) + return bitsPerSample, sampleRate, totalSamples +} + func parseALACSpecificConfig(payload []byte) (int, int, bool) { if len(payload) < 24 { return 0, 0, false @@ -1879,8 +2002,14 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6 func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) { const chunkSize = 64 * 1024 - patternMP4A := []byte("mp4a") - patternALAC := []byte("alac") + patterns := [][]byte{ + []byte("mp4a"), + []byte("alac"), + []byte("fLaC"), + []byte("ec-3"), + []byte("ac-3"), + []byte("ac-4"), + } var tail []byte readPos := start @@ -1901,26 +2030,14 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string } data := append(tail, buf[:n]...) - mp4aIdx := bytes.Index(data, patternMP4A) - alacIdx := bytes.Index(data, patternALAC) - bestIdx := -1 bestType := "" - switch { - case mp4aIdx >= 0 && alacIdx >= 0: - if mp4aIdx <= alacIdx { - bestIdx = mp4aIdx - bestType = "mp4a" - } else { - bestIdx = alacIdx - bestType = "alac" + for _, pattern := range patterns { + idx := bytes.Index(data, pattern) + if idx >= 0 && (bestIdx < 0 || idx < bestIdx) { + bestIdx = idx + bestType = string(pattern) } - case mp4aIdx >= 0: - bestIdx = mp4aIdx - bestType = "mp4a" - case alacIdx >= 0: - bestIdx = alacIdx - bestType = "alac" } if bestIdx >= 0 { diff --git a/go_backend/metadata_m4a_quality_test.go b/go_backend/metadata_m4a_quality_test.go index df3b8cb0..1d9a64f6 100644 --- a/go_backend/metadata_m4a_quality_test.go +++ b/go_backend/metadata_m4a_quality_test.go @@ -47,3 +47,53 @@ func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) { t.Fatal("expected short ALAC payload to be rejected") } } + +func TestM4ACodecFormatMapping(t *testing.T) { + cases := map[string]string{ + "mp4a": "aac", + "alac": "alac", + "fLaC": "flac", + "ec-3": "eac3", + "ac-3": "ac3", + "ac-4": "ac4", + } + for atomType, want := range cases { + if got := normalizeM4AAudioCodec(atomType); got != want { + t.Fatalf("normalizeM4AAudioCodec(%q) = %q, want %q", atomType, got, want) + } + } + + if got := libraryFormatForM4ACodec("flac"); got != "flac" { + t.Fatalf("libraryFormatForM4ACodec(flac) = %q", got) + } + if got := libraryFormatForM4ACodec("eac3"); got != "eac3" { + t.Fatalf("libraryFormatForM4ACodec(eac3) = %q", got) + } + if got := libraryFormatForM4ACodec("aac"); got != "m4a" { + t.Fatalf("libraryFormatForM4ACodec(aac) = %q", got) + } +} + +func TestParseMP4FLACSpecificConfig(t *testing.T) { + streamInfo := make([]byte, 34) + sampleRate := 48000 + bitsPerSample := 24 + totalSamples := int64(48000 * 180) + streamInfo[10] = byte(sampleRate >> 12) + streamInfo[11] = byte(sampleRate >> 4) + streamInfo[12] = byte((sampleRate&0x0F)<<4 | ((bitsPerSample-1)>>4)&0x01) + streamInfo[13] = byte(((bitsPerSample-1)&0x0F)<<4 | int((totalSamples>>32)&0x0F)) + streamInfo[14] = byte(totalSamples >> 24) + streamInfo[15] = byte(totalSamples >> 16) + streamInfo[16] = byte(totalSamples >> 8) + streamInfo[17] = byte(totalSamples) + + payload := append([]byte{0, 0, 0, 0, 0, 0, 0, 34}, streamInfo...) + bitDepth, parsedRate, parsedSamples, ok := parseMP4FLACSpecificConfig(payload) + if !ok { + t.Fatal("expected MP4 FLAC config to parse") + } + if bitDepth != bitsPerSample || parsedRate != sampleRate || parsedSamples != totalSamples { + t.Fatalf("FLAC config = %d/%d/%d", bitDepth, parsedRate, parsedSamples) + } +} diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 7413a6b4..8dd14090 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -73,15 +73,21 @@ String? _nonPlaceholderQuality(String? quality) { String? _resolveDisplayQuality({ required String? filePath, String? fileName, + String? detectedFormat, int? bitDepth, int? sampleRate, int? bitrateKbps, String? storedQuality, }) { - final format = _audioFormatForPath(filePath, fileName: fileName); + final format = + _displayFormatForCodec(detectedFormat) ?? + _audioFormatForPath(filePath, fileName: fileName); if (format == 'OPUS' || format == 'MP3' || format == 'AAC' || + format == 'EAC3' || + format == 'AC3' || + format == 'AC4' || (format == 'M4A' && (bitDepth == null || bitDepth <= 0))) { return buildDisplayAudioQuality(bitrateKbps: bitrateKbps, format: format) ?? _nonPlaceholderQuality(storedQuality) ?? @@ -94,6 +100,23 @@ String? _resolveDisplayQuality({ ); } +String? _displayFormatForCodec(String? value) { + final normalized = normalizeOptionalString( + value, + )?.toLowerCase().replaceAll('-', '_'); + return switch (normalized) { + 'flac' => 'FLAC', + 'alac' => 'ALAC', + 'aac' || 'mp4a' => 'AAC', + 'eac3' || 'ec_3' => 'EAC3', + 'ac3' || 'ac_3' => 'AC3', + 'ac4' || 'ac_4' => 'AC4', + 'mp3' => 'MP3', + 'opus' => 'OPUS', + _ => null, + }; +} + /// log10 helper using dart:math's natural log. double _log10(num x) => log(x) / ln10; final _yearRegex = RegExp(r'^(\d{4})'); @@ -741,6 +764,8 @@ class DownloadHistoryNotifier extends Notifier { final bitrateKbps = _readPositiveBitrateKbps(result['bitrate']); final quality = _resolveDisplayQuality( filePath: filePath, + detectedFormat: + result['audio_codec']?.toString() ?? result['format']?.toString(), bitDepth: bitDepth, sampleRate: sampleRate, bitrateKbps: bitrateKbps, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index a0295fc1..cbb8f0ee 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1486,6 +1486,14 @@ class _QueueTabState extends ConsumerState { return filePath.substring(dotIndex + 1).toLowerCase(); } + String _itemFormatLower(UnifiedLibraryItem item) { + final localFormat = normalizeOptionalString(item.localItem?.format); + if (localFormat != null) { + return localFormat.toLowerCase().replaceAll('-', '_'); + } + return _fileExtLower(item.filePath); + } + List _applyAdvancedFilters( List items, ) { @@ -1523,7 +1531,7 @@ class _QueueTabState extends ConsumerState { } if (_filterFormat != null) { - final ext = _fileExtLower(item.filePath); + final ext = _itemFormatLower(item); if (ext != _filterFormat) return false; } @@ -1656,8 +1664,21 @@ class _QueueTabState extends ConsumerState { Set _getAvailableFormats(List items) { final formats = {}; for (final item in items) { - final ext = _fileExtLower(item.filePath); - if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) { + final ext = _itemFormatLower(item); + if ([ + 'flac', + 'alac', + 'mp3', + 'm4a', + 'aac', + 'eac3', + 'ac3', + 'ac4', + 'opus', + 'ogg', + 'wav', + 'aiff', + ].contains(ext)) { formats.add(ext); } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 54378da5..04066065 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -1611,6 +1611,42 @@ class _TrackMetadataScreenState extends ConsumerState { return '$minutes:${secs.toString().padLeft(2, '0')}'; } + String _displayFormatLabelForFile(String fileName) { + final localFormat = _isLocalItem + ? normalizeOptionalString(_localLibraryItem?.format) + : null; + final raw = + localFormat ?? + (fileName.contains('.') ? fileName.split('.').last : 'Unknown'); + final normalized = raw.toLowerCase().replaceAll('-', '_'); + return switch (normalized) { + 'flac' => 'FLAC', + 'alac' => 'ALAC', + 'eac3' || 'ec_3' => 'EAC3', + 'ac3' || 'ac_3' => 'AC3', + 'ac4' || 'ac_4' => 'AC4', + 'aac' || 'mp4a' => 'AAC', + 'm4a' => 'M4A', + 'mp3' => 'MP3', + 'opus' => 'Opus', + 'ogg' => 'OGG', + _ => raw.toUpperCase(), + }; + } + + bool _isBitrateFormatLabel(String label) { + return const { + 'MP3', + 'OPUS', + 'OGG', + 'M4A', + 'AAC', + 'EAC3', + 'AC3', + 'AC4', + }.contains(label.toUpperCase()); + } + Widget _buildFileInfoCard( BuildContext context, ColorScheme colorScheme, @@ -1619,9 +1655,7 @@ class _TrackMetadataScreenState extends ConsumerState { ) { final displayFilePath = _formatPathForDisplay(rawFilePath); final fileName = _extractFileNameFromPathOrUri(rawFilePath); - final fileExtension = fileName.contains('.') - ? fileName.split('.').last.toUpperCase() - : 'Unknown'; + final fileExtension = _displayFormatLabelForFile(fileName); final resolvedQuality = _displayAudioQuality; final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality); @@ -1694,9 +1728,7 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - if ((fileExtension == 'MP3' || - fileExtension == 'OPUS' || - fileExtension == 'OGG') && + if (_isBitrateFormatLabel(fileExtension) && lossyBitrateLabel != null) Container( padding: const EdgeInsets.symmetric( @@ -1719,9 +1751,7 @@ class _TrackMetadataScreenState extends ConsumerState { else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0 && - (fileExtension == 'MP3' || - fileExtension == 'OPUS' || - fileExtension == 'OGG')) + _isBitrateFormatLabel(fileExtension)) Container( padding: const EdgeInsets.symmetric( horizontal: 12, @@ -3249,6 +3279,23 @@ class _TrackMetadataScreenState extends ConsumerState { return 'CUE+$audioFmt'; } } + if (_isLocalItem && _localLibraryItem != null) { + final format = normalizeOptionalString( + _localLibraryItem!.format, + )?.toLowerCase().replaceAll('-', '_'); + switch (format) { + case 'flac': + return 'FLAC'; + case 'alac': + case 'm4a': + return 'M4A'; + case 'mp3': + return 'MP3'; + case 'opus': + case 'ogg': + return 'Opus'; + } + } final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.flac')) return 'FLAC'; if (lower.endsWith('.m4a')) return 'M4A'; diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 310f9487..4afced0c 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -34,7 +34,7 @@ class LocalLibraryItem { final String? composer; final String? label; final String? copyright; - final String? format; // flac, mp3, opus, m4a + final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a const LocalLibraryItem({ required this.id, @@ -1299,6 +1299,7 @@ class LibraryDatabase { args, request, filePathExpr: 'h.file_path', + formatExpr: null, qualityExpr: 'h.quality', bitDepthExpr: 'h.bit_depth', artistExpr: 'h.artist_name', @@ -1335,6 +1336,7 @@ class LibraryDatabase { args, request, filePathExpr: 'l.file_path', + formatExpr: 'l.format', qualityExpr: 'NULL', bitDepthExpr: 'l.bit_depth', artistExpr: 'l.artist_name', @@ -1353,6 +1355,7 @@ class LibraryDatabase { List args, QueueLibraryDbQuery request, { required String filePathExpr, + required String? formatExpr, required String qualityExpr, required String bitDepthExpr, required String artistExpr, @@ -1385,8 +1388,15 @@ class LibraryDatabase { final format = request.format?.trim().toLowerCase(); if (format != null && format.isNotEmpty) { - where.add('LOWER($filePathExpr) LIKE ?'); - args.add('%.$format'); + if (formatExpr == null) { + where.add('LOWER($filePathExpr) LIKE ?'); + args.add('%.$format'); + } else { + where.add( + '(LOWER(COALESCE($formatExpr, \'\')) = ? OR LOWER($filePathExpr) LIKE ?)', + ); + args.addAll([format, '%.$format']); + } } final metadata = request.metadata?.trim();