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'],