mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
refactor(library): isolate schema, row mapping and queue queries
Repair lookup counts during schema migration and resolve local FTS matches through the base table.
This commit is contained in:
1 parent
f8f489a7d5
commit
2e17963804
11 files changed
+1703
-1062
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,17 @@
|
||||
part of 'library_database.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
|
||||
// Row models and query descriptors for the local library database.
|
||||
|
||||
// v4 records ReplayGain availability; older rows rescan once.
|
||||
const libraryAudioMetadataScanVersion = 4;
|
||||
const libraryLyricsMetadataScanVersion = 3;
|
||||
|
||||
int libraryIncrementalSnapshotModTime({
|
||||
required int storedModTime,
|
||||
required int storedScanVersion,
|
||||
}) {
|
||||
return storedScanVersion >= LibraryDatabase.audioMetadataScanVersion
|
||||
return storedScanVersion >= libraryAudioMetadataScanVersion
|
||||
? storedModTime
|
||||
: -1;
|
||||
}
|
||||
@@ -181,9 +186,9 @@ class LocalLibraryItem {
|
||||
}
|
||||
|
||||
String get matchKey =>
|
||||
'${LibraryDatabase.normalizeLookupText(trackName)}|${LibraryDatabase.normalizeLookupText(artistName)}';
|
||||
'${sqlite.normalizeLookupText(trackName)}|${sqlite.normalizeLookupText(artistName)}';
|
||||
String get albumKey =>
|
||||
'${LibraryDatabase.normalizeLookupText(albumName)}|${LibraryDatabase.normalizeLookupText(albumArtist ?? artistName)}';
|
||||
'${sqlite.normalizeLookupText(albumName)}|${sqlite.normalizeLookupText(albumArtist ?? artistName)}';
|
||||
}
|
||||
|
||||
class LocalLibrarySource {
|
||||
|
||||
+327
-86
@@ -1,4 +1,8 @@
|
||||
part of 'library_database.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/services/album_completeness.dart';
|
||||
import 'package:spotiflac_android/services/library_database_models.dart';
|
||||
import 'package:spotiflac_android/services/library_schema.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
|
||||
// SQL builders for the queue tab's history+local union queries.
|
||||
|
||||
@@ -17,7 +21,280 @@ class _QueueOrderTerm {
|
||||
const _QueueOrderTerm(this.column, {this.descending = false});
|
||||
}
|
||||
|
||||
extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
/// Queue reads against an already opened library with history attached.
|
||||
class LibraryQueueStore {
|
||||
final Database _db;
|
||||
final bool historyFts;
|
||||
final bool localFts;
|
||||
|
||||
const LibraryQueueStore(
|
||||
this._db, {
|
||||
required this.historyFts,
|
||||
required this.localFts,
|
||||
});
|
||||
|
||||
Future<QueueLibraryDbPage> trackPage(QueueLibraryDbQuery request) async {
|
||||
final args = <Object?>[];
|
||||
final orderTerms = _queueTrackOrderTerms(request.sortMode);
|
||||
final usesCursor =
|
||||
request.cursor != null &&
|
||||
request.cursor!.values.length == orderTerms.length;
|
||||
final unionSql = _queueTrackUnionSql(
|
||||
request,
|
||||
args,
|
||||
orderTerms: orderTerms,
|
||||
usesCursor: usesCursor,
|
||||
);
|
||||
final rows = await _db.rawQuery(
|
||||
'''
|
||||
SELECT *
|
||||
FROM ($unionSql)
|
||||
ORDER BY ${_queueOrderBy(orderTerms)}
|
||||
LIMIT ? ${usesCursor ? '' : 'OFFSET ?'}
|
||||
''',
|
||||
[...args, request.limit, if (!usesCursor) request.offset],
|
||||
);
|
||||
return QueueLibraryDbPage(
|
||||
rows: rows.map(_queueTrackRowToJson).toList(growable: false),
|
||||
nextCursor: _queueCursorFromRow(rows.lastOrNull, orderTerms),
|
||||
);
|
||||
}
|
||||
|
||||
Future<QueueLibraryCounts> counts(QueueLibraryDbQuery request) async {
|
||||
final fastCounts = await _getUnfilteredQueueCounts(request);
|
||||
if (fastCounts != null) return fastCounts;
|
||||
final parts = <String>[];
|
||||
final args = <Object?>[];
|
||||
|
||||
if (request.source != 'local') {
|
||||
final where = <String>[];
|
||||
_appendQueueHistoryFilters(where, args, request);
|
||||
parts.add('''
|
||||
SELECT
|
||||
COUNT(*) AS all_count,
|
||||
COUNT(DISTINCT CASE WHEN grouped.track_count > ${isAlbumCompletenessFilter(request.metadata) ? 0 : 1} THEN h.album_key END) AS album_count,
|
||||
COALESCE(SUM(CASE WHEN grouped.track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
|
||||
FROM history_db.history h
|
||||
JOIN (
|
||||
SELECT album_key, COUNT(*) AS track_count
|
||||
FROM history_db.history
|
||||
GROUP BY album_key
|
||||
) grouped ON grouped.album_key = h.album_key
|
||||
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
|
||||
''');
|
||||
}
|
||||
|
||||
if (request.includeLocal && request.source != 'downloaded') {
|
||||
final where = <String>[
|
||||
'''
|
||||
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
|
||||
)
|
||||
''',
|
||||
];
|
||||
_appendQueueLocalFilters(where, args, request);
|
||||
parts.add('''
|
||||
SELECT
|
||||
COUNT(*) AS all_count,
|
||||
COUNT(DISTINCT CASE WHEN grouped.track_count > ${isAlbumCompletenessFilter(request.metadata) ? 0 : 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 ${LibrarySchema.visibleView} l
|
||||
JOIN (
|
||||
SELECT album_key, COUNT(*) AS track_count
|
||||
FROM ${LibrarySchema.visibleView} 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 = candidate.id
|
||||
)
|
||||
GROUP BY album_key
|
||||
) grouped ON grouped.album_key = l.album_key
|
||||
WHERE ${where.join(' AND ')}
|
||||
''');
|
||||
}
|
||||
|
||||
if (parts.isEmpty) {
|
||||
return const QueueLibraryCounts(
|
||||
allTrackCount: 0,
|
||||
albumCount: 0,
|
||||
singleTrackCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
final rows = await _db.rawQuery('''
|
||||
SELECT
|
||||
COALESCE(SUM(all_count), 0) AS all_count,
|
||||
COALESCE(SUM(single_count), 0) AS single_count,
|
||||
COALESCE(SUM(album_count), 0) AS album_count
|
||||
FROM (${parts.join(' UNION ALL ')})
|
||||
''', args);
|
||||
final row = rows.isNotEmpty ? rows.first : const <String, Object?>{};
|
||||
|
||||
return QueueLibraryCounts(
|
||||
allTrackCount: (row['all_count'] as num?)?.toInt() ?? 0,
|
||||
albumCount: (row['album_count'] as num?)?.toInt() ?? 0,
|
||||
singleTrackCount: (row['single_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// The default Library badges do not need a row-by-row join against album
|
||||
/// counts. Aggregate the covering album-key indexes directly and reserve the
|
||||
/// more expensive filtered query for active search/quality/metadata filters.
|
||||
Future<QueueLibraryCounts?> _getUnfilteredQueueCounts(
|
||||
QueueLibraryDbQuery request,
|
||||
) async {
|
||||
if (sqlite.normalizeLookupText(request.searchQuery).isNotEmpty ||
|
||||
request.quality != null ||
|
||||
request.format != null ||
|
||||
request.metadata != null) {
|
||||
return null;
|
||||
}
|
||||
final source = request.source;
|
||||
if (source != null && source != 'downloaded' && source != 'local') {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parts = <String>[];
|
||||
if (source != 'local') {
|
||||
parts.add('''
|
||||
SELECT
|
||||
COALESCE(SUM(track_count), 0) AS all_count,
|
||||
COALESCE(SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END), 0) AS album_count,
|
||||
COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
|
||||
FROM (
|
||||
SELECT album_key, COUNT(*) AS track_count
|
||||
FROM history_db.history
|
||||
GROUP BY album_key
|
||||
)
|
||||
''');
|
||||
}
|
||||
if (request.includeLocal && source != 'downloaded') {
|
||||
parts.add('''
|
||||
SELECT
|
||||
COALESCE(SUM(track_count), 0) AS all_count,
|
||||
COALESCE(SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END), 0) AS album_count,
|
||||
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 ${LibrarySchema.visibleView} l
|
||||
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 = l.id
|
||||
)
|
||||
GROUP BY l.album_key
|
||||
)
|
||||
''');
|
||||
}
|
||||
if (parts.isEmpty) {
|
||||
return const QueueLibraryCounts(
|
||||
allTrackCount: 0,
|
||||
albumCount: 0,
|
||||
singleTrackCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
final rows = await _db.rawQuery('''
|
||||
SELECT
|
||||
COALESCE(SUM(all_count), 0) AS all_count,
|
||||
COALESCE(SUM(album_count), 0) AS album_count,
|
||||
COALESCE(SUM(single_count), 0) AS single_count
|
||||
FROM (${parts.join(' UNION ALL ')})
|
||||
''');
|
||||
final row = rows.isEmpty ? const <String, Object?>{} : rows.first;
|
||||
return QueueLibraryCounts(
|
||||
allTrackCount: (row['all_count'] as num?)?.toInt() ?? 0,
|
||||
albumCount: (row['album_count'] as num?)?.toInt() ?? 0,
|
||||
singleTrackCount: (row['single_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<QueueLibraryDbPage> albumPage(QueueLibraryDbQuery request) async {
|
||||
final args = <Object?>[];
|
||||
final orderTerms = _queueAlbumOrderTerms(request.sortMode);
|
||||
final usesCursor =
|
||||
request.cursor != null &&
|
||||
request.cursor!.values.length == orderTerms.length;
|
||||
final unionSql = _queueAlbumUnionSql(
|
||||
request,
|
||||
args,
|
||||
orderTerms: orderTerms,
|
||||
usesCursor: usesCursor,
|
||||
);
|
||||
final rows = await _db.rawQuery(
|
||||
'''
|
||||
SELECT *
|
||||
FROM ($unionSql)
|
||||
ORDER BY ${_queueOrderBy(orderTerms)}
|
||||
LIMIT ? ${usesCursor ? '' : 'OFFSET ?'}
|
||||
''',
|
||||
[...args, request.limit, if (!usesCursor) request.offset],
|
||||
);
|
||||
return QueueLibraryDbPage(
|
||||
rows: rows.toList(growable: false),
|
||||
nextCursor: _queueCursorFromRow(rows.lastOrNull, orderTerms),
|
||||
);
|
||||
}
|
||||
|
||||
/// Album artists across downloaded and scanned music, without loading tracks
|
||||
/// into Dart. Scanned paths already represented by downloads are excluded.
|
||||
Future<List<Map<String, dynamic>>> artistPage(
|
||||
QueueLibraryDbQuery request,
|
||||
) async {
|
||||
final parts = <String>[
|
||||
'''
|
||||
SELECT sort_album_artist AS artist_key,
|
||||
COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name,
|
||||
cover_url, NULL AS cover_path, file_path AS sample_file_path
|
||||
FROM history_db.history
|
||||
''',
|
||||
if (request.includeLocal)
|
||||
'''
|
||||
SELECT album_artist_norm AS artist_key,
|
||||
COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name,
|
||||
NULL AS cover_url, cover_path, file_path AS sample_file_path
|
||||
FROM ${LibrarySchema.visibleView} l
|
||||
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 = l.id
|
||||
)
|
||||
''',
|
||||
];
|
||||
final search = sqlite.normalizeLookupText(request.searchQuery);
|
||||
return _db.rawQuery(
|
||||
'''
|
||||
SELECT artist_key, MIN(artist_name) AS artist_name,
|
||||
MAX(NULLIF(cover_url, '')) AS cover_url,
|
||||
MAX(NULLIF(cover_path, '')) AS cover_path,
|
||||
MAX(sample_file_path) AS sample_file_path,
|
||||
COUNT(*) AS track_count
|
||||
FROM (${parts.join(' UNION ALL ')})
|
||||
WHERE artist_key != '' ${search.isEmpty ? '' : "AND artist_key LIKE ? ESCAPE '\\'"}
|
||||
GROUP BY artist_key
|
||||
ORDER BY artist_key
|
||||
LIMIT ? OFFSET ?
|
||||
''',
|
||||
[
|
||||
if (search.isNotEmpty) '%${_escapeLikePattern(search)}%',
|
||||
request.limit,
|
||||
request.offset,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _escapeLikePattern(String value) {
|
||||
return value
|
||||
.replaceAll('\\', r'\\')
|
||||
.replaceAll('%', r'\%')
|
||||
.replaceAll('_', r'\_');
|
||||
}
|
||||
|
||||
String _queueTrackUnionSql(
|
||||
QueueLibraryDbQuery request,
|
||||
List<Object?> args, {
|
||||
@@ -115,7 +392,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
where.add('''
|
||||
l.album_key IN (
|
||||
SELECT album_key
|
||||
FROM ${LibraryDatabase.visibleLibraryView} candidate
|
||||
FROM ${LibrarySchema.visibleView} candidate
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM library_path_keys lpk
|
||||
@@ -167,7 +444,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
l.bitrate,
|
||||
l.format,
|
||||
l.has_replaygain,
|
||||
CASE WHEN l.audio_metadata_scan_version >= ${LibraryDatabase.audioMetadataScanVersion}
|
||||
CASE WHEN l.audio_metadata_scan_version >= $libraryAudioMetadataScanVersion
|
||||
THEN 1 ELSE 0 END AS replaygain_metadata_scan_version,
|
||||
l.track_name_norm AS sort_track,
|
||||
l.artist_name_norm AS sort_artist,
|
||||
@@ -175,7 +452,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
l.sort_genre,
|
||||
l.sort_release,
|
||||
l.sort_added
|
||||
FROM ${LibraryDatabase.visibleLibraryView} l
|
||||
FROM ${LibrarySchema.visibleView} l
|
||||
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
|
||||
''';
|
||||
parts.add(
|
||||
@@ -343,13 +620,13 @@ 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 ${LibraryDatabase.visibleLibraryView} l
|
||||
FROM ${LibrarySchema.visibleView} l
|
||||
JOIN (
|
||||
SELECT
|
||||
album_key,
|
||||
COUNT(*) AS track_count,
|
||||
MAX(COALESCE(sort_added, 0)) AS latest_added
|
||||
FROM ${LibraryDatabase.visibleLibraryView} candidate
|
||||
FROM ${LibrarySchema.visibleView} candidate
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM library_path_keys lpk
|
||||
@@ -413,12 +690,12 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
);
|
||||
if (request.albumArtist != null) {
|
||||
where.add('h.sort_album_artist = ?');
|
||||
args.add(LibraryDatabase.normalizeLookupText(request.albumArtist));
|
||||
args.add(sqlite.normalizeLookupText(request.albumArtist));
|
||||
}
|
||||
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
|
||||
final query = sqlite.normalizeLookupText(request.searchQuery);
|
||||
if (query.isNotEmpty) {
|
||||
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
|
||||
if (HistoryDatabase.instance.searchFtsAvailable && ftsQuery != null) {
|
||||
if (historyFts && ftsQuery != null) {
|
||||
where.add('''
|
||||
h.rowid IN (
|
||||
SELECT rowid
|
||||
@@ -469,17 +746,19 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
);
|
||||
if (request.albumArtist != null) {
|
||||
where.add('l.album_artist_norm = ?');
|
||||
args.add(LibraryDatabase.normalizeLookupText(request.albumArtist));
|
||||
args.add(sqlite.normalizeLookupText(request.albumArtist));
|
||||
}
|
||||
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
|
||||
final query = sqlite.normalizeLookupText(request.searchQuery);
|
||||
if (query.isNotEmpty) {
|
||||
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
|
||||
if (searchFtsAvailable && ftsQuery != null) {
|
||||
if (localFts && ftsQuery != null) {
|
||||
where.add('''
|
||||
l.rowid IN (
|
||||
SELECT rowid
|
||||
FROM library_search_fts
|
||||
WHERE library_search_fts MATCH ?
|
||||
l.id IN (
|
||||
SELECT id FROM library WHERE rowid IN (
|
||||
SELECT rowid
|
||||
FROM library_search_fts
|
||||
WHERE library_search_fts MATCH ?
|
||||
)
|
||||
)
|
||||
''');
|
||||
args.add(ftsQuery);
|
||||
@@ -507,7 +786,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
labelExpr: 'l.label',
|
||||
hasLyricsExpr: 'l.has_lyrics',
|
||||
lyricsKnownExpr:
|
||||
'COALESCE(l.audio_metadata_scan_version, 0) >= ${LibraryDatabase.lyricsMetadataScanVersion}',
|
||||
'COALESCE(l.audio_metadata_scan_version, 0) >= $libraryLyricsMetadataScanVersion',
|
||||
hasReplayGainExpr: 'l.has_replaygain',
|
||||
);
|
||||
}
|
||||
@@ -658,14 +937,6 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
String _queueTrackOrderBy(String sortMode) {
|
||||
return _queueOrderBy(_queueTrackOrderTerms(sortMode));
|
||||
}
|
||||
|
||||
String _queueAlbumOrderBy(String sortMode) {
|
||||
return _queueOrderBy(_queueAlbumOrderTerms(sortMode));
|
||||
}
|
||||
|
||||
String _boundedQueuePart(
|
||||
String selectSql,
|
||||
QueueLibraryDbQuery request,
|
||||
@@ -881,50 +1152,36 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
|
||||
Map<String, dynamic> _queueTrackRowToJson(Map<String, dynamic> row) {
|
||||
final source = row['queue_source'] as String? ?? '';
|
||||
if (source == 'local') {
|
||||
return {
|
||||
'source': source,
|
||||
'item': {
|
||||
'id': row['id'],
|
||||
'trackName': row['track_name'],
|
||||
'artistName': row['artist_name'],
|
||||
'albumName': row['album_name'],
|
||||
'albumArtist': row['album_artist'],
|
||||
'filePath': row['file_path'],
|
||||
'coverPath': row['cover_path'],
|
||||
'scannedAt': row['scanned_at'],
|
||||
'fileModTime': row['file_mod_time'],
|
||||
'isrc': row['isrc'],
|
||||
'trackNumber': row['track_number'],
|
||||
'totalTracks': row['total_tracks'],
|
||||
'discNumber': row['disc_number'],
|
||||
'totalDiscs': row['total_discs'],
|
||||
'duration': row['duration'],
|
||||
'releaseDate': row['release_date'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'bitrate': row['bitrate'],
|
||||
'genre': row['genre'],
|
||||
'composer': row['composer'],
|
||||
'label': row['label'],
|
||||
'copyright': row['copyright'],
|
||||
'format': row['format'],
|
||||
'hasReplayGain':
|
||||
row['has_replaygain'] == 1 || row['has_replaygain'] == true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
'source': source,
|
||||
'item': {
|
||||
'id': row['id'],
|
||||
'trackName': row['track_name'],
|
||||
'artistName': row['artist_name'],
|
||||
'albumName': row['album_name'],
|
||||
'albumArtist': row['album_artist'],
|
||||
final item = <String, dynamic>{
|
||||
'id': row['id'],
|
||||
'trackName': row['track_name'],
|
||||
'artistName': row['artist_name'],
|
||||
'albumName': row['album_name'],
|
||||
'albumArtist': row['album_artist'],
|
||||
'filePath': row['file_path'],
|
||||
'isrc': row['isrc'],
|
||||
'trackNumber': row['track_number'],
|
||||
'totalTracks': row['total_tracks'],
|
||||
'discNumber': row['disc_number'],
|
||||
'totalDiscs': row['total_discs'],
|
||||
'duration': row['duration'],
|
||||
'releaseDate': row['release_date'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'bitrate': row['bitrate'],
|
||||
'format': row['format'],
|
||||
'hasReplayGain':
|
||||
row['has_replaygain'] == 1 || row['has_replaygain'] == true,
|
||||
'genre': row['genre'],
|
||||
'composer': row['composer'],
|
||||
'label': row['label'],
|
||||
'copyright': row['copyright'],
|
||||
if (source == 'local') ...{
|
||||
'coverPath': row['cover_path'],
|
||||
'scannedAt': row['scanned_at'],
|
||||
'fileModTime': row['file_mod_time'],
|
||||
} else ...{
|
||||
'coverUrl': row['cover_url'],
|
||||
'filePath': row['file_path'],
|
||||
'storageMode': row['storage_mode'],
|
||||
'downloadTreeUri': row['download_tree_uri'],
|
||||
'safRelativeDir': row['saf_relative_dir'],
|
||||
@@ -932,28 +1189,12 @@ extension _LibraryDbQueueSql on LibraryDatabase {
|
||||
'safRepaired': row['saf_repaired'] == 1 || row['saf_repaired'] == true,
|
||||
'service': row['service'],
|
||||
'downloadedAt': row['downloaded_at'],
|
||||
'isrc': row['isrc'],
|
||||
'spotifyId': row['spotify_id'],
|
||||
'trackNumber': row['track_number'],
|
||||
'totalTracks': row['total_tracks'],
|
||||
'discNumber': row['disc_number'],
|
||||
'totalDiscs': row['total_discs'],
|
||||
'duration': row['duration'],
|
||||
'releaseDate': row['release_date'],
|
||||
'quality': row['quality'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'bitrate': row['bitrate'],
|
||||
'format': row['format'],
|
||||
'hasReplayGain':
|
||||
row['has_replaygain'] == 1 || row['has_replaygain'] == true,
|
||||
'replayGainMetadataScanVersion':
|
||||
row['replaygain_metadata_scan_version'] ?? 0,
|
||||
'genre': row['genre'],
|
||||
'composer': row['composer'],
|
||||
'label': row['label'],
|
||||
'copyright': row['copyright'],
|
||||
},
|
||||
};
|
||||
return {'source': source, 'item': item};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:spotiflac_android/services/library_database_models.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
|
||||
/// Maps scan/domain metadata to storage rows without opening a database.
|
||||
abstract final class LibraryRowMapper {
|
||||
static const _columns = {
|
||||
'id': 'id',
|
||||
'trackName': 'track_name',
|
||||
'artistName': 'artist_name',
|
||||
'albumName': 'album_name',
|
||||
'albumArtist': 'album_artist',
|
||||
'filePath': 'file_path',
|
||||
'coverPath': 'cover_path',
|
||||
'scannedAt': 'scanned_at',
|
||||
'fileModTime': 'file_mod_time',
|
||||
'isrc': 'isrc',
|
||||
'trackNumber': 'track_number',
|
||||
'totalTracks': 'total_tracks',
|
||||
'discNumber': 'disc_number',
|
||||
'totalDiscs': 'total_discs',
|
||||
'duration': 'duration',
|
||||
'releaseDate': 'release_date',
|
||||
'bitDepth': 'bit_depth',
|
||||
'sampleRate': 'sample_rate',
|
||||
'bitrate': 'bitrate',
|
||||
'genre': 'genre',
|
||||
'composer': 'composer',
|
||||
'label': 'label',
|
||||
'copyright': 'copyright',
|
||||
'format': 'format',
|
||||
};
|
||||
|
||||
static bool _flag(Object? value) => value == true || value == 1;
|
||||
|
||||
static Map<String, dynamic> encode(
|
||||
Map<String, dynamic> json, {
|
||||
String? sourceId,
|
||||
}) => {
|
||||
for (final column in _columns.entries) column.value: json[column.key],
|
||||
'source_id':
|
||||
sourceId ?? json['sourceId'] ?? LocalLibraryItem.legacySourceId,
|
||||
'explicit': _flag(json['explicit']) ? 1 : 0,
|
||||
'has_lyrics': _flag(json['hasLyrics']) ? 1 : 0,
|
||||
'has_replaygain': metadataHasReplayGain(json) ? 1 : 0,
|
||||
'audio_metadata_scan_version':
|
||||
(json['audioMetadataScanVersion'] as num?)?.toInt() ??
|
||||
(json['metadataFromFilename'] == true
|
||||
? 0
|
||||
: libraryAudioMetadataScanVersion),
|
||||
...normalized(
|
||||
trackName: json['trackName'] as String? ?? '',
|
||||
artistName: json['artistName'] as String? ?? '',
|
||||
albumName: json['albumName'] as String? ?? '',
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
),
|
||||
...queue(
|
||||
trackName: json['trackName'] as String?,
|
||||
artistName: json['artistName'] as String?,
|
||||
albumName: json['albumName'] as String?,
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
genre: json['genre'] as String?,
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
fileModTime: (json['fileModTime'] as num?)?.toInt(),
|
||||
scannedAt: json['scannedAt'] as String?,
|
||||
),
|
||||
};
|
||||
|
||||
static Map<String, dynamic> decode(Map<String, dynamic> row) => {
|
||||
for (final column in _columns.entries) column.key: row[column.value],
|
||||
'sourceId': row['source_id'] ?? LocalLibraryItem.legacySourceId,
|
||||
'explicit': _flag(row['explicit']),
|
||||
'hasLyrics': _flag(row['has_lyrics']),
|
||||
'hasReplayGain': _flag(row['has_replaygain']),
|
||||
};
|
||||
|
||||
static Map<String, dynamic> normalized({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required String albumName,
|
||||
required String? albumArtist,
|
||||
}) {
|
||||
final track = sqlite.normalizeLookupText(trackName);
|
||||
final artist = sqlite.normalizeLookupText(artistName);
|
||||
final album = sqlite.normalizeLookupText(albumName);
|
||||
final albumArtistValue = sqlite.normalizeLookupText(
|
||||
albumArtist ?? artistName,
|
||||
);
|
||||
return {
|
||||
'track_name_norm': track,
|
||||
'artist_name_norm': artist,
|
||||
'album_name_norm': album,
|
||||
'album_artist_norm': albumArtistValue,
|
||||
'match_key': '$track|$artist',
|
||||
'album_key': '$album|$albumArtistValue',
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> queue({
|
||||
required String? trackName,
|
||||
required String? artistName,
|
||||
required String? albumName,
|
||||
required String? albumArtist,
|
||||
required String? genre,
|
||||
required String? releaseDate,
|
||||
required int? fileModTime,
|
||||
required String? scannedAt,
|
||||
}) => {
|
||||
'search_text':
|
||||
[
|
||||
trackName,
|
||||
artistName,
|
||||
albumName,
|
||||
(albumArtist ?? '').trim().isEmpty ? artistName : albumArtist,
|
||||
]
|
||||
.map(sqlite.normalizeLookupText)
|
||||
.where((value) => value.isNotEmpty)
|
||||
.join(' '),
|
||||
'sort_genre': sqlite.normalizeLookupText(genre),
|
||||
'sort_release': releaseDate?.trim() ?? '',
|
||||
'sort_added':
|
||||
fileModTime ??
|
||||
DateTime.tryParse(scannedAt ?? '')?.millisecondsSinceEpoch ??
|
||||
0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/services/library_database_models.dart';
|
||||
import 'package:spotiflac_android/services/library_row_mapper.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
import 'package:spotiflac_android/utils/ios_container_paths.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('LibraryDatabase');
|
||||
|
||||
/// Schema lifecycle is independent of collection reads and scan transactions.
|
||||
abstract final class LibrarySchema {
|
||||
static const version = 17;
|
||||
static const visibleView = 'library_visible';
|
||||
static const searchFtsTable = 'library_search_fts';
|
||||
static const lookupSummaryTable = 'library_lookup_summary';
|
||||
static const _legacySourceId = LocalLibraryItem.legacySourceId;
|
||||
|
||||
static Future<bool> create(Database db, int version) async {
|
||||
_log.i('Creating library database schema v$version');
|
||||
await db.execute('''
|
||||
CREATE TABLE library (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL DEFAULT '$_legacySourceId',
|
||||
track_name TEXT NOT NULL,
|
||||
artist_name TEXT NOT NULL,
|
||||
album_name TEXT NOT NULL,
|
||||
album_artist TEXT,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
scanned_at TEXT NOT NULL,
|
||||
file_mod_time INTEGER,
|
||||
isrc TEXT,
|
||||
track_number INTEGER,
|
||||
total_tracks INTEGER,
|
||||
disc_number INTEGER,
|
||||
total_discs INTEGER,
|
||||
duration INTEGER,
|
||||
release_date TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
bitrate INTEGER,
|
||||
genre TEXT,
|
||||
composer TEXT,
|
||||
label TEXT,
|
||||
copyright TEXT,
|
||||
explicit INTEGER NOT NULL DEFAULT 0,
|
||||
has_lyrics INTEGER NOT NULL DEFAULT 0,
|
||||
has_replaygain INTEGER NOT NULL DEFAULT 0,
|
||||
format TEXT,
|
||||
audio_metadata_scan_version INTEGER NOT NULL DEFAULT 4,
|
||||
track_name_norm TEXT,
|
||||
artist_name_norm TEXT,
|
||||
album_name_norm TEXT,
|
||||
album_artist_norm TEXT,
|
||||
match_key TEXT,
|
||||
album_key TEXT,
|
||||
search_text TEXT,
|
||||
sort_genre TEXT,
|
||||
sort_release TEXT,
|
||||
sort_added INTEGER
|
||||
)
|
||||
''');
|
||||
for (final index in const {
|
||||
'isrc': 'isrc',
|
||||
'track_artist': 'track_name, artist_name',
|
||||
'album': 'album_name, album_artist',
|
||||
'file_path': 'file_path',
|
||||
}.entries) {
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_library_${index.key} ON library(${index.value})',
|
||||
);
|
||||
}
|
||||
await _createNormalizedIndexes(db);
|
||||
await _createQueueIndexes(db);
|
||||
await sqlite.createPathKeyTable(db, 'library_path_keys');
|
||||
await _createSources(db);
|
||||
await _createLookupSummary(db);
|
||||
final ftsAvailable = await createSearchFts(db);
|
||||
_log.i('Library database schema created with indexes');
|
||||
return ftsAvailable;
|
||||
}
|
||||
|
||||
static Future<void> upgrade(
|
||||
Database db,
|
||||
int oldVersion,
|
||||
int newVersion,
|
||||
) async {
|
||||
_log.i('Upgrading library database from v$oldVersion to v$newVersion');
|
||||
// Keep historical defaults: older rows require a metadata rescan, while
|
||||
// freshly scanned rows are written with their actual scan version.
|
||||
for (final addition in const [
|
||||
(2, 'cover_path', 'TEXT'),
|
||||
(3, 'file_mod_time', 'INTEGER'),
|
||||
(4, 'bitrate', 'INTEGER'),
|
||||
(5, 'label', 'TEXT'),
|
||||
(5, 'copyright', 'TEXT'),
|
||||
(6, 'total_tracks', 'INTEGER'),
|
||||
(6, 'total_discs', 'INTEGER'),
|
||||
(6, 'composer', 'TEXT'),
|
||||
]) {
|
||||
if (oldVersion < addition.$1) {
|
||||
await db.execute(
|
||||
'ALTER TABLE library ADD COLUMN ${addition.$2} ${addition.$3}',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (oldVersion < 7) {
|
||||
for (final column in const [
|
||||
'track_name_norm',
|
||||
'artist_name_norm',
|
||||
'album_name_norm',
|
||||
'album_artist_norm',
|
||||
'match_key',
|
||||
'album_key',
|
||||
]) {
|
||||
await sqlite.addColumnIfMissing(db, 'library', column, 'TEXT');
|
||||
}
|
||||
await _backfillNormalizedColumns(db);
|
||||
await _createNormalizedIndexes(db);
|
||||
}
|
||||
if (oldVersion < 8) {
|
||||
await sqlite.createPathKeyTable(db, 'library_path_keys');
|
||||
}
|
||||
if (oldVersion < 9) {
|
||||
await sqlite.addColumnIfMissing(
|
||||
db,
|
||||
'library',
|
||||
'audio_metadata_scan_version',
|
||||
'INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 10) {
|
||||
for (final column in const [
|
||||
'search_text',
|
||||
'sort_genre',
|
||||
'sort_release',
|
||||
'sort_added',
|
||||
]) {
|
||||
await sqlite.addColumnIfMissing(
|
||||
db,
|
||||
'library',
|
||||
column,
|
||||
column == 'sort_added' ? 'INTEGER' : 'TEXT',
|
||||
);
|
||||
}
|
||||
await _backfillQueueColumns(db);
|
||||
await _createQueueIndexes(db);
|
||||
}
|
||||
if (oldVersion < 11) {
|
||||
await sqlite.addColumnIfMissing(
|
||||
db,
|
||||
'library',
|
||||
'source_id',
|
||||
"TEXT NOT NULL DEFAULT '$_legacySourceId'",
|
||||
);
|
||||
await _createSources(db);
|
||||
}
|
||||
for (final addition in const [(12, 'explicit'), (13, 'has_lyrics')]) {
|
||||
if (oldVersion < addition.$1) {
|
||||
await sqlite.addColumnIfMissing(
|
||||
db,
|
||||
'library',
|
||||
addition.$2,
|
||||
'INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (oldVersion < 15) {
|
||||
await sqlite.addColumnIfMissing(
|
||||
db,
|
||||
'library',
|
||||
'has_replaygain',
|
||||
'INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 16) {
|
||||
await sqlite.backfillPathKeys(db, 'library', 'library_path_keys');
|
||||
}
|
||||
if (oldVersion < 17) {
|
||||
// Reinstall the triggers and repair counts created by older upserts.
|
||||
await _createLookupSummary(db);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> createSearchFts(DatabaseExecutor db) =>
|
||||
sqlite.createTrigramFtsIndex(
|
||||
db,
|
||||
ftsTable: searchFtsTable,
|
||||
contentTable: 'library',
|
||||
triggerPrefix: 'library_search_fts',
|
||||
);
|
||||
|
||||
static Future<void> _createSources(DatabaseExecutor db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS library_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
bookmark TEXT,
|
||||
volume_id TEXT,
|
||||
is_removable INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
available INTEGER NOT NULL DEFAULT 1,
|
||||
last_scanned_at TEXT,
|
||||
last_seen_at TEXT,
|
||||
last_scan_error TEXT
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_library_source_id ON library(source_id)',
|
||||
);
|
||||
await db.insert('library_sources', {
|
||||
'id': _legacySourceId,
|
||||
'path': 'legacy://local-library',
|
||||
'display_name': 'Music',
|
||||
'enabled': 1,
|
||||
'available': 1,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
await db.execute('DROP VIEW IF EXISTS $visibleView');
|
||||
await db.execute('''
|
||||
CREATE VIEW $visibleView AS
|
||||
SELECT l.* FROM library l
|
||||
JOIN library_sources s ON s.id = l.source_id
|
||||
WHERE s.enabled = 1 AND s.available = 1
|
||||
''');
|
||||
}
|
||||
|
||||
static Future<void> _createNormalizedIndexes(DatabaseExecutor db) async {
|
||||
for (final column in const {
|
||||
'match_key': 'match_key',
|
||||
'album_key': 'album_key',
|
||||
'track_norm': 'track_name_norm',
|
||||
'artist_norm': 'artist_name_norm',
|
||||
'album_norm': 'album_name_norm',
|
||||
'scanned_at': 'scanned_at',
|
||||
}.entries) {
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_library_${column.key} ON library(${column.value})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _createQueueIndexes(DatabaseExecutor db) async {
|
||||
for (final order in const {
|
||||
'added': 'sort_added DESC, track_name_norm, id',
|
||||
'track': 'track_name_norm, artist_name_norm, id',
|
||||
'artist': 'artist_name_norm, track_name_norm, id',
|
||||
'album': 'album_name_norm, track_name_norm, id',
|
||||
'genre': 'sort_genre, track_name_norm, id',
|
||||
'release': 'sort_release, track_name_norm, id',
|
||||
}.entries) {
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_library_queue_${order.key} ON library(${order.value})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _backfillNormalizedColumns(Database db) async {
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
columns: [
|
||||
'id',
|
||||
'track_name',
|
||||
'artist_name',
|
||||
'album_name',
|
||||
'album_artist',
|
||||
],
|
||||
);
|
||||
final batch = db.batch();
|
||||
for (final row in rows) {
|
||||
batch.update(
|
||||
'library',
|
||||
LibraryRowMapper.normalized(
|
||||
trackName: row['track_name'] as String? ?? '',
|
||||
artistName: row['artist_name'] as String? ?? '',
|
||||
albumName: row['album_name'] as String? ?? '',
|
||||
albumArtist: row['album_artist'] as String?,
|
||||
),
|
||||
where: 'id = ?',
|
||||
whereArgs: [row['id']],
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
static Future<void> _backfillQueueColumns(Database db) async {
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
columns: [
|
||||
'id',
|
||||
'track_name',
|
||||
'artist_name',
|
||||
'album_name',
|
||||
'album_artist',
|
||||
'genre',
|
||||
'release_date',
|
||||
'file_mod_time',
|
||||
'scanned_at',
|
||||
],
|
||||
);
|
||||
final batch = db.batch();
|
||||
for (final row in rows) {
|
||||
batch.update(
|
||||
'library',
|
||||
LibraryRowMapper.queue(
|
||||
trackName: row['track_name'] as String?,
|
||||
artistName: row['artist_name'] as String?,
|
||||
albumName: row['album_name'] as String?,
|
||||
albumArtist: row['album_artist'] as String?,
|
||||
genre: row['genre'] as String?,
|
||||
releaseDate: row['release_date'] as String?,
|
||||
fileModTime: (row['file_mod_time'] as num?)?.toInt(),
|
||||
scannedAt: row['scanned_at'] as String?,
|
||||
),
|
||||
where: 'id = ?',
|
||||
whereArgs: [row['id']],
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
static Future<void> _createLookupSummary(DatabaseExecutor db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS $lookupSummaryTable (
|
||||
source_id TEXT NOT NULL, kind TEXT NOT NULL,
|
||||
value TEXT NOT NULL, ref_count INTEGER NOT NULL,
|
||||
PRIMARY KEY (source_id, kind, value)
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_library_lookup_summary_value ON $lookupSummaryTable(kind, value)',
|
||||
);
|
||||
for (final (kind, column) in const [
|
||||
('isrc', 'isrc'),
|
||||
('match', 'match_key'),
|
||||
]) {
|
||||
for (final operation in const ['insert', 'delete', 'update']) {
|
||||
await db.execute(
|
||||
'DROP TRIGGER IF EXISTS library_lookup_${operation}_$kind',
|
||||
);
|
||||
}
|
||||
await db.execute('''
|
||||
CREATE TRIGGER library_lookup_insert_$kind AFTER INSERT ON library
|
||||
WHEN NEW.$column IS NOT NULL AND NEW.$column != ''
|
||||
BEGIN
|
||||
INSERT INTO $lookupSummaryTable(source_id, kind, value, ref_count)
|
||||
SELECT NEW.source_id, '$kind', NEW.$column, 0
|
||||
-- An outer INSERT OR REPLACE overrides trigger conflict policies.
|
||||
-- Avoid a conflicting insert instead of relying on OR IGNORE.
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM $lookupSummaryTable
|
||||
WHERE source_id = NEW.source_id AND kind = '$kind' AND value = NEW.$column
|
||||
);
|
||||
UPDATE $lookupSummaryTable SET ref_count = ref_count + 1
|
||||
WHERE source_id = NEW.source_id AND kind = '$kind' AND value = NEW.$column;
|
||||
END
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TRIGGER library_lookup_delete_$kind AFTER DELETE ON library
|
||||
WHEN OLD.$column IS NOT NULL AND OLD.$column != ''
|
||||
BEGIN
|
||||
UPDATE $lookupSummaryTable SET ref_count = ref_count - 1
|
||||
WHERE source_id = OLD.source_id AND kind = '$kind' AND value = OLD.$column;
|
||||
DELETE FROM $lookupSummaryTable
|
||||
WHERE source_id = OLD.source_id AND kind = '$kind' AND value = OLD.$column
|
||||
AND ref_count <= 0;
|
||||
END
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TRIGGER library_lookup_update_$kind AFTER UPDATE OF source_id, $column ON library
|
||||
WHEN OLD.source_id IS NOT NEW.source_id OR OLD.$column IS NOT NEW.$column
|
||||
BEGIN
|
||||
UPDATE $lookupSummaryTable SET ref_count = ref_count - 1
|
||||
WHERE OLD.$column IS NOT NULL AND OLD.$column != ''
|
||||
AND source_id = OLD.source_id AND kind = '$kind' AND value = OLD.$column;
|
||||
DELETE FROM $lookupSummaryTable
|
||||
WHERE source_id = OLD.source_id AND kind = '$kind' AND value = OLD.$column
|
||||
AND ref_count <= 0;
|
||||
INSERT INTO $lookupSummaryTable(source_id, kind, value, ref_count)
|
||||
SELECT NEW.source_id, '$kind', NEW.$column, 0
|
||||
WHERE NEW.$column IS NOT NULL AND NEW.$column != '' AND NOT EXISTS (
|
||||
SELECT 1 FROM $lookupSummaryTable
|
||||
WHERE source_id = NEW.source_id AND kind = '$kind' AND value = NEW.$column
|
||||
);
|
||||
UPDATE $lookupSummaryTable SET ref_count = ref_count + 1
|
||||
WHERE NEW.$column IS NOT NULL AND NEW.$column != ''
|
||||
AND source_id = NEW.source_id AND kind = '$kind' AND value = NEW.$column;
|
||||
END
|
||||
''');
|
||||
}
|
||||
await db.delete(lookupSummaryTable);
|
||||
for (final (kind, column) in const [
|
||||
('isrc', 'isrc'),
|
||||
('match', 'match_key'),
|
||||
]) {
|
||||
await db.rawInsert('''
|
||||
INSERT INTO $lookupSummaryTable(source_id, kind, value, ref_count)
|
||||
SELECT source_id, '$kind', $column, COUNT(*)
|
||||
FROM library WHERE $column IS NOT NULL AND $column != ''
|
||||
GROUP BY source_id, $column
|
||||
''');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> migrateIosContainerPaths(Database db) async {
|
||||
if (!Platform.isIOS) return;
|
||||
final documents = await getApplicationDocumentsDirectory();
|
||||
await db.transaction((txn) async {
|
||||
final sources = await txn.query(
|
||||
'library_sources',
|
||||
columns: ['id', 'path'],
|
||||
where: "bookmark IS NULL OR bookmark = ''",
|
||||
);
|
||||
final localSourceIds = sources.map((source) => source['id']).toSet();
|
||||
final rows = await txn.query(
|
||||
'library',
|
||||
columns: ['id', 'source_id', 'file_path', 'cover_path'],
|
||||
);
|
||||
final batch = txn.batch();
|
||||
for (final row in rows) {
|
||||
final updates = <String, Object?>{};
|
||||
for (final column in ['file_path', 'cover_path']) {
|
||||
// External audio paths remain bookmark-owned. Extracted covers
|
||||
// always belong to our sandbox, including bookmarked sources.
|
||||
if (column == 'file_path' &&
|
||||
!localSourceIds.contains(row['source_id'])) {
|
||||
continue;
|
||||
}
|
||||
final previous = row[column] as String?;
|
||||
if (previous == null) continue;
|
||||
final current = rebaseIosSandboxPath(previous, documents.path);
|
||||
if (current != previous) updates[column] = current;
|
||||
}
|
||||
if (updates.isEmpty) continue;
|
||||
final id = row['id'] as String;
|
||||
batch.update('library', updates, where: 'id = ?', whereArgs: [id]);
|
||||
if (updates.containsKey('file_path')) {
|
||||
sqlite.putPathKeysInBatch(
|
||||
batch,
|
||||
'library_path_keys',
|
||||
id,
|
||||
updates['file_path'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final source in sources) {
|
||||
final previous = source['path'] as String;
|
||||
final current = rebaseIosSandboxPath(previous, documents.path);
|
||||
if (current == previous) continue;
|
||||
batch.update(
|
||||
'library_sources',
|
||||
{'path': current},
|
||||
where: 'id = ?',
|
||||
whereArgs: [source['id']],
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ print(json.dumps([dict(row) for row in db.execute(data['sql'], data['args'])]))
|
||||
''',
|
||||
jsonEncode({
|
||||
'history': _definition('lib/services/history_database.dart', 'history'),
|
||||
'library': _definition('lib/services/library_database.dart', 'library'),
|
||||
'library': _definition('lib/services/library_schema.dart', 'library'),
|
||||
'sql': sql,
|
||||
'args': arguments ?? [],
|
||||
}),
|
||||
|
||||
@@ -143,7 +143,7 @@ print('inventory passed')
|
||||
'history',
|
||||
),
|
||||
'library': _tableDefinition(
|
||||
'lib/services/library_database.dart',
|
||||
'lib/services/library_schema.dart',
|
||||
'library',
|
||||
),
|
||||
'combined': albumCompletenessSql(
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/library_database_models.dart';
|
||||
import 'package:spotiflac_android/services/library_queue_store.dart';
|
||||
import 'package:spotiflac_android/services/library_row_mapper.dart';
|
||||
import 'package:spotiflac_android/services/library_schema.dart';
|
||||
|
||||
import 'support/sqlite_process_database.dart';
|
||||
|
||||
List<String> _tracks(QueueLibraryDbPage page) => [
|
||||
for (final row in page.rows) '${row['source']}:${(row['item'] as Map)['id']}',
|
||||
];
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late SqliteProcessDatabase db;
|
||||
late LibraryQueueStore store;
|
||||
|
||||
Future<void> addHistory(
|
||||
String id,
|
||||
String album,
|
||||
int number, {
|
||||
String artist = 'Artist',
|
||||
String title = 'Song',
|
||||
String? path,
|
||||
}) async {
|
||||
final file = path ?? '/$id.flac';
|
||||
await db.insert('history_db.history', {
|
||||
'id': id,
|
||||
'track_name': title,
|
||||
'artist_name': artist,
|
||||
'album_name': album,
|
||||
'album_artist': artist,
|
||||
'file_path': file,
|
||||
'downloaded_at': '2026-01-01',
|
||||
'service': 'example-provider',
|
||||
'album_key': '${album.toLowerCase()}|${artist.toLowerCase()}',
|
||||
'track_number': number,
|
||||
'total_tracks': 2,
|
||||
'disc_number': 1,
|
||||
'total_discs': 1,
|
||||
'bit_depth': 16,
|
||||
'genre': 'Pop',
|
||||
'release_date': '2026',
|
||||
'sort_track': title.toLowerCase(),
|
||||
'sort_artist': artist.toLowerCase(),
|
||||
'sort_album': album.toLowerCase(),
|
||||
'sort_album_artist': artist.toLowerCase(),
|
||||
'sort_genre': 'pop',
|
||||
'sort_release': '2026',
|
||||
'sort_added': 1,
|
||||
'search_text': '$title $artist $album'.toLowerCase(),
|
||||
'has_replaygain': number == 1 ? 1 : 0,
|
||||
});
|
||||
await db.insert('history_db.history_path_keys', {
|
||||
'item_id': id,
|
||||
'path_key': file,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> addLocal(
|
||||
String id,
|
||||
int number, {
|
||||
String? path,
|
||||
String source = 'legacy',
|
||||
}) async {
|
||||
final file = path ?? '/$id.flac';
|
||||
final track = LocalLibraryItem(
|
||||
id: id,
|
||||
sourceId: source,
|
||||
trackName: 'Song',
|
||||
artistName: 'Artist',
|
||||
albumName: 'Local',
|
||||
filePath: file,
|
||||
scannedAt: DateTime.utc(2026),
|
||||
fileModTime: 1,
|
||||
trackNumber: number,
|
||||
totalTracks: 2,
|
||||
discNumber: 1,
|
||||
totalDiscs: 1,
|
||||
bitDepth: 16,
|
||||
genre: 'Pop',
|
||||
releaseDate: '2026',
|
||||
hasReplayGain: number == 1,
|
||||
);
|
||||
await db.insert('library', LibraryRowMapper.encode(track.toJson()));
|
||||
await db.insert('library_path_keys', {'item_id': id, 'path_key': file});
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
db = await SqliteProcessDatabase.open();
|
||||
await LibrarySchema.create(db, LibrarySchema.version);
|
||||
await db.execute("ATTACH DATABASE ':memory:' AS history_db");
|
||||
final definition = RegExp(r'CREATE TABLE history\s*\([\s\S]*?\n\s*\)')
|
||||
.firstMatch(
|
||||
File('lib/services/history_database.dart').readAsStringSync(),
|
||||
)!
|
||||
.group(0)!;
|
||||
await db.execute(
|
||||
definition.replaceFirst(
|
||||
'CREATE TABLE history',
|
||||
'CREATE TABLE history_db.history',
|
||||
),
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE TABLE history_db.history_path_keys (item_id TEXT, path_key TEXT)',
|
||||
);
|
||||
await addHistory('h1', 'Downloaded', 1);
|
||||
await addHistory('h2', 'Downloaded', 2);
|
||||
await addHistory('single', 'Single', 2, artist: 'Another Artist');
|
||||
await addLocal('l1', 1);
|
||||
await addLocal('l2', 2);
|
||||
await addLocal('mirror', 1, path: '/h1.flac');
|
||||
await db.insert('library_sources', {
|
||||
'id': 'offline',
|
||||
'path': '/offline',
|
||||
'display_name': 'Offline',
|
||||
'available': 0,
|
||||
});
|
||||
await addLocal('hidden', 1, source: 'offline');
|
||||
store = LibraryQueueStore(db, historyFts: false, localFts: false);
|
||||
});
|
||||
tearDown(() => db.close());
|
||||
|
||||
test(
|
||||
'track cursors match offset ordering across source ties for every sort',
|
||||
() async {
|
||||
for (final sort in [
|
||||
'latest',
|
||||
'oldest',
|
||||
'a-z',
|
||||
'z-a',
|
||||
'artist-asc',
|
||||
'artist-desc',
|
||||
'album-asc',
|
||||
'album-desc',
|
||||
'release-oldest',
|
||||
'release-newest',
|
||||
'genre-asc',
|
||||
'genre-desc',
|
||||
]) {
|
||||
final expected = _tracks(
|
||||
await store.trackPage(QueueLibraryDbQuery(sortMode: sort)),
|
||||
);
|
||||
final actual = <String>[];
|
||||
QueueLibraryDbCursor? cursor;
|
||||
do {
|
||||
final page = await store.trackPage(
|
||||
QueueLibraryDbQuery(sortMode: sort, limit: 2, cursor: cursor),
|
||||
);
|
||||
actual.addAll(_tracks(page));
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor != null);
|
||||
expect(actual, expected, reason: sort);
|
||||
expect(actual, hasLength(5), reason: sort);
|
||||
expect(actual, isNot(contains('local:mirror')));
|
||||
expect(actual, isNot(contains('local:hidden')));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'album cursors and invalid-cursor offset fallback preserve results',
|
||||
() async {
|
||||
for (final sort in [
|
||||
'latest',
|
||||
'album-asc',
|
||||
'album-desc',
|
||||
'artist-asc',
|
||||
'release-newest',
|
||||
]) {
|
||||
final expected = (await store.albumPage(
|
||||
QueueLibraryDbQuery(sortMode: sort),
|
||||
)).rows;
|
||||
final first = await store.albumPage(
|
||||
QueueLibraryDbQuery(sortMode: sort, limit: 1),
|
||||
);
|
||||
final second = await store.albumPage(
|
||||
QueueLibraryDbQuery(
|
||||
sortMode: sort,
|
||||
limit: 1,
|
||||
cursor: first.nextCursor,
|
||||
),
|
||||
);
|
||||
expect([...first.rows, ...second.rows], expected, reason: sort);
|
||||
final fallback = await store.albumPage(
|
||||
QueueLibraryDbQuery(
|
||||
sortMode: sort,
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
cursor: const QueueLibraryDbCursor(['wrong length']),
|
||||
),
|
||||
);
|
||||
expect(fallback.rows, [expected.last], reason: sort);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'fast counts agree with filtered counts and deduplicate visible paths',
|
||||
() async {
|
||||
final fast = await store.counts(const QueueLibraryDbQuery());
|
||||
final filtered = await store.counts(
|
||||
const QueueLibraryDbQuery(quality: 'cd'),
|
||||
);
|
||||
expect(
|
||||
[fast.allTrackCount, fast.albumCount, fast.singleTrackCount],
|
||||
[5, 2, 1],
|
||||
);
|
||||
expect(
|
||||
[
|
||||
filtered.allTrackCount,
|
||||
filtered.albumCount,
|
||||
filtered.singleTrackCount,
|
||||
],
|
||||
[5, 2, 1],
|
||||
);
|
||||
final local = await store.counts(
|
||||
const QueueLibraryDbQuery(source: 'local'),
|
||||
);
|
||||
expect(local.allTrackCount, 2);
|
||||
final gain = await store.trackPage(
|
||||
const QueueLibraryDbQuery(metadata: 'missing-replaygain'),
|
||||
);
|
||||
expect(_tracks(gain).toSet(), {
|
||||
'downloaded:h2',
|
||||
'downloaded:single',
|
||||
'local:l2',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'independent FTS inputs match LIKE fallback and escape literal search',
|
||||
() async {
|
||||
await db.execute(
|
||||
"CREATE VIRTUAL TABLE history_db.history_search_fts USING fts5(search_text, tokenize='trigram')",
|
||||
);
|
||||
await db.execute(
|
||||
'INSERT INTO history_db.history_search_fts(rowid, search_text) SELECT rowid, search_text FROM history_db.history',
|
||||
);
|
||||
final expected = _tracks(
|
||||
await store.trackPage(const QueueLibraryDbQuery(searchQuery: 'Artist')),
|
||||
);
|
||||
const search = QueueLibraryDbQuery(searchQuery: 'Artist');
|
||||
final expectedAlbums = (await store.albumPage(search)).rows;
|
||||
final expectedCounts = await store.counts(search);
|
||||
final expectedArtists = await store.artistPage(search);
|
||||
for (final flags in [(true, false), (false, true), (true, true)]) {
|
||||
final indexed = LibraryQueueStore(
|
||||
db,
|
||||
historyFts: flags.$1,
|
||||
localFts: flags.$2,
|
||||
);
|
||||
expect(
|
||||
_tracks(
|
||||
await indexed.trackPage(
|
||||
const QueueLibraryDbQuery(searchQuery: 'Artist'),
|
||||
),
|
||||
),
|
||||
expected,
|
||||
);
|
||||
expect((await indexed.albumPage(search)).rows, expectedAlbums);
|
||||
final counts = await indexed.counts(search);
|
||||
expect(
|
||||
[counts.allTrackCount, counts.albumCount, counts.singleTrackCount],
|
||||
[
|
||||
expectedCounts.allTrackCount,
|
||||
expectedCounts.albumCount,
|
||||
expectedCounts.singleTrackCount,
|
||||
],
|
||||
);
|
||||
expect(await indexed.artistPage(search), expectedArtists);
|
||||
}
|
||||
await addHistory(
|
||||
'literal',
|
||||
'Special',
|
||||
1,
|
||||
artist: 'Literal',
|
||||
title: 'Percent % Track',
|
||||
);
|
||||
expect(
|
||||
_tracks(
|
||||
await store.trackPage(const QueueLibraryDbQuery(searchQuery: '%')),
|
||||
),
|
||||
['downloaded:literal'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'artist pages aggregate visible inventory without loading track models',
|
||||
() async {
|
||||
final artists = await store.artistPage(const QueueLibraryDbQuery());
|
||||
expect(artists.map((row) => row['artist_name']), [
|
||||
'Another Artist',
|
||||
'Artist',
|
||||
]);
|
||||
expect(artists.map((row) => row['track_count']), [1, 4]);
|
||||
final downloaded = await store.artistPage(
|
||||
const QueueLibraryDbQuery(includeLocal: false),
|
||||
);
|
||||
expect(downloaded.map((row) => row['track_count']), [1, 2]);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/services/library_database_models.dart';
|
||||
import 'package:spotiflac_android/services/library_row_mapper.dart';
|
||||
import 'package:spotiflac_android/services/library_schema.dart';
|
||||
|
||||
import 'support/sqlite_process_database.dart';
|
||||
|
||||
Map<String, dynamic> _track(String id, {String isrc = 'same'}) =>
|
||||
LocalLibraryItem(
|
||||
id: id,
|
||||
trackName: ' Song ',
|
||||
artistName: 'ARTIST',
|
||||
albumName: 'Album',
|
||||
filePath: '/music/$id.flac',
|
||||
scannedAt: DateTime.utc(2026),
|
||||
isrc: isrc,
|
||||
trackNumber: 1,
|
||||
totalTracks: 2,
|
||||
hasLyrics: true,
|
||||
hasReplayGain: true,
|
||||
).toJson();
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late SqliteProcessDatabase db;
|
||||
setUp(() async {
|
||||
db = await SqliteProcessDatabase.open();
|
||||
await db.execute('PRAGMA recursive_triggers = ON');
|
||||
});
|
||||
tearDown(() => db.close());
|
||||
|
||||
test(
|
||||
'fresh schema maintains lookup counts across replace, source move and deletion',
|
||||
() async {
|
||||
await LibrarySchema.create(db, LibrarySchema.version);
|
||||
for (final id in ['a', 'b']) {
|
||||
await db.insert(
|
||||
'library',
|
||||
LibraryRowMapper.encode(_track(id)),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
Future<List<Map<String, Object?>>> counts() => db.query(
|
||||
LibrarySchema.lookupSummaryTable,
|
||||
orderBy: 'source_id, kind, value',
|
||||
);
|
||||
expect((await counts()).map((row) => row['ref_count']), [2, 2]);
|
||||
await db.insert(
|
||||
'library',
|
||||
LibraryRowMapper.encode(_track('a', isrc: 'new')),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
expect((await counts()).map((row) => row['ref_count']), [1, 1, 2]);
|
||||
await db.insert('library_sources', {
|
||||
'id': 'nas',
|
||||
'path': 'network://nas/music',
|
||||
'display_name': 'NAS',
|
||||
});
|
||||
await db.update(
|
||||
'library',
|
||||
{'source_id': 'nas'},
|
||||
where: 'id = ?',
|
||||
whereArgs: ['a'],
|
||||
);
|
||||
expect((await db.query(LibrarySchema.visibleView)).length, 2);
|
||||
await db.update(
|
||||
'library_sources',
|
||||
{'available': 0},
|
||||
where: 'id = ?',
|
||||
whereArgs: ['nas'],
|
||||
);
|
||||
expect((await db.query(LibrarySchema.visibleView)).single['id'], 'b');
|
||||
await db.delete('library', where: 'id = ?', whereArgs: ['a']);
|
||||
expect((await counts()).map((row) => row['ref_count']), [1, 1]);
|
||||
await db.delete('library');
|
||||
expect(await counts(), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'v1 upgrade preserves tracks and backfills current search and path identities',
|
||||
() async {
|
||||
await db.execute('''CREATE TABLE library (
|
||||
id TEXT PRIMARY KEY, track_name TEXT NOT NULL, artist_name TEXT NOT NULL,
|
||||
album_name TEXT NOT NULL, album_artist TEXT, file_path TEXT NOT NULL UNIQUE,
|
||||
scanned_at TEXT NOT NULL, isrc TEXT, track_number INTEGER, disc_number INTEGER,
|
||||
duration INTEGER, release_date TEXT, bit_depth INTEGER, sample_rate INTEGER,
|
||||
genre TEXT, format TEXT
|
||||
)''');
|
||||
await db.insert('library', {
|
||||
'id': 'old',
|
||||
'track_name': ' Song ',
|
||||
'artist_name': 'ARTIST',
|
||||
'album_name': 'Album',
|
||||
'file_path':
|
||||
'content://example.documents/document/nas%3AMusic%2FSong.flac',
|
||||
'scanned_at': '2026-01-01T00:00:00.000Z',
|
||||
'isrc': 'legacy-isrc',
|
||||
'genre': ' Pop ',
|
||||
});
|
||||
await LibrarySchema.upgrade(db, 1, LibrarySchema.version);
|
||||
final row = (await db.query('library')).single;
|
||||
expect(row['source_id'], LocalLibraryItem.legacySourceId);
|
||||
expect(row['search_text'], 'song artist album artist');
|
||||
expect(row['match_key'], 'song|artist');
|
||||
expect(row['sort_genre'], 'pop');
|
||||
expect(row['audio_metadata_scan_version'], 0);
|
||||
expect(row['has_lyrics'], 0);
|
||||
expect(row['has_replaygain'], 0);
|
||||
expect(
|
||||
(await db.query(
|
||||
'library_path_keys',
|
||||
)).every((key) => key['item_id'] == 'old'),
|
||||
isTrue,
|
||||
);
|
||||
expect(await db.query('library_path_keys'), isNotEmpty);
|
||||
expect((await db.query(LibrarySchema.visibleView)).single['id'], 'old');
|
||||
expect(
|
||||
(await db.query(
|
||||
LibrarySchema.lookupSummaryTable,
|
||||
)).map((row) => row['ref_count']),
|
||||
[1, 1],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'v16 upgrade repairs existing lookup counts and replacement triggers',
|
||||
() async {
|
||||
await LibrarySchema.create(db, 16);
|
||||
await db.execute('DROP TRIGGER library_lookup_insert_isrc');
|
||||
await db.execute('''
|
||||
CREATE TRIGGER library_lookup_insert_isrc AFTER INSERT ON library
|
||||
WHEN NEW.isrc IS NOT NULL AND NEW.isrc != ''
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO library_lookup_summary(source_id, kind, value, ref_count)
|
||||
VALUES (NEW.source_id, 'isrc', NEW.isrc, 0);
|
||||
UPDATE library_lookup_summary SET ref_count = ref_count + 1
|
||||
WHERE source_id = NEW.source_id AND kind = 'isrc' AND value = NEW.isrc;
|
||||
END
|
||||
''');
|
||||
for (final id in ['a', 'b']) {
|
||||
await db.insert(
|
||||
'library',
|
||||
LibraryRowMapper.encode(_track(id)),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
Future<List<Map<String, Object?>>> counts() =>
|
||||
db.query(LibrarySchema.lookupSummaryTable, orderBy: 'kind');
|
||||
expect((await counts()).map((row) => row['ref_count']), [1, 2]);
|
||||
await LibrarySchema.upgrade(db, 16, LibrarySchema.version);
|
||||
expect((await counts()).map((row) => row['ref_count']), [2, 2]);
|
||||
await db.insert(
|
||||
'library',
|
||||
LibraryRowMapper.encode(_track('a')),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
expect((await counts()).map((row) => row['ref_count']), [2, 2]);
|
||||
await db.delete('library', where: 'id = ?', whereArgs: ['a']);
|
||||
expect((await counts()).map((row) => row['ref_count']), [1, 1]);
|
||||
expect((await db.query('library')).single['id'], 'b');
|
||||
},
|
||||
);
|
||||
|
||||
test('row mapping round-trips metadata and preserves unknown scan flags', () {
|
||||
final track = _track('full');
|
||||
expect(LibraryRowMapper.decode(LibraryRowMapper.encode(track)), track);
|
||||
final filename = LibraryRowMapper.encode({
|
||||
...track,
|
||||
'sourceId': 'nas',
|
||||
'metadataFromFilename': true,
|
||||
'hasReplayGain': false,
|
||||
});
|
||||
expect(filename['source_id'], 'nas');
|
||||
expect(filename['audio_metadata_scan_version'], 0);
|
||||
expect(filename['has_replaygain'], 0);
|
||||
final tagged = LibraryRowMapper.encode({
|
||||
...track,
|
||||
'hasReplayGain': false,
|
||||
'replaygain_track_gain': '-4.5 dB',
|
||||
'explicit': 1,
|
||||
'audioMetadataScanVersion': 3,
|
||||
});
|
||||
expect(tagged['has_replaygain'], 1);
|
||||
expect(tagged['audio_metadata_scan_version'], 3);
|
||||
expect(LibraryRowMapper.decode(tagged)['explicit'], true);
|
||||
});
|
||||
}
|
||||
@@ -60,7 +60,7 @@ print('both databases passed')
|
||||
'history',
|
||||
),
|
||||
'library': _tableDefinition(
|
||||
'lib/services/library_database.dart',
|
||||
'lib/services/library_schema.dart',
|
||||
'library',
|
||||
),
|
||||
'historyPredicate': historyPredicate,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
/// Executes repository SQL in real SQLite without a platform plugin.
|
||||
/// Python's standard-library SQLite is also used by the existing SQL tests.
|
||||
class SqliteProcessDatabase implements Database, Transaction {
|
||||
SqliteProcessDatabase._(this._process)
|
||||
: _responses = StreamIterator(
|
||||
_process.stdout.transform(utf8.decoder).transform(const LineSplitter()),
|
||||
);
|
||||
|
||||
final Process _process;
|
||||
final StreamIterator<String> _responses;
|
||||
|
||||
static Future<SqliteProcessDatabase> open() async => SqliteProcessDatabase._(
|
||||
await Process.start('python3', [
|
||||
'-u',
|
||||
'-c',
|
||||
r'''
|
||||
import json, sqlite3, sys
|
||||
db = sqlite3.connect(':memory:', isolation_level=None)
|
||||
db.row_factory = sqlite3.Row
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
request = json.loads(line)
|
||||
cursor = db.execute(request['sql'], request.get('args', []))
|
||||
value = ([dict(row) for row in cursor.fetchall()] if request['rows']
|
||||
else cursor.lastrowid if request.get('insert')
|
||||
else max(cursor.rowcount, 0))
|
||||
print(json.dumps({'value': value}), flush=True)
|
||||
except Exception as error:
|
||||
print(json.dumps({'error': str(error)}), flush=True)
|
||||
''',
|
||||
]),
|
||||
);
|
||||
|
||||
Future<Object?> _request(
|
||||
String sql,
|
||||
List<Object?>? args, {
|
||||
bool rows = false,
|
||||
bool insert = false,
|
||||
}) async {
|
||||
_process.stdin.writeln(
|
||||
jsonEncode({
|
||||
'sql': sql,
|
||||
'args': args ?? [],
|
||||
'rows': rows,
|
||||
'insert': insert,
|
||||
}),
|
||||
);
|
||||
await _process.stdin.flush();
|
||||
if (!await _responses.moveNext()) throw StateError('SQLite process exited');
|
||||
final response = jsonDecode(_responses.current) as Map;
|
||||
if (response['error'] != null) {
|
||||
throw StateError('${response['error']}\n$sql');
|
||||
}
|
||||
return response['value'];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> execute(String sql, [List<Object?>? arguments]) async {
|
||||
await _request(sql, arguments);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, Object?>>> rawQuery(
|
||||
String sql, [
|
||||
List<Object?>? arguments,
|
||||
]) async => (await _request(sql, arguments, rows: true) as List)
|
||||
.map((row) => Map<String, Object?>.from(row as Map))
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<int> rawInsert(String sql, [List<Object?>? arguments]) async =>
|
||||
(await _request(sql, arguments, insert: true) as num).toInt();
|
||||
|
||||
@override
|
||||
Future<int> rawUpdate(String sql, [List<Object?>? arguments]) async =>
|
||||
(await _request(sql, arguments) as num).toInt();
|
||||
|
||||
@override
|
||||
Future<int> rawDelete(String sql, [List<Object?>? arguments]) =>
|
||||
rawUpdate(sql, arguments);
|
||||
|
||||
@override
|
||||
Future<List<Map<String, Object?>>> query(
|
||||
String table, {
|
||||
bool? distinct,
|
||||
List<String>? columns,
|
||||
String? where,
|
||||
List<Object?>? whereArgs,
|
||||
String? groupBy,
|
||||
String? having,
|
||||
String? orderBy,
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) => rawQuery(
|
||||
[
|
||||
'SELECT ${distinct == true ? 'DISTINCT ' : ''}${columns?.join(', ') ?? '*'} FROM $table',
|
||||
if (where != null) 'WHERE $where',
|
||||
if (groupBy != null) 'GROUP BY $groupBy',
|
||||
if (having != null) 'HAVING $having',
|
||||
if (orderBy != null) 'ORDER BY $orderBy',
|
||||
if (limit != null) 'LIMIT $limit',
|
||||
if (offset != null) 'OFFSET $offset',
|
||||
].join(' '),
|
||||
whereArgs,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<int> insert(
|
||||
String table,
|
||||
Map<String, Object?> values, {
|
||||
String? nullColumnHack,
|
||||
ConflictAlgorithm? conflictAlgorithm,
|
||||
}) => rawInsert(
|
||||
'INSERT ${conflictAlgorithm == ConflictAlgorithm.ignore
|
||||
? 'OR IGNORE '
|
||||
: conflictAlgorithm == ConflictAlgorithm.replace
|
||||
? 'OR REPLACE '
|
||||
: ''}'
|
||||
'INTO $table (${values.keys.join(', ')}) VALUES (${List.filled(values.length, '?').join(', ')})',
|
||||
values.values.toList(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<int> update(
|
||||
String table,
|
||||
Map<String, Object?> values, {
|
||||
String? where,
|
||||
List<Object?>? whereArgs,
|
||||
ConflictAlgorithm? conflictAlgorithm,
|
||||
}) => rawUpdate(
|
||||
'UPDATE $table SET ${values.keys.map((key) => '$key = ?').join(', ')}'
|
||||
'${where == null ? '' : ' WHERE $where'}',
|
||||
[...values.values, ...?whereArgs],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<int> delete(String table, {String? where, List<Object?>? whereArgs}) =>
|
||||
rawDelete(
|
||||
'DELETE FROM $table${where == null ? '' : ' WHERE $where'}',
|
||||
whereArgs,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<T> transaction<T>(
|
||||
Future<T> Function(Transaction txn) action, {
|
||||
bool? exclusive,
|
||||
}) async {
|
||||
await execute('BEGIN');
|
||||
try {
|
||||
final result = await action(this);
|
||||
await execute('COMMIT');
|
||||
return result;
|
||||
} catch (_) {
|
||||
await execute('ROLLBACK');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Batch batch() => _Batch(this);
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _process.stdin.close();
|
||||
await _process.exitCode;
|
||||
await _responses.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Batch implements Batch {
|
||||
_Batch(this.database);
|
||||
final SqliteProcessDatabase database;
|
||||
final _operations = <Future<int> Function()>[];
|
||||
|
||||
@override
|
||||
void insert(
|
||||
String table,
|
||||
Map<String, Object?> values, {
|
||||
String? nullColumnHack,
|
||||
ConflictAlgorithm? conflictAlgorithm,
|
||||
}) => _operations.add(
|
||||
() => database.insert(table, values, conflictAlgorithm: conflictAlgorithm),
|
||||
);
|
||||
|
||||
@override
|
||||
void update(
|
||||
String table,
|
||||
Map<String, Object?> values, {
|
||||
String? where,
|
||||
List<Object?>? whereArgs,
|
||||
ConflictAlgorithm? conflictAlgorithm,
|
||||
}) => _operations.add(
|
||||
() => database.update(table, values, where: where, whereArgs: whereArgs),
|
||||
);
|
||||
|
||||
@override
|
||||
void delete(String table, {String? where, List<Object?>? whereArgs}) =>
|
||||
_operations.add(
|
||||
() => database.delete(table, where: where, whereArgs: whereArgs),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<Object?>> commit({
|
||||
bool? exclusive,
|
||||
bool? noResult,
|
||||
bool? continueOnError,
|
||||
}) async {
|
||||
final results = <Object?>[];
|
||||
for (final operation in _operations) {
|
||||
final result = await operation();
|
||||
if (noResult != true) results.add(result);
|
||||
}
|
||||
_operations.clear();
|
||||
return results;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
Reference in new issue
Block a user