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:
zarzet committed 2026-10-05 03:28:48 +07:00
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
+9 -4
View File
@@ -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 {
@@ -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};
}
}
+126
View File
@@ -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,
};
}
+463
View File
@@ -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);
});
}
}
+1 -1
View File
@@ -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 ?? [],
}),
+1 -1
View File
@@ -143,7 +143,7 @@ print('inventory passed')
'history',
),
'library': _tableDefinition(
'lib/services/library_database.dart',
'lib/services/library_schema.dart',
'library',
),
'combined': albumCompletenessSql(
+307
View File
@@ -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]);
},
);
}
+190
View File
@@ -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);
});
}
+1 -1
View File
@@ -60,7 +60,7 @@ print('both databases passed')
'history',
),
'library': _tableDefinition(
'lib/services/library_database.dart',
'lib/services/library_schema.dart',
'library',
),
'historyPredicate': historyPredicate,
+228
View File
@@ -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);
}