From 79bb3214aecb0fc85bad638d0a0b2b2cf6182f6c Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 15 Jul 2026 22:16:10 +0700 Subject: [PATCH] perf(library): streamline search and grouped counts --- lib/screens/queue_tab.dart | 2 +- lib/services/history_database.dart | 55 ++++++++++-- lib/services/library_database.dart | 131 ++++++++++++++++------------- 3 files changed, 120 insertions(+), 68 deletions(-) diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 62e092cc..3d7b4b6d 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -273,7 +273,7 @@ class _QueueTabState extends ConsumerState { void _onSearchChanged(String value) { _searchDebounce?.cancel(); final normalized = value.trim().toLowerCase(); - _searchDebounce = Timer(const Duration(milliseconds: 180), () { + _searchDebounce = Timer(const Duration(milliseconds: 350), () { if (!mounted || _searchQuery == normalized) return; setState(() { _searchQuery = normalized; diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 0205d79f..6d67dcb7 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -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 _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 {} : rows.first; diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 7f115cfd..cf2ae3ba 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -838,52 +838,75 @@ class LibraryDatabase { Future getQueueCounts(QueueLibraryDbQuery request) async { final db = await database; await _ensureHistoryAttached(db); + final parts = []; + final args = []; - final allArgs = []; - 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 = []; - 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 = []; + _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 = []; - final albumSql = _queueAlbumUnionSql(request, albumArgs); + if (request.includeLocal && request.source != 'downloaded') { + final 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 + ) + ''', + ]; + _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 {}; 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,