Files
SpotiFLAC-Mobile/lib/services/library_schema.dart
T
zarzet 2e17963804 refactor(library): isolate schema, row mapping and queue queries
Repair lookup counts during schema migration and resolve local FTS matches through the base table.
2026-10-05 03:28:48 +07:00

464 lines
15 KiB
Dart

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<bool> 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<void> 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<bool> createSearchFts(DatabaseExecutor db) =>
sqlite.createTrigramFtsIndex(
db,
ftsTable: searchFtsTable,
contentTable: 'library',
triggerPrefix: 'library_search_fts',
);
static Future<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> 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 = <String, Object?>{};
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);
});
}
}