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 84d61265..714b315e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -841,7 +841,7 @@ object NativeDownloadFinalizer { } val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate") ?: optPositiveBitrateKbps(metadata, "bit_rate") - if (bitrateKbps != null && isLossyAudioCodec(state.audioCodec)) { + if (bitrateKbps != null) { state.bitrateKbps = bitrateKbps result.put("bitrate", bitrateKbps) } @@ -1096,7 +1096,7 @@ 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 { + state.bitrateKbps?.takeIf { it >= 16 }?.let { values.put("bitrate", it) } normalizeAudioCodec(state.audioCodec)?.let { values.put("format", it) } diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 349ab95a..41089fa0 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -30,7 +30,7 @@ type LibraryScanResult struct { ReleaseDate string `json:"releaseDate,omitempty"` BitDepth int `json:"bitDepth,omitempty"` SampleRate int `json:"sampleRate,omitempty"` - Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis) + Bitrate int `json:"bitrate,omitempty"` // average kbps for both lossless and lossy audio Genre string `json:"genre,omitempty"` Composer string `json:"composer,omitempty"` Label string `json:"label,omitempty"` diff --git a/go_backend/library_scan_formats.go b/go_backend/library_scan_formats.go index f50d3c06..23e539bc 100644 --- a/go_backend/library_scan_formats.go +++ b/go_backend/library_scan_formats.go @@ -147,7 +147,11 @@ func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, disp result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate if quality.SampleRate > 0 && quality.TotalSamples > 0 { - result.Duration = int(quality.TotalSamples / int64(quality.SampleRate)) + durationSeconds := float64(quality.TotalSamples) / float64(quality.SampleRate) + result.Duration = int(durationSeconds) + if info, statErr := os.Stat(filePath); statErr == nil && info.Size() > 0 { + result.Bitrate = int(float64(info.Size()) * 8 / durationSeconds / 1000) + } } } if coverCacheDir != "" { @@ -205,9 +209,6 @@ func scanM4AFileWithCoverCache(filePath string, result *LibraryScanResult, displ } if format := libraryFormatForM4ACodec(quality.Codec); format != "" { result.Format = format - if isLosslessLibraryFormat(format) { - result.Bitrate = 0 - } } } if coverCacheDir != "" { diff --git a/go_backend/library_scan_single_pass_test.go b/go_backend/library_scan_single_pass_test.go index e41d8e83..3a517141 100644 --- a/go_backend/library_scan_single_pass_test.go +++ b/go_backend/library_scan_single_pass_test.go @@ -72,6 +72,33 @@ func TestScanFLACSinglePassReadsMetadataQualityAndCover(t *testing.T) { } } +func TestScanFLACPersistsAverageBitrate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bitrate.flac") + writeSinglePassTestFlac(t, path, nil) + + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(make([]byte, 2_000_000)); err != nil { + f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + result := &LibraryScanResult{FilePath: path, Format: "flac"} + result, err = scanFLACFile(path, result, "") + if err != nil { + t.Fatal(err) + } + if result.Bitrate < 1_590 || result.Bitrate > 1_610 { + t.Fatalf("average bitrate = %d kbps", result.Bitrate) + } +} + func TestScanM4ASingleOpenReadsMetadataAndCover(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "track.m4a") diff --git a/lib/providers/download_history_provider_maintenance.dart b/lib/providers/download_history_provider_maintenance.dart index 442f6d3f..7862366c 100644 --- a/lib/providers/download_history_provider_maintenance.dart +++ b/lib/providers/download_history_provider_maintenance.dart @@ -275,6 +275,18 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier { } bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) { + return _needsAverageBitrateBackfill(item) || + _shouldBackfillAudioMetadataIgnoringBitrate(item); + } + + bool _needsAverageBitrateBackfill(DownloadHistoryItem item) { + return _supportsAudioMetadataProbe(item.filePath) && + (item.bitrate == null || item.bitrate! <= 0) && + item.duration != null && + item.duration! > 0; + } + + bool _shouldBackfillAudioMetadataIgnoringBitrate(DownloadHistoryItem item) { if (!_supportsAudioMetadataProbe(item.filePath)) { return false; } @@ -396,10 +408,9 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier { final detectedFormat = normalizeAudioFormatValue( result['audio_codec']?.toString() ?? result['format']?.toString(), ); - final rawBitrateKbps = readPositiveBitrateKbps(result['bitrate']); - final bitrateKbps = isLossyAudioFormat(detectedFormat) - ? rawBitrateKbps - : null; + final bitrateKbps = readPositiveBitrateKbps( + result['bitrate'] ?? result['bit_rate'], + ); final quality = resolveDisplayQuality( filePath: filePath, detectedFormat: detectedFormat, @@ -496,10 +507,24 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier { for (final index in selectedIndexes) { final item = items[index]; - final probed = await _probeAudioMetadata( - item.filePath, - fallbackQuality: item.quality, - ); + Map? probed; + if (_shouldBackfillAudioMetadataIgnoringBitrate(item)) { + probed = await _probeAudioMetadata( + item.filePath, + fallbackQuality: item.quality, + ); + } else if (_needsAverageBitrateBackfill(item)) { + // A stat is enough for average bitrate and avoids copying an entire + // SAF file to cache merely to update the Library badge. + final stat = await fileStat(item.filePath); + final bitrate = estimateAverageBitrateKbps( + fileSizeBytes: stat?.size, + durationSeconds: item.duration, + ); + if (bitrate != null) { + probed = {'bitrate': bitrate}; + } + } if (probed == null) { continue; } diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index b7a1af5d..f39ec6b0 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -382,7 +382,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { if (bitDepth != null) result['actual_bit_depth'] = bitDepth; if (sampleRate != null) result['actual_sample_rate'] = sampleRate; if (detectedFormat != null) result['audio_codec'] = detectedFormat; - if (bitrateKbps != null && isLossyAudioFormat(detectedFormat)) { + if (bitrateKbps != null) { result['bitrate'] = bitrateKbps; } diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 619854ea..d7cf44b4 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -50,9 +50,9 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { : readPositiveInt( result['actual_sample_rate'] ?? result['sample_rate'], ); - final bitrate = isLossy - ? readPositiveBitrateKbps(result['actual_bitrate'] ?? result['bitrate']) - : null; + final bitrate = readPositiveBitrateKbps( + result['actual_bitrate'] ?? result['bitrate'], + ); final storedQuality = result['quality']?.toString().trim().isNotEmpty == true ? result['quality'].toString() @@ -1023,9 +1023,9 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { result['audio_codec']?.toString() ?? result['format']?.toString(), ) ?? normalizeAudioFormatValue(audioFormatForPath(filePath)); - var actualBitrate = isLossyAudioFormat(actualFormat) - ? readPositiveBitrateKbps(result['bitrate'] ?? result['actual_bitrate']) - : null; + var actualBitrate = readPositiveBitrateKbps( + result['bitrate'] ?? result['actual_bitrate'], + ); final resolvedQuality = resolveDisplayQuality( filePath: filePath, detectedFormat: actualFormat, @@ -1119,11 +1119,9 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { actualFormat = normalizeAudioFormatValue(result['audio_codec']?.toString()) ?? normalizeAudioFormatValue(audioFormatForPath(filePath)); - actualBitrate = isLossyAudioFormat(actualFormat) - ? readPositiveBitrateKbps( - result['bitrate'] ?? result['actual_bitrate'], - ) - : null; + actualBitrate = readPositiveBitrateKbps( + result['bitrate'] ?? result['actual_bitrate'], + ); final finalQuality = resolveDisplayQuality( filePath: filePath, fileName: variantOutcome.fileName, @@ -1199,7 +1197,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { : context.safFileName, bitDepth: isLossyOutput ? null : actualBitDepth, sampleRate: isLossyOutput ? null : actualSampleRate, - bitrate: isLossyOutput ? actualBitrate : null, + bitrate: actualBitrate, format: historyFormat, genre: normalizeOptionalString(result['genre'] as String?), label: normalizeOptionalString(result['label'] as String?), diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index d0dc4fe9..f60cc002 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -1481,9 +1481,7 @@ class _DownloadRun { int? finalBitDepth = backendBitDepth; int? finalSampleRate = backendSampleRate; String? finalFormat = backendFormat; - int? finalBitrateKbps = isLossyAudioFormat(finalFormat) - ? backendBitrateKbps - : null; + int? finalBitrateKbps = backendBitrateKbps; final lowerFilePath = path.toLowerCase(); final canProbeFinalMetadata = path.startsWith('content://') || @@ -1525,7 +1523,7 @@ class _DownloadRun { final probedBitrateKbps = readPositiveBitrateKbps( metadata['bitrate'] ?? metadata['bit_rate'], ); - if (probedBitrateKbps != null && isLossyAudioFormat(finalFormat)) { + if (probedBitrateKbps != null) { finalBitrateKbps = probedBitrateKbps; } @@ -1556,7 +1554,7 @@ class _DownloadRun { lowerFilePath.endsWith('.ogg'); final historyBitDepth = isLossyOutput ? null : finalBitDepth; final historySampleRate = isLossyOutput ? null : finalSampleRate; - final historyBitrate = isLossyOutput ? finalBitrateKbps : null; + final historyBitrate = finalBitrateKbps; await persistBeforePublishingDownloadCompletion( persist: () async { diff --git a/lib/services/library_database_models.dart b/lib/services/library_database_models.dart index b64f12c8..9fb056d4 100644 --- a/lib/services/library_database_models.dart +++ b/lib/services/library_database_models.dart @@ -21,7 +21,7 @@ class LocalLibraryItem { final String? releaseDate; final int? bitDepth; final int? sampleRate; - final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg) + final int? bitrate; // average kbps for both lossless and lossy audio final String? genre; final String? composer; final String? label; diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index 90ef8a4e..7bf6ca9c 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -10,6 +10,24 @@ int? readPositiveBitrateKbps(dynamic value) { return kbps >= 16 ? kbps : null; } +/// Estimates average stream bitrate without decoding audio. Older SAF-backed +/// Library rows can therefore be updated with a cheap size query instead of +/// copying the complete audio file into app cache. +int? estimateAverageBitrateKbps({ + required int? fileSizeBytes, + required int? durationSeconds, +}) { + if (fileSizeBytes == null || + fileSizeBytes <= 0 || + durationSeconds == null || + durationSeconds <= 0) { + return null; + } + return readPositiveBitrateKbps( + (fileSizeBytes * 8 / durationSeconds / 1000).round(), + ); +} + String? audioFormatForPath(String? filePath, {String? fileName}) { final candidates = [?filePath, ?fileName]; for (final candidate in candidates) { diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 9a644208..09689e62 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -167,6 +167,27 @@ void main() { }); group('quality variant filenames', () { + test('estimates average bitrate without decoding the audio file', () { + expect( + estimateAverageBitrateKbps( + fileSizeBytes: 42.9 * 1000 * 1000 ~/ 1, + durationSeconds: 204, + ), + 1682, + ); + expect( + estimateAverageBitrateKbps(fileSizeBytes: null, durationSeconds: 204), + isNull, + ); + expect( + estimateAverageBitrateKbps( + fileSizeBytes: 42 * 1000 * 1000, + durationSeconds: 0, + ), + isNull, + ); + }); + test('uses measured lossless specifications instead of request labels', () { expect( buildQualityVariantFilenameLabel(