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();