From 2e17963804f4efd0cd83afdee7a274ab44352337 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:28:48 +0700 Subject: [PATCH] refactor(library): isolate schema, row mapping and queue queries Repair lookup counts during schema migration and resolve local FTS matches through the base table. --- lib/services/library_database.dart | 1019 +---------------- lib/services/library_database_models.dart | 13 +- ...ueue_sql.dart => library_queue_store.dart} | 413 +++++-- lib/services/library_row_mapper.dart | 126 ++ lib/services/library_schema.dart | 463 ++++++++ test/album_completeness_query_test.dart | 2 +- test/album_completeness_test.dart | 2 +- test/library_queue_store_test.dart | 307 +++++ test/library_schema_test.dart | 190 +++ test/replaygain_library_filter_test.dart | 2 +- test/support/sqlite_process_database.dart | 228 ++++ 11 files changed, 1703 insertions(+), 1062 deletions(-) rename lib/services/{library_database_queue_sql.dart => library_queue_store.dart} (69%) create mode 100644 lib/services/library_row_mapper.dart create mode 100644 lib/services/library_schema.dart create mode 100644 test/library_queue_store_test.dart create mode 100644 test/library_schema_test.dart create mode 100644 test/support/sqlite_process_database.dart diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 119f911d..34eb1770 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -6,15 +6,19 @@ 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/utils/ios_container_paths.dart'; import 'package:spotiflac_android/services/history_database.dart'; -import 'package:spotiflac_android/services/album_completeness.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; -part 'library_database_models.dart'; -part 'library_database_queue_sql.dart'; +export 'library_database_models.dart'; + +export 'library_queue_store.dart' + show confirmedMissingLyricsSqlPredicate, missingReplayGainSqlPredicate; final _log = AppLogger('LibraryDatabase'); @@ -22,11 +26,11 @@ 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 = 16; + static const int schemaVersion = LibrarySchema.version; static const String legacySourceId = LocalLibraryItem.legacySourceId; - static const String visibleLibraryView = 'library_visible'; - static const String searchFtsTable = 'library_search_fts'; - static const String lookupSummaryTable = 'library_lookup_summary'; + 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'; @@ -35,10 +39,10 @@ class LibraryDatabase { static const String _downloadedLibraryIdsStageTable = 'library_downloaded_ids_stage'; // v4 records ReplayGain availability; older rows rescan once. - static const int audioMetadataScanVersion = 4; + static const int audioMetadataScanVersion = libraryAudioMetadataScanVersion; /// First scan version whose rows record lyrics availability (v3). - static const int lyricsMetadataScanVersion = 3; + static const int lyricsMetadataScanVersion = libraryLyricsMetadataScanVersion; static final sqlite.SingleFlightInitializer _database = sqlite.SingleFlightInitializer(); bool _historyAttached = false; @@ -53,9 +57,11 @@ class LibraryDatabase { final db = await sqlite.openAppDatabase( 'local_library.db', version: schemaVersion, - onCreate: _createDB, - onUpgrade: _upgradeDB, - onOpen: _migrateIosContainerPaths, + 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. @@ -63,7 +69,7 @@ class LibraryDatabase { // 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 _createSearchFts(db); + _searchFtsAvailable ??= await LibrarySchema.createSearchFts(db); return db; }); } @@ -92,61 +98,6 @@ class LibraryDatabase { ); } - Future _migrateIosContainerPaths(Database db) async { - if (!Platform.isIOS) return; - final documents = await getApplicationDocumentsDirectory(); - // Keep rows and their lookup keys consistent before any startup cleanup - // can mistake a relocated file for a deleted one. Bookmarks remain the - // authority for external sources. - await db.transaction((txn) async { - const localSources = "bookmark IS NULL OR bookmark = ''"; - final sources = await txn.query( - 'library_sources', - columns: ['id', 'path'], - where: localSources, - ); - final localSourceIds = sources.map((source) => source['id']).toSet(); - final rows = await txn.query( - 'library', - columns: ['id', 'source_id', 'file_path', 'cover_path'], - ); - final batch = txn.batch(); - for (final row in rows) { - final updates = {}; - for (final column in ['file_path', 'cover_path']) { - // Bookmarks own external audio paths, but extracted covers always - // live in our sandbox, including covers from bookmarked folders. - if (column == 'file_path' && - !localSourceIds.contains(row['source_id'])) { - continue; - } - final previous = row[column] as String?; - if (previous == null) continue; - final current = rebaseIosSandboxPath(previous, documents.path); - if (current != previous) updates[column] = current; - } - if (updates.isEmpty) continue; - final id = row['id'] as String; - batch.update('library', updates, where: 'id = ?', whereArgs: [id]); - if (updates.containsKey('file_path')) { - _putPathKeysInBatch(batch, id, updates['file_path'] as String); - } - } - for (final source in sources) { - final previous = source['path'] as String; - final current = rebaseIosSandboxPath(previous, documents.path); - if (current == previous) continue; - batch.update( - 'library_sources', - {'path': current}, - where: 'id = ?', - whereArgs: [source['id']], - ); - } - await batch.commit(noResult: true); - }); - } - Future _ensureHistoryAttached(Database db) async { if (_historyAttached) return; await HistoryDatabase.instance.database; @@ -164,364 +115,6 @@ class LibraryDatabase { _historyAttached = true; } - Future _createDB(Database db, int version) async { - _log.i('Creating library database schema v$version'); - - await db.execute(''' - CREATE TABLE library ( - id TEXT PRIMARY KEY, - source_id TEXT NOT NULL DEFAULT '$legacySourceId', - track_name TEXT NOT NULL, - artist_name TEXT NOT NULL, - album_name TEXT NOT NULL, - album_artist TEXT, - file_path TEXT NOT NULL UNIQUE, - cover_path TEXT, - scanned_at TEXT NOT NULL, - file_mod_time INTEGER, - isrc TEXT, - track_number INTEGER, - total_tracks INTEGER, - disc_number INTEGER, - total_discs INTEGER, - duration INTEGER, - release_date TEXT, - bit_depth INTEGER, - sample_rate INTEGER, - bitrate INTEGER, - genre TEXT, - composer TEXT, - label TEXT, - copyright TEXT, - explicit INTEGER NOT NULL DEFAULT 0, - has_lyrics INTEGER NOT NULL DEFAULT 0, - has_replaygain INTEGER NOT NULL DEFAULT 0, - format TEXT, - audio_metadata_scan_version INTEGER NOT NULL DEFAULT 4, - track_name_norm TEXT, - artist_name_norm TEXT, - album_name_norm TEXT, - album_artist_norm TEXT, - match_key TEXT, - album_key TEXT, - search_text TEXT, - sort_genre TEXT, - sort_release TEXT, - sort_added INTEGER - ) - '''); - - await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)'); - await db.execute( - 'CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)', - ); - await db.execute( - 'CREATE INDEX idx_library_album ON library(album_name, album_artist)', - ); - await db.execute( - 'CREATE INDEX idx_library_file_path ON library(file_path)', - ); - await _createNormalizedIndexes(db); - await _createQueueIndexes(db); - await _createPathKeyTable(db); - await _createLibrarySources(db); - await _createLookupSummary(db); - _searchFtsAvailable = await _createSearchFts(db); - - _log.i('Library database schema created with indexes'); - } - - Future _upgradeDB(Database db, int oldVersion, int newVersion) async { - _log.i('Upgrading library database from v$oldVersion to v$newVersion'); - - if (oldVersion < 2) { - await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT'); - _log.i('Added cover_path column'); - } - - if (oldVersion < 3) { - await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER'); - _log.i('Added file_mod_time column for incremental scanning'); - } - - if (oldVersion < 4) { - await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER'); - _log.i('Added bitrate column for lossy format quality'); - } - - if (oldVersion < 5) { - await db.execute('ALTER TABLE library ADD COLUMN label TEXT'); - await db.execute('ALTER TABLE library ADD COLUMN copyright TEXT'); - _log.i('Added label/copyright columns'); - } - - if (oldVersion < 6) { - await db.execute('ALTER TABLE library ADD COLUMN total_tracks INTEGER'); - await db.execute('ALTER TABLE library ADD COLUMN total_discs INTEGER'); - await db.execute('ALTER TABLE library ADD COLUMN composer TEXT'); - _log.i('Added total_tracks/total_discs/composer columns'); - } - - if (oldVersion < 7) { - await sqlite.addColumnIfMissing(db, 'library', 'track_name_norm', 'TEXT'); - await sqlite.addColumnIfMissing( - db, - 'library', - 'artist_name_norm', - 'TEXT', - ); - await sqlite.addColumnIfMissing(db, 'library', 'album_name_norm', 'TEXT'); - await sqlite.addColumnIfMissing( - db, - 'library', - 'album_artist_norm', - 'TEXT', - ); - await sqlite.addColumnIfMissing(db, 'library', 'match_key', 'TEXT'); - await sqlite.addColumnIfMissing(db, 'library', 'album_key', 'TEXT'); - await _backfillNormalizedColumns(db); - await _createNormalizedIndexes(db); - _log.i('Added normalized local library lookup columns'); - } - if (oldVersion < 8) { - await _createPathKeyTable(db); - _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'); - } - if (oldVersion < 10) { - await sqlite.addColumnIfMissing(db, 'library', 'search_text', 'TEXT'); - await sqlite.addColumnIfMissing(db, 'library', 'sort_genre', 'TEXT'); - await sqlite.addColumnIfMissing(db, 'library', 'sort_release', 'TEXT'); - await sqlite.addColumnIfMissing(db, 'library', 'sort_added', 'INTEGER'); - await _backfillQueueColumns(db); - await _createQueueIndexes(db); - _log.i('Added persisted queue sort/search columns'); - } - if (oldVersion < 11) { - await sqlite.addColumnIfMissing( - db, - 'library', - 'source_id', - "TEXT NOT NULL DEFAULT '$legacySourceId'", - ); - await _createLibrarySources(db); - _log.i('Added multiple local library sources'); - } - if (oldVersion < 12) { - await sqlite.addColumnIfMissing( - db, - 'library', - 'explicit', - 'INTEGER NOT NULL DEFAULT 0', - ); - _log.i('Added explicit-content metadata'); - } - if (oldVersion < 13) { - await sqlite.addColumnIfMissing( - db, - 'library', - 'has_lyrics', - 'INTEGER NOT NULL DEFAULT 0', - ); - _log.i('Added indexed lyrics availability metadata'); - } - if (oldVersion < 14) { - await _createLookupSummary(db); - _log.i('Added incremental Library lookup summary'); - } - if (oldVersion < 15) { - // Rows keep their older audio_metadata_scan_version, so the next - // incremental scan re-reads them once and fills the real value. - await sqlite.addColumnIfMissing( - db, - 'library', - 'has_replaygain', - 'INTEGER NOT NULL DEFAULT 0', - ); - _log.i('Added indexed ReplayGain availability metadata'); - } - if (oldVersion < 16) { - await sqlite.backfillPathKeys(db, 'library', 'library_path_keys'); - _log.i('Updated Library path keys with provider document identities'); - } - } - - Future _createLookupSummary(DatabaseExecutor db) async { - await db.execute(''' - CREATE TABLE IF NOT EXISTS $lookupSummaryTable ( - source_id TEXT NOT NULL, - kind TEXT NOT NULL, - value TEXT NOT NULL, - ref_count INTEGER NOT NULL, - PRIMARY KEY (source_id, kind, value) - ) - '''); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_lookup_summary_value ' - 'ON $lookupSummaryTable(kind, value)', - ); - - for (final name in const [ - 'library_lookup_insert_isrc', - 'library_lookup_delete_isrc', - 'library_lookup_update_isrc', - 'library_lookup_insert_match', - 'library_lookup_delete_match', - 'library_lookup_update_match', - ]) { - await db.execute('DROP TRIGGER IF EXISTS $name'); - } - - await db.execute(''' - CREATE TRIGGER library_lookup_insert_isrc AFTER INSERT ON library - WHEN NEW.isrc IS NOT NULL AND NEW.isrc != '' - BEGIN - INSERT OR IGNORE INTO $lookupSummaryTable(source_id, kind, value, ref_count) - VALUES (NEW.source_id, 'isrc', NEW.isrc, 0); - UPDATE $lookupSummaryTable SET ref_count = ref_count + 1 - WHERE source_id = NEW.source_id AND kind = 'isrc' AND value = NEW.isrc; - END - '''); - await db.execute(''' - CREATE TRIGGER library_lookup_delete_isrc AFTER DELETE ON library - WHEN OLD.isrc IS NOT NULL AND OLD.isrc != '' - BEGIN - UPDATE $lookupSummaryTable SET ref_count = ref_count - 1 - WHERE source_id = OLD.source_id AND kind = 'isrc' AND value = OLD.isrc; - DELETE FROM $lookupSummaryTable - WHERE source_id = OLD.source_id AND kind = 'isrc' AND value = OLD.isrc - AND ref_count <= 0; - END - '''); - await db.execute(''' - CREATE TRIGGER library_lookup_update_isrc AFTER UPDATE OF source_id, isrc ON library - WHEN OLD.source_id IS NOT NEW.source_id OR OLD.isrc IS NOT NEW.isrc - BEGIN - UPDATE $lookupSummaryTable SET ref_count = ref_count - 1 - WHERE OLD.isrc IS NOT NULL AND OLD.isrc != '' - AND source_id = OLD.source_id AND kind = 'isrc' AND value = OLD.isrc; - DELETE FROM $lookupSummaryTable - WHERE source_id = OLD.source_id AND kind = 'isrc' AND value = OLD.isrc - AND ref_count <= 0; - INSERT OR IGNORE INTO $lookupSummaryTable(source_id, kind, value, ref_count) - SELECT NEW.source_id, 'isrc', NEW.isrc, 0 - WHERE NEW.isrc IS NOT NULL AND NEW.isrc != ''; - UPDATE $lookupSummaryTable SET ref_count = ref_count + 1 - WHERE NEW.isrc IS NOT NULL AND NEW.isrc != '' - AND source_id = NEW.source_id AND kind = 'isrc' AND value = NEW.isrc; - END - '''); - await db.execute(''' - CREATE TRIGGER library_lookup_insert_match AFTER INSERT ON library - WHEN NEW.match_key IS NOT NULL AND NEW.match_key != '' - BEGIN - INSERT OR IGNORE INTO $lookupSummaryTable(source_id, kind, value, ref_count) - VALUES (NEW.source_id, 'match', NEW.match_key, 0); - UPDATE $lookupSummaryTable SET ref_count = ref_count + 1 - WHERE source_id = NEW.source_id AND kind = 'match' AND value = NEW.match_key; - END - '''); - await db.execute(''' - CREATE TRIGGER library_lookup_delete_match AFTER DELETE ON library - WHEN OLD.match_key IS NOT NULL AND OLD.match_key != '' - BEGIN - UPDATE $lookupSummaryTable SET ref_count = ref_count - 1 - WHERE source_id = OLD.source_id AND kind = 'match' AND value = OLD.match_key; - DELETE FROM $lookupSummaryTable - WHERE source_id = OLD.source_id AND kind = 'match' AND value = OLD.match_key - AND ref_count <= 0; - END - '''); - await db.execute(''' - CREATE TRIGGER library_lookup_update_match AFTER UPDATE OF source_id, match_key ON library - WHEN OLD.source_id IS NOT NEW.source_id OR OLD.match_key IS NOT NEW.match_key - BEGIN - UPDATE $lookupSummaryTable SET ref_count = ref_count - 1 - WHERE OLD.match_key IS NOT NULL AND OLD.match_key != '' - AND source_id = OLD.source_id AND kind = 'match' AND value = OLD.match_key; - DELETE FROM $lookupSummaryTable - WHERE source_id = OLD.source_id AND kind = 'match' AND value = OLD.match_key - AND ref_count <= 0; - INSERT OR IGNORE INTO $lookupSummaryTable(source_id, kind, value, ref_count) - SELECT NEW.source_id, 'match', NEW.match_key, 0 - WHERE NEW.match_key IS NOT NULL AND NEW.match_key != ''; - UPDATE $lookupSummaryTable SET ref_count = ref_count + 1 - WHERE NEW.match_key IS NOT NULL AND NEW.match_key != '' - AND source_id = NEW.source_id AND kind = 'match' AND value = NEW.match_key; - END - '''); - - await db.delete(lookupSummaryTable); - await db.rawInsert(''' - INSERT INTO $lookupSummaryTable(source_id, kind, value, ref_count) - SELECT source_id, 'isrc', isrc, COUNT(*) - FROM library WHERE isrc IS NOT NULL AND isrc != '' - GROUP BY source_id, isrc - '''); - await db.rawInsert(''' - INSERT INTO $lookupSummaryTable(source_id, kind, value, ref_count) - SELECT source_id, 'match', match_key, COUNT(*) - FROM library WHERE match_key IS NOT NULL AND match_key != '' - GROUP BY source_id, match_key - '''); - } - - Future _createSearchFts(DatabaseExecutor db) { - return sqlite.createTrigramFtsIndex( - db, - ftsTable: searchFtsTable, - contentTable: 'library', - triggerPrefix: 'library_search_fts', - ); - } - - Future _createLibrarySources(DatabaseExecutor db) async { - await db.execute(''' - CREATE TABLE IF NOT EXISTS library_sources ( - id TEXT PRIMARY KEY, - path TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - bookmark TEXT, - volume_id TEXT, - is_removable INTEGER NOT NULL DEFAULT 0, - enabled INTEGER NOT NULL DEFAULT 1, - available INTEGER NOT NULL DEFAULT 1, - last_scanned_at TEXT, - last_seen_at TEXT, - last_scan_error TEXT - ) - '''); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_source_id ON library(source_id)', - ); - await db.insert('library_sources', { - 'id': legacySourceId, - 'path': 'legacy://local-library', - 'display_name': 'Music', - 'enabled': 1, - 'available': 1, - }, conflictAlgorithm: ConflictAlgorithm.ignore); - await db.execute('DROP VIEW IF EXISTS $visibleLibraryView'); - await db.execute(''' - CREATE VIEW $visibleLibraryView AS - SELECT l.* - FROM library l - JOIN library_sources s ON s.id = l.source_id - WHERE s.enabled = 1 AND s.available = 1 - '''); - } - - Future _createPathKeyTable(DatabaseExecutor db) => - sqlite.createPathKeyTable(db, 'library_path_keys'); - void _putPathKeysInBatch(Batch batch, String id, String? filePath) => sqlite.putPathKeysInBatch(batch, 'library_path_keys', id, filePath); @@ -540,276 +133,12 @@ class LibraryDatabase { return '${normalizeLookupText(albumName)}|${normalizeLookupText(albumArtist ?? artistName)}'; } - Future _createNormalizedIndexes(DatabaseExecutor db) async { - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_match_key ON library(match_key)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_album_key ON library(album_key)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_track_norm ON library(track_name_norm)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_artist_norm ON library(artist_name_norm)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_album_norm ON library(album_name_norm)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_scanned_at ON library(scanned_at)', - ); - } - - Future _createQueueIndexes(DatabaseExecutor db) async { - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_queue_added ' - 'ON library(sort_added DESC, track_name_norm, id)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_queue_track ' - 'ON library(track_name_norm, artist_name_norm, id)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_queue_artist ' - 'ON library(artist_name_norm, track_name_norm, id)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_queue_album ' - 'ON library(album_name_norm, track_name_norm, id)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_queue_genre ' - 'ON library(sort_genre, track_name_norm, id)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_library_queue_release ' - 'ON library(sort_release, track_name_norm, id)', - ); - } - - Future _backfillNormalizedColumns(Database db) async { - final rows = await db.query( - 'library', - columns: [ - 'id', - 'track_name', - 'artist_name', - 'album_name', - 'album_artist', - ], - ); - final batch = db.batch(); - for (final row in rows) { - final trackName = row['track_name'] as String? ?? ''; - final artistName = row['artist_name'] as String? ?? ''; - final albumName = row['album_name'] as String? ?? ''; - final albumArtist = row['album_artist'] as String?; - batch.update( - 'library', - _normalizedColumns( - trackName: trackName, - artistName: artistName, - albumName: albumName, - albumArtist: albumArtist, - ), - where: 'id = ?', - whereArgs: [row['id']], - ); - } - await batch.commit(noResult: true); - } - - Map _normalizedColumns({ - required String trackName, - required String artistName, - required String albumName, - required String? albumArtist, - }) { - final trackNorm = normalizeLookupText(trackName); - final artistNorm = normalizeLookupText(artistName); - final albumNorm = normalizeLookupText(albumName); - final albumArtistNorm = normalizeLookupText(albumArtist ?? artistName); - return { - 'track_name_norm': trackNorm, - 'artist_name_norm': artistNorm, - 'album_name_norm': albumNorm, - 'album_artist_norm': albumArtistNorm, - 'match_key': '$trackNorm|$artistNorm', - 'album_key': '$albumNorm|$albumArtistNorm', - }; - } - - Map _queueColumns({ - required String? trackName, - required String? artistName, - required String? albumName, - required String? albumArtist, - required String? genre, - required String? releaseDate, - required int? fileModTime, - required String? scannedAt, - }) { - final trackNorm = normalizeLookupText(trackName); - final artistNorm = normalizeLookupText(artistName); - final albumNorm = normalizeLookupText(albumName); - final albumArtistNorm = normalizeLookupText( - (albumArtist ?? '').trim().isEmpty ? artistName : albumArtist, - ); - return { - 'search_text': [ - trackNorm, - artistNorm, - albumNorm, - albumArtistNorm, - ].where((value) => value.isNotEmpty).join(' '), - 'sort_genre': normalizeLookupText(genre), - 'sort_release': releaseDate?.trim() ?? '', - 'sort_added': - fileModTime ?? - DateTime.tryParse(scannedAt ?? '')?.millisecondsSinceEpoch ?? - 0, - }; - } - - Future _backfillQueueColumns(Database db) async { - final rows = await db.query( - 'library', - columns: [ - 'id', - 'track_name', - 'artist_name', - 'album_name', - 'album_artist', - 'genre', - 'release_date', - 'file_mod_time', - 'scanned_at', - ], - ); - final batch = db.batch(); - for (final row in rows) { - batch.update( - 'library', - _queueColumns( - trackName: row['track_name'] as String?, - artistName: row['artist_name'] as String?, - albumName: row['album_name'] as String?, - albumArtist: row['album_artist'] as String?, - genre: row['genre'] as String?, - releaseDate: row['release_date'] as String?, - fileModTime: (row['file_mod_time'] as num?)?.toInt(), - scannedAt: row['scanned_at'] as String?, - ), - where: 'id = ?', - whereArgs: [row['id']], - ); - } - await batch.commit(noResult: true); - } - - Map _jsonToDbRow( - Map json, { - String? sourceId, - }) { - final fileModTime = (json['fileModTime'] as num?)?.toInt(); - final scannedAt = json['scannedAt'] as String?; - final row = { - 'id': json['id'], - 'source_id': sourceId ?? json['sourceId'] ?? legacySourceId, - 'track_name': json['trackName'], - 'artist_name': json['artistName'], - 'album_name': json['albumName'], - 'album_artist': json['albumArtist'], - 'file_path': json['filePath'], - 'cover_path': json['coverPath'], - 'scanned_at': json['scannedAt'], - 'file_mod_time': json['fileModTime'], - 'isrc': json['isrc'], - 'track_number': json['trackNumber'], - 'total_tracks': json['totalTracks'], - 'disc_number': json['discNumber'], - 'total_discs': json['totalDiscs'], - 'duration': json['duration'], - 'release_date': json['releaseDate'], - 'bit_depth': json['bitDepth'], - 'sample_rate': json['sampleRate'], - 'bitrate': json['bitrate'], - 'genre': json['genre'], - 'composer': json['composer'], - 'label': json['label'], - 'copyright': json['copyright'], - 'explicit': json['explicit'] == true || json['explicit'] == 1 ? 1 : 0, - 'has_lyrics': json['hasLyrics'] == true || json['hasLyrics'] == 1 ? 1 : 0, - 'has_replaygain': metadataHasReplayGain(json) ? 1 : 0, - 'format': json['format'], - 'audio_metadata_scan_version': - (json['audioMetadataScanVersion'] as num?)?.toInt() ?? - (json['metadataFromFilename'] == true ? 0 : audioMetadataScanVersion), - }; - row.addAll( - _queueColumns( - trackName: json['trackName'] as String?, - artistName: json['artistName'] as String?, - albumName: json['albumName'] as String?, - albumArtist: json['albumArtist'] as String?, - genre: json['genre'] as String?, - releaseDate: json['releaseDate'] as String?, - fileModTime: fileModTime, - scannedAt: scannedAt, - ), - ); - row.addAll( - _normalizedColumns( - trackName: json['trackName'] as String? ?? '', - artistName: json['artistName'] as String? ?? '', - albumName: json['albumName'] as String? ?? '', - albumArtist: json['albumArtist'] as String?, - ), - ); - return row; - } - - Map _dbRowToJson(Map row) { - return { - 'id': row['id'], - 'sourceId': row['source_id'] ?? legacySourceId, - 'trackName': row['track_name'], - 'artistName': row['artist_name'], - 'albumName': row['album_name'], - 'albumArtist': row['album_artist'], - 'filePath': row['file_path'], - 'coverPath': row['cover_path'], - 'scannedAt': row['scanned_at'], - 'fileModTime': row['file_mod_time'], - 'isrc': row['isrc'], - 'trackNumber': row['track_number'], - 'totalTracks': row['total_tracks'], - 'discNumber': row['disc_number'], - 'totalDiscs': row['total_discs'], - 'duration': row['duration'], - 'releaseDate': row['release_date'], - 'bitDepth': row['bit_depth'], - 'sampleRate': row['sample_rate'], - 'bitrate': row['bitrate'], - 'genre': row['genre'], - 'composer': row['composer'], - 'label': row['label'], - 'copyright': row['copyright'], - 'explicit': row['explicit'] == 1 || row['explicit'] == true, - 'hasLyrics': row['has_lyrics'] == 1 || row['has_lyrics'] == true, - 'hasReplayGain': - row['has_replaygain'] == 1 || row['has_replaygain'] == true, - 'format': row['format'], - }; - } - Future upsert(Map json, {String? sourceId}) async { final db = await database; await db.transaction((txn) async { await txn.insert( 'library', - _jsonToDbRow(json, sourceId: sourceId), + LibraryRowMapper.encode(json, sourceId: sourceId), conflictAlgorithm: ConflictAlgorithm.replace, ); final batch = txn.batch(); @@ -833,7 +162,7 @@ class LibraryDatabase { for (final json in items) { batch.insert( 'library', - _jsonToDbRow(json, sourceId: sourceId), + LibraryRowMapper.encode(json, sourceId: sourceId), conflictAlgorithm: ConflictAlgorithm.replace, ); _putPathKeysInBatch( @@ -940,7 +269,7 @@ class LibraryDatabase { } batch.insert( _incrementalStageTable, - _jsonToDbRow(json, sourceId: sourceId), + LibraryRowMapper.encode(json, sourceId: sourceId), conflictAlgorithm: ConflictAlgorithm.replace, ); sqlite.putPathKeysInBatch( @@ -1018,7 +347,7 @@ class LibraryDatabase { for (final json in items) { batch.insert( 'library', - _jsonToDbRow(json), + LibraryRowMapper.encode(json), conflictAlgorithm: ConflictAlgorithm.replace, ); _putPathKeysInBatch( @@ -1090,7 +419,7 @@ class LibraryDatabase { } batch.insert( _scanStageTable, - _jsonToDbRow(json, sourceId: sourceId), + LibraryRowMapper.encode(json, sourceId: sourceId), conflictAlgorithm: ConflictAlgorithm.replace, ); sqlite.putPathKeysInBatch( @@ -1350,14 +679,7 @@ class LibraryDatabase { limit: limit, offset: offset, ); - return rows.map(_dbRowToJson).toList(); - } - - String _escapeLikePattern(String value) { - return value - .replaceAll('\\', r'\\') - .replaceAll('%', r'\%') - .replaceAll('_', r'\_'); + return rows.map(LibraryRowMapper.decode).toList(); } String _orderByForSort(LocalLibrarySortMode sortMode) { @@ -1381,193 +703,23 @@ class LibraryDatabase { 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 { - final db = await database; - await _ensureHistoryAttached(db); - final args = []; - final orderTerms = _queueTrackOrderTerms(request.sortMode); - final usesCursor = - request.cursor != null && - request.cursor!.values.length == orderTerms.length; - final unionSql = _queueTrackUnionSql( - request, - args, - orderTerms: orderTerms, - usesCursor: usesCursor, - ); - final rows = await db.rawQuery( - ''' - SELECT * - FROM ($unionSql) - ORDER BY ${_queueTrackOrderBy(request.sortMode)} - LIMIT ? ${usesCursor ? '' : 'OFFSET ?'} - ''', - [...args, request.limit, if (!usesCursor) request.offset], - ); - return QueueLibraryDbPage( - rows: rows.map(_queueTrackRowToJson).toList(growable: false), - nextCursor: _queueCursorFromRow(rows.lastOrNull, orderTerms), - ); - } + ) async => (await _queueStore()).trackPage(request); - Future getQueueCounts(QueueLibraryDbQuery request) async { - final db = await database; - await _ensureHistoryAttached(db); - final fastCounts = await _getUnfilteredQueueCounts(db, request); - if (fastCounts != null) return fastCounts; - final parts = []; - final args = []; - - if (request.source != 'local') { - final where = []; - _appendQueueHistoryFilters(where, args, request); - parts.add(''' - SELECT - COUNT(*) AS all_count, - COUNT(DISTINCT CASE WHEN grouped.track_count > ${isAlbumCompletenessFilter(request.metadata) ? 0 : 1} THEN h.album_key END) AS album_count, - COALESCE(SUM(CASE WHEN grouped.track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count - FROM history_db.history h - JOIN ( - SELECT album_key, COUNT(*) AS track_count - FROM history_db.history - GROUP BY album_key - ) grouped ON grouped.album_key = h.album_key - ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} - '''); - } - - if (request.includeLocal && request.source != 'downloaded') { - final where = [ - ''' - 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 - ) - ''', - ]; - _appendQueueLocalFilters(where, args, request); - parts.add(''' - SELECT - COUNT(*) AS all_count, - COUNT(DISTINCT CASE WHEN grouped.track_count > ${isAlbumCompletenessFilter(request.metadata) ? 0 : 1} THEN l.album_key END) AS album_count, - COALESCE(SUM(CASE WHEN grouped.track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count - FROM $visibleLibraryView l - JOIN ( - SELECT album_key, COUNT(*) AS track_count - FROM $visibleLibraryView candidate - WHERE 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 = candidate.id - ) - GROUP BY album_key - ) grouped ON grouped.album_key = l.album_key - WHERE ${where.join(' AND ')} - '''); - } - - if (parts.isEmpty) { - return const QueueLibraryCounts( - allTrackCount: 0, - albumCount: 0, - singleTrackCount: 0, - ); - } - - final rows = await db.rawQuery(''' - SELECT - COALESCE(SUM(all_count), 0) AS all_count, - COALESCE(SUM(single_count), 0) AS single_count, - COALESCE(SUM(album_count), 0) AS album_count - FROM (${parts.join(' UNION ALL ')}) - ''', args); - final row = rows.isNotEmpty ? rows.first : const {}; - - return QueueLibraryCounts( - allTrackCount: (row['all_count'] as num?)?.toInt() ?? 0, - albumCount: (row['album_count'] as num?)?.toInt() ?? 0, - singleTrackCount: (row['single_count'] as num?)?.toInt() ?? 0, - ); - } - - /// The default Library badges do not need a row-by-row join against album - /// counts. Aggregate the covering album-key indexes directly and reserve the - /// more expensive filtered query for active search/quality/metadata filters. - Future _getUnfilteredQueueCounts( - Database db, + Future getQueueCounts( QueueLibraryDbQuery request, - ) async { - if (normalizeLookupText(request.searchQuery).isNotEmpty || - request.quality != null || - request.format != null || - request.metadata != null) { - return null; - } - final source = request.source; - if (source != null && source != 'downloaded' && source != 'local') { - return null; - } - - final parts = []; - if (source != 'local') { - parts.add(''' - SELECT - COALESCE(SUM(track_count), 0) AS all_count, - COALESCE(SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END), 0) AS album_count, - COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count - FROM ( - SELECT album_key, COUNT(*) AS track_count - FROM history_db.history - GROUP BY album_key - ) - '''); - } - if (request.includeLocal && source != 'downloaded') { - parts.add(''' - SELECT - COALESCE(SUM(track_count), 0) AS all_count, - COALESCE(SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END), 0) AS album_count, - COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count - FROM ( - SELECT l.album_key, COUNT(*) AS track_count - FROM $visibleLibraryView l - WHERE 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 - ) - GROUP BY l.album_key - ) - '''); - } - if (parts.isEmpty) { - return const QueueLibraryCounts( - allTrackCount: 0, - albumCount: 0, - singleTrackCount: 0, - ); - } - - final rows = await db.rawQuery(''' - SELECT - COALESCE(SUM(all_count), 0) AS all_count, - COALESCE(SUM(album_count), 0) AS album_count, - COALESCE(SUM(single_count), 0) AS single_count - FROM (${parts.join(' UNION ALL ')}) - '''); - final row = rows.isEmpty ? const {} : rows.first; - return QueueLibraryCounts( - allTrackCount: (row['all_count'] as num?)?.toInt() ?? 0, - albumCount: (row['album_count'] as num?)?.toInt() ?? 0, - singleTrackCount: (row['single_count'] as num?)?.toInt() ?? 0, - ); - } + ) async => (await _queueStore()).counts(request); Future>> getQueueAlbumPage( QueueLibraryDbQuery request, @@ -1577,83 +729,12 @@ class LibraryDatabase { Future getQueueAlbumPageResult( QueueLibraryDbQuery request, - ) async { - final db = await database; - await _ensureHistoryAttached(db); - final args = []; - final orderTerms = _queueAlbumOrderTerms(request.sortMode); - final usesCursor = - request.cursor != null && - request.cursor!.values.length == orderTerms.length; - final unionSql = _queueAlbumUnionSql( - request, - args, - orderTerms: orderTerms, - usesCursor: usesCursor, - ); - final rows = await db.rawQuery( - ''' - SELECT * - FROM ($unionSql) - ORDER BY ${_queueAlbumOrderBy(request.sortMode)} - LIMIT ? ${usesCursor ? '' : 'OFFSET ?'} - ''', - [...args, request.limit, if (!usesCursor) request.offset], - ); - return QueueLibraryDbPage( - rows: rows.toList(growable: false), - nextCursor: _queueCursorFromRow(rows.lastOrNull, orderTerms), - ); - } + ) async => (await _queueStore()).albumPage(request); - /// Album artists across downloaded and scanned music, without loading tracks - /// into Dart. Scanned paths already represented by downloads are excluded. + /// Album artists across downloaded and scanned music, excluding duplicate paths. Future>> getQueueArtistPage( QueueLibraryDbQuery request, - ) async { - final db = await database; - await _ensureHistoryAttached(db); - final parts = [ - ''' - SELECT sort_album_artist AS artist_key, - COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name, - cover_url, NULL AS cover_path, file_path AS sample_file_path - FROM history_db.history - ''', - if (request.includeLocal) - ''' - SELECT album_artist_norm AS artist_key, - COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name, - NULL AS cover_url, cover_path, file_path AS sample_file_path - FROM $visibleLibraryView l - WHERE 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 - ) - ''', - ]; - final search = normalizeLookupText(request.searchQuery); - return db.rawQuery( - ''' - SELECT artist_key, MIN(artist_name) AS artist_name, - MAX(NULLIF(cover_url, '')) AS cover_url, - MAX(NULLIF(cover_path, '')) AS cover_path, - MAX(sample_file_path) AS sample_file_path, - COUNT(*) AS track_count - FROM (${parts.join(' UNION ALL ')}) - WHERE artist_key != '' ${search.isEmpty ? '' : "AND artist_key LIKE ? ESCAPE '\\'"} - GROUP BY artist_key - ORDER BY artist_key - LIMIT ? OFFSET ? - ''', - [ - if (search.isNotEmpty) '%${_escapeLikePattern(search)}%', - request.limit, - request.offset, - ], - ); - } + ) async => (await _queueStore()).artistPage(request); Future>> getQueueLocalAlbumTracks( String albumName, @@ -1668,7 +749,7 @@ class LibraryDatabase { orderBy: 'COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name', ); - return rows.map(_dbRowToJson).toList(growable: false); + return rows.map(LibraryRowMapper.decode).toList(growable: false); } Future>> getQueueLocalAlbumTracksByKey( @@ -1682,7 +763,7 @@ class LibraryDatabase { orderBy: 'COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name', ); - return rows.map(_dbRowToJson).toList(growable: false); + return rows.map(LibraryRowMapper.decode).toList(growable: false); } Future?> getById(String id) async { @@ -1694,7 +775,7 @@ class LibraryDatabase { limit: 1, ); if (rows.isEmpty) return null; - return _dbRowToJson(rows.first); + return LibraryRowMapper.decode(rows.first); } Future?> getByIsrc(String isrc) async { @@ -1706,7 +787,7 @@ class LibraryDatabase { limit: 1, ); if (rows.isEmpty) return null; - return _dbRowToJson(rows.first); + return LibraryRowMapper.decode(rows.first); } Future>> findByTrackAndArtist( @@ -1719,7 +800,7 @@ class LibraryDatabase { where: 'match_key = ?', whereArgs: [matchKeyFor(trackName, artistName)], ); - return rows.map(_dbRowToJson).toList(); + return rows.map(LibraryRowMapper.decode).toList(); } Future?> findFirstByTrackAndArtist( @@ -1735,7 +816,7 @@ class LibraryDatabase { limit: 1, ); if (rows.isEmpty) return null; - return _dbRowToJson(rows.first); + return LibraryRowMapper.decode(rows.first); } Future?> findExisting({ @@ -1778,7 +859,7 @@ class LibraryDatabase { column: column, rawValues: rawValues, destination: destination, - mapRow: _dbRowToJson, + mapRow: LibraryRowMapper.decode, ); } @@ -1988,7 +1069,7 @@ class LibraryDatabase { } await txn.insert( 'library', - _jsonToDbRow(updated), + LibraryRowMapper.encode(updated), conflictAlgorithm: ConflictAlgorithm.replace, ); final batch = txn.batch(); diff --git a/lib/services/library_database_models.dart b/lib/services/library_database_models.dart index f481742c..1cad77ed 100644 --- a/lib/services/library_database_models.dart +++ b/lib/services/library_database_models.dart @@ -1,12 +1,17 @@ -part of 'library_database.dart'; +import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; // Row models and query descriptors for the local library database. +// v4 records ReplayGain availability; older rows rescan once. +const libraryAudioMetadataScanVersion = 4; +const libraryLyricsMetadataScanVersion = 3; + int libraryIncrementalSnapshotModTime({ required int storedModTime, required int storedScanVersion, }) { - return storedScanVersion >= LibraryDatabase.audioMetadataScanVersion + return storedScanVersion >= libraryAudioMetadataScanVersion ? storedModTime : -1; } @@ -181,9 +186,9 @@ class LocalLibraryItem { } String get matchKey => - '${LibraryDatabase.normalizeLookupText(trackName)}|${LibraryDatabase.normalizeLookupText(artistName)}'; + '${sqlite.normalizeLookupText(trackName)}|${sqlite.normalizeLookupText(artistName)}'; String get albumKey => - '${LibraryDatabase.normalizeLookupText(albumName)}|${LibraryDatabase.normalizeLookupText(albumArtist ?? artistName)}'; + '${sqlite.normalizeLookupText(albumName)}|${sqlite.normalizeLookupText(albumArtist ?? artistName)}'; } class LocalLibrarySource { diff --git a/lib/services/library_database_queue_sql.dart b/lib/services/library_queue_store.dart similarity index 69% rename from lib/services/library_database_queue_sql.dart rename to lib/services/library_queue_store.dart index a969e998..5d6dd043 100644 --- a/lib/services/library_database_queue_sql.dart +++ b/lib/services/library_queue_store.dart @@ -1,4 +1,8 @@ -part of 'library_database.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/services/album_completeness.dart'; +import 'package:spotiflac_android/services/library_database_models.dart'; +import 'package:spotiflac_android/services/library_schema.dart'; +import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; // SQL builders for the queue tab's history+local union queries. @@ -17,7 +21,280 @@ class _QueueOrderTerm { const _QueueOrderTerm(this.column, {this.descending = false}); } -extension _LibraryDbQueueSql on LibraryDatabase { +/// Queue reads against an already opened library with history attached. +class LibraryQueueStore { + final Database _db; + final bool historyFts; + final bool localFts; + + const LibraryQueueStore( + this._db, { + required this.historyFts, + required this.localFts, + }); + + Future trackPage(QueueLibraryDbQuery request) async { + final args = []; + final orderTerms = _queueTrackOrderTerms(request.sortMode); + final usesCursor = + request.cursor != null && + request.cursor!.values.length == orderTerms.length; + final unionSql = _queueTrackUnionSql( + request, + args, + orderTerms: orderTerms, + usesCursor: usesCursor, + ); + final rows = await _db.rawQuery( + ''' + SELECT * + FROM ($unionSql) + ORDER BY ${_queueOrderBy(orderTerms)} + LIMIT ? ${usesCursor ? '' : 'OFFSET ?'} + ''', + [...args, request.limit, if (!usesCursor) request.offset], + ); + return QueueLibraryDbPage( + rows: rows.map(_queueTrackRowToJson).toList(growable: false), + nextCursor: _queueCursorFromRow(rows.lastOrNull, orderTerms), + ); + } + + Future counts(QueueLibraryDbQuery request) async { + final fastCounts = await _getUnfilteredQueueCounts(request); + if (fastCounts != null) return fastCounts; + final parts = []; + final args = []; + + if (request.source != 'local') { + final where = []; + _appendQueueHistoryFilters(where, args, request); + parts.add(''' + SELECT + COUNT(*) AS all_count, + COUNT(DISTINCT CASE WHEN grouped.track_count > ${isAlbumCompletenessFilter(request.metadata) ? 0 : 1} THEN h.album_key END) AS album_count, + COALESCE(SUM(CASE WHEN grouped.track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count + FROM history_db.history h + JOIN ( + SELECT album_key, COUNT(*) AS track_count + FROM history_db.history + GROUP BY album_key + ) grouped ON grouped.album_key = h.album_key + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + '''); + } + + if (request.includeLocal && request.source != 'downloaded') { + final where = [ + ''' + 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 + ) + ''', + ]; + _appendQueueLocalFilters(where, args, request); + parts.add(''' + SELECT + COUNT(*) AS all_count, + COUNT(DISTINCT CASE WHEN grouped.track_count > ${isAlbumCompletenessFilter(request.metadata) ? 0 : 1} THEN l.album_key END) AS album_count, + COALESCE(SUM(CASE WHEN grouped.track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count + FROM ${LibrarySchema.visibleView} l + JOIN ( + SELECT album_key, COUNT(*) AS track_count + FROM ${LibrarySchema.visibleView} candidate + WHERE 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 = candidate.id + ) + GROUP BY album_key + ) grouped ON grouped.album_key = l.album_key + WHERE ${where.join(' AND ')} + '''); + } + + if (parts.isEmpty) { + return const QueueLibraryCounts( + allTrackCount: 0, + albumCount: 0, + singleTrackCount: 0, + ); + } + + final rows = await _db.rawQuery(''' + SELECT + COALESCE(SUM(all_count), 0) AS all_count, + COALESCE(SUM(single_count), 0) AS single_count, + COALESCE(SUM(album_count), 0) AS album_count + FROM (${parts.join(' UNION ALL ')}) + ''', args); + final row = rows.isNotEmpty ? rows.first : const {}; + + return QueueLibraryCounts( + allTrackCount: (row['all_count'] as num?)?.toInt() ?? 0, + albumCount: (row['album_count'] as num?)?.toInt() ?? 0, + singleTrackCount: (row['single_count'] as num?)?.toInt() ?? 0, + ); + } + + /// The default Library badges do not need a row-by-row join against album + /// counts. Aggregate the covering album-key indexes directly and reserve the + /// more expensive filtered query for active search/quality/metadata filters. + Future _getUnfilteredQueueCounts( + QueueLibraryDbQuery request, + ) async { + if (sqlite.normalizeLookupText(request.searchQuery).isNotEmpty || + request.quality != null || + request.format != null || + request.metadata != null) { + return null; + } + final source = request.source; + if (source != null && source != 'downloaded' && source != 'local') { + return null; + } + + final parts = []; + if (source != 'local') { + parts.add(''' + SELECT + COALESCE(SUM(track_count), 0) AS all_count, + COALESCE(SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END), 0) AS album_count, + COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count + FROM ( + SELECT album_key, COUNT(*) AS track_count + FROM history_db.history + GROUP BY album_key + ) + '''); + } + if (request.includeLocal && source != 'downloaded') { + parts.add(''' + SELECT + COALESCE(SUM(track_count), 0) AS all_count, + COALESCE(SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END), 0) AS album_count, + COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count + FROM ( + SELECT l.album_key, COUNT(*) AS track_count + FROM ${LibrarySchema.visibleView} l + WHERE 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 + ) + GROUP BY l.album_key + ) + '''); + } + if (parts.isEmpty) { + return const QueueLibraryCounts( + allTrackCount: 0, + albumCount: 0, + singleTrackCount: 0, + ); + } + + final rows = await _db.rawQuery(''' + SELECT + COALESCE(SUM(all_count), 0) AS all_count, + COALESCE(SUM(album_count), 0) AS album_count, + COALESCE(SUM(single_count), 0) AS single_count + FROM (${parts.join(' UNION ALL ')}) + '''); + final row = rows.isEmpty ? const {} : rows.first; + return QueueLibraryCounts( + allTrackCount: (row['all_count'] as num?)?.toInt() ?? 0, + albumCount: (row['album_count'] as num?)?.toInt() ?? 0, + singleTrackCount: (row['single_count'] as num?)?.toInt() ?? 0, + ); + } + + Future albumPage(QueueLibraryDbQuery request) async { + final args = []; + final orderTerms = _queueAlbumOrderTerms(request.sortMode); + final usesCursor = + request.cursor != null && + request.cursor!.values.length == orderTerms.length; + final unionSql = _queueAlbumUnionSql( + request, + args, + orderTerms: orderTerms, + usesCursor: usesCursor, + ); + final rows = await _db.rawQuery( + ''' + SELECT * + FROM ($unionSql) + ORDER BY ${_queueOrderBy(orderTerms)} + LIMIT ? ${usesCursor ? '' : 'OFFSET ?'} + ''', + [...args, request.limit, if (!usesCursor) request.offset], + ); + return QueueLibraryDbPage( + rows: rows.toList(growable: false), + nextCursor: _queueCursorFromRow(rows.lastOrNull, orderTerms), + ); + } + + /// Album artists across downloaded and scanned music, without loading tracks + /// into Dart. Scanned paths already represented by downloads are excluded. + Future>> artistPage( + QueueLibraryDbQuery request, + ) async { + final parts = [ + ''' + SELECT sort_album_artist AS artist_key, + COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name, + cover_url, NULL AS cover_path, file_path AS sample_file_path + FROM history_db.history + ''', + if (request.includeLocal) + ''' + SELECT album_artist_norm AS artist_key, + COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name, + NULL AS cover_url, cover_path, file_path AS sample_file_path + FROM ${LibrarySchema.visibleView} l + WHERE 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 + ) + ''', + ]; + final search = sqlite.normalizeLookupText(request.searchQuery); + return _db.rawQuery( + ''' + SELECT artist_key, MIN(artist_name) AS artist_name, + MAX(NULLIF(cover_url, '')) AS cover_url, + MAX(NULLIF(cover_path, '')) AS cover_path, + MAX(sample_file_path) AS sample_file_path, + COUNT(*) AS track_count + FROM (${parts.join(' UNION ALL ')}) + WHERE artist_key != '' ${search.isEmpty ? '' : "AND artist_key LIKE ? ESCAPE '\\'"} + GROUP BY artist_key + ORDER BY artist_key + LIMIT ? OFFSET ? + ''', + [ + if (search.isNotEmpty) '%${_escapeLikePattern(search)}%', + request.limit, + request.offset, + ], + ); + } + + String _escapeLikePattern(String value) { + return value + .replaceAll('\\', r'\\') + .replaceAll('%', r'\%') + .replaceAll('_', r'\_'); + } + String _queueTrackUnionSql( QueueLibraryDbQuery request, List args, { @@ -115,7 +392,7 @@ extension _LibraryDbQueueSql on LibraryDatabase { where.add(''' l.album_key IN ( SELECT album_key - FROM ${LibraryDatabase.visibleLibraryView} candidate + FROM ${LibrarySchema.visibleView} candidate WHERE NOT EXISTS ( SELECT 1 FROM library_path_keys lpk @@ -167,7 +444,7 @@ extension _LibraryDbQueueSql on LibraryDatabase { l.bitrate, l.format, l.has_replaygain, - CASE WHEN l.audio_metadata_scan_version >= ${LibraryDatabase.audioMetadataScanVersion} + CASE WHEN l.audio_metadata_scan_version >= $libraryAudioMetadataScanVersion THEN 1 ELSE 0 END AS replaygain_metadata_scan_version, l.track_name_norm AS sort_track, l.artist_name_norm AS sort_artist, @@ -175,7 +452,7 @@ extension _LibraryDbQueueSql on LibraryDatabase { l.sort_genre, l.sort_release, l.sort_added - FROM ${LibraryDatabase.visibleLibraryView} l + FROM ${LibrarySchema.visibleView} l ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} '''; parts.add( @@ -343,13 +620,13 @@ extension _LibraryDbQueueSql on LibraryDatabase { MIN(l.album_artist_norm) AS sort_artist, COALESCE(MAX(l.release_date), '') AS sort_release, COALESCE(MAX(l.sort_genre), '') AS sort_genre - FROM ${LibraryDatabase.visibleLibraryView} l + FROM ${LibrarySchema.visibleView} l JOIN ( SELECT album_key, COUNT(*) AS track_count, MAX(COALESCE(sort_added, 0)) AS latest_added - FROM ${LibraryDatabase.visibleLibraryView} candidate + FROM ${LibrarySchema.visibleView} candidate WHERE NOT EXISTS ( SELECT 1 FROM library_path_keys lpk @@ -413,12 +690,12 @@ extension _LibraryDbQueueSql on LibraryDatabase { ); if (request.albumArtist != null) { where.add('h.sort_album_artist = ?'); - args.add(LibraryDatabase.normalizeLookupText(request.albumArtist)); + args.add(sqlite.normalizeLookupText(request.albumArtist)); } - final query = LibraryDatabase.normalizeLookupText(request.searchQuery); + final query = sqlite.normalizeLookupText(request.searchQuery); if (query.isNotEmpty) { final ftsQuery = sqlite.ftsPhraseSearchQuery(query); - if (HistoryDatabase.instance.searchFtsAvailable && ftsQuery != null) { + if (historyFts && ftsQuery != null) { where.add(''' h.rowid IN ( SELECT rowid @@ -469,17 +746,19 @@ extension _LibraryDbQueueSql on LibraryDatabase { ); if (request.albumArtist != null) { where.add('l.album_artist_norm = ?'); - args.add(LibraryDatabase.normalizeLookupText(request.albumArtist)); + args.add(sqlite.normalizeLookupText(request.albumArtist)); } - final query = LibraryDatabase.normalizeLookupText(request.searchQuery); + final query = sqlite.normalizeLookupText(request.searchQuery); if (query.isNotEmpty) { final ftsQuery = sqlite.ftsPhraseSearchQuery(query); - if (searchFtsAvailable && ftsQuery != null) { + if (localFts && ftsQuery != null) { where.add(''' - l.rowid IN ( - SELECT rowid - FROM library_search_fts - WHERE library_search_fts MATCH ? + l.id IN ( + SELECT id FROM library WHERE rowid IN ( + SELECT rowid + FROM library_search_fts + WHERE library_search_fts MATCH ? + ) ) '''); args.add(ftsQuery); @@ -507,7 +786,7 @@ extension _LibraryDbQueueSql on LibraryDatabase { labelExpr: 'l.label', hasLyricsExpr: 'l.has_lyrics', lyricsKnownExpr: - 'COALESCE(l.audio_metadata_scan_version, 0) >= ${LibraryDatabase.lyricsMetadataScanVersion}', + 'COALESCE(l.audio_metadata_scan_version, 0) >= $libraryLyricsMetadataScanVersion', hasReplayGainExpr: 'l.has_replaygain', ); } @@ -658,14 +937,6 @@ extension _LibraryDbQueueSql on LibraryDatabase { } } - String _queueTrackOrderBy(String sortMode) { - return _queueOrderBy(_queueTrackOrderTerms(sortMode)); - } - - String _queueAlbumOrderBy(String sortMode) { - return _queueOrderBy(_queueAlbumOrderTerms(sortMode)); - } - String _boundedQueuePart( String selectSql, QueueLibraryDbQuery request, @@ -881,50 +1152,36 @@ extension _LibraryDbQueueSql on LibraryDatabase { Map _queueTrackRowToJson(Map row) { final source = row['queue_source'] as String? ?? ''; - if (source == 'local') { - return { - 'source': source, - 'item': { - 'id': row['id'], - 'trackName': row['track_name'], - 'artistName': row['artist_name'], - 'albumName': row['album_name'], - 'albumArtist': row['album_artist'], - 'filePath': row['file_path'], - 'coverPath': row['cover_path'], - 'scannedAt': row['scanned_at'], - 'fileModTime': row['file_mod_time'], - 'isrc': row['isrc'], - 'trackNumber': row['track_number'], - 'totalTracks': row['total_tracks'], - 'discNumber': row['disc_number'], - 'totalDiscs': row['total_discs'], - 'duration': row['duration'], - 'releaseDate': row['release_date'], - 'bitDepth': row['bit_depth'], - 'sampleRate': row['sample_rate'], - 'bitrate': row['bitrate'], - 'genre': row['genre'], - 'composer': row['composer'], - 'label': row['label'], - 'copyright': row['copyright'], - 'format': row['format'], - 'hasReplayGain': - row['has_replaygain'] == 1 || row['has_replaygain'] == true, - }, - }; - } - - return { - 'source': source, - 'item': { - 'id': row['id'], - 'trackName': row['track_name'], - 'artistName': row['artist_name'], - 'albumName': row['album_name'], - 'albumArtist': row['album_artist'], + final item = { + 'id': row['id'], + 'trackName': row['track_name'], + 'artistName': row['artist_name'], + 'albumName': row['album_name'], + 'albumArtist': row['album_artist'], + 'filePath': row['file_path'], + 'isrc': row['isrc'], + 'trackNumber': row['track_number'], + 'totalTracks': row['total_tracks'], + 'discNumber': row['disc_number'], + 'totalDiscs': row['total_discs'], + 'duration': row['duration'], + 'releaseDate': row['release_date'], + 'bitDepth': row['bit_depth'], + 'sampleRate': row['sample_rate'], + 'bitrate': row['bitrate'], + 'format': row['format'], + 'hasReplayGain': + row['has_replaygain'] == 1 || row['has_replaygain'] == true, + 'genre': row['genre'], + 'composer': row['composer'], + 'label': row['label'], + 'copyright': row['copyright'], + if (source == 'local') ...{ + 'coverPath': row['cover_path'], + 'scannedAt': row['scanned_at'], + 'fileModTime': row['file_mod_time'], + } else ...{ 'coverUrl': row['cover_url'], - 'filePath': row['file_path'], 'storageMode': row['storage_mode'], 'downloadTreeUri': row['download_tree_uri'], 'safRelativeDir': row['saf_relative_dir'], @@ -932,28 +1189,12 @@ extension _LibraryDbQueueSql on LibraryDatabase { 'safRepaired': row['saf_repaired'] == 1 || row['saf_repaired'] == true, 'service': row['service'], 'downloadedAt': row['downloaded_at'], - 'isrc': row['isrc'], 'spotifyId': row['spotify_id'], - 'trackNumber': row['track_number'], - 'totalTracks': row['total_tracks'], - 'discNumber': row['disc_number'], - 'totalDiscs': row['total_discs'], - 'duration': row['duration'], - 'releaseDate': row['release_date'], 'quality': row['quality'], - 'bitDepth': row['bit_depth'], - 'sampleRate': row['sample_rate'], - 'bitrate': row['bitrate'], - 'format': row['format'], - 'hasReplayGain': - row['has_replaygain'] == 1 || row['has_replaygain'] == true, 'replayGainMetadataScanVersion': row['replaygain_metadata_scan_version'] ?? 0, - 'genre': row['genre'], - 'composer': row['composer'], - 'label': row['label'], - 'copyright': row['copyright'], }, }; + return {'source': source, 'item': item}; } } diff --git a/lib/services/library_row_mapper.dart b/lib/services/library_row_mapper.dart new file mode 100644 index 00000000..2c7e1b0f --- /dev/null +++ b/lib/services/library_row_mapper.dart @@ -0,0 +1,126 @@ +import 'package:spotiflac_android/services/library_database_models.dart'; +import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; + +/// Maps scan/domain metadata to storage rows without opening a database. +abstract final class LibraryRowMapper { + static const _columns = { + 'id': 'id', + 'trackName': 'track_name', + 'artistName': 'artist_name', + 'albumName': 'album_name', + 'albumArtist': 'album_artist', + 'filePath': 'file_path', + 'coverPath': 'cover_path', + 'scannedAt': 'scanned_at', + 'fileModTime': 'file_mod_time', + 'isrc': 'isrc', + 'trackNumber': 'track_number', + 'totalTracks': 'total_tracks', + 'discNumber': 'disc_number', + 'totalDiscs': 'total_discs', + 'duration': 'duration', + 'releaseDate': 'release_date', + 'bitDepth': 'bit_depth', + 'sampleRate': 'sample_rate', + 'bitrate': 'bitrate', + 'genre': 'genre', + 'composer': 'composer', + 'label': 'label', + 'copyright': 'copyright', + 'format': 'format', + }; + + static bool _flag(Object? value) => value == true || value == 1; + + static Map encode( + Map json, { + String? sourceId, + }) => { + for (final column in _columns.entries) column.value: json[column.key], + 'source_id': + sourceId ?? json['sourceId'] ?? LocalLibraryItem.legacySourceId, + 'explicit': _flag(json['explicit']) ? 1 : 0, + 'has_lyrics': _flag(json['hasLyrics']) ? 1 : 0, + 'has_replaygain': metadataHasReplayGain(json) ? 1 : 0, + 'audio_metadata_scan_version': + (json['audioMetadataScanVersion'] as num?)?.toInt() ?? + (json['metadataFromFilename'] == true + ? 0 + : libraryAudioMetadataScanVersion), + ...normalized( + trackName: json['trackName'] as String? ?? '', + artistName: json['artistName'] as String? ?? '', + albumName: json['albumName'] as String? ?? '', + albumArtist: json['albumArtist'] as String?, + ), + ...queue( + trackName: json['trackName'] as String?, + artistName: json['artistName'] as String?, + albumName: json['albumName'] as String?, + albumArtist: json['albumArtist'] as String?, + genre: json['genre'] as String?, + releaseDate: json['releaseDate'] as String?, + fileModTime: (json['fileModTime'] as num?)?.toInt(), + scannedAt: json['scannedAt'] as String?, + ), + }; + + static Map decode(Map row) => { + for (final column in _columns.entries) column.key: row[column.value], + 'sourceId': row['source_id'] ?? LocalLibraryItem.legacySourceId, + 'explicit': _flag(row['explicit']), + 'hasLyrics': _flag(row['has_lyrics']), + 'hasReplayGain': _flag(row['has_replaygain']), + }; + + static Map normalized({ + required String trackName, + required String artistName, + required String albumName, + required String? albumArtist, + }) { + final track = sqlite.normalizeLookupText(trackName); + final artist = sqlite.normalizeLookupText(artistName); + final album = sqlite.normalizeLookupText(albumName); + final albumArtistValue = sqlite.normalizeLookupText( + albumArtist ?? artistName, + ); + return { + 'track_name_norm': track, + 'artist_name_norm': artist, + 'album_name_norm': album, + 'album_artist_norm': albumArtistValue, + 'match_key': '$track|$artist', + 'album_key': '$album|$albumArtistValue', + }; + } + + static Map queue({ + required String? trackName, + required String? artistName, + required String? albumName, + required String? albumArtist, + required String? genre, + required String? releaseDate, + required int? fileModTime, + required String? scannedAt, + }) => { + 'search_text': + [ + trackName, + artistName, + albumName, + (albumArtist ?? '').trim().isEmpty ? artistName : albumArtist, + ] + .map(sqlite.normalizeLookupText) + .where((value) => value.isNotEmpty) + .join(' '), + 'sort_genre': sqlite.normalizeLookupText(genre), + 'sort_release': releaseDate?.trim() ?? '', + 'sort_added': + fileModTime ?? + DateTime.tryParse(scannedAt ?? '')?.millisecondsSinceEpoch ?? + 0, + }; +} diff --git a/lib/services/library_schema.dart b/lib/services/library_schema.dart new file mode 100644 index 00000000..5a067260 --- /dev/null +++ b/lib/services/library_schema.dart @@ -0,0 +1,463 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/services/library_database_models.dart'; +import 'package:spotiflac_android/services/library_row_mapper.dart'; +import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; +import 'package:spotiflac_android/utils/ios_container_paths.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('LibraryDatabase'); + +/// Schema lifecycle is independent of collection reads and scan transactions. +abstract final class LibrarySchema { + static const version = 17; + static const visibleView = 'library_visible'; + static const searchFtsTable = 'library_search_fts'; + static const lookupSummaryTable = 'library_lookup_summary'; + static const _legacySourceId = LocalLibraryItem.legacySourceId; + + static Future create(Database db, int version) async { + _log.i('Creating library database schema v$version'); + await db.execute(''' + CREATE TABLE library ( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL DEFAULT '$_legacySourceId', + track_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + album_name TEXT NOT NULL, + album_artist TEXT, + file_path TEXT NOT NULL UNIQUE, + cover_path TEXT, + scanned_at TEXT NOT NULL, + file_mod_time INTEGER, + isrc TEXT, + track_number INTEGER, + total_tracks INTEGER, + disc_number INTEGER, + total_discs INTEGER, + duration INTEGER, + release_date TEXT, + bit_depth INTEGER, + sample_rate INTEGER, + bitrate INTEGER, + genre TEXT, + composer TEXT, + label TEXT, + copyright TEXT, + explicit INTEGER NOT NULL DEFAULT 0, + has_lyrics INTEGER NOT NULL DEFAULT 0, + has_replaygain INTEGER NOT NULL DEFAULT 0, + format TEXT, + audio_metadata_scan_version INTEGER NOT NULL DEFAULT 4, + track_name_norm TEXT, + artist_name_norm TEXT, + album_name_norm TEXT, + album_artist_norm TEXT, + match_key TEXT, + album_key TEXT, + search_text TEXT, + sort_genre TEXT, + sort_release TEXT, + sort_added INTEGER + ) + '''); + for (final index in const { + 'isrc': 'isrc', + 'track_artist': 'track_name, artist_name', + 'album': 'album_name, album_artist', + 'file_path': 'file_path', + }.entries) { + await db.execute( + 'CREATE INDEX idx_library_${index.key} ON library(${index.value})', + ); + } + await _createNormalizedIndexes(db); + await _createQueueIndexes(db); + await sqlite.createPathKeyTable(db, 'library_path_keys'); + await _createSources(db); + await _createLookupSummary(db); + final ftsAvailable = await createSearchFts(db); + _log.i('Library database schema created with indexes'); + return ftsAvailable; + } + + static Future upgrade( + Database db, + int oldVersion, + int newVersion, + ) async { + _log.i('Upgrading library database from v$oldVersion to v$newVersion'); + // Keep historical defaults: older rows require a metadata rescan, while + // freshly scanned rows are written with their actual scan version. + for (final addition in const [ + (2, 'cover_path', 'TEXT'), + (3, 'file_mod_time', 'INTEGER'), + (4, 'bitrate', 'INTEGER'), + (5, 'label', 'TEXT'), + (5, 'copyright', 'TEXT'), + (6, 'total_tracks', 'INTEGER'), + (6, 'total_discs', 'INTEGER'), + (6, 'composer', 'TEXT'), + ]) { + if (oldVersion < addition.$1) { + await db.execute( + 'ALTER TABLE library ADD COLUMN ${addition.$2} ${addition.$3}', + ); + } + } + if (oldVersion < 7) { + for (final column in const [ + 'track_name_norm', + 'artist_name_norm', + 'album_name_norm', + 'album_artist_norm', + 'match_key', + 'album_key', + ]) { + await sqlite.addColumnIfMissing(db, 'library', column, 'TEXT'); + } + await _backfillNormalizedColumns(db); + await _createNormalizedIndexes(db); + } + if (oldVersion < 8) { + await sqlite.createPathKeyTable(db, 'library_path_keys'); + } + if (oldVersion < 9) { + await sqlite.addColumnIfMissing( + db, + 'library', + 'audio_metadata_scan_version', + 'INTEGER NOT NULL DEFAULT 0', + ); + } + if (oldVersion < 10) { + for (final column in const [ + 'search_text', + 'sort_genre', + 'sort_release', + 'sort_added', + ]) { + await sqlite.addColumnIfMissing( + db, + 'library', + column, + column == 'sort_added' ? 'INTEGER' : 'TEXT', + ); + } + await _backfillQueueColumns(db); + await _createQueueIndexes(db); + } + if (oldVersion < 11) { + await sqlite.addColumnIfMissing( + db, + 'library', + 'source_id', + "TEXT NOT NULL DEFAULT '$_legacySourceId'", + ); + await _createSources(db); + } + for (final addition in const [(12, 'explicit'), (13, 'has_lyrics')]) { + if (oldVersion < addition.$1) { + await sqlite.addColumnIfMissing( + db, + 'library', + addition.$2, + 'INTEGER NOT NULL DEFAULT 0', + ); + } + } + if (oldVersion < 15) { + await sqlite.addColumnIfMissing( + db, + 'library', + 'has_replaygain', + 'INTEGER NOT NULL DEFAULT 0', + ); + } + if (oldVersion < 16) { + await sqlite.backfillPathKeys(db, 'library', 'library_path_keys'); + } + if (oldVersion < 17) { + // Reinstall the triggers and repair counts created by older upserts. + await _createLookupSummary(db); + } + } + + static Future createSearchFts(DatabaseExecutor db) => + sqlite.createTrigramFtsIndex( + db, + ftsTable: searchFtsTable, + contentTable: 'library', + triggerPrefix: 'library_search_fts', + ); + + static Future _createSources(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS library_sources ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + bookmark TEXT, + volume_id TEXT, + is_removable INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + available INTEGER NOT NULL DEFAULT 1, + last_scanned_at TEXT, + last_seen_at TEXT, + last_scan_error TEXT + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_source_id ON library(source_id)', + ); + await db.insert('library_sources', { + 'id': _legacySourceId, + 'path': 'legacy://local-library', + 'display_name': 'Music', + 'enabled': 1, + 'available': 1, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + await db.execute('DROP VIEW IF EXISTS $visibleView'); + await db.execute(''' + CREATE VIEW $visibleView AS + SELECT l.* FROM library l + JOIN library_sources s ON s.id = l.source_id + WHERE s.enabled = 1 AND s.available = 1 + '''); + } + + static Future _createNormalizedIndexes(DatabaseExecutor db) async { + for (final column in const { + 'match_key': 'match_key', + 'album_key': 'album_key', + 'track_norm': 'track_name_norm', + 'artist_norm': 'artist_name_norm', + 'album_norm': 'album_name_norm', + 'scanned_at': 'scanned_at', + }.entries) { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_${column.key} ON library(${column.value})', + ); + } + } + + static Future _createQueueIndexes(DatabaseExecutor db) async { + for (final order in const { + 'added': 'sort_added DESC, track_name_norm, id', + 'track': 'track_name_norm, artist_name_norm, id', + 'artist': 'artist_name_norm, track_name_norm, id', + 'album': 'album_name_norm, track_name_norm, id', + 'genre': 'sort_genre, track_name_norm, id', + 'release': 'sort_release, track_name_norm, id', + }.entries) { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_queue_${order.key} ON library(${order.value})', + ); + } + } + + static Future _backfillNormalizedColumns(Database db) async { + final rows = await db.query( + 'library', + columns: [ + 'id', + 'track_name', + 'artist_name', + 'album_name', + 'album_artist', + ], + ); + final batch = db.batch(); + for (final row in rows) { + batch.update( + 'library', + LibraryRowMapper.normalized( + trackName: row['track_name'] as String? ?? '', + artistName: row['artist_name'] as String? ?? '', + albumName: row['album_name'] as String? ?? '', + albumArtist: row['album_artist'] as String?, + ), + where: 'id = ?', + whereArgs: [row['id']], + ); + } + await batch.commit(noResult: true); + } + + static Future _backfillQueueColumns(Database db) async { + final rows = await db.query( + 'library', + columns: [ + 'id', + 'track_name', + 'artist_name', + 'album_name', + 'album_artist', + 'genre', + 'release_date', + 'file_mod_time', + 'scanned_at', + ], + ); + final batch = db.batch(); + for (final row in rows) { + batch.update( + 'library', + LibraryRowMapper.queue( + trackName: row['track_name'] as String?, + artistName: row['artist_name'] as String?, + albumName: row['album_name'] as String?, + albumArtist: row['album_artist'] as String?, + genre: row['genre'] as String?, + releaseDate: row['release_date'] as String?, + fileModTime: (row['file_mod_time'] as num?)?.toInt(), + scannedAt: row['scanned_at'] as String?, + ), + where: 'id = ?', + whereArgs: [row['id']], + ); + } + await batch.commit(noResult: true); + } + + static Future _createLookupSummary(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS $lookupSummaryTable ( + source_id TEXT NOT NULL, kind TEXT NOT NULL, + value TEXT NOT NULL, ref_count INTEGER NOT NULL, + PRIMARY KEY (source_id, kind, value) + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_lookup_summary_value ON $lookupSummaryTable(kind, value)', + ); + for (final (kind, column) in const [ + ('isrc', 'isrc'), + ('match', 'match_key'), + ]) { + for (final operation in const ['insert', 'delete', 'update']) { + await db.execute( + 'DROP TRIGGER IF EXISTS library_lookup_${operation}_$kind', + ); + } + await db.execute(''' + CREATE TRIGGER library_lookup_insert_$kind AFTER INSERT ON library + WHEN NEW.$column IS NOT NULL AND NEW.$column != '' + BEGIN + INSERT INTO $lookupSummaryTable(source_id, kind, value, ref_count) + SELECT NEW.source_id, '$kind', NEW.$column, 0 + -- An outer INSERT OR REPLACE overrides trigger conflict policies. + -- Avoid a conflicting insert instead of relying on OR IGNORE. + WHERE NOT EXISTS ( + SELECT 1 FROM $lookupSummaryTable + WHERE source_id = NEW.source_id AND kind = '$kind' AND value = NEW.$column + ); + UPDATE $lookupSummaryTable SET ref_count = ref_count + 1 + WHERE source_id = NEW.source_id AND kind = '$kind' AND value = NEW.$column; + END + '''); + await db.execute(''' + CREATE TRIGGER library_lookup_delete_$kind AFTER DELETE ON library + WHEN OLD.$column IS NOT NULL AND OLD.$column != '' + BEGIN + UPDATE $lookupSummaryTable SET ref_count = ref_count - 1 + WHERE source_id = OLD.source_id AND kind = '$kind' AND value = OLD.$column; + DELETE FROM $lookupSummaryTable + WHERE source_id = OLD.source_id AND kind = '$kind' AND value = OLD.$column + AND ref_count <= 0; + END + '''); + await db.execute(''' + CREATE TRIGGER library_lookup_update_$kind AFTER UPDATE OF source_id, $column ON library + WHEN OLD.source_id IS NOT NEW.source_id OR OLD.$column IS NOT NEW.$column + BEGIN + UPDATE $lookupSummaryTable SET ref_count = ref_count - 1 + WHERE OLD.$column IS NOT NULL AND OLD.$column != '' + AND source_id = OLD.source_id AND kind = '$kind' AND value = OLD.$column; + DELETE FROM $lookupSummaryTable + WHERE source_id = OLD.source_id AND kind = '$kind' AND value = OLD.$column + AND ref_count <= 0; + INSERT INTO $lookupSummaryTable(source_id, kind, value, ref_count) + SELECT NEW.source_id, '$kind', NEW.$column, 0 + WHERE NEW.$column IS NOT NULL AND NEW.$column != '' AND NOT EXISTS ( + SELECT 1 FROM $lookupSummaryTable + WHERE source_id = NEW.source_id AND kind = '$kind' AND value = NEW.$column + ); + UPDATE $lookupSummaryTable SET ref_count = ref_count + 1 + WHERE NEW.$column IS NOT NULL AND NEW.$column != '' + AND source_id = NEW.source_id AND kind = '$kind' AND value = NEW.$column; + END + '''); + } + await db.delete(lookupSummaryTable); + for (final (kind, column) in const [ + ('isrc', 'isrc'), + ('match', 'match_key'), + ]) { + await db.rawInsert(''' + INSERT INTO $lookupSummaryTable(source_id, kind, value, ref_count) + SELECT source_id, '$kind', $column, COUNT(*) + FROM library WHERE $column IS NOT NULL AND $column != '' + GROUP BY source_id, $column + '''); + } + } + + static Future migrateIosContainerPaths(Database db) async { + if (!Platform.isIOS) return; + final documents = await getApplicationDocumentsDirectory(); + await db.transaction((txn) async { + final sources = await txn.query( + 'library_sources', + columns: ['id', 'path'], + where: "bookmark IS NULL OR bookmark = ''", + ); + final localSourceIds = sources.map((source) => source['id']).toSet(); + final rows = await txn.query( + 'library', + columns: ['id', 'source_id', 'file_path', 'cover_path'], + ); + final batch = txn.batch(); + for (final row in rows) { + final updates = {}; + for (final column in ['file_path', 'cover_path']) { + // External audio paths remain bookmark-owned. Extracted covers + // always belong to our sandbox, including bookmarked sources. + if (column == 'file_path' && + !localSourceIds.contains(row['source_id'])) { + continue; + } + final previous = row[column] as String?; + if (previous == null) continue; + final current = rebaseIosSandboxPath(previous, documents.path); + if (current != previous) updates[column] = current; + } + if (updates.isEmpty) continue; + final id = row['id'] as String; + batch.update('library', updates, where: 'id = ?', whereArgs: [id]); + if (updates.containsKey('file_path')) { + sqlite.putPathKeysInBatch( + batch, + 'library_path_keys', + id, + updates['file_path'] as String, + ); + } + } + for (final source in sources) { + final previous = source['path'] as String; + final current = rebaseIosSandboxPath(previous, documents.path); + if (current == previous) continue; + batch.update( + 'library_sources', + {'path': current}, + where: 'id = ?', + whereArgs: [source['id']], + ); + } + await batch.commit(noResult: true); + }); + } +} diff --git a/test/album_completeness_query_test.dart b/test/album_completeness_query_test.dart index 0d330226..3687208c 100644 --- a/test/album_completeness_query_test.dart +++ b/test/album_completeness_query_test.dart @@ -77,7 +77,7 @@ print(json.dumps([dict(row) for row in db.execute(data['sql'], data['args'])])) ''', jsonEncode({ 'history': _definition('lib/services/history_database.dart', 'history'), - 'library': _definition('lib/services/library_database.dart', 'library'), + 'library': _definition('lib/services/library_schema.dart', 'library'), 'sql': sql, 'args': arguments ?? [], }), diff --git a/test/album_completeness_test.dart b/test/album_completeness_test.dart index 3f162eab..89faa2ca 100644 --- a/test/album_completeness_test.dart +++ b/test/album_completeness_test.dart @@ -143,7 +143,7 @@ print('inventory passed') 'history', ), 'library': _tableDefinition( - 'lib/services/library_database.dart', + 'lib/services/library_schema.dart', 'library', ), 'combined': albumCompletenessSql( diff --git a/test/library_queue_store_test.dart b/test/library_queue_store_test.dart new file mode 100644 index 00000000..1ebd4b40 --- /dev/null +++ b/test/library_queue_store_test.dart @@ -0,0 +1,307 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/library_database_models.dart'; +import 'package:spotiflac_android/services/library_queue_store.dart'; +import 'package:spotiflac_android/services/library_row_mapper.dart'; +import 'package:spotiflac_android/services/library_schema.dart'; + +import 'support/sqlite_process_database.dart'; + +List _tracks(QueueLibraryDbPage page) => [ + for (final row in page.rows) '${row['source']}:${(row['item'] as Map)['id']}', +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SqliteProcessDatabase db; + late LibraryQueueStore store; + + Future addHistory( + String id, + String album, + int number, { + String artist = 'Artist', + String title = 'Song', + String? path, + }) async { + final file = path ?? '/$id.flac'; + await db.insert('history_db.history', { + 'id': id, + 'track_name': title, + 'artist_name': artist, + 'album_name': album, + 'album_artist': artist, + 'file_path': file, + 'downloaded_at': '2026-01-01', + 'service': 'example-provider', + 'album_key': '${album.toLowerCase()}|${artist.toLowerCase()}', + 'track_number': number, + 'total_tracks': 2, + 'disc_number': 1, + 'total_discs': 1, + 'bit_depth': 16, + 'genre': 'Pop', + 'release_date': '2026', + 'sort_track': title.toLowerCase(), + 'sort_artist': artist.toLowerCase(), + 'sort_album': album.toLowerCase(), + 'sort_album_artist': artist.toLowerCase(), + 'sort_genre': 'pop', + 'sort_release': '2026', + 'sort_added': 1, + 'search_text': '$title $artist $album'.toLowerCase(), + 'has_replaygain': number == 1 ? 1 : 0, + }); + await db.insert('history_db.history_path_keys', { + 'item_id': id, + 'path_key': file, + }); + } + + Future addLocal( + String id, + int number, { + String? path, + String source = 'legacy', + }) async { + final file = path ?? '/$id.flac'; + final track = LocalLibraryItem( + id: id, + sourceId: source, + trackName: 'Song', + artistName: 'Artist', + albumName: 'Local', + filePath: file, + scannedAt: DateTime.utc(2026), + fileModTime: 1, + trackNumber: number, + totalTracks: 2, + discNumber: 1, + totalDiscs: 1, + bitDepth: 16, + genre: 'Pop', + releaseDate: '2026', + hasReplayGain: number == 1, + ); + await db.insert('library', LibraryRowMapper.encode(track.toJson())); + await db.insert('library_path_keys', {'item_id': id, 'path_key': file}); + } + + setUp(() async { + db = await SqliteProcessDatabase.open(); + await LibrarySchema.create(db, LibrarySchema.version); + await db.execute("ATTACH DATABASE ':memory:' AS history_db"); + final definition = RegExp(r'CREATE TABLE history\s*\([\s\S]*?\n\s*\)') + .firstMatch( + File('lib/services/history_database.dart').readAsStringSync(), + )! + .group(0)!; + await db.execute( + definition.replaceFirst( + 'CREATE TABLE history', + 'CREATE TABLE history_db.history', + ), + ); + await db.execute( + 'CREATE TABLE history_db.history_path_keys (item_id TEXT, path_key TEXT)', + ); + await addHistory('h1', 'Downloaded', 1); + await addHistory('h2', 'Downloaded', 2); + await addHistory('single', 'Single', 2, artist: 'Another Artist'); + await addLocal('l1', 1); + await addLocal('l2', 2); + await addLocal('mirror', 1, path: '/h1.flac'); + await db.insert('library_sources', { + 'id': 'offline', + 'path': '/offline', + 'display_name': 'Offline', + 'available': 0, + }); + await addLocal('hidden', 1, source: 'offline'); + store = LibraryQueueStore(db, historyFts: false, localFts: false); + }); + tearDown(() => db.close()); + + test( + 'track cursors match offset ordering across source ties for every sort', + () async { + for (final sort in [ + 'latest', + 'oldest', + 'a-z', + 'z-a', + 'artist-asc', + 'artist-desc', + 'album-asc', + 'album-desc', + 'release-oldest', + 'release-newest', + 'genre-asc', + 'genre-desc', + ]) { + final expected = _tracks( + await store.trackPage(QueueLibraryDbQuery(sortMode: sort)), + ); + final actual = []; + QueueLibraryDbCursor? cursor; + do { + final page = await store.trackPage( + QueueLibraryDbQuery(sortMode: sort, limit: 2, cursor: cursor), + ); + actual.addAll(_tracks(page)); + cursor = page.nextCursor; + } while (cursor != null); + expect(actual, expected, reason: sort); + expect(actual, hasLength(5), reason: sort); + expect(actual, isNot(contains('local:mirror'))); + expect(actual, isNot(contains('local:hidden'))); + } + }, + ); + + test( + 'album cursors and invalid-cursor offset fallback preserve results', + () async { + for (final sort in [ + 'latest', + 'album-asc', + 'album-desc', + 'artist-asc', + 'release-newest', + ]) { + final expected = (await store.albumPage( + QueueLibraryDbQuery(sortMode: sort), + )).rows; + final first = await store.albumPage( + QueueLibraryDbQuery(sortMode: sort, limit: 1), + ); + final second = await store.albumPage( + QueueLibraryDbQuery( + sortMode: sort, + limit: 1, + cursor: first.nextCursor, + ), + ); + expect([...first.rows, ...second.rows], expected, reason: sort); + final fallback = await store.albumPage( + QueueLibraryDbQuery( + sortMode: sort, + offset: 1, + limit: 1, + cursor: const QueueLibraryDbCursor(['wrong length']), + ), + ); + expect(fallback.rows, [expected.last], reason: sort); + } + }, + ); + + test( + 'fast counts agree with filtered counts and deduplicate visible paths', + () async { + final fast = await store.counts(const QueueLibraryDbQuery()); + final filtered = await store.counts( + const QueueLibraryDbQuery(quality: 'cd'), + ); + expect( + [fast.allTrackCount, fast.albumCount, fast.singleTrackCount], + [5, 2, 1], + ); + expect( + [ + filtered.allTrackCount, + filtered.albumCount, + filtered.singleTrackCount, + ], + [5, 2, 1], + ); + final local = await store.counts( + const QueueLibraryDbQuery(source: 'local'), + ); + expect(local.allTrackCount, 2); + final gain = await store.trackPage( + const QueueLibraryDbQuery(metadata: 'missing-replaygain'), + ); + expect(_tracks(gain).toSet(), { + 'downloaded:h2', + 'downloaded:single', + 'local:l2', + }); + }, + ); + + test( + 'independent FTS inputs match LIKE fallback and escape literal search', + () async { + await db.execute( + "CREATE VIRTUAL TABLE history_db.history_search_fts USING fts5(search_text, tokenize='trigram')", + ); + await db.execute( + 'INSERT INTO history_db.history_search_fts(rowid, search_text) SELECT rowid, search_text FROM history_db.history', + ); + final expected = _tracks( + await store.trackPage(const QueueLibraryDbQuery(searchQuery: 'Artist')), + ); + const search = QueueLibraryDbQuery(searchQuery: 'Artist'); + final expectedAlbums = (await store.albumPage(search)).rows; + final expectedCounts = await store.counts(search); + final expectedArtists = await store.artistPage(search); + for (final flags in [(true, false), (false, true), (true, true)]) { + final indexed = LibraryQueueStore( + db, + historyFts: flags.$1, + localFts: flags.$2, + ); + expect( + _tracks( + await indexed.trackPage( + const QueueLibraryDbQuery(searchQuery: 'Artist'), + ), + ), + expected, + ); + expect((await indexed.albumPage(search)).rows, expectedAlbums); + final counts = await indexed.counts(search); + expect( + [counts.allTrackCount, counts.albumCount, counts.singleTrackCount], + [ + expectedCounts.allTrackCount, + expectedCounts.albumCount, + expectedCounts.singleTrackCount, + ], + ); + expect(await indexed.artistPage(search), expectedArtists); + } + await addHistory( + 'literal', + 'Special', + 1, + artist: 'Literal', + title: 'Percent % Track', + ); + expect( + _tracks( + await store.trackPage(const QueueLibraryDbQuery(searchQuery: '%')), + ), + ['downloaded:literal'], + ); + }, + ); + + test( + 'artist pages aggregate visible inventory without loading track models', + () async { + final artists = await store.artistPage(const QueueLibraryDbQuery()); + expect(artists.map((row) => row['artist_name']), [ + 'Another Artist', + 'Artist', + ]); + expect(artists.map((row) => row['track_count']), [1, 4]); + final downloaded = await store.artistPage( + const QueueLibraryDbQuery(includeLocal: false), + ); + expect(downloaded.map((row) => row['track_count']), [1, 2]); + }, + ); +} diff --git a/test/library_schema_test.dart b/test/library_schema_test.dart new file mode 100644 index 00000000..afec0021 --- /dev/null +++ b/test/library_schema_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite/sqflite.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 'support/sqlite_process_database.dart'; + +Map _track(String id, {String isrc = 'same'}) => + LocalLibraryItem( + id: id, + trackName: ' Song ', + artistName: 'ARTIST', + albumName: 'Album', + filePath: '/music/$id.flac', + scannedAt: DateTime.utc(2026), + isrc: isrc, + trackNumber: 1, + totalTracks: 2, + hasLyrics: true, + hasReplayGain: true, + ).toJson(); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SqliteProcessDatabase db; + setUp(() async { + db = await SqliteProcessDatabase.open(); + await db.execute('PRAGMA recursive_triggers = ON'); + }); + tearDown(() => db.close()); + + test( + 'fresh schema maintains lookup counts across replace, source move and deletion', + () async { + await LibrarySchema.create(db, LibrarySchema.version); + for (final id in ['a', 'b']) { + await db.insert( + 'library', + LibraryRowMapper.encode(_track(id)), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + Future>> counts() => db.query( + LibrarySchema.lookupSummaryTable, + orderBy: 'source_id, kind, value', + ); + expect((await counts()).map((row) => row['ref_count']), [2, 2]); + await db.insert( + 'library', + LibraryRowMapper.encode(_track('a', isrc: 'new')), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + expect((await counts()).map((row) => row['ref_count']), [1, 1, 2]); + await db.insert('library_sources', { + 'id': 'nas', + 'path': 'network://nas/music', + 'display_name': 'NAS', + }); + await db.update( + 'library', + {'source_id': 'nas'}, + where: 'id = ?', + whereArgs: ['a'], + ); + expect((await db.query(LibrarySchema.visibleView)).length, 2); + await db.update( + 'library_sources', + {'available': 0}, + where: 'id = ?', + whereArgs: ['nas'], + ); + expect((await db.query(LibrarySchema.visibleView)).single['id'], 'b'); + await db.delete('library', where: 'id = ?', whereArgs: ['a']); + expect((await counts()).map((row) => row['ref_count']), [1, 1]); + await db.delete('library'); + expect(await counts(), isEmpty); + }, + ); + + test( + 'v1 upgrade preserves tracks and backfills current search and path identities', + () async { + await db.execute('''CREATE TABLE library ( + id TEXT PRIMARY KEY, track_name TEXT NOT NULL, artist_name TEXT NOT NULL, + album_name TEXT NOT NULL, album_artist TEXT, file_path TEXT NOT NULL UNIQUE, + scanned_at TEXT NOT NULL, isrc TEXT, track_number INTEGER, disc_number INTEGER, + duration INTEGER, release_date TEXT, bit_depth INTEGER, sample_rate INTEGER, + genre TEXT, format TEXT + )'''); + await db.insert('library', { + 'id': 'old', + 'track_name': ' Song ', + 'artist_name': 'ARTIST', + 'album_name': 'Album', + 'file_path': + 'content://example.documents/document/nas%3AMusic%2FSong.flac', + 'scanned_at': '2026-01-01T00:00:00.000Z', + 'isrc': 'legacy-isrc', + 'genre': ' Pop ', + }); + await LibrarySchema.upgrade(db, 1, LibrarySchema.version); + final row = (await db.query('library')).single; + expect(row['source_id'], LocalLibraryItem.legacySourceId); + expect(row['search_text'], 'song artist album artist'); + expect(row['match_key'], 'song|artist'); + expect(row['sort_genre'], 'pop'); + expect(row['audio_metadata_scan_version'], 0); + expect(row['has_lyrics'], 0); + expect(row['has_replaygain'], 0); + expect( + (await db.query( + 'library_path_keys', + )).every((key) => key['item_id'] == 'old'), + isTrue, + ); + expect(await db.query('library_path_keys'), isNotEmpty); + expect((await db.query(LibrarySchema.visibleView)).single['id'], 'old'); + expect( + (await db.query( + LibrarySchema.lookupSummaryTable, + )).map((row) => row['ref_count']), + [1, 1], + ); + }, + ); + + test( + 'v16 upgrade repairs existing lookup counts and replacement triggers', + () async { + await LibrarySchema.create(db, 16); + await db.execute('DROP TRIGGER library_lookup_insert_isrc'); + await db.execute(''' + CREATE TRIGGER library_lookup_insert_isrc AFTER INSERT ON library + WHEN NEW.isrc IS NOT NULL AND NEW.isrc != '' + BEGIN + INSERT OR IGNORE INTO library_lookup_summary(source_id, kind, value, ref_count) + VALUES (NEW.source_id, 'isrc', NEW.isrc, 0); + UPDATE library_lookup_summary SET ref_count = ref_count + 1 + WHERE source_id = NEW.source_id AND kind = 'isrc' AND value = NEW.isrc; + END + '''); + for (final id in ['a', 'b']) { + await db.insert( + 'library', + LibraryRowMapper.encode(_track(id)), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + Future>> counts() => + db.query(LibrarySchema.lookupSummaryTable, orderBy: 'kind'); + expect((await counts()).map((row) => row['ref_count']), [1, 2]); + await LibrarySchema.upgrade(db, 16, LibrarySchema.version); + expect((await counts()).map((row) => row['ref_count']), [2, 2]); + await db.insert( + 'library', + LibraryRowMapper.encode(_track('a')), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + expect((await counts()).map((row) => row['ref_count']), [2, 2]); + await db.delete('library', where: 'id = ?', whereArgs: ['a']); + expect((await counts()).map((row) => row['ref_count']), [1, 1]); + expect((await db.query('library')).single['id'], 'b'); + }, + ); + + test('row mapping round-trips metadata and preserves unknown scan flags', () { + final track = _track('full'); + expect(LibraryRowMapper.decode(LibraryRowMapper.encode(track)), track); + final filename = LibraryRowMapper.encode({ + ...track, + 'sourceId': 'nas', + 'metadataFromFilename': true, + 'hasReplayGain': false, + }); + expect(filename['source_id'], 'nas'); + expect(filename['audio_metadata_scan_version'], 0); + expect(filename['has_replaygain'], 0); + final tagged = LibraryRowMapper.encode({ + ...track, + 'hasReplayGain': false, + 'replaygain_track_gain': '-4.5 dB', + 'explicit': 1, + 'audioMetadataScanVersion': 3, + }); + expect(tagged['has_replaygain'], 1); + expect(tagged['audio_metadata_scan_version'], 3); + expect(LibraryRowMapper.decode(tagged)['explicit'], true); + }); +} diff --git a/test/replaygain_library_filter_test.dart b/test/replaygain_library_filter_test.dart index 93c8fb26..bc7441c1 100644 --- a/test/replaygain_library_filter_test.dart +++ b/test/replaygain_library_filter_test.dart @@ -60,7 +60,7 @@ print('both databases passed') 'history', ), 'library': _tableDefinition( - 'lib/services/library_database.dart', + 'lib/services/library_schema.dart', 'library', ), 'historyPredicate': historyPredicate, diff --git a/test/support/sqlite_process_database.dart b/test/support/sqlite_process_database.dart new file mode 100644 index 00000000..b33f20cb --- /dev/null +++ b/test/support/sqlite_process_database.dart @@ -0,0 +1,228 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:sqflite/sqflite.dart'; + +/// Executes repository SQL in real SQLite without a platform plugin. +/// Python's standard-library SQLite is also used by the existing SQL tests. +class SqliteProcessDatabase implements Database, Transaction { + SqliteProcessDatabase._(this._process) + : _responses = StreamIterator( + _process.stdout.transform(utf8.decoder).transform(const LineSplitter()), + ); + + final Process _process; + final StreamIterator _responses; + + static Future open() async => SqliteProcessDatabase._( + await Process.start('python3', [ + '-u', + '-c', + r''' +import json, sqlite3, sys +db = sqlite3.connect(':memory:', isolation_level=None) +db.row_factory = sqlite3.Row +for line in sys.stdin: + try: + request = json.loads(line) + cursor = db.execute(request['sql'], request.get('args', [])) + value = ([dict(row) for row in cursor.fetchall()] if request['rows'] + else cursor.lastrowid if request.get('insert') + else max(cursor.rowcount, 0)) + print(json.dumps({'value': value}), flush=True) + except Exception as error: + print(json.dumps({'error': str(error)}), flush=True) +''', + ]), + ); + + Future _request( + String sql, + List? args, { + bool rows = false, + bool insert = false, + }) async { + _process.stdin.writeln( + jsonEncode({ + 'sql': sql, + 'args': args ?? [], + 'rows': rows, + 'insert': insert, + }), + ); + await _process.stdin.flush(); + if (!await _responses.moveNext()) throw StateError('SQLite process exited'); + final response = jsonDecode(_responses.current) as Map; + if (response['error'] != null) { + throw StateError('${response['error']}\n$sql'); + } + return response['value']; + } + + @override + Future execute(String sql, [List? arguments]) async { + await _request(sql, arguments); + } + + @override + Future>> rawQuery( + String sql, [ + List? arguments, + ]) async => (await _request(sql, arguments, rows: true) as List) + .map((row) => Map.from(row as Map)) + .toList(); + + @override + Future rawInsert(String sql, [List? arguments]) async => + (await _request(sql, arguments, insert: true) as num).toInt(); + + @override + Future rawUpdate(String sql, [List? arguments]) async => + (await _request(sql, arguments) as num).toInt(); + + @override + Future rawDelete(String sql, [List? arguments]) => + rawUpdate(sql, arguments); + + @override + Future>> query( + String table, { + bool? distinct, + List? columns, + String? where, + List? whereArgs, + String? groupBy, + String? having, + String? orderBy, + int? limit, + int? offset, + }) => rawQuery( + [ + 'SELECT ${distinct == true ? 'DISTINCT ' : ''}${columns?.join(', ') ?? '*'} FROM $table', + if (where != null) 'WHERE $where', + if (groupBy != null) 'GROUP BY $groupBy', + if (having != null) 'HAVING $having', + if (orderBy != null) 'ORDER BY $orderBy', + if (limit != null) 'LIMIT $limit', + if (offset != null) 'OFFSET $offset', + ].join(' '), + whereArgs, + ); + + @override + Future insert( + String table, + Map values, { + String? nullColumnHack, + ConflictAlgorithm? conflictAlgorithm, + }) => rawInsert( + 'INSERT ${conflictAlgorithm == ConflictAlgorithm.ignore + ? 'OR IGNORE ' + : conflictAlgorithm == ConflictAlgorithm.replace + ? 'OR REPLACE ' + : ''}' + 'INTO $table (${values.keys.join(', ')}) VALUES (${List.filled(values.length, '?').join(', ')})', + values.values.toList(), + ); + + @override + Future update( + String table, + Map values, { + String? where, + List? whereArgs, + ConflictAlgorithm? conflictAlgorithm, + }) => rawUpdate( + 'UPDATE $table SET ${values.keys.map((key) => '$key = ?').join(', ')}' + '${where == null ? '' : ' WHERE $where'}', + [...values.values, ...?whereArgs], + ); + + @override + Future delete(String table, {String? where, List? whereArgs}) => + rawDelete( + 'DELETE FROM $table${where == null ? '' : ' WHERE $where'}', + whereArgs, + ); + + @override + Future transaction( + Future Function(Transaction txn) action, { + bool? exclusive, + }) async { + await execute('BEGIN'); + try { + final result = await action(this); + await execute('COMMIT'); + return result; + } catch (_) { + await execute('ROLLBACK'); + rethrow; + } + } + + @override + Batch batch() => _Batch(this); + + @override + Future close() async { + await _process.stdin.close(); + await _process.exitCode; + await _responses.cancel(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Batch implements Batch { + _Batch(this.database); + final SqliteProcessDatabase database; + final _operations = Function()>[]; + + @override + void insert( + String table, + Map values, { + String? nullColumnHack, + ConflictAlgorithm? conflictAlgorithm, + }) => _operations.add( + () => database.insert(table, values, conflictAlgorithm: conflictAlgorithm), + ); + + @override + void update( + String table, + Map values, { + String? where, + List? whereArgs, + ConflictAlgorithm? conflictAlgorithm, + }) => _operations.add( + () => database.update(table, values, where: where, whereArgs: whereArgs), + ); + + @override + void delete(String table, {String? where, List? whereArgs}) => + _operations.add( + () => database.delete(table, where: where, whereArgs: whereArgs), + ); + + @override + Future> commit({ + bool? exclusive, + bool? noResult, + bool? continueOnError, + }) async { + final results = []; + for (final operation in _operations) { + final result = await operation(); + if (noResult != true) results.add(result); + } + _operations.clear(); + return results; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}