mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
Move full and incremental ingestion, source snapshots, path keys, and download exclusion to native transactions. Preserve schema 17, FTS, WAL coordination, cancellation, and Android SAF timestamp backfill.
1512 lines
50 KiB
Dart
1512 lines
50 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_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;
|
|
|
|
Future<Map<String, dynamic>> _nativeScanRequest(String sourceId) async {
|
|
final db = await database;
|
|
final history = await HistoryDatabase.instance.database;
|
|
final sources = await db.query(
|
|
'library_sources',
|
|
columns: ['path', 'enabled'],
|
|
where: 'id = ?',
|
|
whereArgs: [sourceId],
|
|
limit: 1,
|
|
);
|
|
if (sources.isEmpty || sources.single['enabled'] != 1) {
|
|
throw StateError('Library source changed or was removed during scan');
|
|
}
|
|
return {
|
|
'library_path': db.path,
|
|
'history_path': history.path,
|
|
'source_id': sourceId,
|
|
'expected_source_path': sources.single['path'],
|
|
'platform': Platform.isAndroid ? 'android' : 'ios',
|
|
};
|
|
}
|
|
|
|
/// 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 request = await _nativeScanRequest(sourceId);
|
|
if (isCancelled()) throw StateError('Library scan cancelled');
|
|
final result = await PlatformBridge.runNativeDataJob({
|
|
...request,
|
|
'operation': 'library_full_import',
|
|
'ndjson_path': scan.file.path,
|
|
'expected_count': scan.expectedCount,
|
|
'error_count': scan.errorCount,
|
|
}, requestId: requestId);
|
|
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 {
|
|
final request = await _nativeScanRequest(sourceId);
|
|
if (isCancelled()) throw StateError('Library scan cancelled');
|
|
final directory = await getTemporaryDirectory();
|
|
if (isCancelled()) throw StateError('Library scan cancelled');
|
|
final result = await PlatformBridge.runNativeDataJob({
|
|
...request,
|
|
'operation': 'library_snapshot',
|
|
'snapshot_path': join(
|
|
directory.path,
|
|
'library_file_mod_times_${DateTime.now().microsecondsSinceEpoch}.tsv',
|
|
),
|
|
}, requestId: requestId);
|
|
return result['path'] as String;
|
|
}
|
|
|
|
/// 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 request = await _nativeScanRequest(sourceId);
|
|
if (isCancelled()) throw StateError('Library scan cancelled');
|
|
return PlatformBridge.runNativeDataJob({
|
|
...request,
|
|
'operation': 'library_scan_incremental',
|
|
if (isSaf) 'tree_uri': folderPath else 'folder_path': folderPath,
|
|
'snapshot_path': snapshotPath,
|
|
}, requestId: requestId);
|
|
}
|
|
|
|
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 db.transaction((txn) async {
|
|
await txn.insert(
|
|
'library',
|
|
LibraryRowMapper.encode(json, sourceId: sourceId),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
final batch = txn.batch();
|
|
_putPathKeysInBatch(
|
|
batch,
|
|
json['id'] as String,
|
|
json['filePath'] as String?,
|
|
);
|
|
await batch.commit(noResult: true);
|
|
});
|
|
}
|
|
|
|
Future<void> upsertBatch(
|
|
List<Map<String, dynamic>> items, {
|
|
String? sourceId,
|
|
}) async {
|
|
if (items.isEmpty) return;
|
|
final db = await database;
|
|
await db.transaction((txn) async {
|
|
final batch = txn.batch();
|
|
for (final json in items) {
|
|
batch.insert(
|
|
'library',
|
|
LibraryRowMapper.encode(json, sourceId: sourceId),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
_putPathKeysInBatch(
|
|
batch,
|
|
json['id'] as String,
|
|
json['filePath'] as String?,
|
|
);
|
|
}
|
|
await batch.commit(noResult: true);
|
|
});
|
|
_log.i('Batch inserted ${items.length} items');
|
|
}
|
|
|
|
/// Removes rows from one source whose normalized path keys already exist in
|
|
/// download History, without materializing either database's paths in Dart.
|
|
Future<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 db.transaction((txn) async {
|
|
await txn.rawDelete(
|
|
'DELETE FROM library_path_keys WHERE item_id IN '
|
|
'(SELECT id FROM $_downloadedLibraryIdsStageTable)',
|
|
);
|
|
await txn.rawDelete(
|
|
'DELETE FROM library WHERE id IN '
|
|
'(SELECT id FROM $_downloadedLibraryIdsStageTable)',
|
|
);
|
|
});
|
|
return count;
|
|
} finally {
|
|
await db.execute('DROP TABLE IF EXISTS $_downloadedLibraryIdsStageTable');
|
|
}
|
|
}
|
|
|
|
/// Upserts incremental scan rows after filtering them through the attached
|
|
/// History path-key index. This keeps the hot incremental path independent
|
|
/// of total History size.
|
|
Future<({int upserted, int skipped})> upsertBatchExcludingHistory(
|
|
List<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 db.transaction((txn) async {
|
|
await txn.rawDelete('''
|
|
DELETE FROM library_path_keys
|
|
WHERE item_id IN (
|
|
SELECT s.id FROM $_incrementalStageTable s WHERE NOT $historyMatch
|
|
)
|
|
''');
|
|
await txn.rawInsert('''
|
|
INSERT OR REPLACE INTO library ($columnList)
|
|
SELECT $selectedColumns FROM $_incrementalStageTable s
|
|
WHERE NOT $historyMatch
|
|
''');
|
|
await txn.rawInsert('''
|
|
INSERT OR IGNORE INTO library_path_keys(item_id, path_key)
|
|
SELECT sk.item_id, sk.path_key
|
|
FROM $_incrementalStagePathKeysTable sk
|
|
JOIN $_incrementalStageTable s ON s.id = sk.item_id
|
|
WHERE NOT $historyMatch
|
|
''');
|
|
});
|
|
return (upserted: staged - skipped, skipped: skipped);
|
|
} finally {
|
|
await db.execute('DROP TABLE IF EXISTS $_incrementalStagePathKeysTable');
|
|
await db.execute('DROP TABLE IF EXISTS $_incrementalStageTable');
|
|
}
|
|
}
|
|
|
|
Future<void> replaceAll(List<Map<String, dynamic>> items) async {
|
|
final db = await database;
|
|
await db.transaction((txn) async {
|
|
await txn.delete('library_path_keys');
|
|
await txn.delete('library');
|
|
if (items.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final batch = txn.batch();
|
|
for (final json in items) {
|
|
batch.insert(
|
|
'library',
|
|
LibraryRowMapper.encode(json),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
_putPathKeysInBatch(
|
|
batch,
|
|
json['id'] as String,
|
|
json['filePath'] as String?,
|
|
);
|
|
}
|
|
await batch.commit(noResult: true);
|
|
});
|
|
_log.i('Replaced library with ${items.length} items');
|
|
}
|
|
|
|
/// Stages scan rows in bounded, independently committed batches, then swaps
|
|
/// only this source in one short transaction. Download-history exclusion is
|
|
/// an indexed SQLite anti-join, avoiding a full History path set in Dart.
|
|
Future<({int inserted, int skipped})> replaceSourceStream(
|
|
String sourceId,
|
|
Stream<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 db.transaction((txn) async {
|
|
await deleteReplacedLibraryScanRows(
|
|
txn,
|
|
sourceId,
|
|
stageTable: _scanStageTable,
|
|
preserveMissing: preserveMissing,
|
|
);
|
|
await txn.rawDelete('''
|
|
DELETE FROM library_path_keys
|
|
WHERE item_id IN (
|
|
SELECT s.id FROM $_scanStageTable s WHERE NOT $historyMatch
|
|
)
|
|
''');
|
|
await txn.rawInsert('''
|
|
INSERT OR REPLACE INTO library ($columnList)
|
|
SELECT $selectedColumns FROM $_scanStageTable s
|
|
WHERE NOT $historyMatch
|
|
''');
|
|
await txn.rawInsert('''
|
|
INSERT OR IGNORE INTO library_path_keys(item_id, path_key)
|
|
SELECT sk.item_id, sk.path_key
|
|
FROM $_scanStagePathKeysTable sk
|
|
JOIN $_scanStageTable s ON s.id = sk.item_id
|
|
WHERE NOT $historyMatch
|
|
''');
|
|
});
|
|
final inserted = staged - skipped;
|
|
_log.i(
|
|
'Streamed $streamed rows, staged $staged unique rows, and swapped '
|
|
'$inserted into Library source '
|
|
'$sourceId ($skipped downloads excluded)',
|
|
);
|
|
return (inserted: inserted, skipped: skipped);
|
|
} finally {
|
|
await db.execute('DROP TABLE IF EXISTS $_scanStagePathKeysTable');
|
|
await db.execute('DROP TABLE IF EXISTS $_scanStageTable');
|
|
}
|
|
}
|
|
|
|
Future<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 db.transaction((txn) async {
|
|
await txn.rawDelete(
|
|
'DELETE FROM library_path_keys WHERE item_id IN '
|
|
'(SELECT id FROM library WHERE source_id = ?)',
|
|
[sourceId],
|
|
);
|
|
await txn.delete(
|
|
'library',
|
|
where: 'source_id = ?',
|
|
whereArgs: [sourceId],
|
|
);
|
|
if (sourceId == legacySourceId) {
|
|
await txn.update(
|
|
'library_sources',
|
|
{
|
|
'path': 'legacy://local-library',
|
|
'display_name': 'Music',
|
|
'bookmark': null,
|
|
'volume_id': null,
|
|
'is_removable': 0,
|
|
'enabled': 1,
|
|
'available': 1,
|
|
'last_scanned_at': null,
|
|
'last_seen_at': null,
|
|
'last_scan_error': null,
|
|
},
|
|
where: 'id = ?',
|
|
whereArgs: [sourceId],
|
|
);
|
|
} else {
|
|
await txn.delete(
|
|
'library_sources',
|
|
where: 'id = ?',
|
|
whereArgs: [sourceId],
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<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 db.transaction((txn) async {
|
|
for (final id in ids) {
|
|
await txn.delete(
|
|
'library_path_keys',
|
|
where: 'item_id = ?',
|
|
whereArgs: [id],
|
|
);
|
|
}
|
|
await txn.delete(
|
|
'library',
|
|
where: 'file_path = ?',
|
|
whereArgs: [filePath],
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<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 db.transaction((txn) async {
|
|
if (!keepOriginal) {
|
|
await txn.delete(
|
|
'library_path_keys',
|
|
where: 'item_id = ?',
|
|
whereArgs: [item.id],
|
|
);
|
|
await txn.delete(
|
|
'library',
|
|
where: 'id = ? OR file_path = ?',
|
|
whereArgs: [item.id, item.filePath],
|
|
);
|
|
}
|
|
await txn.insert(
|
|
'library',
|
|
LibraryRowMapper.encode(updated),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
final batch = txn.batch();
|
|
_putPathKeysInBatch(
|
|
batch,
|
|
updated['id'] as String,
|
|
updated['filePath'] as String?,
|
|
);
|
|
await batch.commit(noResult: true);
|
|
});
|
|
}
|
|
|
|
Future<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 db.transaction((txn) async {
|
|
await txn.delete('library_path_keys');
|
|
await txn.delete('library');
|
|
await txn.update('library_sources', {
|
|
'last_scanned_at': null,
|
|
'last_scan_error': null,
|
|
});
|
|
});
|
|
_log.i('Cleared all library data');
|
|
}
|
|
|
|
Future<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 db.transaction((txn) async {
|
|
for (var i = 0; i < filePaths.length; i += chunkSize) {
|
|
final end = (i + chunkSize < filePaths.length)
|
|
? i + chunkSize
|
|
: filePaths.length;
|
|
final chunk = filePaths.sublist(i, end);
|
|
final placeholders = List.filled(chunk.length, '?').join(',');
|
|
await txn.rawDelete(
|
|
'DELETE FROM library_path_keys WHERE item_id IN '
|
|
'(SELECT id FROM library WHERE file_path IN ($placeholders))',
|
|
chunk,
|
|
);
|
|
totalDeleted += await txn.rawDelete(
|
|
'DELETE FROM library WHERE file_path IN ($placeholders)',
|
|
chunk,
|
|
);
|
|
}
|
|
});
|
|
if (totalDeleted > 0) {
|
|
_log.i('Deleted $totalDeleted items from library');
|
|
}
|
|
return totalDeleted;
|
|
}
|
|
|
|
String _normalizeConvertedFormat(String targetFormat) {
|
|
return normalizeAudioFormatValue(targetFormat) ?? 'mp3';
|
|
}
|
|
|
|
int? _convertedBitrate({
|
|
required String targetFormat,
|
|
required String bitrate,
|
|
}) {
|
|
switch (targetFormat.trim().toLowerCase()) {
|
|
case 'mp3':
|
|
case 'opus':
|
|
case 'aac':
|
|
final match = RegExp(r'(\d+)').firstMatch(bitrate);
|
|
return match != null ? int.tryParse(match.group(1)!) : null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
String _generateLibraryId(String filePath) {
|
|
return 'lib_${_hashString(filePath).toRadixString(16)}';
|
|
}
|
|
|
|
int _hashString(String input) {
|
|
var hash = 5381;
|
|
for (final codeUnit in input.codeUnits) {
|
|
hash = (((hash << 5) + hash) + codeUnit) & 0xffffffff;
|
|
}
|
|
return hash;
|
|
}
|
|
}
|
|
|
|
/// Migrates the pre-multiple-folder setting even when its placeholder row is
|
|
/// absent. Do not REPLACE an existing source or change the IDs of its tracks.
|
|
Future<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 db.transaction((txn) async {
|
|
final updated = await txn.update(
|
|
'library_sources',
|
|
values,
|
|
where: 'id = ?',
|
|
whereArgs: [source.id],
|
|
);
|
|
if (updated == 0) {
|
|
await txn.insert('library_sources', {
|
|
'id': source.id,
|
|
'enabled': source.enabled ? 1 : 0,
|
|
...values,
|
|
});
|
|
}
|
|
});
|
|
}
|