perf(library): streamline search and grouped counts

This commit is contained in:
zarzet
2026-07-15 22:16:10 +07:00
parent 6f566a9f03
commit 79bb3214ae
3 changed files with 120 additions and 68 deletions
+46 -9
View File
@@ -73,7 +73,7 @@ class HistoryDatabase {
if (_database != null) return _database!;
_database = await sqlite.openAppDatabase(
'history.db',
version: 9,
version: 10,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -118,7 +118,9 @@ class HistoryDatabase {
copyright TEXT,
spotify_id_norm TEXT,
isrc_norm TEXT,
match_key TEXT
match_key TEXT,
album_key TEXT,
search_text TEXT
)
''');
@@ -188,13 +190,17 @@ class HistoryDatabase {
await sqlite.addColumnIfMissing(db, 'history', 'spotify_id_norm', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'isrc_norm', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'match_key', 'TEXT');
await _backfillNormalizedColumns(db);
await _createNormalizedIndexes(db);
}
if (oldVersion < 9) {
await sqlite.addColumnIfMissing(db, 'history', 'bitrate', 'INTEGER');
await sqlite.addColumnIfMissing(db, 'history', 'format', 'TEXT');
}
if (oldVersion < 10) {
await sqlite.addColumnIfMissing(db, 'history', 'album_key', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'search_text', 'TEXT');
await _backfillNormalizedColumns(db);
await _createNormalizedIndexes(db);
}
}
static String normalizeLookupText(String? value) =>
@@ -238,12 +244,23 @@ class HistoryDatabase {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_album_key ON history(album_key)',
);
}
Future<void> _backfillNormalizedColumns(Database db) async {
final rows = await db.query(
'history',
columns: ['id', 'spotify_id', 'isrc', 'track_name', 'artist_name'],
columns: [
'id',
'spotify_id',
'isrc',
'track_name',
'artist_name',
'album_name',
'album_artist',
],
);
final batch = db.batch();
for (final row in rows) {
@@ -254,6 +271,8 @@ class HistoryDatabase {
isrc: row['isrc'] as String?,
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']],
@@ -267,11 +286,26 @@ class HistoryDatabase {
required String? isrc,
required String? trackName,
required String? artistName,
required String? albumName,
required String? albumArtist,
}) {
final normalizedTrack = normalizeLookupText(trackName);
final normalizedArtist = normalizeLookupText(artistName);
final normalizedAlbum = normalizeLookupText(albumName);
final normalizedAlbumArtist = normalizeLookupText(
(albumArtist ?? '').trim().isEmpty ? artistName : albumArtist,
);
return {
'spotify_id_norm': normalizeSpotifyId(spotifyId),
'isrc_norm': normalizeIsrc(isrc),
'match_key': matchKeyFor(trackName, artistName),
'album_key': '$normalizedAlbum|$normalizedAlbumArtist',
'search_text': [
normalizedTrack,
normalizedArtist,
normalizedAlbum,
normalizedAlbumArtist,
].where((value) => value.isNotEmpty).join(' '),
};
}
@@ -466,6 +500,8 @@ class HistoryDatabase {
isrc: json['isrc'] as String?,
trackName: json['trackName'] as String?,
artistName: json['artistName'] as String?,
albumName: json['albumName'] as String?,
albumArtist: json['albumArtist'] as String?,
),
);
return row;
@@ -562,11 +598,12 @@ class HistoryDatabase {
String artistName,
) async {
final db = await database;
final albumKey =
'${normalizeLookupText(albumName)}|${normalizeLookupText(artistName)}';
final rows = await db.query(
'history',
where:
'LOWER(album_name) = ? AND LOWER(COALESCE(album_artist, artist_name)) = ?',
whereArgs: [albumName.toLowerCase(), artistName.toLowerCase()],
where: 'album_key = ?',
whereArgs: [albumKey],
orderBy:
'COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name',
);
@@ -794,7 +831,7 @@ class HistoryDatabase {
FROM (
SELECT COUNT(*) AS track_count
FROM history
GROUP BY LOWER(album_name), LOWER(COALESCE(album_artist, artist_name))
GROUP BY album_key
)
''');
final row = rows.isEmpty ? const <String, Object?>{} : rows.first;
+73 -58
View File
@@ -838,52 +838,75 @@ class LibraryDatabase {
Future<QueueLibraryCounts> getQueueCounts(QueueLibraryDbQuery request) async {
final db = await database;
await _ensureHistoryAttached(db);
final parts = <String>[];
final args = <Object?>[];
final allArgs = <Object?>[];
final allSql = _queueTrackUnionSql(
QueueLibraryDbQuery(
limit: request.limit,
offset: request.offset,
filterMode: 'all',
searchQuery: request.searchQuery,
source: request.source,
quality: request.quality,
format: request.format,
metadata: request.metadata,
sortMode: request.sortMode,
includeLocal: request.includeLocal,
),
allArgs,
);
final singleArgs = <Object?>[];
final singleSql = _queueTrackUnionSql(
QueueLibraryDbQuery(
limit: request.limit,
offset: request.offset,
filterMode: 'singles',
searchQuery: request.searchQuery,
source: request.source,
quality: request.quality,
format: request.format,
metadata: request.metadata,
sortMode: request.sortMode,
includeLocal: request.includeLocal,
),
singleArgs,
);
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 > 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 ')}'}
''');
}
final albumArgs = <Object?>[];
final albumSql = _queueAlbumUnionSql(request, albumArgs);
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 > 1 THEN l.album_key END) AS album_count,
COALESCE(SUM(CASE WHEN grouped.track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
FROM library l
JOIN (
SELECT album_key, COUNT(*) AS track_count
FROM library 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 ')}
''');
}
final rows = await db.rawQuery(
'''
if (parts.isEmpty) {
return const QueueLibraryCounts(
allTrackCount: 0,
albumCount: 0,
singleTrackCount: 0,
);
}
final rows = await db.rawQuery('''
SELECT
(SELECT COUNT(*) FROM ($allSql)) AS all_count,
(SELECT COUNT(*) FROM ($singleSql)) AS single_count,
(SELECT COUNT(*) FROM ($albumSql)) AS album_count
''',
[...allArgs, ...singleArgs, ...albumArgs],
);
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(
@@ -949,11 +972,10 @@ class LibraryDatabase {
_appendQueueHistoryFilters(where, args, request);
if (request.filterMode == 'singles') {
where.add('''
LOWER(h.album_name) || '|' || LOWER(COALESCE(h.album_artist, h.artist_name))
IN (
SELECT LOWER(album_name) || '|' || LOWER(COALESCE(album_artist, artist_name))
h.album_key IN (
SELECT album_key
FROM history_db.history
GROUP BY LOWER(album_name), LOWER(COALESCE(album_artist, artist_name))
GROUP BY album_key
HAVING COUNT(*) = 1
)
''');
@@ -1158,14 +1180,14 @@ class LibraryDatabase {
FROM history_db.history h
JOIN (
SELECT
LOWER(album_name) || '|' || LOWER(COALESCE(album_artist, artist_name)) AS album_key,
album_key,
COUNT(*) AS track_count,
MAX(CAST(strftime('%s', downloaded_at) AS INTEGER) * 1000) AS latest_added
FROM history_db.history
GROUP BY LOWER(album_name), LOWER(COALESCE(album_artist, artist_name))
GROUP BY album_key
HAVING COUNT(*) > 1
) c
ON c.album_key = LOWER(h.album_name) || '|' || LOWER(COALESCE(h.album_artist, h.artist_name))
ON c.album_key = h.album_key
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
GROUP BY c.album_key
''');
@@ -1249,15 +1271,8 @@ class LibraryDatabase {
final query = normalizeLookupText(request.searchQuery);
if (query.isNotEmpty) {
final like = '%${_escapeLikePattern(query)}%';
where.add('''
(
LOWER(h.track_name) LIKE ? ESCAPE '\\' OR
LOWER(h.artist_name) LIKE ? ESCAPE '\\' OR
LOWER(h.album_name) LIKE ? ESCAPE '\\' OR
LOWER(COALESCE(h.album_artist, '')) LIKE ? ESCAPE '\\'
)
''');
args.addAll([like, like, like, like]);
where.add("h.search_text LIKE ? ESCAPE '\\'");
args.add(like);
}
_appendQueueCommonFilters(
where,