diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 24041527..98ab96aa 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -411,6 +411,17 @@ class _TrackMetadataScreenState extends ConsumerState 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 ); }); } - } 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) { diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 161e330f..5ed4e61c 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -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 _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> 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 = {}; 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 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); } diff --git a/lib/services/library_database_models.dart b/lib/services/library_database_models.dart index 9fb056d4..a6a4924e 100644 --- a/lib/services/library_database_models.dart +++ b/lib/services/library_database_models.dart @@ -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 => diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index d6dfa09a..0511cc4e 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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/'