fix(library): persist lossless bitrate metadata

This commit is contained in:
zarzet
2026-07-28 00:50:20 +07:00
parent ad08b95c0f
commit 26570792a9
11 changed files with 122 additions and 34 deletions
@@ -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) }
+1 -1
View File
@@ -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"`
+5 -4
View File
@@ -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 != "" {
@@ -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")
@@ -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<String, dynamic>? 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;
}
@@ -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;
}
@@ -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?),
@@ -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 {
+1 -1
View File
@@ -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;
+18
View File
@@ -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 = <String>[?filePath, ?fileName];
for (final candidate in candidates) {
+21
View File
@@ -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(