mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 00:58:37 +02:00
fix(library): backfill local audio bitrates
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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/'
|
||||
|
||||
Reference in New Issue
Block a user