Files
SpotiFLAC-Mobile/lib/services/library_database.dart
T

1484 lines
48 KiB
Dart

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