From 8e605cbd0f0d5257e5217c9e4b2e47ef3d409077 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 10 May 2026 23:18:32 +0700 Subject: [PATCH] feat: persist codec format and bitrate in download history Bump the history schema on both the Kotlin finalizer and the Dart database to v9, adding bitrate (kbps) and format (codec label) columns, and let the download flow fill them from backend/probe metadata so lossy downloads keep a 'AAC 256kbps' label instead of falling back to the stored placeholder. Library filtering and the track metadata screen now read format/bitrate directly from those columns, which also fixes mis-tagged quality badges after re-downloading a track at a different format. Additional fixes bundled in: EditFileMetadata now routes ReplayGain writes through the M4A path whenever the file starts with ftyp (fixing .flac files that actually hold MP4 containers); GetM4AQuality falls back to the first trak/mdia/mdhd duration when mvhd is zero so EAC3 streams no longer report 0s; and both Kotlin and Dart reject bitrate values below 16 kbps to prevent probe noise from surfacing as '0 kbps' labels. New unit tests cover the EAC3 mdhd fallback and the mis-named M4A replaygain path. --- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 38 ++++- go_backend/audio_metadata_supplement_test.go | 22 +++ go_backend/exports.go | 41 +++-- go_backend/exports_supplement_test.go | 8 + go_backend/metadata.go | 72 ++++++++- lib/providers/download_queue_provider.dart | 134 +++++++++++++++- lib/screens/queue_tab.dart | 4 + lib/screens/queue_tab_helpers.dart | 21 ++- lib/screens/track_metadata_screen.dart | 148 ++++++++++++++++-- lib/services/history_database.dart | 12 +- lib/services/library_database.dart | 8 +- 11 files changed, 451 insertions(+), 57 deletions(-) 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 54822771..4aff6ae6 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -30,7 +30,7 @@ object NativeDownloadFinalizer { const val NATIVE_WORKER_CONTRACT_VERSION = 1 // Native finalizer owns background-safe history writes while Flutter may be suspended. // Keep this schema contract in sync with Dart HistoryDatabase before bumping either side. - private const val HISTORY_SCHEMA_VERSION = 8 + private const val HISTORY_SCHEMA_VERSION = 9 private val activeFFmpegSessionIds = mutableSetOf() private val nativeFFmpegSessionIds = mutableSetOf() private val activeFFmpegSessionLock = Any() @@ -73,6 +73,8 @@ object NativeDownloadFinalizer { "quality", "bit_depth", "sample_rate", + "bitrate", + "format", "genre", "composer", "label", @@ -177,6 +179,9 @@ object NativeDownloadFinalizer { sampleRate = optPositiveInt(result, "actual_sample_rate"), bitrateKbps = optPositiveBitrateKbps(result, "bitrate") ?: optPositiveBitrateKbps(result, "actual_bitrate"), + audioCodec = normalizeAudioCodec( + result.optString("audio_codec", "").ifBlank { result.optString("format", "") }, + ), ) try { @@ -676,7 +681,7 @@ object NativeDownloadFinalizer { } val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate") ?: optPositiveBitrateKbps(metadata, "bit_rate") - if (bitrateKbps != null) { + if (bitrateKbps != null && isLossyAudioCodec(state.audioCodec)) { state.bitrateKbps = bitrateKbps result.put("bitrate", bitrateKbps) } @@ -737,7 +742,7 @@ object NativeDownloadFinalizer { format == "AC4" || (format == "M4A" && (bitDepth == null || bitDepth <= 0)) ) { - return if (bitrateKbps != null && bitrateKbps > 0) { + return if (bitrateKbps != null && bitrateKbps >= 16) { "$format ${bitrateKbps}kbps" } else { nonPlaceholderQuality(storedQuality) ?: format @@ -767,6 +772,13 @@ object NativeDownloadFinalizer { } } + private fun isLossyAudioCodec(codec: String?): Boolean { + return when (normalizeAudioCodec(codec)) { + "aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true + else -> false + } + } + private fun normalizeAudioCodec(codec: String?): String? { val normalized = normalizeOptional(codec) ?.lowercase(Locale.ROOT) @@ -777,6 +789,8 @@ object NativeDownloadFinalizer { "ec_3" -> "eac3" "ac_3" -> "ac3" "ac_4" -> "ac4" + "mp4" -> "m4a" + "ogg" -> "opus" else -> normalized } } @@ -796,6 +810,11 @@ object NativeDownloadFinalizer { private fun nonPlaceholderQuality(quality: String?): String? { val normalized = normalizeOptional(quality) ?: return null + val bitrateMatch = Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized) + if (bitrateMatch != null) { + val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull() + if (bitrate != null && bitrate < 16) return null + } val key = normalized.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "_").trim('_') val placeholders = setOf( "best", @@ -1654,6 +1673,10 @@ object NativeDownloadFinalizer { values.put("quality", state.quality) state.bitDepth?.let { values.put("bit_depth", it) } state.sampleRate?.let { values.put("sample_rate", it) } + state.bitrateKbps?.takeIf { it >= 16 && isLossyAudioCodec(state.audioCodec) }?.let { + values.put("bitrate", it) + } + normalizeAudioCodec(state.audioCodec)?.let { values.put("format", it) } values.put("genre", normalizeOptional(result.optString("genre", "").ifBlank { input.request.optString("genre", "") })) values.put("composer", normalizeOptional(resultString(input, "composer").ifBlank { trackString(input, "composer", requestString(input, "composer")) })) values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") })) @@ -1710,6 +1733,8 @@ object NativeDownloadFinalizer { quality TEXT, bit_depth INTEGER, sample_rate INTEGER, + bitrate INTEGER, + format TEXT, genre TEXT, composer TEXT, label TEXT, @@ -1725,6 +1750,8 @@ object NativeDownloadFinalizer { ensureHistoryColumn(db, "composer", "ALTER TABLE history ADD COLUMN composer TEXT") ensureHistoryColumn(db, "total_tracks", "ALTER TABLE history ADD COLUMN total_tracks INTEGER") ensureHistoryColumn(db, "total_discs", "ALTER TABLE history ADD COLUMN total_discs INTEGER") + ensureHistoryColumn(db, "bitrate", "ALTER TABLE history ADD COLUMN bitrate INTEGER") + ensureHistoryColumn(db, "format", "ALTER TABLE history ADD COLUMN format TEXT") ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT") ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT") ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT") @@ -2096,6 +2123,8 @@ object NativeDownloadFinalizer { putCamel("quality", "quality") putCamel("bit_depth", "bitDepth") putCamel("sample_rate", "sampleRate") + putCamel("bitrate", "bitrate") + putCamel("format", "format") putCamel("genre", "genre") putCamel("composer", "composer") putCamel("label", "label") @@ -2127,11 +2156,12 @@ object NativeDownloadFinalizer { private fun optPositiveBitrateKbps(obj: JSONObject, key: String): Int? { val value = optPositiveInt(obj, key) ?: return null - return if (value >= 10000) { + val kbps = if (value >= 10000) { Math.round(value / 1000.0).toInt() } else { value } + return if (kbps >= 16) kbps else null } private fun positiveOrNull(primary: Int, fallback: Int): Int? { diff --git a/go_backend/audio_metadata_supplement_test.go b/go_backend/audio_metadata_supplement_test.go index 4348344e..9eca758e 100644 --- a/go_backend/audio_metadata_supplement_test.go +++ b/go_backend/audio_metadata_supplement_test.go @@ -340,6 +340,28 @@ func TestM4AMetadataAtomHelpers(t *testing.T) { if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 { t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err) } + eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a") + zeroMvhd := make([]byte, 20) + eac3SampleEntry := make([]byte, 32) + copy(eac3SampleEntry[0:4], "ec-3") + eac3SampleEntry[28] = 0xBB + eac3SampleEntry[29] = 0x80 + mdhd := make([]byte, 20) + binary.BigEndian.PutUint32(mdhd[12:16], 48000) + binary.BigEndian.PutUint32(mdhd[16:20], 48000*123) + eac3QualityFile := append( + buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), + buildM4AAtom("moov", append( + append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...), + eac3SampleEntry..., + ))..., + ) + if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil { + t.Fatal(err) + } + if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 { + t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err) + } if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok { t.Fatal("short ALAC config should not parse") } diff --git a/go_backend/exports.go b/go_backend/exports.go index cecdf6c1..4537e49e 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1423,6 +1423,19 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b") coverPath := strings.TrimSpace(fields["cover_path"]) + if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) { + if err := EditM4AReplayGain(filePath, fields); err != nil { + return "", fmt.Errorf("failed to write M4A metadata: %w", err) + } + + resp := map[string]any{ + "success": true, + "method": "native_m4a_replaygain", + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + if isFlac { if err := EditFlacFields(filePath, fields); err != nil { return "", fmt.Errorf("failed to write FLAC metadata: %w", err) @@ -1533,19 +1546,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } - if isM4AFile && hasOnlyM4AReplayGainFields(fields) { - if err := EditM4AReplayGain(filePath, fields); err != nil { - return "", fmt.Errorf("failed to write M4A metadata: %w", err) - } - - resp := map[string]any{ - "success": true, - "method": "native_m4a_replaygain", - } - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil - } - resp := map[string]any{ "success": true, "method": "ffmpeg", @@ -1555,6 +1555,21 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } +func isMP4ContainerFile(filePath string) bool { + f, err := os.Open(filePath) + if err != nil { + return false + } + defer f.Close() + + header := make([]byte, 12) + n, err := f.Read(header) + if err != nil || n < 8 { + return false + } + return string(header[4:8]) == "ftyp" +} + func hasOnlyM4AReplayGainFields(fields map[string]string) bool { allowed := map[string]struct{}{ "replaygain_track_gain": {}, diff --git a/go_backend/exports_supplement_test.go b/go_backend/exports_supplement_test.go index df23af30..7a678ae9 100644 --- a/go_backend/exports_supplement_test.go +++ b/go_backend/exports_supplement_test.go @@ -85,6 +85,14 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) { if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") { t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err) } + misnamedM4APath := filepath.Join(dir, "misnamed.flac") + if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil { + t.Fatal(err) + } + replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}` + if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") { + t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err) + } if _, err := EditFileMetadata(apePath, `not-json`); err == nil { t.Fatal("expected invalid metadata JSON") } diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 09c955de..090aa12c 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -1578,11 +1578,11 @@ func looksLikeEmbeddedLyrics(value string) bool { } type AudioQuality struct { - BitDepth int `json:"bit_depth"` - 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 + BitDepth int `json:"bit_depth"` + 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"` } @@ -1727,6 +1727,9 @@ func GetM4AQuality(filePath string) (AudioQuality, error) { } bitrate := estimateAudioBitrateKbps(fileSize, duration) + if bitrate > 0 && bitrate < 16 { + bitrate = 0 + } return AudioQuality{ BitDepth: bitDepth, SampleRate: sampleRate, @@ -1766,11 +1769,17 @@ func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) i childStart := moovHeader.offset + moovHeader.headerSize childSize := moovHeader.size - moovHeader.headerSize mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize) - if err != nil || !found { - return 0 + if err == nil && found { + if duration := readMP4DurationAtomSeconds(f, mvhdHeader, fileSize); duration > 0 { + return duration + } } - payloadOffset := mvhdHeader.offset + mvhdHeader.headerSize + return readM4ATrackDurationSeconds(f, moovHeader, fileSize) +} + +func readMP4DurationAtomSeconds(f *os.File, header atomHeader, fileSize int64) int { + payloadOffset := header.offset + header.headerSize versionBuf := make([]byte, 1) if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil { return 0 @@ -1801,6 +1810,53 @@ func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) i return int(math.Round(float64(duration) / float64(timescale))) } +func readM4ATrackDurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int { + childStart := moovHeader.offset + moovHeader.headerSize + childSize := moovHeader.size - moovHeader.headerSize + bestDuration := 0 + _ = walkMP4AtomsInRange(f, childStart, childSize, fileSize, func(header atomHeader) bool { + if header.typ == "mdhd" { + if duration := readMP4DurationAtomSeconds(f, header, fileSize); duration > bestDuration { + bestDuration = duration + } + return false + } + return header.typ == "trak" || header.typ == "mdia" + }) + return bestDuration +} + +func walkMP4AtomsInRange(f *os.File, start, size, fileSize int64, visit func(atomHeader) bool) error { + if size <= 0 { + return nil + } + + end := start + size + for pos := start; pos+8 <= end; { + header, err := readAtomHeaderAt(f, pos, fileSize) + if err != nil { + return err + } + atomSize := header.size + if atomSize == 0 { + atomSize = end - pos + } + if atomSize < header.headerSize { + return fmt.Errorf("invalid atom size for %s", header.typ) + } + header.size = atomSize + if visit(header) { + childStart := header.offset + header.headerSize + childSize := header.size - header.headerSize + if err := walkMP4AtomsInRange(f, childStart, childSize, fileSize, visit); err != nil { + return err + } + } + pos += atomSize + } + return nil +} + func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) { if sampleOffset < 4 { return 0, 0, false diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 8dd14090..44ac748a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -38,7 +38,8 @@ final _multiUnderscoreRegex = RegExp(r'_+'); int? _readPositiveBitrateKbps(dynamic value) { final parsed = readPositiveInt(value); if (parsed == null) return null; - return parsed >= 10000 ? (parsed / 1000).round() : parsed; + final kbps = parsed >= 10000 ? (parsed / 1000).round() : parsed; + return kbps >= 16 ? kbps : null; } String? _audioFormatForPath(String? filePath, {String? fileName}) { @@ -58,6 +59,14 @@ String? _nonPlaceholderQuality(String? quality) { if (normalized == null || isPlaceholderQualityLabel(normalized)) { return null; } + final bitrateMatch = RegExp( + r'\b(\d+)\s*kbps\b', + caseSensitive: false, + ).firstMatch(normalized); + if (bitrateMatch != null) { + final bitrate = int.tryParse(bitrateMatch.group(1) ?? ''); + if (bitrate != null && bitrate < 16) return null; + } final lower = normalized.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_'); const requestedLosslessLabels = { 'hi_res_lossless', @@ -70,6 +79,36 @@ String? _nonPlaceholderQuality(String? quality) { return normalized; } +String? _normalizeAudioFormatValue(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' || 'ogg' => 'opus', + 'm4a' || 'mp4' => 'm4a', + _ => null, + }; +} + +bool _isLossyAudioFormat(String? value) { + return const { + 'aac', + 'eac3', + 'ac3', + 'ac4', + 'mp3', + 'opus', + 'm4a', + }.contains(_normalizeAudioFormatValue(value)); +} + String? _resolveDisplayQuality({ required String? filePath, String? fileName, @@ -151,6 +190,8 @@ class DownloadHistoryItem { final String? quality; final int? bitDepth; final int? sampleRate; + final int? bitrate; + final String? format; final String? genre; final String? composer; final String? label; @@ -182,6 +223,8 @@ class DownloadHistoryItem { this.quality, this.bitDepth, this.sampleRate, + this.bitrate, + this.format, this.genre, this.composer, this.label, @@ -214,6 +257,8 @@ class DownloadHistoryItem { 'quality': quality, 'bitDepth': bitDepth, 'sampleRate': sampleRate, + 'bitrate': bitrate, + 'format': format, 'genre': genre, 'composer': composer, 'label': label, @@ -247,6 +292,8 @@ class DownloadHistoryItem { quality: json['quality'] as String?, bitDepth: json['bitDepth'] as int?, sampleRate: json['sampleRate'] as int?, + bitrate: (json['bitrate'] as num?)?.toInt(), + format: json['format'] as String?, genre: json['genre'] as String?, composer: json['composer'] as String?, label: json['label'] as String?, @@ -276,6 +323,8 @@ class DownloadHistoryItem { String? quality, int? bitDepth, int? sampleRate, + int? bitrate, + String? format, String? genre, String? composer, String? label, @@ -307,6 +356,8 @@ class DownloadHistoryItem { quality: quality ?? this.quality, bitDepth: bitDepth ?? this.bitDepth, sampleRate: sampleRate ?? this.sampleRate, + bitrate: bitrate ?? this.bitrate, + format: format ?? this.format, genre: genre ?? this.genre, composer: composer ?? this.composer, label: label ?? this.label, @@ -703,6 +754,7 @@ class DownloadHistoryNotifier extends Notifier { item.bitDepth! > 0 && item.sampleRate != null && item.sampleRate! > 0; + final needsFormatBackfill = normalizeOptionalString(item.format) == null; final needsLosslessSpecProbe = !hasResolvedSpecs && (trimmedPath.endsWith('.flac') || @@ -720,6 +772,7 @@ class DownloadHistoryNotifier extends Notifier { final needsDiscNumberBackfill = item.discNumber == null; final needsTotalDiscsBackfill = item.totalDiscs == null; return needsComposerBackfill || + needsFormatBackfill || needsDurationBackfill || needsTrackNumberBackfill || needsTotalTracksBackfill || @@ -735,6 +788,7 @@ class DownloadHistoryNotifier extends Notifier { final needsDiscNumberBackfill = item.discNumber == null; final needsTotalDiscsBackfill = item.totalDiscs == null; return needsLosslessSpecProbe || + needsFormatBackfill || isPlaceholderQualityLabel(item.quality) || normalizeOptionalString(item.quality) == null || needsComposerBackfill || @@ -761,11 +815,16 @@ class DownloadHistoryNotifier extends Notifier { final bitDepth = readPositiveInt(result['bit_depth']); final sampleRate = readPositiveInt(result['sample_rate']); - final bitrateKbps = _readPositiveBitrateKbps(result['bitrate']); + final detectedFormat = _normalizeAudioFormatValue( + result['audio_codec']?.toString() ?? result['format']?.toString(), + ); + final rawBitrateKbps = _readPositiveBitrateKbps(result['bitrate']); + final bitrateKbps = _isLossyAudioFormat(detectedFormat) + ? rawBitrateKbps + : null; final quality = _resolveDisplayQuality( filePath: filePath, - detectedFormat: - result['audio_codec']?.toString() ?? result['format']?.toString(), + detectedFormat: detectedFormat, bitDepth: bitDepth, sampleRate: sampleRate, bitrateKbps: bitrateKbps, @@ -782,6 +841,7 @@ class DownloadHistoryNotifier extends Notifier { bitDepth == null && sampleRate == null && bitrateKbps == null && + detectedFormat == null && composer == null && duration == null && trackNumber == null && @@ -795,6 +855,8 @@ class DownloadHistoryNotifier extends Notifier { 'quality': quality, 'bitDepth': bitDepth, 'sampleRate': sampleRate, + 'bitrate': bitrateKbps, + 'format': detectedFormat, 'bitrateKbps': bitrateKbps, 'composer': composer, 'duration': duration, @@ -868,6 +930,10 @@ class DownloadHistoryNotifier extends Notifier { ); final resolvedBitDepth = probed['bitDepth'] as int?; final resolvedSampleRate = probed['sampleRate'] as int?; + final resolvedBitrate = probed['bitrate'] as int?; + final resolvedFormat = normalizeOptionalString( + probed['format'] as String?, + ); final resolvedComposer = normalizeOptionalString( probed['composer'] as String?, ); @@ -883,6 +949,10 @@ class DownloadHistoryNotifier extends Notifier { resolvedBitDepth != null && resolvedBitDepth != item.bitDepth; final sampleRateChanged = resolvedSampleRate != null && resolvedSampleRate != item.sampleRate; + final bitrateChanged = + resolvedBitrate != null && resolvedBitrate != item.bitrate; + final formatChanged = + resolvedFormat != null && resolvedFormat != item.format; final composerChanged = resolvedComposer != null && resolvedComposer != item.composer; final durationChanged = @@ -901,6 +971,8 @@ class DownloadHistoryNotifier extends Notifier { if (!qualityChanged && !bitDepthChanged && !sampleRateChanged && + !bitrateChanged && + !formatChanged && !composerChanged && !durationChanged && !trackNumberChanged && @@ -914,6 +986,8 @@ class DownloadHistoryNotifier extends Notifier { quality: resolvedQuality, bitDepth: resolvedBitDepth, sampleRate: resolvedSampleRate, + bitrate: resolvedBitrate, + format: resolvedFormat, composer: resolvedComposer, duration: resolvedDuration, trackNumber: resolvedTrackNumber, @@ -1197,6 +1271,8 @@ class DownloadHistoryNotifier extends Notifier { String? quality, int? bitDepth, int? sampleRate, + int? bitrate, + String? format, int? trackNumber, int? totalTracks, int? discNumber, @@ -1217,6 +1293,8 @@ class DownloadHistoryNotifier extends Notifier { quality: quality, bitDepth: bitDepth, sampleRate: sampleRate, + bitrate: bitrate, + format: format, trackNumber: trackNumber, totalTracks: totalTracks, discNumber: discNumber, @@ -1228,6 +1306,8 @@ class DownloadHistoryNotifier extends Notifier { if (updated.quality == current.quality && updated.bitDepth == current.bitDepth && updated.sampleRate == current.sampleRate && + updated.bitrate == current.bitrate && + updated.format == current.format && updated.trackNumber == current.trackNumber && updated.totalTracks == current.totalTracks && updated.discNumber == current.discNumber && @@ -5706,10 +5786,22 @@ class DownloadQueueNotifier extends Notifier { var actualQuality = context.quality; final actualBitDepth = result['actual_bit_depth'] as int?; final actualSampleRate = result['actual_sample_rate'] as int?; + final actualFormat = + _normalizeAudioFormatValue( + result['audio_codec']?.toString() ?? result['format']?.toString(), + ) ?? + _normalizeAudioFormatValue(_audioFormatForPath(filePath)); + final actualBitrate = _isLossyAudioFormat(actualFormat) + ? _readPositiveBitrateKbps( + result['bitrate'] ?? result['actual_bitrate'], + ) + : null; final resolvedQuality = _resolveDisplayQuality( filePath: filePath, + detectedFormat: actualFormat, bitDepth: actualBitDepth, sampleRate: actualSampleRate, + bitrateKbps: actualBitrate, storedQuality: actualQuality, ); if (resolvedQuality != null) { @@ -5818,7 +5910,13 @@ class DownloadQueueNotifier extends Notifier { final backendComposer = result['composer'] as String?; final resultSafFileName = result['file_name'] as String?; final lowerFilePath = filePath.toLowerCase(); + final historyFormat = + _normalizeAudioFormatValue( + result['audio_codec']?.toString() ?? result['format']?.toString(), + ) ?? + _normalizeAudioFormatValue(_audioFormatForPath(filePath)); final isLossyOutput = + _isLossyAudioFormat(historyFormat) || lowerFilePath.endsWith('.mp3') || lowerFilePath.endsWith('.opus') || lowerFilePath.endsWith('.ogg'); @@ -5896,6 +5994,8 @@ class DownloadQueueNotifier extends Notifier { quality: actualQuality, bitDepth: isLossyOutput ? null : actualBitDepth, sampleRate: isLossyOutput ? null : actualSampleRate, + bitrate: isLossyOutput ? actualBitrate : null, + format: historyFormat, genre: normalizeOptionalString(backendGenre), composer: historyComposer, label: normalizeOptionalString(backendLabel), @@ -8192,6 +8292,12 @@ class DownloadQueueNotifier extends Notifier { final backendTotalDiscs = _parsePositiveInt(result['total_discs']); final backendBitDepth = result['actual_bit_depth'] as int?; final backendSampleRate = result['actual_sample_rate'] as int?; + final backendFormat = + _normalizeAudioFormatValue( + result['audio_codec']?.toString() ?? + result['format']?.toString(), + ) ?? + _normalizeAudioFormatValue(_audioFormatForPath(filePath)); final backendBitrateKbps = _readPositiveBitrateKbps( result['bitrate'] ?? result['actual_bitrate'], ); @@ -8215,7 +8321,10 @@ class DownloadQueueNotifier extends Notifier { int? finalBitDepth = backendBitDepth; int? finalSampleRate = backendSampleRate; - int? finalBitrateKbps = backendBitrateKbps; + String? finalFormat = backendFormat; + int? finalBitrateKbps = _isLossyAudioFormat(finalFormat) + ? backendBitrateKbps + : null; final lowerFilePath = filePath.toLowerCase(); final canProbeFinalMetadata = filePath.startsWith('content://') || @@ -8244,16 +8353,25 @@ class DownloadQueueNotifier extends Notifier { if (probedSampleRate != null && probedSampleRate > 0) { finalSampleRate = probedSampleRate; } + final probedFormat = _normalizeAudioFormatValue( + metadata['audio_codec']?.toString() ?? + metadata['format']?.toString(), + ); + if (probedFormat != null) { + finalFormat = probedFormat; + } final probedBitrateKbps = _readPositiveBitrateKbps( metadata['bitrate'] ?? metadata['bit_rate'], ); - if (probedBitrateKbps != null && probedBitrateKbps > 0) { + if (probedBitrateKbps != null && + _isLossyAudioFormat(finalFormat)) { finalBitrateKbps = probedBitrateKbps; } final resolvedQuality = _resolveDisplayQuality( filePath: filePath, fileName: finalSafFileName, + detectedFormat: finalFormat, bitDepth: finalBitDepth, sampleRate: finalSampleRate, bitrateKbps: finalBitrateKbps, @@ -8275,11 +8393,13 @@ class DownloadQueueNotifier extends Notifier { ); final isLossyOutput = + _isLossyAudioFormat(finalFormat) || lowerFilePath.endsWith('.mp3') || lowerFilePath.endsWith('.opus') || lowerFilePath.endsWith('.ogg'); final historyBitDepth = isLossyOutput ? null : finalBitDepth; final historySampleRate = isLossyOutput ? null : finalSampleRate; + final historyBitrate = isLossyOutput ? finalBitrateKbps : null; final historyTotalTracks = _resolvePositiveMetadataInt( trackToDownload.totalTracks, backendTotalTracks, @@ -8353,6 +8473,8 @@ class DownloadQueueNotifier extends Notifier { quality: actualQuality, bitDepth: historyBitDepth, sampleRate: historySampleRate, + bitrate: historyBitrate, + format: finalFormat, genre: effectiveGenre, composer: historyComposer, label: effectiveLabel, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index cbb8f0ee..42c737ce 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1491,6 +1491,10 @@ class _QueueTabState extends ConsumerState { if (localFormat != null) { return localFormat.toLowerCase().replaceAll('-', '_'); } + final historyFormat = normalizeOptionalString(item.historyItem?.format); + if (historyFormat != null) { + return historyFormat.toLowerCase().replaceAll('-', '_'); + } return _fileExtLower(item.filePath); } diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart index cffca9f1..d89d3f21 100644 --- a/lib/screens/queue_tab_helpers.dart +++ b/lib/screens/queue_tab_helpers.dart @@ -33,6 +33,21 @@ class UnifiedLibraryItem { }); factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) { + String? quality; + if (item.bitrate != null && item.bitrate! > 0) { + quality = buildDisplayAudioQuality( + bitrateKbps: item.bitrate, + format: item.format, + ); + } else if (item.bitDepth != null && + item.bitDepth! > 0 && + item.sampleRate != null) { + quality = buildDisplayAudioQuality( + bitDepth: item.bitDepth, + sampleRate: item.sampleRate, + ); + } + quality ??= item.quality; return UnifiedLibraryItem( id: 'dl_${item.id}', trackName: item.trackName, @@ -40,11 +55,7 @@ class UnifiedLibraryItem { albumName: item.albumName, coverUrl: item.coverUrl, filePath: item.filePath, - quality: buildDisplayAudioQuality( - bitDepth: item.bitDepth, - sampleRate: item.sampleRate, - storedQuality: item.quality, - ), + quality: quality, addedAt: item.downloadedAt, source: LibraryItemSource.downloaded, historyItem: item, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 04066065..70f79974 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -368,11 +368,21 @@ class _TrackMetadataScreenState extends ConsumerState { final resolvedBitDepth = readPositiveInt(metadata['bit_depth']); final resolvedSampleRate = readPositiveInt(metadata['sample_rate']); + final resolvedFormat = _normalizeAudioFormatValue( + metadata['audio_codec']?.toString() ?? metadata['format']?.toString(), + ); + final resolvedBitrate = _isBitrateFormatValue(resolvedFormat) + ? _readPlausibleBitrateKbps( + metadata['bitrate'] ?? metadata['bit_rate'], + ) + : null; final resolvedDuration = readPositiveInt(metadata['duration']); final resolvedAlbum = metadata['album']?.toString(); - final resolvedQuality = buildDisplayAudioQuality( + final resolvedQuality = _displayQualityForValues( + format: resolvedFormat ?? _storedAudioFormat, bitDepth: resolvedBitDepth ?? bitDepth, sampleRate: resolvedSampleRate ?? sampleRate, + bitrateKbps: resolvedBitrate ?? _audioBitrate, storedQuality: _quality, ); @@ -427,6 +437,8 @@ class _TrackMetadataScreenState extends ConsumerState { !_isLocalItem && (resolvedBitDepth != null || resolvedSampleRate != null || + resolvedBitrate != null || + resolvedFormat != null || needsTrackNumber || needsTotalTracks || needsDiscNumber || @@ -476,6 +488,8 @@ class _TrackMetadataScreenState extends ConsumerState { quality: resolvedQuality, bitDepth: resolvedBitDepth, sampleRate: resolvedSampleRate, + bitrate: resolvedBitrate, + format: resolvedFormat, trackNumber: needsTrackNumber ? resolvedTrackNumber : null, totalTracks: needsTotalTracks ? resolvedTotalTracks : null, discNumber: needsDiscNumber ? resolvedDiscNumber : null, @@ -483,6 +497,23 @@ class _TrackMetadataScreenState extends ConsumerState { duration: needsDuration ? resolvedDuration : null, composer: needsComposer ? resolvedComposer : null, ); + if (mounted && _downloadItem != null) { + setState(() { + _currentDownloadItem = _downloadItem!.copyWith( + quality: resolvedQuality, + bitDepth: resolvedBitDepth, + sampleRate: resolvedSampleRate, + bitrate: resolvedBitrate, + format: resolvedFormat, + trackNumber: needsTrackNumber ? resolvedTrackNumber : null, + totalTracks: needsTotalTracks ? resolvedTotalTracks : null, + discNumber: needsDiscNumber ? resolvedDiscNumber : null, + totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null, + duration: needsDuration ? resolvedDuration : null, + composer: needsComposer ? resolvedComposer : null, + ); + }); + } } else if (_isLocalItem && needsDuration) { await LibraryDatabase.instance.updateAudioMetadata( _localLibraryItem!.id, @@ -681,7 +712,10 @@ class _TrackMetadataScreenState extends ConsumerState { (_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate); - int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null; + int? get _audioBitrate => + _isLocalItem ? _localLibraryItem!.bitrate : _downloadItem?.bitrate; + String? get _storedAudioFormat => + _isLocalItem ? _localLibraryItem?.format : _downloadItem?.format; String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; @@ -707,6 +741,85 @@ class _TrackMetadataScreenState extends ConsumerState { String? get _quality => _isLocalItem ? null : _downloadItem!.quality; + String? _normalizeAudioFormatValue(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' || 'ogg' => 'opus', + 'm4a' || 'mp4' => 'm4a', + _ => null, + }; + } + + int? _readPlausibleBitrateKbps(dynamic value) { + final parsed = readPositiveInt(value); + if (parsed == null) return null; + final kbps = parsed >= 10000 ? (parsed / 1000).round() : parsed; + return kbps >= 16 ? kbps : null; + } + + bool _isBitrateFormatValue(String? value) { + return const { + 'aac', + 'eac3', + 'ac3', + 'ac4', + 'mp3', + 'opus', + 'm4a', + }.contains(_normalizeAudioFormatValue(value)); + } + + String? _usableStoredQuality(String? quality) { + final normalized = normalizeOptionalString(quality); + if (normalized == null || isPlaceholderQualityLabel(normalized)) { + return null; + } + final bitrateMatch = RegExp( + r'\b(\d+)\s*kbps\b', + caseSensitive: false, + ).firstMatch(normalized); + if (bitrateMatch != null) { + final bitrate = int.tryParse(bitrateMatch.group(1) ?? ''); + if (bitrate != null && bitrate < 16) return null; + } + return normalized; + } + + String? _displayQualityForValues({ + required String? format, + int? bitDepth, + int? sampleRate, + int? bitrateKbps, + String? storedQuality, + }) { + final normalizedFormat = _normalizeAudioFormatValue(format); + final formatLabel = normalizedFormat == null + ? normalizeOptionalString(format)?.toUpperCase() + : _formatLabelForRaw(normalizedFormat); + if (_isBitrateFormatValue(normalizedFormat)) { + return buildDisplayAudioQuality( + bitrateKbps: bitrateKbps, + format: formatLabel, + ) ?? + _usableStoredQuality(storedQuality) ?? + formatLabel; + } + return buildDisplayAudioQuality( + bitDepth: bitDepth, + sampleRate: sampleRate, + storedQuality: _usableStoredQuality(storedQuality), + ); + } + String _displayServiceTrackId(String value) { final raw = value.trim(); if (raw.isEmpty) return raw; @@ -766,11 +879,11 @@ class _TrackMetadataScreenState extends ConsumerState { ? fileName.split('.').last.toUpperCase() : null; - return buildDisplayAudioQuality( + return _displayQualityForValues( + format: _storedAudioFormat ?? fileExt, bitDepth: bitDepth, sampleRate: sampleRate, - bitrateKbps: _isLocalItem ? _localBitrate : null, - format: _isLocalItem ? (_localLibraryItem!.format ?? fileExt) : fileExt, + bitrateKbps: _audioBitrate, storedQuality: _quality, ); } @@ -1611,13 +1724,7 @@ 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'); + String _formatLabelForRaw(String raw) { final normalized = raw.toLowerCase().replaceAll('-', '_'); return switch (normalized) { 'flac' => 'FLAC', @@ -1626,7 +1733,7 @@ class _TrackMetadataScreenState extends ConsumerState { 'ac3' || 'ac_3' => 'AC3', 'ac4' || 'ac_4' => 'AC4', 'aac' || 'mp4a' => 'AAC', - 'm4a' => 'M4A', + 'm4a' || 'mp4' => 'M4A', 'mp3' => 'MP3', 'opus' => 'Opus', 'ogg' => 'OGG', @@ -1634,6 +1741,14 @@ class _TrackMetadataScreenState extends ConsumerState { }; } + String _displayFormatLabelForFile(String fileName) { + final storedFormat = normalizeOptionalString(_storedAudioFormat); + final raw = + storedFormat ?? + (fileName.contains('.') ? fileName.split('.').last : 'Unknown'); + return _formatLabelForRaw(raw); + } + bool _isBitrateFormatLabel(String label) { return const { 'MP3', @@ -1748,9 +1863,8 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ) - else if (_isLocalItem && - _localBitrate != null && - _localBitrate! > 0 && + else if (_audioBitrate != null && + _audioBitrate! > 0 && _isBitrateFormatLabel(fileExtension)) Container( padding: const EdgeInsets.symmetric( @@ -1762,7 +1876,7 @@ class _TrackMetadataScreenState extends ConsumerState { borderRadius: BorderRadius.circular(20), ), child: Text( - '${_localBitrate}kbps', + '${_audioBitrate}kbps', style: TextStyle( color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 7260f80c..26d251b2 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -84,7 +84,7 @@ class HistoryDatabase { return await openDatabase( path, - version: 8, + version: 9, onConfigure: (db) async { await db.rawQuery('PRAGMA journal_mode = WAL'); await db.execute('PRAGMA synchronous = NORMAL'); @@ -124,6 +124,8 @@ class HistoryDatabase { quality TEXT, bit_depth INTEGER, sample_rate INTEGER, + bitrate INTEGER, + format TEXT, genre TEXT, composer TEXT, label TEXT, @@ -203,6 +205,10 @@ class HistoryDatabase { await _backfillNormalizedColumns(db); await _createNormalizedIndexes(db); } + if (oldVersion < 9) { + await _addColumnIfMissing(db, 'history', 'bitrate', 'INTEGER'); + await _addColumnIfMissing(db, 'history', 'format', 'TEXT'); + } } static String normalizeLookupText(String? value) { @@ -507,6 +513,8 @@ class HistoryDatabase { 'quality': json['quality'], 'bit_depth': json['bitDepth'], 'sample_rate': json['sampleRate'], + 'bitrate': json['bitrate'], + 'format': json['format'], 'genre': json['genre'], 'composer': json['composer'], 'label': json['label'], @@ -550,6 +558,8 @@ class HistoryDatabase { 'quality': row['quality'], 'bitDepth': row['bit_depth'], 'sampleRate': row['sample_rate'], + 'bitrate': row['bitrate'], + 'format': row['format'], 'genre': row['genre'], 'composer': row['composer'], 'label': row['label'], diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 4afced0c..120ae90f 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -1029,8 +1029,8 @@ class LibraryDatabase { NULL AS cover_path, NULL AS scanned_at, NULL AS file_mod_time, - NULL AS bitrate, - NULL AS format, + h.bitrate, + h.format, LOWER(h.track_name) AS sort_track, LOWER(h.artist_name) AS sort_artist, LOWER(h.album_name) AS sort_album, @@ -1299,7 +1299,7 @@ class LibraryDatabase { args, request, filePathExpr: 'h.file_path', - formatExpr: null, + formatExpr: 'h.format', qualityExpr: 'h.quality', bitDepthExpr: 'h.bit_depth', artistExpr: 'h.artist_name', @@ -1544,6 +1544,8 @@ class LibraryDatabase { 'quality': row['quality'], 'bitDepth': row['bit_depth'], 'sampleRate': row['sample_rate'], + 'bitrate': row['bitrate'], + 'format': row['format'], 'genre': row['genre'], 'composer': row['composer'], 'label': row['label'],