mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 13:18:02 +02:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user