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 b510a4b9..d473f130 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -110,6 +110,7 @@ object NativeDownloadFinalizer { var quality: String, var bitDepth: Int?, var sampleRate: Int?, + var bitrateKbps: Int? = null, var pendingExternalLrc: String? = null, var pendingExternalLrcFileName: String? = null, ) @@ -170,6 +171,8 @@ object NativeDownloadFinalizer { quality = requestQuality(effectiveInput), bitDepth = optPositiveInt(result, "actual_bit_depth"), sampleRate = optPositiveInt(result, "actual_sample_rate"), + bitrateKbps = optPositiveBitrateKbps(result, "bitrate") + ?: optPositiveBitrateKbps(result, "actual_bitrate"), ) try { @@ -637,10 +640,19 @@ object NativeDownloadFinalizer { state.sampleRate = sampleRate result.put("actual_sample_rate", sampleRate) } + val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate") + ?: optPositiveBitrateKbps(metadata, "bit_rate") + if (bitrateKbps != null) { + state.bitrateKbps = bitrateKbps + result.put("bitrate", bitrateKbps) + } val displayQuality = displayAudioQuality( + filePath = state.filePath, + fileName = state.fileName, bitDepth = state.bitDepth, sampleRate = state.sampleRate, + bitrateKbps = state.bitrateKbps, storedQuality = state.quality, ) if (displayQuality != null) { @@ -672,14 +684,64 @@ object NativeDownloadFinalizer { lowerName.endsWith(".ogg") } - private fun displayAudioQuality(bitDepth: Int?, sampleRate: Int?, storedQuality: String?): String? { + private fun displayAudioQuality( + filePath: String, + fileName: String, + bitDepth: Int?, + sampleRate: Int?, + bitrateKbps: Int?, + storedQuality: String?, + ): String? { + val format = audioFormatForPath(filePath, fileName) + if (format == "OPUS" || + format == "MP3" || + format == "AAC" || + (format == "M4A" && (bitDepth == null || bitDepth <= 0)) + ) { + return if (bitrateKbps != null && bitrateKbps > 0) { + "$format ${bitrateKbps}kbps" + } else { + nonPlaceholderQuality(storedQuality) ?: format + } + } + if (bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0) { val khz = sampleRate / 1000.0 val precision = if (sampleRate % 1000 == 0) 0 else 1 val sampleRateLabel = "%.${precision}f".format(Locale.US, khz) return "$bitDepth-bit/${sampleRateLabel}kHz" } - return normalizeOptional(storedQuality) + return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality) + } + + private fun audioFormatForPath(filePath: String, fileName: String): String? { + for (candidate in listOf(filePath, fileName)) { + val lower = candidate.trim().lowercase(Locale.ROOT) + when { + lower.endsWith(".opus") || lower.endsWith(".ogg") -> return "OPUS" + lower.endsWith(".mp3") -> return "MP3" + lower.endsWith(".aac") -> return "AAC" + lower.endsWith(".m4a") || lower.endsWith(".mp4") -> return "M4A" + } + } + return null + } + + private fun nonPlaceholderQuality(quality: String?): String? { + val normalized = normalizeOptional(quality) ?: return null + val key = normalized.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "_").trim('_') + val placeholders = setOf( + "best", + "lossless", + "hi_res", + "hires", + "hi_res_lossless", + "hires_lossless", + "high", + "cd", + "flac_best_available", + ) + return if (placeholders.contains(key)) null else normalized } private fun writeExternalLrc(context: Context, input: FinalizeInput, state: FinalizeState) { @@ -1898,6 +1960,15 @@ object NativeDownloadFinalizer { return if (value > 0) value else null } + private fun optPositiveBitrateKbps(obj: JSONObject, key: String): Int? { + val value = optPositiveInt(obj, key) ?: return null + return if (value >= 10000) { + Math.round(value / 1000.0).toInt() + } else { + value + } + } + private fun positiveOrNull(primary: Int, fallback: Int): Int? { val value = if (primary > 0) primary else fallback return if (value > 0) value else null diff --git a/go_backend/exports.go b/go_backend/exports.go index 97416180..9d03afc8 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1157,6 +1157,9 @@ func ReadFileMetadata(filePath string) (string, error) { if qualityErr == nil { result["sample_rate"] = quality.SampleRate result["duration"] = quality.Duration + if quality.Bitrate > 0 { + result["bitrate"] = quality.Bitrate / 1000 + } } } else { return "", fmt.Errorf("failed to read metadata: %w", err) @@ -1257,6 +1260,9 @@ func ReadFileMetadata(filePath string) (string, error) { result["bit_depth"] = quality.BitDepth result["sample_rate"] = quality.SampleRate result["duration"] = quality.Duration + if quality.Bitrate > 0 { + result["bitrate"] = quality.Bitrate / 1000 + } } } else if isOgg { meta, err := ReadOggVorbisComments(filePath) @@ -1289,6 +1295,9 @@ func ReadFileMetadata(filePath string) (string, error) { if qualityErr == nil { result["sample_rate"] = quality.SampleRate result["duration"] = quality.Duration + if quality.Bitrate > 0 { + result["bitrate"] = quality.Bitrate / 1000 + } } } else if isApe || isWv || isMpc { // APE, WavPack, Musepack: read APEv2 tags diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 02381976..429d558d 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -34,6 +34,76 @@ final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$'); final _multiWhitespaceRegex = RegExp(r'\s+'); final _multiUnderscoreRegex = RegExp(r'_+'); +int? _readPositiveIntValue(dynamic value) { + if (value == null) return null; + if (value is num) { + final asInt = value.toInt(); + return asInt > 0 ? asInt : null; + } + final parsed = int.tryParse(value.toString()); + if (parsed == null || parsed <= 0) return null; + return parsed; +} + +int? _readPositiveBitrateKbps(dynamic value) { + final parsed = _readPositiveIntValue(value); + if (parsed == null) return null; + return parsed >= 10000 ? (parsed / 1000).round() : parsed; +} + +String? _audioFormatForPath(String? filePath, {String? fileName}) { + final candidates = [?filePath, ?fileName]; + for (final candidate in candidates) { + final lower = candidate.trim().toLowerCase(); + if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'OPUS'; + if (lower.endsWith('.mp3')) return 'MP3'; + if (lower.endsWith('.aac')) return 'AAC'; + if (lower.endsWith('.m4a') || lower.endsWith('.mp4')) return 'M4A'; + } + return null; +} + +String? _nonPlaceholderQuality(String? quality) { + final normalized = normalizeOptionalString(quality); + if (normalized == null || isPlaceholderQualityLabel(normalized)) { + return null; + } + final lower = normalized.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_'); + const requestedLosslessLabels = { + 'hi_res_lossless', + 'hires_lossless', + 'hi_res', + 'hires', + 'flac_best_available', + }; + if (requestedLosslessLabels.contains(lower)) return null; + return normalized; +} + +String? _resolveDisplayQuality({ + required String? filePath, + String? fileName, + int? bitDepth, + int? sampleRate, + int? bitrateKbps, + String? storedQuality, +}) { + final format = _audioFormatForPath(filePath, fileName: fileName); + if (format == 'OPUS' || + format == 'MP3' || + format == 'AAC' || + (format == 'M4A' && (bitDepth == null || bitDepth <= 0))) { + return buildDisplayAudioQuality(bitrateKbps: bitrateKbps, format: format) ?? + _nonPlaceholderQuality(storedQuality) ?? + format; + } + return buildDisplayAudioQuality( + bitDepth: bitDepth, + sampleRate: sampleRate, + storedQuality: _nonPlaceholderQuality(storedQuality) ?? storedQuality, + ); +} + /// log10 helper using dart:math's natural log. double _log10(num x) => log(x) / ln10; final _yearRegex = RegExp(r'^(\d{4})'); @@ -689,9 +759,12 @@ class DownloadHistoryNotifier extends Notifier { final bitDepth = _readPositiveInt(result['bit_depth']); final sampleRate = _readPositiveInt(result['sample_rate']); - final quality = buildDisplayAudioQuality( + final bitrateKbps = _readPositiveBitrateKbps(result['bitrate']); + final quality = _resolveDisplayQuality( + filePath: filePath, bitDepth: bitDepth, sampleRate: sampleRate, + bitrateKbps: bitrateKbps, storedQuality: fallbackQuality, ); final composer = normalizeOptionalString(result['composer']?.toString()); @@ -704,6 +777,7 @@ class DownloadHistoryNotifier extends Notifier { if (quality == null && bitDepth == null && sampleRate == null && + bitrateKbps == null && composer == null && duration == null && trackNumber == null && @@ -717,6 +791,7 @@ class DownloadHistoryNotifier extends Notifier { 'quality': quality, 'bitDepth': bitDepth, 'sampleRate': sampleRate, + 'bitrateKbps': bitrateKbps, 'composer': composer, 'duration': duration, 'trackNumber': trackNumber, @@ -5532,7 +5607,8 @@ 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 resolvedQuality = buildDisplayAudioQuality( + final resolvedQuality = _resolveDisplayQuality( + filePath: filePath, bitDepth: actualBitDepth, sampleRate: actualSampleRate, storedQuality: actualQuality, @@ -7931,6 +8007,9 @@ class DownloadQueueNotifier extends Notifier { final backendTotalDiscs = result['total_discs'] as int?; final backendBitDepth = result['actual_bit_depth'] as int?; final backendSampleRate = result['actual_sample_rate'] as int?; + final backendBitrateKbps = _readPositiveBitrateKbps( + result['bitrate'] ?? result['actual_bitrate'], + ); final backendISRC = result['isrc'] as String?; final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; @@ -7951,6 +8030,7 @@ class DownloadQueueNotifier extends Notifier { int? finalBitDepth = backendBitDepth; int? finalSampleRate = backendSampleRate; + int? finalBitrateKbps = backendBitrateKbps; final lowerFilePath = filePath.toLowerCase(); final canProbeFinalMetadata = filePath.startsWith('content://') || @@ -7979,10 +8059,19 @@ class DownloadQueueNotifier extends Notifier { if (probedSampleRate != null && probedSampleRate > 0) { finalSampleRate = probedSampleRate; } + final probedBitrateKbps = _readPositiveBitrateKbps( + metadata['bitrate'] ?? metadata['bit_rate'], + ); + if (probedBitrateKbps != null && probedBitrateKbps > 0) { + finalBitrateKbps = probedBitrateKbps; + } - final resolvedQuality = buildDisplayAudioQuality( + final resolvedQuality = _resolveDisplayQuality( + filePath: filePath, + fileName: finalSafFileName, bitDepth: finalBitDepth, sampleRate: finalSampleRate, + bitrateKbps: finalBitrateKbps, storedQuality: actualQuality, ); if (resolvedQuality != null) {