mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
fix(library): persist lossless bitrate metadata
This commit is contained in:
@@ -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) }
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user