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); }); } }