feat(library): support multiple storage folders

This commit is contained in:
zarzet
2026-08-22 11:15:42 +07:00
parent 89d39c4074
commit 60624436a6
25 changed files with 1530 additions and 134 deletions
+337 -26
View File
@@ -16,7 +16,9 @@ final _log = AppLogger('LibraryDatabase');
class LibraryDatabase {
static final LibraryDatabase instance = LibraryDatabase._init();
static const int schemaVersion = 10;
static const int schemaVersion = 11;
static const String legacySourceId = LocalLibraryItem.legacySourceId;
static const String visibleLibraryView = 'library_visible';
static const int audioMetadataScanVersion = 1;
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
@@ -58,6 +60,7 @@ class LibraryDatabase {
await db.execute('''
CREATE TABLE library (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL DEFAULT '$legacySourceId',
track_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT NOT NULL,
@@ -108,6 +111,7 @@ class LibraryDatabase {
await _createNormalizedIndexes(db);
await _createQueueIndexes(db);
await _createPathKeyTable(db);
await _createLibrarySources(db);
_log.i('Library database schema created with indexes');
}
@@ -187,6 +191,52 @@ class LibraryDatabase {
await _createQueueIndexes(db);
_log.i('Added persisted queue sort/search columns');
}
if (oldVersion < 11) {
await sqlite.addColumnIfMissing(
db,
'library',
'source_id',
"TEXT NOT NULL DEFAULT '$legacySourceId'",
);
await _createLibrarySources(db);
_log.i('Added multiple local library sources');
}
}
Future<void> _createLibrarySources(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS library_sources (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
bookmark TEXT,
volume_id TEXT,
is_removable INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
available INTEGER NOT NULL DEFAULT 1,
last_scanned_at TEXT,
last_seen_at TEXT,
last_scan_error TEXT
)
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_library_source_id ON library(source_id)',
);
await db.insert('library_sources', {
'id': legacySourceId,
'path': 'legacy://local-library',
'display_name': 'Music',
'enabled': 1,
'available': 1,
}, conflictAlgorithm: ConflictAlgorithm.ignore);
await db.execute('DROP VIEW IF EXISTS $visibleLibraryView');
await db.execute('''
CREATE VIEW $visibleLibraryView AS
SELECT l.*
FROM library l
JOIN library_sources s ON s.id = l.source_id
WHERE s.enabled = 1 AND s.available = 1
''');
}
Future<void> _createPathKeyTable(DatabaseExecutor db) =>
@@ -378,11 +428,15 @@ class LibraryDatabase {
await batch.commit(noResult: true);
}
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
Map<String, dynamic> _jsonToDbRow(
Map<String, dynamic> json, {
String? sourceId,
}) {
final fileModTime = (json['fileModTime'] as num?)?.toInt();
final scannedAt = json['scannedAt'] as String?;
final row = {
'id': json['id'],
'source_id': sourceId ?? json['sourceId'] ?? legacySourceId,
'track_name': json['trackName'],
'artist_name': json['artistName'],
'album_name': json['albumName'],
@@ -436,6 +490,7 @@ class LibraryDatabase {
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
return {
'id': row['id'],
'sourceId': row['source_id'] ?? legacySourceId,
'trackName': row['track_name'],
'artistName': row['artist_name'],
'albumName': row['album_name'],
@@ -462,12 +517,12 @@ class LibraryDatabase {
};
}
Future<void> upsert(Map<String, dynamic> json) async {
Future<void> upsert(Map<String, dynamic> json, {String? sourceId}) async {
final db = await database;
await db.transaction((txn) async {
await txn.insert(
'library',
_jsonToDbRow(json),
_jsonToDbRow(json, sourceId: sourceId),
conflictAlgorithm: ConflictAlgorithm.replace,
);
final batch = txn.batch();
@@ -480,7 +535,10 @@ class LibraryDatabase {
});
}
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
Future<void> upsertBatch(
List<Map<String, dynamic>> items, {
String? sourceId,
}) async {
if (items.isEmpty) return;
final db = await database;
await db.transaction((txn) async {
@@ -488,7 +546,7 @@ class LibraryDatabase {
for (final json in items) {
batch.insert(
'library',
_jsonToDbRow(json),
_jsonToDbRow(json, sourceId: sourceId),
conflictAlgorithm: ConflictAlgorithm.replace,
);
_putPathKeysInBatch(
@@ -575,10 +633,237 @@ class LibraryDatabase {
return inserted;
}
/// Atomically replaces only one source. Other folders, including temporarily
/// disconnected removable storage, retain their index rows.
Future<int> replaceSourceStream(
String sourceId,
Stream<Map<String, dynamic>> items, {
int batchSize = 300,
}) async {
if (batchSize <= 0) {
throw ArgumentError.value(batchSize, 'batchSize', 'Must be positive');
}
final db = await database;
var inserted = 0;
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],
);
var batch = txn.batch();
var pending = 0;
Future<void> flush() async {
if (pending == 0) return;
await batch.commit(noResult: true);
batch = txn.batch();
pending = 0;
}
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(
'library',
_jsonToDbRow(json, sourceId: sourceId),
conflictAlgorithm: ConflictAlgorithm.replace,
);
_putPathKeysInBatch(batch, id, json['filePath'] as String?);
inserted++;
pending++;
if (pending >= batchSize) await flush();
}
await flush();
});
_log.i('Stream-replaced library source $sourceId with $inserted items');
return inserted;
}
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 db.update(
'library_sources',
{
'path': path,
'display_name': displayName,
'bookmark': bookmark,
'volume_id': volumeId,
'is_removable': isRemovable ? 1 : 0,
'available': available ? 1 : 0,
'last_scanned_at': lastScannedAt?.toIso8601String(),
'last_seen_at': available ? DateTime.now().toIso8601String() : null,
},
where: 'id = ?',
whereArgs: [legacySourceId],
);
}
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(
'library',
visibleLibraryView,
orderBy: 'album_artist, album_name, disc_number, track_number',
limit: limit,
offset: offset,
@@ -688,10 +973,10 @@ class LibraryDatabase {
COUNT(*) AS all_count,
COUNT(DISTINCT CASE WHEN grouped.track_count > 1 THEN l.album_key END) AS album_count,
COALESCE(SUM(CASE WHEN grouped.track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
FROM library l
FROM $visibleLibraryView l
JOIN (
SELECT album_key, COUNT(*) AS track_count
FROM library candidate
FROM $visibleLibraryView candidate
WHERE NOT EXISTS (
SELECT 1
FROM library_path_keys lpk
@@ -768,7 +1053,7 @@ class LibraryDatabase {
COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
FROM (
SELECT l.album_key, COUNT(*) AS track_count
FROM library l
FROM $visibleLibraryView l
WHERE NOT EXISTS (
SELECT 1
FROM library_path_keys lpk
@@ -845,7 +1130,7 @@ class LibraryDatabase {
) async {
final db = await database;
final rows = await db.query(
'library',
visibleLibraryView,
where:
"LOWER(album_name) = ? AND LOWER(COALESCE(NULLIF(album_artist, ''), artist_name)) = ?",
whereArgs: [albumName.toLowerCase(), artistName.toLowerCase()],
@@ -860,7 +1145,7 @@ class LibraryDatabase {
) async {
final db = await database;
final rows = await db.query(
'library',
visibleLibraryView,
where: 'album_key = ?',
whereArgs: [albumKey],
orderBy:
@@ -872,7 +1157,7 @@ class LibraryDatabase {
Future<Map<String, dynamic>?> getById(String id) async {
final db = await database;
final rows = await db.query(
'library',
visibleLibraryView,
where: 'id = ?',
whereArgs: [id],
limit: 1,
@@ -884,7 +1169,7 @@ class LibraryDatabase {
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
final db = await database;
final rows = await db.query(
'library',
visibleLibraryView,
where: 'isrc = ?',
whereArgs: [isrc],
limit: 1,
@@ -899,7 +1184,7 @@ class LibraryDatabase {
) async {
final db = await database;
final rows = await db.query(
'library',
visibleLibraryView,
where: 'match_key = ?',
whereArgs: [matchKeyFor(trackName, artistName)],
);
@@ -912,7 +1197,7 @@ class LibraryDatabase {
) async {
final db = await database;
final rows = await db.query(
'library',
visibleLibraryView,
where: 'match_key = ?',
whereArgs: [matchKeyFor(trackName, artistName)],
orderBy: _orderByForSort(LocalLibrarySortMode.album),
@@ -958,7 +1243,7 @@ class LibraryDatabase {
) {
return sqlite.loadRowsByColumn(
db,
table: 'library',
table: visibleLibraryView,
column: column,
rawValues: rawValues,
destination: destination,
@@ -991,7 +1276,9 @@ class LibraryDatabase {
Future<LocalLibraryLookupIndex> getLookupIndex() async {
final db = await database;
final rows = await db.rawQuery('SELECT isrc, match_key FROM library');
final rows = await db.rawQuery(
'SELECT isrc, match_key FROM $visibleLibraryView',
);
final isrcs = <String>{};
final matchKeys = <String>{};
for (final row in rows) {
@@ -1042,7 +1329,7 @@ class LibraryDatabase {
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 library l
FROM $visibleLibraryView l
WHERE l.isrc IS NOT NULL AND TRIM(l.isrc) != ''
AND NOT EXISTS (
SELECT 1
@@ -1224,9 +1511,14 @@ class LibraryDatabase {
});
}
Future<int> cleanupMissingFiles() async {
Future<int> cleanupMissingFiles({String? sourceId}) async {
final db = await database;
final rows = await db.query('library', columns: ['id', 'file_path']);
final rows = await db.query(
'library',
columns: ['id', 'file_path'],
where: sourceId == null ? null : 'source_id = ?',
whereArgs: sourceId == null ? null : [sourceId],
);
final missingIds = <String>[];
const checkChunkSize = 16;
@@ -1282,13 +1574,28 @@ class LibraryDatabase {
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 library');
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;
}
@@ -1299,11 +1606,13 @@ class LibraryDatabase {
_historyAttached = false;
}
Future<Map<String, int>> getFileModTimes() async {
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',
'audio_metadata_scan_version FROM library '
'${sourceId == null ? '' : 'WHERE source_id = ?'}',
sourceId == null ? const [] : [sourceId],
);
final result = <String, int>{};
for (final row in rows) {
@@ -1321,11 +1630,13 @@ class LibraryDatabase {
return result;
}
Future<String> writeFileModTimesSnapshot() async {
Future<String> writeFileModTimesSnapshot({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',
'audio_metadata_scan_version FROM library '
'${sourceId == null ? '' : 'WHERE source_id = ?'}',
sourceId == null ? const [] : [sourceId],
);
final tempDir = await getTemporaryDirectory();
final file = File(
+66
View File
@@ -12,7 +12,10 @@ int libraryIncrementalSnapshotModTime({
}
class LocalLibraryItem {
static const legacySourceId = 'legacy';
final String id;
final String sourceId;
final String trackName;
final String artistName;
final String albumName;
@@ -39,6 +42,7 @@ class LocalLibraryItem {
const LocalLibraryItem({
required this.id,
this.sourceId = legacySourceId,
required this.trackName,
required this.artistName,
required this.albumName,
@@ -66,6 +70,7 @@ class LocalLibraryItem {
Map<String, dynamic> toJson() => {
'id': id,
'sourceId': sourceId,
'trackName': trackName,
'artistName': artistName,
'albumName': albumName,
@@ -94,6 +99,7 @@ class LocalLibraryItem {
factory LocalLibraryItem.fromJson(Map<String, dynamic> json) =>
LocalLibraryItem(
id: json['id'] as String,
sourceId: json['sourceId'] as String? ?? legacySourceId,
trackName: json['trackName'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
@@ -128,6 +134,7 @@ class LocalLibraryItem {
}) {
return LocalLibraryItem(
id: id,
sourceId: sourceId,
trackName: trackName,
artistName: artistName,
albumName: albumName,
@@ -160,6 +167,65 @@ class LocalLibraryItem {
'${LibraryDatabase.normalizeLookupText(albumName)}|${LibraryDatabase.normalizeLookupText(albumArtist ?? artistName)}';
}
class LocalLibrarySource {
final String id;
final String path;
final String displayName;
final String? bookmark;
final String? volumeId;
final bool isRemovable;
final bool enabled;
final bool available;
final int trackCount;
final DateTime? lastScannedAt;
final DateTime? lastSeenAt;
final String? lastScanError;
const LocalLibrarySource({
required this.id,
required this.path,
required this.displayName,
this.bookmark,
this.volumeId,
this.isRemovable = false,
this.enabled = true,
this.available = true,
this.trackCount = 0,
this.lastScannedAt,
this.lastSeenAt,
this.lastScanError,
});
bool get isIndexed => lastScannedAt != null || trackCount > 0;
LocalLibrarySource copyWith({
bool? enabled,
bool? available,
int? trackCount,
DateTime? lastScannedAt,
DateTime? lastSeenAt,
String? lastScanError,
bool clearLastScanError = false,
}) {
return LocalLibrarySource(
id: id,
path: path,
displayName: displayName,
bookmark: bookmark,
volumeId: volumeId,
isRemovable: isRemovable,
enabled: enabled ?? this.enabled,
available: available ?? this.available,
trackCount: trackCount ?? this.trackCount,
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
lastSeenAt: lastSeenAt ?? this.lastSeenAt,
lastScanError: clearLastScanError
? null
: lastScanError ?? this.lastScanError,
);
}
}
enum LocalLibrarySortMode { album, title, artist, latest, quality }
enum LocalLibraryFilterMode { all, albums, singles }
+6 -6
View File
@@ -105,12 +105,12 @@ extension _LibraryDbQueueSql on LibraryDatabase {
where.add('''
l.album_key IN (
SELECT album_key
FROM library
FROM ${LibraryDatabase.visibleLibraryView} candidate
WHERE 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 = library.id
WHERE lpk.item_id = candidate.id
)
GROUP BY album_key
HAVING COUNT(*) = 1
@@ -162,7 +162,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
l.sort_genre,
l.sort_release,
l.sort_added
FROM library l
FROM ${LibraryDatabase.visibleLibraryView} l
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
''';
parts.add(
@@ -306,18 +306,18 @@ extension _LibraryDbQueueSql on LibraryDatabase {
MIN(l.album_artist_norm) AS sort_artist,
COALESCE(MAX(l.release_date), '') AS sort_release,
COALESCE(MAX(l.sort_genre), '') AS sort_genre
FROM library l
FROM ${LibraryDatabase.visibleLibraryView} l
JOIN (
SELECT
album_key,
COUNT(*) AS track_count,
MAX(COALESCE(sort_added, 0)) AS latest_added
FROM library
FROM ${LibraryDatabase.visibleLibraryView} candidate
WHERE 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 = library.id
WHERE lpk.item_id = candidate.id
)
GROUP BY album_key
HAVING COUNT(*) > 1
+10
View File
@@ -179,6 +179,8 @@ class PlatformBridge {
static final StreamController<ExtensionSessionGrantEvent>
_extensionSessionGrantEvents =
StreamController<ExtensionSessionGrantEvent>.broadcast();
static final StreamController<void> _libraryStorageEvents =
StreamController<void>.broadcast();
static bool _backendEventHandlerInstalled = false;
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
@@ -191,6 +193,11 @@ class PlatformBridge {
return _extensionSessionGrantEvents.stream;
}
static Stream<void> libraryStorageEvents() {
_ensureBackendEventHandler();
return _libraryStorageEvents.stream;
}
static void _ensureBackendEventHandler() {
if (_backendEventHandlerInstalled) return;
_backendEventHandlerInstalled = true;
@@ -210,6 +217,9 @@ class PlatformBridge {
}
}
return null;
case 'libraryStorageChanged':
_libraryStorageEvents.add(null);
return null;
default:
return null;
}