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"
}
}
+24 -1
View File
@@ -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 {
+45 -2
View File
@@ -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 {
+138 -21
View File
@@ -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 {
+50
View File
@@ -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)
}
}
+26 -1
View File
@@ -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<DownloadHistoryState> {
final bitrateKbps = _readPositiveBitrateKbps(result['bitrate']);
final quality = _resolveDisplayQuality(
filePath: filePath,
detectedFormat:
result['audio_codec']?.toString() ?? result['format']?.toString(),
bitDepth: bitDepth,
sampleRate: sampleRate,
bitrateKbps: bitrateKbps,
+24 -3
View File
@@ -1486,6 +1486,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<UnifiedLibraryItem> _applyAdvancedFilters(
List<UnifiedLibraryItem> items,
) {
@@ -1523,7 +1531,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
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<QueueTab> {
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
final formats = <String>{};
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);
}
}
+56 -9
View File
@@ -1611,6 +1611,42 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
) {
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<TrackMetadataScreen> {
),
),
),
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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';
+13 -3
View File
@@ -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<Object?> 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();