mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
1484 lines
48 KiB
Dart
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,
|
|
});
|
|
}
|
|
});
|
|
}
|