fix(library): backfill local audio bitrates

This commit is contained in:
zarzet
2026-07-28 12:51:28 +07:00
parent 4c33f112ad
commit 7f277f7a88
4 changed files with 161 additions and 11 deletions
+29 -2
View File
@@ -411,6 +411,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
needsDuration ||
needsComposer ||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
final localItem = _localLibraryItem;
final localAudioMetadataChanged =
localItem != null &&
((resolvedBitDepth != null &&
resolvedBitDepth != localItem.bitDepth) ||
(resolvedSampleRate != null &&
resolvedSampleRate != localItem.sampleRate) ||
(resolvedBitrate != null &&
resolvedBitrate != localItem.bitrate) ||
needsDuration ||
formatChanged);
if ((resolvedBitDepth != null ||
resolvedSampleRate != null ||
@@ -493,12 +504,28 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
);
});
}
} else if (_isLocalItem && (needsDuration || formatChanged)) {
} else if (_isLocalItem && localAudioMetadataChanged) {
await LibraryDatabase.instance.updateAudioMetadata(
_localLibraryItem!.id,
localItem.id,
duration: resolvedDuration,
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
format: formatChanged ? resolvedFormat : null,
);
if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath) {
setState(() {
_currentLocalLibraryItem = localItem.withAudioMetadata(
duration: resolvedDuration,
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
format: resolvedFormat,
);
});
}
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
} catch (e) {
+47 -9
View File
@@ -16,6 +16,8 @@ final _log = AppLogger('LibraryDatabase');
class LibraryDatabase {
static final LibraryDatabase instance = LibraryDatabase._init();
static const int schemaVersion = 9;
static const int audioMetadataScanVersion = 1;
static Database? _database;
bool _historyAttached = false;
@@ -25,7 +27,7 @@ class LibraryDatabase {
if (_database != null) return _database!;
_database = await sqlite.openAppDatabase(
'local_library.db',
version: 8,
version: schemaVersion,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -78,6 +80,7 @@ class LibraryDatabase {
label TEXT,
copyright TEXT,
format TEXT,
audio_metadata_scan_version INTEGER NOT NULL DEFAULT 1,
track_name_norm TEXT,
artist_name_norm TEXT,
album_name_norm TEXT,
@@ -160,6 +163,15 @@ class LibraryDatabase {
await sqlite.backfillPathKeys(db, 'library', 'library_path_keys');
_log.i('Added local library path-key lookup table');
}
if (oldVersion < 9) {
await sqlite.addColumnIfMissing(
db,
'library',
'audio_metadata_scan_version',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Marked existing rows for one-time audio metadata rescan');
}
}
Future<void> _createPathKeyTable(DatabaseExecutor db) =>
@@ -282,6 +294,9 @@ class LibraryDatabase {
'label': json['label'],
'copyright': json['copyright'],
'format': json['format'],
'audio_metadata_scan_version':
(json['audioMetadataScanVersion'] as num?)?.toInt() ??
audioMetadataScanVersion,
};
row.addAll(
_normalizedColumns(
@@ -828,16 +843,22 @@ class LibraryDatabase {
final stat = await fileStat(newFilePath);
final now = DateTime.now();
final normalizedFormat = _normalizeConvertedFormat(targetFormat);
final convertedBitrate =
_convertedBitrate(targetFormat: targetFormat, bitrate: bitrate) ??
estimateAverageBitrateKbps(
fileSizeBytes: stat?.size,
durationSeconds: item.duration,
);
final updated = item.toJson()
..['id'] = _generateLibraryId(newFilePath)
..['filePath'] = newFilePath
..['scannedAt'] = now.toIso8601String()
..['fileModTime'] = stat?.modified?.millisecondsSinceEpoch
..['format'] = normalizedFormat
..['bitrate'] = _convertedBitrate(
targetFormat: targetFormat,
bitrate: bitrate,
);
..['bitrate'] = convertedBitrate
..['audioMetadataScanVersion'] = convertedBitrate != null
? audioMetadataScanVersion
: 0;
if (normalizedFormat == 'mp3' ||
normalizedFormat == 'opus' ||
@@ -903,6 +924,7 @@ class LibraryDatabase {
values['format'] = normalizedFormat;
}
if (values.isEmpty) return;
values['audio_metadata_scan_version'] = audioMetadataScanVersion;
final db = await database;
await db.update('library', values, where: 'id = ?', whereArgs: [id]);
@@ -998,13 +1020,21 @@ class LibraryDatabase {
Future<Map<String, int>> getFileModTimes() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library',
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time, '
'audio_metadata_scan_version FROM library',
);
final result = <String, int>{};
for (final row in rows) {
final path = row['file_path'] as String;
final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0;
result[path] = modTime;
final scanVersion =
(row['audio_metadata_scan_version'] as num?)?.toInt() ?? 0;
// A sentinel timestamp keeps the path in deletion/dedup checks while
// making the incremental scanner treat a legacy row as changed once.
result[path] = libraryIncrementalSnapshotModTime(
storedModTime: modTime,
storedScanVersion: scanVersion,
);
}
return result;
}
@@ -1012,7 +1042,8 @@ class LibraryDatabase {
Future<String> writeFileModTimesSnapshot() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library',
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time, '
'audio_metadata_scan_version FROM library',
);
final tempDir = await getTemporaryDirectory();
final file = File(
@@ -1026,8 +1057,15 @@ class LibraryDatabase {
final path = row['file_path'] as String?;
if (path == null || path.isEmpty) continue;
final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0;
final scanVersion =
(row['audio_metadata_scan_version'] as num?)?.toInt() ?? 0;
buffer
..write(modTime)
..write(
libraryIncrementalSnapshotModTime(
storedModTime: modTime,
storedScanVersion: scanVersion,
),
)
..write('\t')
..writeln(path);
}
+44
View File
@@ -2,6 +2,15 @@ part of 'library_database.dart';
// Row models and query descriptors for the local library database.
int libraryIncrementalSnapshotModTime({
required int storedModTime,
required int storedScanVersion,
}) {
return storedScanVersion >= LibraryDatabase.audioMetadataScanVersion
? storedModTime
: -1;
}
class LocalLibraryItem {
final String id;
final String trackName;
@@ -110,6 +119,41 @@ class LocalLibraryItem {
format: json['format'] as String?,
);
LocalLibraryItem withAudioMetadata({
int? duration,
int? bitDepth,
int? sampleRate,
int? bitrate,
String? format,
}) {
return LocalLibraryItem(
id: id,
trackName: trackName,
artistName: artistName,
albumName: albumName,
albumArtist: albumArtist,
filePath: filePath,
coverPath: coverPath,
scannedAt: scannedAt,
fileModTime: fileModTime,
isrc: isrc,
trackNumber: trackNumber,
totalTracks: totalTracks,
discNumber: discNumber,
totalDiscs: totalDiscs,
duration: duration ?? this.duration,
releaseDate: releaseDate,
bitDepth: bitDepth ?? this.bitDepth,
sampleRate: sampleRate ?? this.sampleRate,
bitrate: bitrate ?? this.bitrate,
genre: genre,
composer: composer,
label: label,
copyright: copyright,
format: format ?? this.format,
);
}
String get matchKey =>
'${LibraryDatabase.normalizeLookupText(trackName)}|${LibraryDatabase.normalizeLookupText(artistName)}';
String get albumKey =>
+41
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/audio_format_utils.dart';
@@ -51,6 +52,46 @@ void main() {
});
});
group('local library incremental scan', () {
test('rescans legacy metadata rows exactly once', () {
expect(
libraryIncrementalSnapshotModTime(
storedModTime: 1234,
storedScanVersion: 0,
),
-1,
);
expect(
libraryIncrementalSnapshotModTime(
storedModTime: 1234,
storedScanVersion: LibraryDatabase.audioMetadataScanVersion,
),
1234,
);
});
test('keeps a local item current after an audio metadata probe', () {
final item = LocalLibraryItem(
id: 'local-1',
trackName: 'Song',
artistName: 'Artist',
albumName: 'Album',
filePath: '/music/song.flac',
scannedAt: DateTime(2026),
bitDepth: 24,
sampleRate: 96000,
);
final updated = item.withAudioMetadata(bitrate: 1840);
expect(updated.bitrate, 1840);
expect(updated.bitDepth, 24);
expect(updated.sampleRate, 96000);
expect(updated.trackName, 'Song');
expect(updated.filePath, '/music/song.flac');
});
});
group('native worker contracts', () {
final finalizerSource = File(
'android/app/src/main/kotlin/com/zarz/spotiflac/'