import 'dart:io'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_queue_store.dart'; import 'package:spotiflac_android/services/library_cleanup.dart'; import 'package:spotiflac_android/services/library_search.dart'; import 'package:spotiflac_android/services/library_database_models.dart'; import 'package:spotiflac_android/services/library_row_mapper.dart'; import 'package:spotiflac_android/services/library_schema.dart'; import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; export 'library_database_models.dart'; export 'library_queue_store.dart' show confirmedMissingLyricsSqlPredicate, missingReplayGainSqlPredicate; final _log = AppLogger('LibraryDatabase'); class LibraryDatabase { static final LibraryDatabase instance = LibraryDatabase._init(); // The FTS table is a derived, optional index and is initialized lazily after // the existing schema migration, so it does not require a user_version bump. static const int schemaVersion = LibrarySchema.version; static const String legacySourceId = LocalLibraryItem.legacySourceId; static const String visibleLibraryView = LibrarySchema.visibleView; static const String searchFtsTable = LibrarySchema.searchFtsTable; static const String lookupSummaryTable = LibrarySchema.lookupSummaryTable; static const String _scanStageTable = 'library_scan_stage'; static const String _scanStagePathKeysTable = 'library_scan_path_keys_stage'; static const String _incrementalStageTable = 'library_incremental_stage'; static const String _incrementalStagePathKeysTable = 'library_incremental_path_keys_stage'; static const String _downloadedLibraryIdsStageTable = 'library_downloaded_ids_stage'; // v4 records ReplayGain availability; older rows rescan once. static const int audioMetadataScanVersion = libraryAudioMetadataScanVersion; /// First scan version whose rows record lyrics availability (v3). static const int lyricsMetadataScanVersion = libraryLyricsMetadataScanVersion; static final sqlite.SingleFlightInitializer _database = sqlite.SingleFlightInitializer(); bool _historyAttached = false; // null means this database connection has not attempted FTS setup yet. // false is a completed capability/setup result and must not be retried. bool? _searchFtsAvailable; LibraryDatabase._init(); Future get database { return _database.getOrCreate(() async { final db = await sqlite.openAppDatabase( 'local_library.db', version: schemaVersion, onCreate: (db, version) async { _searchFtsAvailable = await LibrarySchema.create(db, version); }, onUpgrade: LibrarySchema.upgrade, onOpen: LibrarySchema.migrateIosContainerPaths, ); // Library upserts use INSERT OR REPLACE. Recursive triggers ensure the // implicit delete also decrements materialized lookup ref-counts. await db.execute('PRAGMA recursive_triggers = ON'); // onCreate normally initializes this derived index. Retry once after // opening an existing database in case an earlier setup was // interrupted; unsupported SQLite builds remain on the LIKE fallback. _searchFtsAvailable ??= await LibrarySchema.createSearchFts(db); return db; }); } bool get searchFtsAvailable => _searchFtsAvailable ?? false; Future> searchLibrary({ required String query, required LibrarySearchKind kind, required bool includeLocal, int limit = 40, int offset = 0, }) async { final db = await database; await _ensureHistoryAttached(db); return LibrarySearchStore( db, historyFts: HistoryDatabase.instance.searchFtsAvailable, localFts: searchFtsAvailable, ).search( query: query, kind: kind, includeLocal: includeLocal, limit: limit, offset: offset, ); } Future _ensureHistoryAttached(Database db) async { if (_historyAttached) return; await HistoryDatabase.instance.database; final dbPath = await getApplicationDocumentsDirectory(); final historyPath = join(dbPath.path, 'history.db'); try { await db.execute('ATTACH DATABASE ? AS history_db', [historyPath]); } catch (e) { final message = e.toString().toLowerCase(); if (!message.contains('already in use') && !message.contains('already exists')) { rethrow; } } _historyAttached = true; } void _putPathKeysInBatch(Batch batch, String id, String? filePath) => sqlite.putPathKeysInBatch(batch, 'library_path_keys', id, filePath); static String normalizeLookupText(String? value) => sqlite.normalizeLookupText(value); static String matchKeyFor(String trackName, String artistName) { return '${normalizeLookupText(trackName)}|${normalizeLookupText(artistName)}'; } static String albumKeyFor( String albumName, String? albumArtist, String artistName, ) { return '${normalizeLookupText(albumName)}|${normalizeLookupText(albumArtist ?? artistName)}'; } Future upsert(Map json, {String? sourceId}) async { final db = await database; await db.transaction((txn) async { await txn.insert( 'library', LibraryRowMapper.encode(json, sourceId: sourceId), conflictAlgorithm: ConflictAlgorithm.replace, ); final batch = txn.batch(); _putPathKeysInBatch( batch, json['id'] as String, json['filePath'] as String?, ); await batch.commit(noResult: true); }); } Future upsertBatch( List> items, { String? sourceId, }) async { if (items.isEmpty) return; final db = await database; await db.transaction((txn) async { final batch = txn.batch(); for (final json in items) { batch.insert( 'library', LibraryRowMapper.encode(json, sourceId: sourceId), conflictAlgorithm: ConflictAlgorithm.replace, ); _putPathKeysInBatch( batch, json['id'] as String, json['filePath'] as String?, ); } await batch.commit(noResult: true); }); _log.i('Batch inserted ${items.length} items'); } /// Removes rows from one source whose normalized path keys already exist in /// download History, without materializing either database's paths in Dart. Future deleteDownloadedRowsForSource(String sourceId) async { final db = await database; await _ensureHistoryAttached(db); const downloadedMatch = ''' EXISTS ( SELECT 1 FROM library_path_keys lk JOIN history_db.history_path_keys hk ON hk.path_key = lk.path_key WHERE lk.item_id = library.id ) '''; await db.execute('DROP TABLE IF EXISTS $_downloadedLibraryIdsStageTable'); await db.execute( 'CREATE TEMP TABLE $_downloadedLibraryIdsStageTable ' '(id TEXT PRIMARY KEY)', ); try { await db.rawInsert( 'INSERT INTO $_downloadedLibraryIdsStageTable(id) ' 'SELECT id FROM library ' 'WHERE source_id = ? AND $downloadedMatch', [sourceId], ); final countRows = await db.rawQuery( 'SELECT COUNT(*) AS count FROM $_downloadedLibraryIdsStageTable', ); final count = Sqflite.firstIntValue(countRows) ?? 0; if (count == 0) return 0; await db.transaction((txn) async { await txn.rawDelete( 'DELETE FROM library_path_keys WHERE item_id IN ' '(SELECT id FROM $_downloadedLibraryIdsStageTable)', ); await txn.rawDelete( 'DELETE FROM library WHERE id IN ' '(SELECT id FROM $_downloadedLibraryIdsStageTable)', ); }); return count; } finally { await db.execute('DROP TABLE IF EXISTS $_downloadedLibraryIdsStageTable'); } } /// Upserts incremental scan rows after filtering them through the attached /// History path-key index. This keeps the hot incremental path independent /// of total History size. Future<({int upserted, int skipped})> upsertBatchExcludingHistory( List> items, { required String sourceId, }) async { if (items.isEmpty) return (upserted: 0, skipped: 0); final db = await database; await _ensureHistoryAttached(db); await db.execute('DROP TABLE IF EXISTS $_incrementalStageTable'); await db.execute('DROP TABLE IF EXISTS $_incrementalStagePathKeysTable'); await db.execute( 'CREATE TEMP TABLE $_incrementalStageTable ' 'AS SELECT * FROM library WHERE 0', ); await db.execute( 'CREATE UNIQUE INDEX idx_${_incrementalStageTable}_id ' 'ON $_incrementalStageTable(id)', ); await db.execute( 'CREATE UNIQUE INDEX idx_${_incrementalStageTable}_path ' 'ON $_incrementalStageTable(file_path)', ); await db.execute(''' CREATE TEMP TABLE $_incrementalStagePathKeysTable ( item_id TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (item_id, path_key) ) '''); await db.execute( 'CREATE INDEX idx_${_incrementalStagePathKeysTable}_key ' 'ON $_incrementalStagePathKeysTable(path_key)', ); try { final batch = db.batch(); final stagedIds = {}; for (final json in items) { final id = json['id'] as String?; if (id == null || id.trim().isEmpty) { throw const FormatException('Library scan row has no valid id'); } batch.insert( _incrementalStageTable, LibraryRowMapper.encode(json, sourceId: sourceId), conflictAlgorithm: ConflictAlgorithm.replace, ); sqlite.putPathKeysInBatch( batch, _incrementalStagePathKeysTable, id, json['filePath'] as String?, // The stage starts empty: only a repeated id has keys to replace. replaceExisting: !stagedIds.add(id), ); } await batch.commit(noResult: true); const historyMatch = ''' EXISTS ( SELECT 1 FROM $_incrementalStagePathKeysTable sk JOIN history_db.history_path_keys hk ON hk.path_key = sk.path_key WHERE sk.item_id = s.id ) '''; final skippedRows = await db.rawQuery( 'SELECT COUNT(*) AS count FROM $_incrementalStageTable s ' 'WHERE $historyMatch', ); final skipped = Sqflite.firstIntValue(skippedRows) ?? 0; final stagedRows = await db.rawQuery( 'SELECT COUNT(*) AS count FROM $_incrementalStageTable', ); final staged = Sqflite.firstIntValue(stagedRows) ?? 0; final columns = (await db.rawQuery( 'PRAGMA table_info(library)', )).map((row) => row['name'] as String).toList(growable: false); final columnList = columns.join(', '); final selectedColumns = columns.map((column) => 's.$column').join(', '); await db.transaction((txn) async { await txn.rawDelete(''' DELETE FROM library_path_keys WHERE item_id IN ( SELECT s.id FROM $_incrementalStageTable s WHERE NOT $historyMatch ) '''); await txn.rawInsert(''' INSERT OR REPLACE INTO library ($columnList) SELECT $selectedColumns FROM $_incrementalStageTable s WHERE NOT $historyMatch '''); await txn.rawInsert(''' INSERT OR IGNORE INTO library_path_keys(item_id, path_key) SELECT sk.item_id, sk.path_key FROM $_incrementalStagePathKeysTable sk JOIN $_incrementalStageTable s ON s.id = sk.item_id WHERE NOT $historyMatch '''); }); return (upserted: staged - skipped, skipped: skipped); } finally { await db.execute('DROP TABLE IF EXISTS $_incrementalStagePathKeysTable'); await db.execute('DROP TABLE IF EXISTS $_incrementalStageTable'); } } Future replaceAll(List> items) async { final db = await database; await db.transaction((txn) async { await txn.delete('library_path_keys'); await txn.delete('library'); if (items.isEmpty) { return; } final batch = txn.batch(); for (final json in items) { batch.insert( 'library', LibraryRowMapper.encode(json), conflictAlgorithm: ConflictAlgorithm.replace, ); _putPathKeysInBatch( batch, json['id'] as String, json['filePath'] as String?, ); } await batch.commit(noResult: true); }); _log.i('Replaced library with ${items.length} items'); } /// Stages scan rows in bounded, independently committed batches, then swaps /// only this source in one short transaction. Download-history exclusion is /// an indexed SQLite anti-join, avoiding a full History path set in Dart. Future<({int inserted, int skipped})> replaceSourceStream( String sourceId, Stream> items, { int batchSize = 300, bool preserveMissing = false, }) async { if (batchSize <= 0) { throw ArgumentError.value(batchSize, 'batchSize', 'Must be positive'); } final db = await database; await _ensureHistoryAttached(db); await db.execute('DROP TABLE IF EXISTS $_scanStageTable'); await db.execute('DROP TABLE IF EXISTS $_scanStagePathKeysTable'); await db.execute( 'CREATE TEMP TABLE $_scanStageTable AS SELECT * FROM library WHERE 0', ); await db.execute( 'CREATE UNIQUE INDEX idx_${_scanStageTable}_id ' 'ON $_scanStageTable(id)', ); await db.execute( 'CREATE UNIQUE INDEX idx_${_scanStageTable}_path ' 'ON $_scanStageTable(file_path)', ); await db.execute(''' CREATE TEMP TABLE $_scanStagePathKeysTable ( item_id TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (item_id, path_key) ) '''); await db.execute( 'CREATE INDEX idx_${_scanStagePathKeysTable}_key ' 'ON $_scanStagePathKeysTable(path_key)', ); var streamed = 0; try { var batch = db.batch(); var pending = 0; Future flush() async { if (pending == 0) return; await batch.commit(noResult: true); batch = db.batch(); pending = 0; } final stagedIds = {}; await for (final json in items) { final id = json['id'] as String?; if (id == null || id.trim().isEmpty) { throw const FormatException('Library scan row has no valid id'); } batch.insert( _scanStageTable, LibraryRowMapper.encode(json, sourceId: sourceId), conflictAlgorithm: ConflictAlgorithm.replace, ); sqlite.putPathKeysInBatch( batch, _scanStagePathKeysTable, id, json['filePath'] as String?, // The stage starts empty: only a repeated id has keys to replace. replaceExisting: !stagedIds.add(id), ); streamed++; pending++; if (pending >= batchSize) await flush(); } await flush(); const historyMatch = ''' EXISTS ( SELECT 1 FROM $_scanStagePathKeysTable sk JOIN history_db.history_path_keys hk ON hk.path_key = sk.path_key WHERE sk.item_id = s.id ) '''; final skippedRows = await db.rawQuery( 'SELECT COUNT(*) AS count FROM $_scanStageTable s ' 'WHERE $historyMatch', ); final skipped = Sqflite.firstIntValue(skippedRows) ?? 0; final stagedRows = await db.rawQuery( 'SELECT COUNT(*) AS count FROM $_scanStageTable', ); final staged = Sqflite.firstIntValue(stagedRows) ?? 0; final columns = (await db.rawQuery( 'PRAGMA table_info(library)', )).map((row) => row['name'] as String).toList(growable: false); final columnList = columns.join(', '); final selectedColumns = columns.map((column) => 's.$column').join(', '); await db.transaction((txn) async { await deleteReplacedLibraryScanRows( txn, sourceId, stageTable: _scanStageTable, preserveMissing: preserveMissing, ); await txn.rawDelete(''' DELETE FROM library_path_keys WHERE item_id IN ( SELECT s.id FROM $_scanStageTable s WHERE NOT $historyMatch ) '''); await txn.rawInsert(''' INSERT OR REPLACE INTO library ($columnList) SELECT $selectedColumns FROM $_scanStageTable s WHERE NOT $historyMatch '''); await txn.rawInsert(''' INSERT OR IGNORE INTO library_path_keys(item_id, path_key) SELECT sk.item_id, sk.path_key FROM $_scanStagePathKeysTable sk JOIN $_scanStageTable s ON s.id = sk.item_id WHERE NOT $historyMatch '''); }); final inserted = staged - skipped; _log.i( 'Streamed $streamed rows, staged $staged unique rows, and swapped ' '$inserted into Library source ' '$sourceId ($skipped downloads excluded)', ); return (inserted: inserted, skipped: skipped); } finally { await db.execute('DROP TABLE IF EXISTS $_scanStagePathKeysTable'); await db.execute('DROP TABLE IF EXISTS $_scanStageTable'); } } Future> getSources() async { final db = await database; final rows = await db.rawQuery(''' SELECT s.*, COUNT(l.id) AS track_count FROM library_sources s LEFT JOIN library l ON l.source_id = s.id GROUP BY s.id HAVING s.path != 'legacy://local-library' OR COUNT(l.id) > 0 ORDER BY s.is_removable, LOWER(s.display_name), LOWER(s.path) '''); return rows.map(_sourceFromRow).toList(growable: false); } Future getSourceByPath(String path) async { final db = await database; final rows = await db.rawQuery( ''' SELECT s.*, COUNT(l.id) AS track_count FROM library_sources s LEFT JOIN library l ON l.source_id = s.id WHERE s.path = ? GROUP BY s.id LIMIT 1 ''', [path], ); return rows.isEmpty ? null : _sourceFromRow(rows.first); } LocalLibrarySource _sourceFromRow(Map row) { DateTime? parseDate(Object? value) => value is String ? DateTime.tryParse(value) : null; return LocalLibrarySource( id: row['id'] as String, path: row['path'] as String, displayName: row['display_name'] as String, bookmark: row['bookmark'] as String?, volumeId: row['volume_id'] as String?, isRemovable: (row['is_removable'] as num?)?.toInt() == 1, enabled: (row['enabled'] as num?)?.toInt() != 0, available: (row['available'] as num?)?.toInt() != 0, trackCount: (row['track_count'] as num?)?.toInt() ?? 0, lastScannedAt: parseDate(row['last_scanned_at']), lastSeenAt: parseDate(row['last_seen_at']), lastScanError: row['last_scan_error'] as String?, ); } Future upsertSource(LocalLibrarySource source) async { final db = await database; final values = { 'path': source.path, 'display_name': source.displayName, 'bookmark': source.bookmark, 'volume_id': source.volumeId, 'is_removable': source.isRemovable ? 1 : 0, 'enabled': source.enabled ? 1 : 0, 'available': source.available ? 1 : 0, 'last_scanned_at': source.lastScannedAt?.toIso8601String(), 'last_seen_at': source.lastSeenAt?.toIso8601String(), 'last_scan_error': source.lastScanError, }; final updated = await db.update( 'library_sources', values, where: 'id = ?', whereArgs: [source.id], ); if (updated == 0) { await db.insert('library_sources', {'id': source.id, ...values}); } } Future updateSourceState( String sourceId, { bool? enabled, bool? available, DateTime? lastScannedAt, DateTime? lastSeenAt, String? lastScanError, bool clearLastScanError = false, }) async { final values = {}; if (enabled != null) values['enabled'] = enabled ? 1 : 0; if (available != null) values['available'] = available ? 1 : 0; if (lastScannedAt != null) { values['last_scanned_at'] = lastScannedAt.toIso8601String(); } if (lastSeenAt != null) { values['last_seen_at'] = lastSeenAt.toIso8601String(); } if (lastScanError != null || clearLastScanError) { values['last_scan_error'] = clearLastScanError ? null : lastScanError; } if (values.isEmpty) return; final db = await database; await db.update( 'library_sources', values, where: 'id = ?', whereArgs: [sourceId], ); } Future migrateLegacySource({ required String path, required String displayName, String? bookmark, String? volumeId, bool isRemovable = false, bool available = true, DateTime? lastScannedAt, }) async { if (path.trim().isEmpty) return; final db = await database; await persistLegacyLibrarySource( db, LocalLibrarySource( id: legacySourceId, path: path, displayName: displayName, bookmark: bookmark, volumeId: volumeId, isRemovable: isRemovable, available: available, lastScannedAt: lastScannedAt, lastSeenAt: available ? DateTime.now() : null, ), ); } Future removeSource(String sourceId) async { final db = await database; await db.transaction((txn) async { await txn.rawDelete( 'DELETE FROM library_path_keys WHERE item_id IN ' '(SELECT id FROM library WHERE source_id = ?)', [sourceId], ); await txn.delete( 'library', where: 'source_id = ?', whereArgs: [sourceId], ); if (sourceId == legacySourceId) { await txn.update( 'library_sources', { 'path': 'legacy://local-library', 'display_name': 'Music', 'bookmark': null, 'volume_id': null, 'is_removable': 0, 'enabled': 1, 'available': 1, 'last_scanned_at': null, 'last_seen_at': null, 'last_scan_error': null, }, where: 'id = ?', whereArgs: [sourceId], ); } else { await txn.delete( 'library_sources', where: 'id = ?', whereArgs: [sourceId], ); } }); } Future>> getAll({int? limit, int? offset}) async { final db = await database; final rows = await db.query( visibleLibraryView, orderBy: 'album_artist, album_name, disc_number, track_number', limit: limit, offset: offset, ); return rows.map(LibraryRowMapper.decode).toList(); } String _orderByForSort(LocalLibrarySortMode sortMode) { return switch (sortMode) { LocalLibrarySortMode.title => 'track_name_norm, artist_name_norm, album_name_norm, disc_number, track_number', LocalLibrarySortMode.artist => 'artist_name_norm, album_name_norm, disc_number, track_number, track_name_norm', LocalLibrarySortMode.latest => 'scanned_at DESC, album_artist_norm, album_name_norm, disc_number, track_number', LocalLibrarySortMode.quality => 'COALESCE(bit_depth, 0) DESC, COALESCE(sample_rate, 0) DESC, COALESCE(bitrate, 0) DESC, album_artist_norm, album_name_norm, disc_number, track_number', LocalLibrarySortMode.album => 'album_artist_norm, album_name_norm, COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name_norm', }; } Future>> getQueueTrackPage( QueueLibraryDbQuery request, ) async { return (await getQueueTrackPageResult(request)).rows; } Future _queueStore() async { final db = await database; await _ensureHistoryAttached(db); return LibraryQueueStore( db, historyFts: HistoryDatabase.instance.searchFtsAvailable, localFts: searchFtsAvailable, ); } Future getQueueTrackPageResult( QueueLibraryDbQuery request, ) async => (await _queueStore()).trackPage(request); Future getQueueCounts( QueueLibraryDbQuery request, ) async => (await _queueStore()).counts(request); Future>> getQueueAlbumPage( QueueLibraryDbQuery request, ) async { return (await getQueueAlbumPageResult(request)).rows; } Future getQueueAlbumPageResult( QueueLibraryDbQuery request, ) async => (await _queueStore()).albumPage(request); /// Album artists across downloaded and scanned music, excluding duplicate paths. Future>> getQueueArtistPage( QueueLibraryDbQuery request, ) async => (await _queueStore()).artistPage(request); Future>> getQueueLocalAlbumTracks( String albumName, String artistName, ) async { final db = await database; final rows = await db.query( visibleLibraryView, where: "LOWER(album_name) = ? AND LOWER(COALESCE(NULLIF(album_artist, ''), artist_name)) = ?", whereArgs: [albumName.toLowerCase(), artistName.toLowerCase()], orderBy: 'COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name', ); return rows.map(LibraryRowMapper.decode).toList(growable: false); } Future>> getQueueLocalAlbumTracksByKey( String albumKey, ) async { final db = await database; final rows = await db.query( visibleLibraryView, where: 'album_key = ?', whereArgs: [albumKey], orderBy: 'COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name', ); return rows.map(LibraryRowMapper.decode).toList(growable: false); } Future?> getById(String id) async { final db = await database; final rows = await db.query( visibleLibraryView, where: 'id = ?', whereArgs: [id], limit: 1, ); if (rows.isEmpty) return null; return LibraryRowMapper.decode(rows.first); } Future?> getByIsrc(String isrc) async { final db = await database; final rows = await db.query( visibleLibraryView, where: 'isrc = ?', whereArgs: [isrc], limit: 1, ); if (rows.isEmpty) return null; return LibraryRowMapper.decode(rows.first); } Future>> findByTrackAndArtist( String trackName, String artistName, ) async { final db = await database; final rows = await db.query( visibleLibraryView, where: 'match_key = ?', whereArgs: [matchKeyFor(trackName, artistName)], ); return rows.map(LibraryRowMapper.decode).toList(); } Future?> findFirstByTrackAndArtist( String trackName, String artistName, ) async { final db = await database; final rows = await db.query( visibleLibraryView, where: 'match_key = ?', whereArgs: [matchKeyFor(trackName, artistName)], orderBy: _orderByForSort(LocalLibrarySortMode.album), limit: 1, ); if (rows.isEmpty) return null; return LibraryRowMapper.decode(rows.first); } Future?> findExisting({ String? isrc, String? trackName, String? artistName, }) async { if (isrc != null && isrc.isNotEmpty) { final byIsrc = await getByIsrc(isrc); if (byIsrc != null) return byIsrc; } if (trackName != null && artistName != null) { final matches = await findByTrackAndArtist(trackName, artistName); if (matches.isNotEmpty) return matches.first; } return null; } /// Resolves a track list with a bounded number of indexed queries instead /// of issuing up to three SQLite calls for every track. Future?>> findExistingBatch( List requests, ) async { if (requests.isEmpty) return const []; final db = await database; final byId = >{}; final byIsrc = >{}; final byMatchKey = >{}; Future loadColumn( String column, Iterable rawValues, Map> destination, ) { return sqlite.loadRowsByColumn( db, table: visibleLibraryView, column: column, rawValues: rawValues, destination: destination, mapRow: LibraryRowMapper.decode, ); } await Future.wait([ loadColumn('id', requests.map((request) => request.id ?? ''), byId), loadColumn('isrc', requests.map((request) => request.isrc ?? ''), byIsrc), loadColumn( 'match_key', requests.map( (request) => matchKeyFor(request.trackName, request.artistName), ), byMatchKey, ), ]); return requests .map((request) { final id = request.id?.trim() ?? ''; if (id.isNotEmpty && byId[id] != null) return byId[id]; final isrc = request.isrc?.trim() ?? ''; if (isrc.isNotEmpty && byIsrc[isrc] != null) return byIsrc[isrc]; return byMatchKey[matchKeyFor(request.trackName, request.artistName)]; }) .toList(growable: false); } Future getLookupIndex() async { final db = await database; final rows = await db.rawQuery(''' SELECT summary.kind, summary.value FROM $lookupSummaryTable summary JOIN library_sources source ON source.id = summary.source_id WHERE source.enabled = 1 AND source.available = 1 GROUP BY summary.kind, summary.value '''); final isrcs = {}; final matchKeys = {}; for (final row in rows) { final value = row['value'] as String?; if (value == null || value.isEmpty) continue; if (row['kind'] == 'isrc') { isrcs.add(value); } else if (row['kind'] == 'match') { matchKeys.add(value); } } return LocalLibraryLookupIndex( isrcs: Set.unmodifiable(isrcs), matchKeys: Set.unmodifiable(matchKeys), ); } Future> getCoverPaths({int? limit, int? offset}) async { final db = await database; final rows = await db.query( 'library', columns: ['cover_path'], where: 'cover_path IS NOT NULL AND cover_path != ""', limit: limit, offset: offset, ); return rows .map((row) => row['cover_path'] as String?) .whereType() .toList(growable: false); } /// Groups of tracks sharing one ISRC across download history and the /// local library. Library rows whose path key already appears in history /// are excluded so a scanned copy of a download doesn't pair with itself. /// Entries come back best-quality first. Future> findIsrcDuplicateGroups() async { final db = await database; final rows = await db.rawQuery(''' WITH merged AS ( SELECT h.id AS id, 'downloaded' AS source, h.track_name, h.artist_name, h.album_name, h.file_path, UPPER(TRIM(h.isrc)) AS isrc_key, h.bit_depth, h.sample_rate, h.bitrate, h.format FROM history_db.history h WHERE h.isrc IS NOT NULL AND TRIM(h.isrc) != '' UNION ALL SELECT l.id AS id, 'local' AS source, l.track_name, l.artist_name, l.album_name, l.file_path, UPPER(TRIM(l.isrc)) AS isrc_key, l.bit_depth, l.sample_rate, l.bitrate, l.format FROM $visibleLibraryView l WHERE l.isrc IS NOT NULL AND TRIM(l.isrc) != '' AND NOT EXISTS ( SELECT 1 FROM library_path_keys lpk JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key WHERE lpk.item_id = l.id ) ) SELECT * FROM merged WHERE isrc_key IN ( SELECT isrc_key FROM merged GROUP BY isrc_key HAVING COUNT(*) > 1 ) ORDER BY isrc_key, bit_depth DESC, sample_rate DESC, bitrate DESC '''); final groups = >{}; for (final row in rows) { final isrc = row['isrc_key'] as String? ?? ''; final filePath = row['file_path'] as String? ?? ''; if (isrc.isEmpty || filePath.isEmpty) continue; groups .putIfAbsent(isrc, () => []) .add( IsrcDuplicateEntry( id: row['id'] as String, source: row['source'] as String, trackName: row['track_name'] as String? ?? '', artistName: row['artist_name'] as String? ?? '', albumName: row['album_name'] as String? ?? '', filePath: filePath, bitDepth: (row['bit_depth'] as num?)?.toInt(), sampleRate: (row['sample_rate'] as num?)?.toInt(), bitrate: (row['bitrate'] as num?)?.toInt(), format: row['format'] as String?, ), ); } return [ for (final entry in groups.entries) if (entry.value.length > 1) IsrcDuplicateGroup(isrc: entry.key, entries: entry.value), ]; } Future> getPhysicalFileIds(Iterable filePaths) async => sqlite.findPhysicalFileRowIds(await database, 'library', filePaths); Future deleteByPath(String filePath) async { final db = await database; final rows = await db.query( 'library', columns: ['id'], where: 'file_path = ?', whereArgs: [filePath], ); final ids = rows.map((row) => row['id'] as String).toList(growable: false); await db.transaction((txn) async { for (final id in ids) { await txn.delete( 'library_path_keys', where: 'item_id = ?', whereArgs: [id], ); } await txn.delete( 'library', where: 'file_path = ?', whereArgs: [filePath], ); }); } Future replaceWithConvertedItem({ required LocalLibraryItem item, required String newFilePath, required String targetFormat, required String bitrate, int? bitDepth, int? sampleRate, bool keepOriginal = false, }) async { final db = await database; 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 // Conversion may rewrite gain tags; rescan the resulting file. ..['hasReplayGain'] = false ..['audioMetadataScanVersion'] = 0; if (normalizedFormat == 'mp3' || normalizedFormat == 'opus' || normalizedFormat == 'aac') { updated['bitDepth'] = null; updated['sampleRate'] = null; } else { updated['bitDepth'] = bitDepth ?? item.bitDepth; updated['sampleRate'] = sampleRate ?? item.sampleRate; } await db.transaction((txn) async { if (!keepOriginal) { await txn.delete( 'library_path_keys', where: 'item_id = ?', whereArgs: [item.id], ); await txn.delete( 'library', where: 'id = ? OR file_path = ?', whereArgs: [item.id, item.filePath], ); } await txn.insert( 'library', LibraryRowMapper.encode(updated), conflictAlgorithm: ConflictAlgorithm.replace, ); final batch = txn.batch(); _putPathKeysInBatch( batch, updated['id'] as String, updated['filePath'] as String?, ); await batch.commit(noResult: true); }); } Future updateAudioMetadata( String id, { int? duration, int? bitDepth, int? sampleRate, int? bitrate, bool? explicit, bool? hasLyrics, bool? hasReplayGain, String? format, }) async { final values = {}; if (duration != null && duration > 0) { values['duration'] = duration; } if (bitDepth != null && bitDepth > 0) { values['bit_depth'] = bitDepth; } if (sampleRate != null && sampleRate > 0) { values['sample_rate'] = sampleRate; } if (bitrate != null && bitrate > 0) { values['bitrate'] = bitrate; } if (explicit != null) { values['explicit'] = explicit ? 1 : 0; } if (hasLyrics != null) { values['has_lyrics'] = hasLyrics ? 1 : 0; } if (hasReplayGain != null) { values['has_replaygain'] = hasReplayGain ? 1 : 0; } final normalizedFormat = normalizeAudioFormatValue(format); if (normalizedFormat != null) { values['format'] = normalizedFormat; } if (values.isEmpty) return; final db = await database; if (hasReplayGain == null && hasLyrics == null) { await db.update('library', values, where: 'id = ?', whereArgs: [id]); return; } // A quality-only update must not confirm absent tags. ReplayGain-only // writes can advance lyrics-aware rows, while older rows still rescan. final replayGainOnly = hasReplayGain != null && hasLyrics == null; final scanVersion = hasReplayGain != null ? audioMetadataScanVersion : lyricsMetadataScanVersion; final versionSql = replayGainOnly ? 'CASE WHEN COALESCE(audio_metadata_scan_version, 0) >= ? ' 'THEN MAX(COALESCE(audio_metadata_scan_version, 0), ?) ' 'ELSE COALESCE(audio_metadata_scan_version, 0) END' : 'MAX(COALESCE(audio_metadata_scan_version, 0), ?)'; final columns = values.keys.toList(growable: false); await db.rawUpdate( 'UPDATE library SET ${columns.map((column) => '$column = ?').join(', ')}, ' 'audio_metadata_scan_version = $versionSql WHERE id = ?', [ ...columns.map((column) => values[column]), if (replayGainOnly) lyricsMetadataScanVersion, scanVersion, id, ], ); } Future delete(String id) async { await deleteByIds([id]); } Future deleteByIds(Iterable ids) async { if (ids.isEmpty) return; await deleteLibraryItemsByIds(await database, ids); } Future cleanupMissingFiles({ String? sourceId, Future Function()? canDelete, }) async { final db = await database; final removed = await cleanupMissingLibraryRows( db, sourceId: sourceId, canDelete: canDelete, ); if (removed > 0) { _log.i('Cleaned up $removed missing files from library'); } return removed; } Future clearAll() async { final db = await database; await db.transaction((txn) async { await txn.delete('library_path_keys'); await txn.delete('library'); await txn.update('library_sources', { 'last_scanned_at': null, 'last_scan_error': null, }); }); _log.i('Cleared all library data'); } Future getCount() async { final db = await database; final result = await db.rawQuery( 'SELECT COUNT(*) as count FROM $visibleLibraryView', ); return Sqflite.firstIntValue(result) ?? 0; } Future getSourceCount(String sourceId) async { final db = await database; final result = await db.rawQuery( 'SELECT COUNT(*) as count FROM library WHERE source_id = ?', [sourceId], ); return Sqflite.firstIntValue(result) ?? 0; } Future close() async { final db = await database; await db.close(); _database.reset(); _historyAttached = false; _searchFtsAvailable = null; } Future> getFileModTimes({String? sourceId}) async { final db = await database; final rows = await db.rawQuery( 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time, ' 'audio_metadata_scan_version FROM library ' '${sourceId == null ? '' : 'WHERE source_id = ?'}', sourceId == null ? const [] : [sourceId], ); final result = {}; for (final row in rows) { final path = row['file_path'] as String; final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0; 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; } /// Uses the already normalized, backfilled timestamps from the scan caller. /// Bounded writes avoid a second query and a library-sized StringBuffer. Future writeFileModTimesSnapshot( Map fileModTimes, ) async { final tempDir = await getTemporaryDirectory(); final file = File( join( tempDir.path, 'library_file_mod_times_${DateTime.now().microsecondsSinceEpoch}.tsv', ), ); try { final output = await file.open(mode: FileMode.write); try { final buffer = StringBuffer(); for (final entry in fileModTimes.entries) { if (entry.key.isEmpty) continue; buffer ..write(entry.value) ..write('\t') ..writeln(entry.key); if (buffer.length >= 64 * 1024) { await output.writeString(buffer.toString()); buffer.clear(); } } if (buffer.isNotEmpty) await output.writeString(buffer.toString()); await output.flush(); } finally { await output.close(); } } catch (_) { if (await file.exists()) await file.delete(); rethrow; } return file.path; } Future updateFileModTimes(Map fileModTimes) async { if (fileModTimes.isEmpty) return; final db = await database; final batch = db.batch(); for (final entry in fileModTimes.entries) { batch.update( 'library', {'file_mod_time': entry.value, 'sort_added': entry.value}, where: 'file_path = ?', whereArgs: [entry.key], ); } await batch.commit(noResult: true); } Future> getAllFilePaths() async { final db = await database; final rows = await db.rawQuery('SELECT file_path FROM library'); return rows.map((r) => r['file_path'] as String).toSet(); } Future deleteByPaths(List filePaths) async { if (filePaths.isEmpty) return 0; final db = await database; var totalDeleted = 0; const chunkSize = 500; // One commit for the whole removal; a rescan that dropped a folder can // otherwise pay several WAL commits per chunk. await db.transaction((txn) async { for (var i = 0; i < filePaths.length; i += chunkSize) { final end = (i + chunkSize < filePaths.length) ? i + chunkSize : filePaths.length; final chunk = filePaths.sublist(i, end); final placeholders = List.filled(chunk.length, '?').join(','); await txn.rawDelete( 'DELETE FROM library_path_keys WHERE item_id IN ' '(SELECT id FROM library WHERE file_path IN ($placeholders))', chunk, ); totalDeleted += await txn.rawDelete( 'DELETE FROM library WHERE file_path IN ($placeholders)', chunk, ); } }); if (totalDeleted > 0) { _log.i('Deleted $totalDeleted items from library'); } return totalDeleted; } String _normalizeConvertedFormat(String targetFormat) { return normalizeAudioFormatValue(targetFormat) ?? 'mp3'; } int? _convertedBitrate({ required String targetFormat, required String bitrate, }) { switch (targetFormat.trim().toLowerCase()) { case 'mp3': case 'opus': case 'aac': final match = RegExp(r'(\d+)').firstMatch(bitrate); return match != null ? int.tryParse(match.group(1)!) : null; default: return null; } } String _generateLibraryId(String filePath) { return 'lib_${_hashString(filePath).toRadixString(16)}'; } int _hashString(String input) { var hash = 5381; for (final codeUnit in input.codeUnits) { hash = (((hash << 5) + hash) + codeUnit) & 0xffffffff; } return hash; } } /// Migrates the pre-multiple-folder setting even when its placeholder row is /// absent. Do not REPLACE an existing source or change the IDs of its tracks. Future persistLegacyLibrarySource( Database db, LocalLibrarySource source, ) async { final values = { 'path': source.path, 'display_name': source.displayName, 'bookmark': source.bookmark, 'volume_id': source.volumeId, 'is_removable': source.isRemovable ? 1 : 0, 'available': source.available ? 1 : 0, 'last_scanned_at': source.lastScannedAt?.toIso8601String(), 'last_seen_at': source.lastSeenAt?.toIso8601String(), }; await db.transaction((txn) async { final updated = await txn.update( 'library_sources', values, where: 'id = ?', whereArgs: [source.id], ); if (updated == 0) { await txn.insert('library_sources', { 'id': source.id, 'enabled': source.enabled ? 1 : 0, ...values, }); } }); }