From 01a5b43613b06747b83d6fd730206a59a4ce09b7 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 6 May 2026 04:38:51 +0700 Subject: [PATCH] perf: unify queue tab with DB-backed pagination and cross-database queries Replace in-memory list merging in the queue tab with fully database- backed pagination using ATTACH DATABASE to join library and history tables in a single UNION ALL query. Queue tab: - Remove localLibraryAllItemsProvider and _queueHistoryStatsProvider - Add _queueLibraryPageProvider and _queueLibraryCountsProvider backed by LibraryDatabase.getQueueTrackPage/getQueueCounts/getQueueAlbumPage - Implement infinite scroll via _handleLibraryScrollNotification with _libraryPageLimit growing by 300 per batch - Album/single/total counts computed via SQL GROUP BY aggregates History database (v5 -> v8): - v6: add idx_history_track_artist index - v7: add history_path_keys table for cross-DB dedup, backfill from existing rows - v8: add spotify_id_norm, isrc_norm, match_key normalized columns with indexes, backfill from existing data - Add getAlbumTracks, findByTrackAndArtist, getGroupedCounts, existsTrack, findExistingTrack, existingTrackKeys batch lookup - deleteBySpotifyId now returns deleted count for accurate totalCount - All write paths maintain history_path_keys consistency Library database (v7 -> v8): - v8: add library_path_keys table for cross-DB dedup - Add getQueueTrackPage, getQueueCounts, getQueueAlbumPage with ATTACH DATABASE for cross-DB UNION ALL queries - Dedup local items against history via path_keys JOIN - All write/delete paths maintain library_path_keys consistency Download history provider: - Load only 100 recent items into state.items at startup - Store lookupItems as immutable List field instead of recomputing from maps on every access - Add async fallback to DB in _putInMemoryHistory for items outside the 100-item window - Add downloadHistoryPageProvider, downloadHistoryGroupedCountsProvider, downloadedAlbumTracksProvider, downloadHistoryBatchExistsProvider - Add catchError to adoptNativeHistoryItem async block - Fix removeBySpotifyId to query actual DB count instead of decrement Screen migrations: - album/artist/playlist/home screens use async DB lookups instead of sync in-memory state for track existence and playback resolution - downloaded_album_screen uses downloadedAlbumTracksProvider - library_tracks_folder_screen uses downloadHistoryBatchExistsProvider for skip-downloaded checks and cover resolution --- lib/providers/download_queue_provider.dart | 463 +++++++-- lib/providers/local_library_provider.dart | 19 - lib/providers/playback_provider.dart | 25 +- lib/screens/album_screen.dart | 65 +- lib/screens/artist_screen.dart | 56 +- lib/screens/downloaded_album_screen.dart | 21 +- lib/screens/home_tab.dart | 34 +- lib/screens/home_tab_widgets.dart | 46 +- lib/screens/library_tracks_folder_screen.dart | 59 +- lib/screens/playlist_screen.dart | 65 +- lib/screens/queue_tab.dart | 257 +++-- lib/screens/queue_tab_helpers.dart | 883 +++++------------ lib/screens/track_metadata_screen.dart | 11 +- lib/services/history_database.dart | 483 +++++++++- lib/services/library_database.dart | 904 +++++++++++++++++- 15 files changed, 2410 insertions(+), 981 deletions(-) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 1a85e7d7..08a522a8 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -22,6 +22,9 @@ import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; +export 'package:spotiflac_android/services/history_database.dart' + show HistoryLookupRequest, HistoryBatchLookupRequest; + final _log = AppLogger('DownloadQueue'); final _historyLog = AppLogger('DownloadHistory'); @@ -231,33 +234,41 @@ class DownloadHistoryItem { class DownloadHistoryState { final List items; + final int totalCount; + final int loadedIndexVersion; + final List _lookupItems; final Map _bySpotifyId; final Map _byIsrc; final Map _byTrackArtistKey; - DownloadHistoryState({this.items = const []}) - : _bySpotifyId = Map.fromEntries( - items - .where( - (item) => item.spotifyId != null && item.spotifyId!.isNotEmpty, - ) - .map((item) => MapEntry(item.spotifyId!, item)), - ), - _byIsrc = Map.fromEntries( - items - .where((item) => item.isrc != null && item.isrc!.isNotEmpty) - .map((item) => MapEntry(item.isrc!, item)), - ), - _byTrackArtistKey = Map.fromEntries( - items - .map( - (item) => MapEntry( - _trackArtistKey(item.trackName, item.artistName), - item, - ), - ) - .where((entry) => entry.key.isNotEmpty), - ); + DownloadHistoryState({ + this.items = const [], + this.totalCount = 0, + this.loadedIndexVersion = 0, + List? lookupItems, + }) : _lookupItems = List.unmodifiable(lookupItems ?? items), + _bySpotifyId = Map.fromEntries( + (lookupItems ?? items) + .where( + (item) => item.spotifyId != null && item.spotifyId!.isNotEmpty, + ) + .map((item) => MapEntry(item.spotifyId!, item)), + ), + _byIsrc = Map.fromEntries( + (lookupItems ?? items) + .where((item) => item.isrc != null && item.isrc!.isNotEmpty) + .map((item) => MapEntry(item.isrc!, item)), + ), + _byTrackArtistKey = Map.fromEntries( + (lookupItems ?? items) + .map( + (item) => MapEntry( + _trackArtistKey(item.trackName, item.artistName), + item, + ), + ) + .where((entry) => entry.key.isNotEmpty), + ); static String _trackArtistKey(String trackName, String artistName) { final normalizedTrack = trackName.trim().toLowerCase(); @@ -282,12 +293,25 @@ class DownloadHistoryState { return _byTrackArtistKey[key]; } - DownloadHistoryState copyWith({List? items}) { - return DownloadHistoryState(items: items ?? this.items); + List get lookupItems => _lookupItems; + + DownloadHistoryState copyWith({ + List? items, + int? totalCount, + int? loadedIndexVersion, + List? lookupItems, + }) { + return DownloadHistoryState( + items: items ?? this.items, + totalCount: totalCount ?? this.totalCount, + loadedIndexVersion: loadedIndexVersion ?? this.loadedIndexVersion, + lookupItems: lookupItems ?? _lookupItems, + ); } } class DownloadHistoryNotifier extends Notifier { + static const int _initialHistoryLoadLimit = 100; static const int _safRepairBatchSize = 20; static const int _safRepairMaxPerLaunch = 60; static const int _orphanCleanupMaxPerLaunch = 80; @@ -332,13 +356,22 @@ class DownloadHistoryNotifier extends Notifier { } } - final jsonList = await _db.getAll(); + final countFuture = _db.getCount(); + final jsonList = await _db.getAll(limit: _initialHistoryLoadLimit); final items = jsonList .map((e) => DownloadHistoryItem.fromJson(e)) .toList(); + final totalCount = await countFuture; - state = state.copyWith(items: items); - _historyLog.i('Loaded ${items.length} items from SQLite database'); + state = state.copyWith( + items: items, + totalCount: totalCount, + loadedIndexVersion: state.loadedIndexVersion + 1, + lookupItems: items, + ); + _historyLog.i( + 'Loaded ${items.length}/$totalCount recent history items from SQLite database', + ); _scheduleStartupMaintenance(items); } catch (e, stack) { _historyLog.e('Failed to load history from database: $e', e, stack); @@ -543,7 +576,11 @@ class DownloadHistoryNotifier extends Notifier { if (changed) { await _db.upsertBatch(persistedUpdates); - state = state.copyWith(items: updatedItems); + state = state.copyWith( + items: updatedItems, + loadedIndexVersion: state.loadedIndexVersion + 1, + lookupItems: _lookupItemsWithUpdates(updatedItems), + ); _historyLog.i( 'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${selectedIndexes.length}', ); @@ -813,7 +850,11 @@ class DownloadHistoryNotifier extends Notifier { if (persistedUpdates.isNotEmpty && updatedItems != null) { await _db.upsertBatch(persistedUpdates); - state = state.copyWith(items: updatedItems); + state = state.copyWith( + items: updatedItems, + loadedIndexVersion: state.loadedIndexVersion + 1, + lookupItems: _lookupItemsWithUpdates(updatedItems), + ); } await _writeStartupCursor( @@ -837,7 +878,13 @@ class DownloadHistoryNotifier extends Notifier { await _loadFromDatabase(); } - DownloadHistoryItem _putInMemoryHistory(DownloadHistoryItem item) { + void _bumpHistoryRevision() { + state = state.copyWith(loadedIndexVersion: state.loadedIndexVersion + 1); + } + + Future _putInMemoryHistory( + DownloadHistoryItem item, + ) async { DownloadHistoryItem? existing; if (item.spotifyId != null && item.spotifyId!.isNotEmpty) { existing = state.getBySpotifyId(item.spotifyId!); @@ -845,10 +892,31 @@ class DownloadHistoryNotifier extends Notifier { if (existing == null && item.isrc != null && item.isrc!.isNotEmpty) { existing = state.getByIsrc(item.isrc!); } + if (existing == null) { + final json = await _db.findExisting( + spotifyId: item.spotifyId, + isrc: item.isrc, + ); + if (json != null) { + existing = DownloadHistoryItem.fromJson(json); + } + } + if (existing == null) { + final json = await _db.findByTrackAndArtist( + item.trackName, + item.artistName, + ); + if (json != null) { + existing = DownloadHistoryItem.fromJson(json); + } + } + final incomingItem = existing != null && existing.id != item.id + ? DownloadHistoryItem.fromJson(item.toJson()..['id'] = existing.id) + : item; final mergedItem = existing == null - ? item - : item.copyWith( + ? incomingItem + : incomingItem.copyWith( trackNumber: item.trackNumber ?? existing.trackNumber, totalTracks: item.totalTracks ?? existing.totalTracks, discNumber: item.discNumber ?? existing.discNumber, @@ -872,43 +940,103 @@ class DownloadHistoryNotifier extends Notifier { .where((i) => i.id != existing!.id) .toList(); updatedItems.insert(0, mergedItem); - state = state.copyWith(items: updatedItems); + final updatedLookupItems = state.lookupItems + .where((i) => i.id != existing!.id) + .toList(growable: false); + state = state.copyWith( + items: updatedItems, + lookupItems: [mergedItem, ...updatedLookupItems], + ); _historyLog.d('Updated existing history entry: ${mergedItem.trackName}'); } else { - state = state.copyWith(items: [mergedItem, ...state.items]); + state = state.copyWith( + items: [mergedItem, ...state.items], + totalCount: state.totalCount + 1, + lookupItems: [mergedItem, ...state.lookupItems], + ); _historyLog.d('Added new history entry: ${mergedItem.trackName}'); } return mergedItem; } + List _lookupItemsWithUpdates( + Iterable updates, { + Set deletedIds = const {}, + }) { + final byId = { + for (final item in state.lookupItems) + if (!deletedIds.contains(item.id)) item.id: item, + }; + for (final item in updates) { + if (!deletedIds.contains(item.id)) { + byId[item.id] = item; + } + } + return byId.values.toList(growable: false); + } + void addToHistory(DownloadHistoryItem item) { - final mergedItem = _putInMemoryHistory(item); - _db.upsert(mergedItem.toJson()).catchError((Object e) { - _historyLog.e('Failed to save to database: $e'); - }); + unawaited( + () async { + final mergedItem = await _putInMemoryHistory(item); + await _db.upsert(mergedItem.toJson()); + _bumpHistoryRevision(); + }().catchError((Object e, StackTrace stack) { + _historyLog.e('Failed to save to database: $e', e, stack); + }), + ); } void adoptNativeHistoryItem(DownloadHistoryItem item) { - _putInMemoryHistory(item); + unawaited( + () async { + final mergedItem = await _putInMemoryHistory(item); + await _db.upsert(mergedItem.toJson()); + _bumpHistoryRevision(); + }().catchError((Object e, StackTrace stack) { + _historyLog.e('Failed to adopt native history item: $e', e, stack); + }), + ); } void removeFromHistory(String id) { state = state.copyWith( items: state.items.where((item) => item.id != id).toList(), + totalCount: state.totalCount > 0 + ? state.totalCount - 1 + : state.totalCount, + lookupItems: state.lookupItems + .where((item) => item.id != id) + .toList(growable: false), ); - _db.deleteById(id).catchError((Object e) { - _historyLog.e('Failed to delete from database: $e'); - }); + _db + .deleteById(id) + .catchError((Object e) { + _historyLog.e('Failed to delete from database: $e'); + }) + .then((_) { + _bumpHistoryRevision(); + }); } void removeBySpotifyId(String spotifyId) { state = state.copyWith( items: state.items.where((item) => item.spotifyId != spotifyId).toList(), + lookupItems: state.lookupItems + .where((item) => item.spotifyId != spotifyId) + .toList(growable: false), + ); + unawaited( + () async { + final deleted = await _db.deleteBySpotifyId(spotifyId); + final totalCount = await _db.getCount(); + state = state.copyWith(totalCount: totalCount); + _bumpHistoryRevision(); + _historyLog.d('Removed $deleted item(s) with spotifyId: $spotifyId'); + }().catchError((Object e, StackTrace stack) { + _historyLog.e('Failed to delete from database: $e', e, stack); + }), ); - _db.deleteBySpotifyId(spotifyId).catchError((Object e) { - _historyLog.e('Failed to delete from database: $e'); - }); - _historyLog.d('Removed item with spotifyId: $spotifyId'); } DownloadHistoryItem? getBySpotifyId(String spotifyId) { @@ -928,6 +1056,63 @@ class DownloadHistoryNotifier extends Notifier { return DownloadHistoryItem.fromJson(json); } + Future getByIsrcAsync(String isrc) async { + final inMemory = state.getByIsrc(isrc); + if (inMemory != null) return inMemory; + + final json = await _db.getByIsrc(isrc); + if (json == null) return null; + return DownloadHistoryItem.fromJson(json); + } + + Future findByTrackAndArtistAsync( + String trackName, + String artistName, + ) async { + final inMemory = state.findByTrackAndArtist(trackName, artistName); + if (inMemory != null) return inMemory; + + final json = await _db.findByTrackAndArtist(trackName, artistName); + if (json == null) return null; + return DownloadHistoryItem.fromJson(json); + } + + Future findExistingTrackAsync( + HistoryLookupRequest request, + ) async { + final bySpotifyId = state.getBySpotifyId(request.spotifyId); + if (bySpotifyId != null) return bySpotifyId; + + final isrc = request.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = state.getByIsrc(isrc); + if (byIsrc != null) return byIsrc; + } + + final byTrackArtist = state.findByTrackAndArtist( + request.trackName, + request.artistName, + ); + if (byTrackArtist != null) return byTrackArtist; + + final json = await _db.findExistingTrack(request); + if (json == null) return null; + return DownloadHistoryItem.fromJson(json); + } + + Future<({DownloadHistoryItem item, int index})?> _historyItemForUpdate( + String id, + ) async { + final index = state.items.indexWhere((item) => item.id == id); + if (index >= 0) { + return (item: state.items[index], index: index); + } + + final json = await _db.getById(id); + if (json == null) return null; + return (item: DownloadHistoryItem.fromJson(json), index: -1); + } + Future updateAudioMetadataForItem({ required String id, String? quality, @@ -940,10 +1125,15 @@ class DownloadHistoryNotifier extends Notifier { int? duration, String? composer, }) async { - final index = state.items.indexWhere((item) => item.id == id); - if (index < 0) return; + final target = await _historyItemForUpdate(id); + if (target == null) { + _historyLog.w( + 'Cannot update audio metadata for missing history item: $id', + ); + return; + } - final current = state.items[index]; + final current = target.item; final updated = current.copyWith( quality: quality, bitDepth: bitDepth, @@ -968,10 +1158,15 @@ class DownloadHistoryNotifier extends Notifier { return; } - final updatedItems = [...state.items]; - updatedItems[index] = updated; - state = state.copyWith(items: updatedItems); + final updatedItems = target.index >= 0 + ? ([...state.items]..[target.index] = updated) + : state.items; + state = state.copyWith( + items: updatedItems, + lookupItems: _lookupItemsWithUpdates([updated]), + ); await _db.upsert(updated.toJson()); + _bumpHistoryRevision(); } Future updateMetadataForItem({ @@ -991,10 +1186,13 @@ class DownloadHistoryNotifier extends Notifier { String? label, String? copyright, }) async { - final index = state.items.indexWhere((item) => item.id == id); - if (index < 0) return; + final target = await _historyItemForUpdate(id); + if (target == null) { + _historyLog.w('Cannot update metadata for missing history item: $id'); + return; + } - final current = state.items[index]; + final current = target.item; final updated = current.copyWith( trackName: trackName, artistName: artistName, @@ -1012,10 +1210,15 @@ class DownloadHistoryNotifier extends Notifier { copyright: copyright, ); - final updatedItems = [...state.items]; - updatedItems[index] = updated; - state = state.copyWith(items: updatedItems); + final updatedItems = target.index >= 0 + ? ([...state.items]..[target.index] = updated) + : state.items; + state = state.copyWith( + items: updatedItems, + lookupItems: _lookupItemsWithUpdates([updated]), + ); await _db.upsert(updated.toJson()); + _bumpHistoryRevision(); } static const _audioExtensions = [ @@ -1126,7 +1329,15 @@ class DownloadHistoryNotifier extends Notifier { updatedItems.add(item); } } - state = state.copyWith(items: updatedItems); + state = state.copyWith( + items: updatedItems, + loadedIndexVersion: state.loadedIndexVersion + 1, + lookupItems: _lookupItemsWithUpdates( + updatedItems, + deletedIds: deletedSet, + ), + totalCount: max(0, state.totalCount - deletedSet.length), + ); } Future _cleanupOrphanedDownloadsIncremental({ @@ -1224,10 +1435,15 @@ class DownloadHistoryNotifier extends Notifier { } void clearHistory() { - state = DownloadHistoryState(); - _db.clearAll().catchError((Object e) { - _historyLog.e('Failed to clear database: $e'); - }); + state = DownloadHistoryState(loadedIndexVersion: state.loadedIndexVersion); + _db + .clearAll() + .then((_) { + _bumpHistoryRevision(); + }) + .catchError((Object e) { + _historyLog.e('Failed to clear database: $e'); + }); } Future getDatabaseCount() async { @@ -1240,6 +1456,121 @@ final downloadHistoryProvider = DownloadHistoryNotifier.new, ); +class DownloadHistoryPageRequest { + final int limit; + final int offset; + + const DownloadHistoryPageRequest({this.limit = 100, this.offset = 0}); + + @override + bool operator ==(Object other) => + other is DownloadHistoryPageRequest && + other.limit == limit && + other.offset == offset; + + @override + int get hashCode => Object.hash(limit, offset); +} + +final downloadHistoryPageProvider = + FutureProvider.family< + List, + DownloadHistoryPageRequest + >((ref, request) async { + ref.watch( + downloadHistoryProvider.select((state) => state.loadedIndexVersion), + ); + final rows = await HistoryDatabase.instance.getAll( + limit: request.limit, + offset: request.offset, + ); + return rows.map(DownloadHistoryItem.fromJson).toList(growable: false); + }); + +class DownloadHistoryGroupedCounts { + final int albumCount; + final int singleTrackCount; + + const DownloadHistoryGroupedCounts({ + required this.albumCount, + required this.singleTrackCount, + }); +} + +final downloadHistoryGroupedCountsProvider = + FutureProvider((ref) async { + ref.watch( + downloadHistoryProvider.select((state) => state.loadedIndexVersion), + ); + final counts = await HistoryDatabase.instance.getGroupedCounts(); + return DownloadHistoryGroupedCounts( + albumCount: counts['albums'] ?? 0, + singleTrackCount: counts['singles'] ?? 0, + ); + }); + +HistoryLookupRequest historyLookupForTrack(Track track) { + return HistoryLookupRequest( + spotifyId: track.id, + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ); +} + +final downloadHistoryExistsProvider = + FutureProvider.family((ref, request) async { + ref.watch( + downloadHistoryProvider.select((state) => state.loadedIndexVersion), + ); + return HistoryDatabase.instance.existsTrack(request); + }); + +final downloadHistoryBatchExistsProvider = + FutureProvider.family, HistoryBatchLookupRequest>(( + ref, + request, + ) async { + ref.watch( + downloadHistoryProvider.select((state) => state.loadedIndexVersion), + ); + return HistoryDatabase.instance.existingTrackKeys(request.tracks); + }); + +class DownloadedAlbumTracksRequest { + final String albumName; + final String artistName; + + const DownloadedAlbumTracksRequest({ + required this.albumName, + required this.artistName, + }); + + @override + bool operator ==(Object other) => + other is DownloadedAlbumTracksRequest && + other.albumName == albumName && + other.artistName == artistName; + + @override + int get hashCode => Object.hash(albumName, artistName); +} + +final downloadedAlbumTracksProvider = + FutureProvider.family< + List, + DownloadedAlbumTracksRequest + >((ref, request) async { + ref.watch( + downloadHistoryProvider.select((state) => state.loadedIndexVersion), + ); + final rows = await HistoryDatabase.instance.getAlbumTracks( + request.albumName, + request.artistName, + ); + return rows.map(DownloadHistoryItem.fromJson).toList(growable: false); + }); + class DownloadQueueState { static const Object _noChange = Object(); final List items; @@ -7560,9 +7891,9 @@ class DownloadQueueNotifier extends Notifier { final historyNotifier = ref.read(downloadHistoryProvider.notifier); final existingInHistory = - historyNotifier.getBySpotifyId(trackToDownload.id) ?? + await historyNotifier.getBySpotifyIdAsync(trackToDownload.id) ?? (trackToDownload.isrc != null - ? historyNotifier.getByIsrc(trackToDownload.isrc!) + ? await historyNotifier.getByIsrcAsync(trackToDownload.isrc!) : null); if (wasExisting && existingInHistory != null) { diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index a6753f5e..64b3ded3 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -1308,22 +1308,3 @@ final localLibraryAlbumCountProvider = searchQuery: request.searchQuery, ); }); - -final localLibraryAllItemsProvider = FutureProvider>(( - ref, -) async { - ref.watch(localLibraryProvider.select((state) => state.loadedIndexVersion)); - const pageSize = 500; - final items = []; - var offset = 0; - while (true) { - final rows = await LibraryDatabase.instance.getPage( - const LocalLibraryPageRequest(limit: pageSize).copyWithOffset(offset), - ); - if (rows.isEmpty) break; - items.addAll(rows.map(LocalLibraryItem.fromJson)); - if (rows.length < pageSize) break; - offset += pageSize; - } - return items; -}); diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index 1e639beb..dbe962e1 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -84,7 +84,10 @@ class PlaybackController extends Notifier { return localItem.filePath; } - final historyItem = _findDownloadHistoryItemForTrack(track, historyState); + final historyItem = await _findDownloadHistoryItemForTrack( + track, + historyState, + ); if (historyItem != null) { if (await fileExists(historyItem.filePath)) { return historyItem.filePath; @@ -114,15 +117,22 @@ class PlaybackController extends Notifier { ); } - DownloadHistoryItem? _findDownloadHistoryItemForTrack( + Future _findDownloadHistoryItemForTrack( Track track, DownloadHistoryState historyState, - ) { + ) async { + final historyNotifier = ref.read(downloadHistoryProvider.notifier); for (final candidateId in _spotifyIdLookupCandidates(track.id)) { final bySpotifyId = historyState.getBySpotifyId(candidateId); if (bySpotifyId != null) { return bySpotifyId; } + final bySpotifyIdAsync = await historyNotifier.getBySpotifyIdAsync( + candidateId, + ); + if (bySpotifyIdAsync != null) { + return bySpotifyIdAsync; + } } final isrc = track.isrc?.trim(); @@ -131,9 +141,16 @@ class PlaybackController extends Notifier { if (byIsrc != null) { return byIsrc; } + final byIsrcAsync = await historyNotifier.getByIsrcAsync(isrc); + if (byIsrcAsync != null) { + return byIsrcAsync; + } } - return historyState.findByTrackAndArtist(track.name, track.artistName); + return historyNotifier.findByTrackAndArtistAsync( + track.name, + track.artistName, + ); } List _spotifyIdLookupCandidates(String rawId) { diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 1cf33f86..5862f38f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -620,15 +620,29 @@ class _AlbumScreenState extends ConsumerState { ColorScheme colorScheme, List tracks, ) { + final historyLookups = tracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = ref + .watch( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ), + ) + .maybeWhen(data: (keys) => keys, orElse: () => const {}); return SliverList( delegate: SliverChildBuilderDelegate((context, index) { final track = tracks[index]; + final isInHistory = existingHistoryKeys.contains( + historyLookups[index].lookupKey, + ); return KeyedSubtree( key: ValueKey(track.id), child: StaggeredListItem( index: index, child: _AlbumTrackItem( track: track, + isInHistory: isInHistory, onDownload: () => _downloadTrack(context, track), ), ), @@ -676,11 +690,19 @@ class _AlbumScreenState extends ConsumerState { } } - void _downloadAll(BuildContext context) { + Future _downloadAll(BuildContext context) async { final tracks = _tracks; if (tracks == null || tracks.isEmpty) return; - final historyState = ref.read(downloadHistoryProvider); + final historyLookups = tracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = await ref.read( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ).future, + ); + if (!context.mounted) return; final settings = ref.read(settingsProvider); final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) @@ -689,12 +711,11 @@ class _AlbumScreenState extends ConsumerState { final tracksToQueue = []; int skippedCount = 0; - for (final track in tracks) { - final isInHistory = - historyState.isDownloaded(track.id) || - (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || - historyState.findByTrackAndArtist(track.name, track.artistName) != - null; + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + final isInHistory = existingHistoryKeys.contains( + historyLookups[i].lookupKey, + ); final isInLocal = localLibState?.existsInLibrary( isrc: track.isrc, @@ -929,9 +950,14 @@ class _AlbumScreenState extends ConsumerState { class _AlbumTrackItem extends ConsumerWidget { final Track track; + final bool isInHistory; final VoidCallback onDownload; - const _AlbumTrackItem({required this.track, required this.onDownload}); + const _AlbumTrackItem({ + required this.track, + required this.isInHistory, + required this.onDownload, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -943,17 +969,6 @@ class _AlbumTrackItem extends ConsumerWidget { ), ); - final isInHistory = ref.watch( - downloadHistoryProvider.select((state) { - if (state.isDownloaded(track.id)) return true; - final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { - return true; - } - return state.findByTrackAndArtist(track.name, track.artistName) != null; - }), - ); - final showLocalLibraryIndicator = ref.watch( settingsProvider.select( (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, @@ -1085,18 +1100,16 @@ class _AlbumTrackItem extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - final historyState = ref.read(downloadHistoryProvider); final historyNotifier = ref.read(downloadHistoryProvider.notifier); try { - DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( - track.id, - ); + DownloadHistoryItem? historyItem = await historyNotifier + .getBySpotifyIdAsync(track.id); final isrc = track.isrc?.trim(); historyItem ??= (isrc != null && isrc.isNotEmpty) - ? historyNotifier.getByIsrc(isrc) + ? await historyNotifier.getByIsrcAsync(isrc) : null; - historyItem ??= historyState.findByTrackAndArtist( + historyItem ??= await historyNotifier.findByTrackAndArtistAsync( track.name, track.artistName, ); diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index ea840181..6ddc54f1 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1011,14 +1011,22 @@ class _ArtistScreenState extends ConsumerState { return; } - final historyState = ref.read(downloadHistoryProvider); + final historyLookups = allTracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = await ref.read( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ).future, + ); final tracksToQueue = []; int skippedCount = 0; - for (final track in allTracks) { - final isDownloaded = - historyState.isDownloaded(track.id) || - (track.isrc != null && historyState.getByIsrc(track.isrc!) != null); + for (var i = 0; i < allTracks.length; i++) { + final track = allTracks[i]; + final isDownloaded = existingHistoryKeys.contains( + historyLookups[i].lookupKey, + ); if (!isDownloaded) { tracksToQueue.add(track); @@ -1334,6 +1342,16 @@ class _ArtistScreenState extends ConsumerState { } final tracks = _topTracks!; + final historyLookups = tracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = ref + .watch( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ), + ) + .maybeWhen(data: (keys) => keys, orElse: () => const {}); const tracksPerPage = 5; final pageCount = (tracks.length / tracksPerPage).ceil(); @@ -1374,6 +1392,9 @@ class _ArtistScreenState extends ConsumerState { globalIndex + 1, entry.value, colorScheme, + existingHistoryKeys.contains( + historyLookups[globalIndex].lookupKey, + ), ); }).toList(), ); @@ -1411,6 +1432,7 @@ class _ArtistScreenState extends ConsumerState { int rank, Track track, ColorScheme colorScheme, + bool isInHistory, ) { return Consumer( builder: (context, ref, child) { @@ -1420,20 +1442,6 @@ class _ArtistScreenState extends ConsumerState { ), ); - final isInHistory = ref.watch( - downloadHistoryProvider.select((state) { - if (state.isDownloaded(track.id)) return true; - final isrc = track.isrc?.trim(); - if (isrc != null && - isrc.isNotEmpty && - state.getByIsrc(isrc) != null) { - return true; - } - return state.findByTrackAndArtist(track.name, track.artistName) != - null; - }), - ); - final showLocalLibraryIndicator = ref.watch( settingsProvider.select( (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, @@ -1600,18 +1608,16 @@ class _ArtistScreenState extends ConsumerState { } Future _playLocalIfAvailable(Track track) async { - final historyState = ref.read(downloadHistoryProvider); final historyNotifier = ref.read(downloadHistoryProvider.notifier); try { - DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( - track.id, - ); + DownloadHistoryItem? historyItem = await historyNotifier + .getBySpotifyIdAsync(track.id); final isrc = track.isrc?.trim(); historyItem ??= (isrc != null && isrc.isNotEmpty) - ? historyNotifier.getByIsrc(isrc) + ? await historyNotifier.getByIsrcAsync(isrc) : null; - historyItem ??= historyState.findByTrackAndArtist( + historyItem ??= await historyNotifier.findByTrackAndArtistAsync( track.name, track.artistName, ); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index fc51252a..fadd22d0 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -356,10 +356,25 @@ class _DownloadedAlbumScreenState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final bottomPadding = MediaQuery.of(context).padding.bottom; - final allHistoryItems = ref.watch( - downloadHistoryProvider.select((s) => s.items), + final tracksValue = ref.watch( + downloadedAlbumTracksProvider( + DownloadedAlbumTracksRequest( + albumName: widget.albumName, + artistName: widget.artistName, + ), + ), ); - final tracks = _getAlbumTracks(allHistoryItems); + final tracks = tracksValue.maybeWhen( + data: (items) => _getAlbumTracks(items), + orElse: () => const [], + ); + + if (tracks.isEmpty && tracksValue.isLoading) { + return Scaffold( + appBar: AppBar(title: Text(widget.albumName)), + body: const Center(child: CircularProgressIndicator()), + ); + } if (tracks.isEmpty) { return Scaffold( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 69626dec..14e16aa6 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -349,8 +349,7 @@ class _HomeTabState extends ConsumerState final canonicalFilter = _canonicalSearchFilterId(filter); - if (currentSearchProvider == null || - currentSearchProvider.isEmpty) { + if (currentSearchProvider == null || currentSearchProvider.isEmpty) { switch (canonicalFilter) { case 'track': case 'artist': @@ -993,13 +992,20 @@ class _HomeTabState extends ConsumerState var skippedDownloadedCount = 0; if (options.skipDownloaded) { - final historyState = ref.read(downloadHistoryProvider); + final historyLookups = tracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = await ref.read( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ).future, + ); tracksToQueue = []; - for (final track in tracks) { - final isDownloaded = - historyState.isDownloaded(track.id) || - (track.isrc != null && - historyState.getByIsrc(track.isrc!) != null); + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + final isDownloaded = existingHistoryKeys.contains( + historyLookups[i].lookupKey, + ); if (isDownloaded) { skippedDownloadedCount++; continue; @@ -1120,10 +1126,7 @@ class _HomeTabState extends ConsumerState final extensions = ref.watch(extensionProvider.select((s) => s.extensions)); final extensionReadiness = ref.watch( extensionProvider.select( - (s) => ( - isInitialized: s.isInitialized, - error: s.error, - ), + (s) => (isInitialized: s.isInitialized, error: s.error), ), ); @@ -2339,7 +2342,7 @@ class _HomeTabState extends ConsumerState ); } - void _navigateToRecentItem(RecentAccessItem item) { + Future _navigateToRecentItem(RecentAccessItem item) async { _searchFocusNode.unfocus(); switch (item.type) { @@ -2407,9 +2410,10 @@ class _HomeTabState extends ConsumerState } return; case RecentAccessType.track: - final historyItem = ref + final historyItem = await ref .read(downloadHistoryProvider.notifier) - .getBySpotifyId(item.id); + .getBySpotifyIdAsync(item.id); + if (!mounted) return; if (historyItem != null) { _navigateToMetadataScreen(historyItem); } else { diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 3637b4e6..ef4ca889 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -232,11 +232,10 @@ class _TrackItemWithStatus extends ConsumerWidget { ), ); - final isInHistory = ref.watch( - downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); - }), - ); + final historyLookup = historyLookupForTrack(track); + final isInHistory = ref + .watch(downloadHistoryExistsProvider(historyLookup)) + .maybeWhen(data: (exists) => exists, orElse: () => false); final isInLocalLibrary = showLocalLibraryIndicator ? ref.watch( @@ -414,28 +413,23 @@ class _TrackItemWithStatus extends ConsumerWidget { return; } - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + final historyItem = await historyNotifier.findExistingTrackAsync( + historyLookupForTrack(track), + ); + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)), + ), + ); } + return; + } else { + historyNotifier.removeFromHistory(historyItem.id); } } diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index eceaaea2..3bbaeb69 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -310,6 +310,16 @@ class _LibraryTracksFolderScreenState final folderTracks = entries .map((entry) => entry.track) .toList(growable: false); + final historyLookups = folderTracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = ref + .watch( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ), + ) + .maybeWhen(data: (keys) => keys, orElse: () => const {}); final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -340,6 +350,9 @@ class _LibraryTracksFolderScreenState delegate: SliverChildBuilderDelegate((context, index) { final entry = entries[index]; final isSelected = _selectedKeys.contains(entry.key); + final isInHistory = existingHistoryKeys.contains( + historyLookups[index].lookupKey, + ); return KeyedSubtree( key: ValueKey(entry.key), child: StaggeredListItem( @@ -349,6 +362,7 @@ class _LibraryTracksFolderScreenState mode: widget.mode, playlistId: widget.playlistId, folderTracks: folderTracks, + isInHistory: isInHistory, isSelectionMode: _isSelectionMode, isSelected: isSelected, onTap: _isSelectionMode @@ -871,9 +885,17 @@ class _LibraryTracksFolderScreenState ); } - void _downloadAll(List tracks) { + Future _downloadAll(List tracks) async { if (tracks.isEmpty) return; - final historyState = ref.read(downloadHistoryProvider); + final historyLookups = tracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = await ref.read( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ).future, + ); + if (!mounted) return; final settings = ref.read(settingsProvider); final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) @@ -885,12 +907,11 @@ class _LibraryTracksFolderScreenState final tracksToQueue = []; var skippedCount = 0; - for (final track in tracks) { - final isInHistory = - historyState.isDownloaded(track.id) || - (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || - historyState.findByTrackAndArtist(track.name, track.artistName) != - null; + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + final isInHistory = existingHistoryKeys.contains( + historyLookups[i].lookupKey, + ); final isInLocal = localLibState?.existsInLibrary( isrc: track.isrc, @@ -1059,6 +1080,7 @@ class _CollectionTrackTile extends ConsumerWidget { final LibraryTracksFolderMode mode; final String? playlistId; final List folderTracks; + final bool isInHistory; final bool isSelectionMode; final bool isSelected; final VoidCallback? onTap; @@ -1069,6 +1091,7 @@ class _CollectionTrackTile extends ConsumerWidget { required this.mode, required this.playlistId, required this.folderTracks, + required this.isInHistory, this.isSelectionMode = false, this.isSelected = false, this.onTap, @@ -1097,7 +1120,7 @@ class _CollectionTrackTile extends ConsumerWidget { // Fine-grained provider watches – only this tile rebuilds when its own // history / local-library entry changes. - final historyItem = ref.watch( + final inMemoryHistoryItem = ref.watch( downloadHistoryProvider.select((state) { final byId = state.getBySpotifyId(track.id); if (byId != null) return byId; @@ -1127,8 +1150,9 @@ class _CollectionTrackTile extends ConsumerWidget { ) : false; - final isInHistory = historyItem != null; - final heroTag = historyItem != null ? 'cover_${historyItem.id}' : null; + final heroTag = inMemoryHistoryItem != null + ? 'cover_${inMemoryHistoryItem.id}' + : null; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1241,7 +1265,7 @@ class _CollectionTrackTile extends ConsumerWidget { ), trailing: isSelectionMode ? null - : historyItem != null || isInLocalLibrary + : isInHistory || isInLocalLibrary ? IconButton( tooltip: context.l10n.tooltipPlay, onPressed: () { @@ -1379,18 +1403,21 @@ class _CollectionTrackTile extends ConsumerWidget { Future _navigateToMetadata(BuildContext context, WidgetRef ref) async { final track = entry.track; - final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); - var historyItem = historyState.getBySpotifyId(track.id); + var historyItem = await historyNotifier.getBySpotifyIdAsync(track.id); + if (!context.mounted) return; if (historyItem == null && track.isrc != null && track.isrc!.isNotEmpty) { - historyItem = historyState.getByIsrc(track.isrc!); + historyItem = await historyNotifier.getByIsrcAsync(track.isrc!); + if (!context.mounted) return; } - historyItem ??= historyState.findByTrackAndArtist( + historyItem ??= await historyNotifier.findByTrackAndArtistAsync( track.name, track.artistName, ); + if (!context.mounted) return; if (historyItem != null) { await Navigator.of(context).push( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 7336e6ef..0c7fc5fc 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -477,15 +477,29 @@ class _PlaylistScreenState extends ConsumerState { ); } + final historyLookups = _tracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = ref + .watch( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ), + ) + .maybeWhen(data: (keys) => keys, orElse: () => const {}); return SliverList( delegate: SliverChildBuilderDelegate((context, index) { final track = _tracks[index]; + final isInHistory = existingHistoryKeys.contains( + historyLookups[index].lookupKey, + ); return KeyedSubtree( key: ValueKey(track.id), child: StaggeredListItem( index: index, child: _PlaylistTrackItem( track: track, + isInHistory: isInHistory, onDownload: () => _downloadTrack(context, track), ), ), @@ -695,10 +709,18 @@ class _PlaylistScreenState extends ConsumerState { _downloadTracks(context, _tracks); } - void _downloadTracks(BuildContext context, List tracks) { + Future _downloadTracks(BuildContext context, List tracks) async { if (tracks.isEmpty) return; - final historyState = ref.read(downloadHistoryProvider); + final historyLookups = tracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = await ref.read( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ).future, + ); + if (!context.mounted) return; final settings = ref.read(settingsProvider); final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) @@ -707,12 +729,11 @@ class _PlaylistScreenState extends ConsumerState { final tracksToQueue = []; int skippedCount = 0; - for (final track in tracks) { - final isInHistory = - historyState.isDownloaded(track.id) || - (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || - historyState.findByTrackAndArtist(track.name, track.artistName) != - null; + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + final isInHistory = existingHistoryKeys.contains( + historyLookups[i].lookupKey, + ); final isInLocal = localLibState?.existsInLibrary( isrc: track.isrc, @@ -781,9 +802,14 @@ class _PlaylistScreenState extends ConsumerState { class _PlaylistTrackItem extends ConsumerWidget { final Track track; + final bool isInHistory; final VoidCallback onDownload; - const _PlaylistTrackItem({required this.track, required this.onDownload}); + const _PlaylistTrackItem({ + required this.track, + required this.isInHistory, + required this.onDownload, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -795,17 +821,6 @@ class _PlaylistTrackItem extends ConsumerWidget { ), ); - final isInHistory = ref.watch( - downloadHistoryProvider.select((state) { - if (state.isDownloaded(track.id)) return true; - final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { - return true; - } - return state.findByTrackAndArtist(track.name, track.artistName) != null; - }), - ); - final showLocalLibraryIndicator = ref.watch( settingsProvider.select( (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, @@ -942,18 +957,16 @@ class _PlaylistTrackItem extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - final historyState = ref.read(downloadHistoryProvider); final historyNotifier = ref.read(downloadHistoryProvider.notifier); try { - DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( - track.id, - ); + DownloadHistoryItem? historyItem = await historyNotifier + .getBySpotifyIdAsync(track.id); final isrc = track.isrc?.trim(); historyItem ??= (isrc != null && isrc.isNotEmpty) - ? historyNotifier.getByIsrc(isrc) + ? await historyNotifier.getByIsrcAsync(isrc) : null; - historyItem ??= historyState.findByTrackAndArtist( + historyItem ??= await historyNotifier.findByTrackAndArtistAsync( track.name, track.artistName, ); diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index ebca6f6e..15d978b2 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -58,6 +58,7 @@ class QueueTab extends ConsumerStatefulWidget { } class _QueueTabState extends ConsumerState { + static const int _libraryPageSize = 300; final _FileExistsListenableCache _fileExistsCache = _FileExistsListenableCache(); static const int _maxSearchIndexCacheSize = 4000; @@ -147,6 +148,8 @@ class _QueueTabState extends ConsumerState { String _sortMode = 'latest'; double _libraryGridExtent = _libraryGridDefaultExtent; double? _libraryGridScaleStartExtent; + int _libraryPageLimit = _libraryPageSize; + bool _libraryPageLoadScheduled = false; double _effectiveTextScale() { final textScale = MediaQuery.textScalerOf(context).scale(1.0); @@ -217,7 +220,10 @@ class _QueueTabState extends ConsumerState { final normalized = value.trim().toLowerCase(); _searchDebounce = Timer(const Duration(milliseconds: 180), () { if (!mounted || _searchQuery == normalized) return; - setState(() => _searchQuery = normalized); + setState(() { + _searchQuery = normalized; + _libraryPageLimit = _libraryPageSize; + }); _requestFilterRefresh(); }); } @@ -225,10 +231,46 @@ class _QueueTabState extends ConsumerState { void _clearSearch() { _searchDebounce?.cancel(); if (_searchQuery.isEmpty) return; - setState(() => _searchQuery = ''); + setState(() { + _searchQuery = ''; + _libraryPageLimit = _libraryPageSize; + }); _requestFilterRefresh(); } + void _loadMoreLibraryItems({required bool hasMoreLibrary}) { + if (_libraryPageLoadScheduled) return; + _libraryPageLoadScheduled = true; + setState(() { + if (hasMoreLibrary) _libraryPageLimit += _libraryPageSize; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _libraryPageLoadScheduled = false; + }); + } + + bool _handleLibraryScrollNotification({ + required ScrollNotification notification, + required String filterMode, + required bool hasMoreLibrary, + required bool isPageLoading, + }) { + if (isPageLoading || !hasMoreLibrary || notification.depth != 0) { + return false; + } + + final metrics = notification.metrics; + if (metrics.maxScrollExtent <= 0) return false; + final threshold = metrics.maxScrollExtent * 0.7; + final nearEnd = + metrics.pixels >= threshold || + metrics.extentAfter <= metrics.viewportDimension * 1.5; + if (!nearEnd) return false; + + _loadMoreLibraryItems(hasMoreLibrary: hasMoreLibrary); + return false; + } + void _invalidateFilterContentCache() { _filterContentDataCache.clear(); _filterCacheAllHistoryItems = null; @@ -237,6 +279,7 @@ class _QueueTabState extends ConsumerState { _filterCacheCollectionState = null; } + // ignore: unused_element void _prepareFilterContentCache({ required List allHistoryItems, required _HistoryStats historyStats, @@ -272,6 +315,7 @@ class _QueueTabState extends ConsumerState { _filterCacheSortMode = _sortMode; } + // ignore: unused_element void _ensureHistoryCaches( List items, List localItems, @@ -1252,6 +1296,7 @@ class _QueueTabState extends ConsumerState { _filterFormat = null; _filterMetadata = null; _sortMode = 'latest'; + _libraryPageLimit = _libraryPageSize; _unifiedItemsCache.clear(); _invalidateFilterContentCache(); }); @@ -1844,6 +1889,7 @@ class _QueueTabState extends ConsumerState { _filterFormat = tempFormat; _filterMetadata = tempMetadata; _sortMode = tempSortMode; + _libraryPageLimit = _libraryPageSize; _unifiedItemsCache.clear(); _invalidateFilterContentCache(); }); @@ -1971,6 +2017,7 @@ class _QueueTabState extends ConsumerState { item: item, historyNavigationItems: navigationItems, navigationIndex: navigationIndex, + coverHeroTag: 'cover_lib_dl_${item.id}', ), ), ); @@ -2002,6 +2049,7 @@ class _QueueTabState extends ConsumerState { localItem: item, localNavigationItems: navigationItems, navigationIndex: navigationIndex, + coverHeroTag: 'cover_lib_local_${item.id}', ), ), ).then((_) => _searchFocusNode.unfocus()); @@ -2059,14 +2107,23 @@ class _QueueTabState extends ConsumerState { ); } - void _navigateToLocalAlbum(_GroupedLocalAlbum album) { + Future _navigateToLocalAlbum(_GroupedLocalAlbum album) async { + var tracks = album.tracks; + if (tracks.isEmpty && album.displayTrackCount > 0) { + final rows = await LibraryDatabase.instance.getQueueLocalAlbumTracks( + album.albumName, + album.artistName, + ); + tracks = rows.map(LocalLibraryItem.fromJson).toList(growable: false); + if (!mounted) return; + } _navigateWithUnfocus( slidePageRoute( page: LocalAlbumScreen( albumName: album.albumName, artistName: album.artistName, coverPath: album.coverPath, - tracks: album.tracks, + tracks: tracks, ), ), ); @@ -2362,20 +2419,9 @@ class _QueueTabState extends ConsumerState { final hasQueueItems = ref.watch( downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty), ); - final allHistoryItems = ref.watch( - downloadHistoryProvider.select((s) => s.items), - ); final localLibraryEnabled = ref.watch( settingsProvider.select((s) => s.localLibraryEnabled), ); - final localLibraryItems = localLibraryEnabled - ? ref - .watch(localLibraryAllItemsProvider) - .maybeWhen( - data: (items) => items, - orElse: () => const [], - ) - : const []; // Watch with selector on key fields to reduce unnecessary rebuilds. // LibraryCollectionsState doesn't implement == so watching without // selector rebuilds on every provider notification. @@ -2392,20 +2438,6 @@ class _QueueTabState extends ConsumerState { ), ); final collectionState = ref.read(libraryCollectionsProvider); - final historyStats = ref.watch(_queueHistoryStatsProvider); - final filteredGrouped = ref.watch( - _queueFilteredAlbumsProvider( - _QueueGroupedAlbumFilterRequest( - searchQuery: _searchQuery, - filterSource: _filterSource, - filterQuality: _filterQuality, - filterFormat: _filterFormat, - filterMetadata: _filterMetadata, - sortMode: _sortMode, - ), - ), - ); - _ensureHistoryCaches(allHistoryItems, localLibraryItems, historyStats); final historyViewMode = ref.watch( settingsProvider.select((s) => s.historyViewMode), ); @@ -2414,33 +2446,78 @@ class _QueueTabState extends ConsumerState { ); final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); - final filteredGroupedAlbums = filteredGrouped.albums; - final filteredGroupedLocalAlbums = filteredGrouped.localAlbums; - final albumCount = historyStats.totalAlbumCount; - final singleCount = historyStats.totalSingleTracks; - _prepareFilterContentCache( - allHistoryItems: allHistoryItems, - historyStats: historyStats, - localLibraryItems: localLibraryItems, - collectionState: collectionState, + final countsRequest = _QueueLibraryCountsRequest( + searchQuery: _searchQuery, + filterSource: _filterSource, + filterQuality: _filterQuality, + filterFormat: _filterFormat, + filterMetadata: _filterMetadata, + localLibraryEnabled: localLibraryEnabled, + ); + final countsValue = ref.watch(_queueLibraryCountsProvider(countsRequest)); + final queueCounts = countsValue.maybeWhen( + data: (counts) => counts, + orElse: () => const QueueLibraryCounts( + allTrackCount: 0, + albumCount: 0, + singleTrackCount: 0, + ), ); - _FilterContentData getFilterData(String filterMode) { - return _filterContentDataCache.putIfAbsent( - filterMode, - () => _computeFilterContentData( + _QueueLibraryPageRequest pageRequest(String filterMode) => + _QueueLibraryPageRequest( filterMode: filterMode, - allHistoryItems: allHistoryItems, - filteredGroupedAlbums: filteredGroupedAlbums, - filteredGroupedLocalAlbums: filteredGroupedLocalAlbums, - albumCounts: historyStats.albumCounts, - localAlbumCounts: historyStats.localAlbumCounts, - localLibraryItems: localLibraryItems, - collectionState: collectionState, - ), + limit: _libraryPageLimit, + searchQuery: _searchQuery, + filterSource: _filterSource, + filterQuality: _filterQuality, + filterFormat: _filterFormat, + filterMetadata: _filterMetadata, + sortMode: _sortMode, + localLibraryEnabled: localLibraryEnabled, + ); + + final pageValues = >{ + for (final mode in _filterModes) + mode: ref.watch(_queueLibraryPageProvider(pageRequest(mode))), + }; + + _QueueLibraryPageData pageData(String filterMode) => + pageValues[filterMode]?.maybeWhen( + data: (data) => data, + orElse: () => const _QueueLibraryPageData(), + ) ?? + const _QueueLibraryPageData(); + + _FilterContentData getFilterData(String filterMode) { + return pageData(filterMode).toFilterContentData( + collectionState, + totalTrackCount: switch (filterMode) { + 'singles' => queueCounts.singleTrackCount, + 'albums' => 0, + _ => queueCounts.allTrackCount, + }, + totalAlbumCount: filterMode == 'albums' ? queueCounts.albumCount : null, ); } + final currentPageData = pageData(historyFilterMode); + final currentLoadedCount = historyFilterMode == 'albums' + ? currentPageData.groupedAlbums.length + + currentPageData.groupedLocalAlbums.length + : currentPageData.items.length; + final currentTotalCount = switch (historyFilterMode) { + 'albums' => queueCounts.albumCount, + 'singles' => queueCounts.singleTrackCount, + _ => queueCounts.allTrackCount, + }; + final hasMoreLibrary = currentLoadedCount < currentTotalCount; + final isLibraryPageLoading = + countsValue.isLoading || + (pageValues[historyFilterMode]?.isLoading ?? false); + final hasAnyLibraryItems = + queueCounts.allTrackCount > 0 || queueCounts.albumCount > 0; + final bottomPadding = MediaQuery.paddingOf(context).bottom; final selectionItems = getFilterData( historyFilterMode, @@ -2519,9 +2596,7 @@ class _QueueTabState extends ConsumerState { ), ), - if (allHistoryItems.isNotEmpty || - hasQueueItems || - localLibraryItems.isNotEmpty) + if (hasAnyLibraryItems || hasQueueItems) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), @@ -2586,7 +2661,7 @@ class _QueueTabState extends ConsumerState { if (hasQueueItems) _buildQueueItemsSliver(context, colorScheme), - if (allHistoryItems.isNotEmpty || localLibraryItems.isNotEmpty) + if (hasAnyLibraryItems) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), @@ -2596,20 +2671,9 @@ class _QueueTabState extends ConsumerState { int filteredAlbumCount; int filteredSingleCount; - if (_activeFilterCount == 0 && _searchQuery.isEmpty) { - filteredAllCount = - allHistoryItems.length + - localLibraryItems.length; - filteredAlbumCount = albumCount; - filteredSingleCount = singleCount; - } else { - final allData = getFilterData('all'); - final albumsData = getFilterData('albums'); - final singlesData = getFilterData('singles'); - filteredAllCount = allData.totalTrackCount; - filteredAlbumCount = albumsData.totalAlbumCount; - filteredSingleCount = singlesData.totalTrackCount; - } + filteredAllCount = queueCounts.allTrackCount; + filteredAlbumCount = queueCounts.albumCount; + filteredSingleCount = queueCounts.singleTrackCount; return SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -2664,8 +2728,11 @@ class _QueueTabState extends ConsumerState { historyViewMode: historyViewMode, hasQueueItems: hasQueueItems, filterData: filterData, - localLibraryItems: localLibraryItems, collectionState: collectionState, + hasMoreLibrary: filterMode == historyFilterMode + ? hasMoreLibrary + : false, + isPageLoading: isLibraryPageLoading, ); }, ), @@ -2738,6 +2805,7 @@ class _QueueTabState extends ConsumerState { return merged; } + // ignore: unused_element _FilterContentData _computeFilterContentData({ required String filterMode, required List allHistoryItems, @@ -3259,8 +3327,9 @@ class _QueueTabState extends ConsumerState { required String historyViewMode, required bool hasQueueItems, required _FilterContentData filterData, - required List localLibraryItems, required LibraryCollectionsState collectionState, + required bool hasMoreLibrary, + required bool isPageLoading, }) { final historyItems = filterData.historyItems; final showFilteringIndicator = filterData.showFilteringIndicator; @@ -3345,7 +3414,7 @@ class _QueueTabState extends ConsumerState { if (filteredGroupedAlbums.isEmpty && filteredGroupedLocalAlbums.isEmpty && filterMode == 'albums' && - (historyItems.isNotEmpty || localLibraryItems.isNotEmpty)) + (historyItems.isNotEmpty || unifiedItems.isNotEmpty)) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), @@ -3697,20 +3766,51 @@ class _QueueTabState extends ConsumerState { hasScrollBody: false, child: _buildEmptyState(context, colorScheme, filterMode), ) - else + else if (isPageLoading && filterMode != 'albums') + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + ), + ), + ), + + if (hasQueueItems || + totalTrackCount > 0 || + (filterMode == 'albums' && + (filteredGroupedAlbums.isNotEmpty || + filteredGroupedLocalAlbums.isNotEmpty))) SliverToBoxAdapter( child: SizedBox(height: _isSelectionMode ? 100 : 16), ), ], ); - if (historyViewMode != 'grid') return content; + final scrollAwareContent = NotificationListener( + onNotification: (notification) => _handleLibraryScrollNotification( + notification: notification, + filterMode: filterMode, + hasMoreLibrary: hasMoreLibrary, + isPageLoading: isPageLoading, + ), + child: content, + ); + + if (historyViewMode != 'grid') return scrollAwareContent; return GestureDetector( behavior: HitTestBehavior.translucent, onScaleStart: _handleLibraryGridScaleStart, onScaleUpdate: _handleLibraryGridScaleUpdate, onScaleEnd: _handleLibraryGridScaleEnd, - child: content, + child: scrollAwareContent, ); } @@ -3848,7 +3948,7 @@ class _QueueTabState extends ConsumerState { context: context, albumName: album.albumName, artistName: album.artistName, - trackCount: album.tracks.length, + trackCount: album.displayTrackCount, colorScheme: colorScheme, coverWidget: embeddedCoverPath != null ? Image.file( @@ -3890,7 +3990,7 @@ class _QueueTabState extends ConsumerState { context: context, albumName: album.albumName, artistName: album.artistName, - trackCount: album.tracks.length, + trackCount: album.displayTrackCount, colorScheme: colorScheme, coverWidget: album.coverPath != null ? Image.file( @@ -6033,7 +6133,10 @@ class _QueueTabState extends ConsumerState { children: [ AspectRatio( aspectRatio: 1, - child: _buildUnifiedCoverImage(item, colorScheme), + child: Hero( + tag: 'cover_lib_${item.id}', + child: _buildUnifiedCoverImage(item, colorScheme), + ), ), Positioned( right: 4, diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart index 2c999df3..cffca9f1 100644 --- a/lib/screens/queue_tab_helpers.dart +++ b/lib/screens/queue_tab_helpers.dart @@ -178,6 +178,7 @@ class _GroupedAlbum { final String? coverUrl; final String sampleFilePath; final List tracks; + final int? trackCount; final DateTime latestDownload; final String searchKey; @@ -187,10 +188,13 @@ class _GroupedAlbum { this.coverUrl, required this.sampleFilePath, required this.tracks, + this.trackCount, required this.latestDownload, }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; String get key => '$albumName|$artistName'; + + int get displayTrackCount => trackCount ?? tracks.length; } class _GroupedLocalAlbum { @@ -198,6 +202,7 @@ class _GroupedLocalAlbum { final String artistName; final String? coverPath; final List tracks; + final int? trackCount; final DateTime latestScanned; final String searchKey; @@ -206,36 +211,27 @@ class _GroupedLocalAlbum { required this.artistName, this.coverPath, required this.tracks, + this.trackCount, required this.latestScanned, }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; String get key => '$albumName|$artistName'; + + int get displayTrackCount => trackCount ?? tracks.length; } class _HistoryStats { final Map albumCounts; - final Map localAlbumCounts; final List<_GroupedAlbum> groupedAlbums; - final List<_GroupedLocalAlbum> groupedLocalAlbums; final int albumCount; final int singleTracks; - final int localAlbumCount; - final int localSingleTracks; const _HistoryStats({ required this.albumCounts, - this.localAlbumCounts = const {}, required this.groupedAlbums, - this.groupedLocalAlbums = const [], required this.albumCount, required this.singleTracks, - this.localAlbumCount = 0, - this.localSingleTracks = 0, }); - - int get totalAlbumCount => albumCount + localAlbumCount; - - int get totalSingleTracks => singleTracks + localSingleTracks; } class _FilterContentData { @@ -245,6 +241,8 @@ class _FilterContentData { final List<_GroupedAlbum> filteredGroupedAlbums; final List<_GroupedLocalAlbum> filteredGroupedLocalAlbums; final bool showFilteringIndicator; + final int? totalTrackCountOverride; + final int? totalAlbumCountOverride; const _FilterContentData({ required this.historyItems, @@ -253,13 +251,258 @@ class _FilterContentData { required this.filteredGroupedAlbums, required this.filteredGroupedLocalAlbums, required this.showFilteringIndicator, + this.totalTrackCountOverride, + this.totalAlbumCountOverride, }); - int get totalTrackCount => filteredUnifiedItems.length; + int get totalTrackCount => + totalTrackCountOverride ?? filteredUnifiedItems.length; int get totalAlbumCount => + totalAlbumCountOverride ?? filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length; } +class _QueueLibraryPageRequest { + final String filterMode; + final int limit; + final String searchQuery; + final String? filterSource; + final String? filterQuality; + final String? filterFormat; + final String? filterMetadata; + final String sortMode; + final bool localLibraryEnabled; + + const _QueueLibraryPageRequest({ + required this.filterMode, + required this.limit, + required this.searchQuery, + required this.filterSource, + required this.filterQuality, + required this.filterFormat, + required this.filterMetadata, + required this.sortMode, + required this.localLibraryEnabled, + }); + + QueueLibraryDbQuery toDbQuery() => QueueLibraryDbQuery( + limit: limit, + filterMode: filterMode, + searchQuery: searchQuery, + source: filterSource, + quality: filterQuality, + format: filterFormat, + metadata: filterMetadata, + sortMode: sortMode, + includeLocal: localLibraryEnabled, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _QueueLibraryPageRequest && + filterMode == other.filterMode && + limit == other.limit && + searchQuery == other.searchQuery && + filterSource == other.filterSource && + filterQuality == other.filterQuality && + filterFormat == other.filterFormat && + filterMetadata == other.filterMetadata && + sortMode == other.sortMode && + localLibraryEnabled == other.localLibraryEnabled; + + @override + int get hashCode => Object.hash( + filterMode, + limit, + searchQuery, + filterSource, + filterQuality, + filterFormat, + filterMetadata, + sortMode, + localLibraryEnabled, + ); +} + +class _QueueLibraryCountsRequest { + final String searchQuery; + final String? filterSource; + final String? filterQuality; + final String? filterFormat; + final String? filterMetadata; + final bool localLibraryEnabled; + + const _QueueLibraryCountsRequest({ + required this.searchQuery, + required this.filterSource, + required this.filterQuality, + required this.filterFormat, + required this.filterMetadata, + required this.localLibraryEnabled, + }); + + QueueLibraryDbQuery toDbQuery() => QueueLibraryDbQuery( + searchQuery: searchQuery, + source: filterSource, + quality: filterQuality, + format: filterFormat, + metadata: filterMetadata, + includeLocal: localLibraryEnabled, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _QueueLibraryCountsRequest && + searchQuery == other.searchQuery && + filterSource == other.filterSource && + filterQuality == other.filterQuality && + filterFormat == other.filterFormat && + filterMetadata == other.filterMetadata && + localLibraryEnabled == other.localLibraryEnabled; + + @override + int get hashCode => Object.hash( + searchQuery, + filterSource, + filterQuality, + filterFormat, + filterMetadata, + localLibraryEnabled, + ); +} + +class _QueueLibraryPageData { + final List items; + final List historyItems; + final List localItems; + final List<_GroupedAlbum> groupedAlbums; + final List<_GroupedLocalAlbum> groupedLocalAlbums; + + const _QueueLibraryPageData({ + this.items = const [], + this.historyItems = const [], + this.localItems = const [], + this.groupedAlbums = const [], + this.groupedLocalAlbums = const [], + }); + + _FilterContentData toFilterContentData( + LibraryCollectionsState collectionState, { + int? totalTrackCount, + int? totalAlbumCount, + }) { + final filteredItems = !collectionState.hasPlaylistTracks + ? items + : items + .where( + (item) => + !collectionState.isTrackInAnyPlaylist(item.collectionKey), + ) + .toList(growable: false); + return _FilterContentData( + historyItems: historyItems, + unifiedItems: items, + filteredUnifiedItems: filteredItems, + filteredGroupedAlbums: groupedAlbums, + filteredGroupedLocalAlbums: groupedLocalAlbums, + showFilteringIndicator: false, + totalTrackCountOverride: totalTrackCount, + totalAlbumCountOverride: totalAlbumCount, + ); + } +} + +final _queueLibraryPageProvider = + FutureProvider.family<_QueueLibraryPageData, _QueueLibraryPageRequest>(( + ref, + request, + ) async { + ref.watch( + downloadHistoryProvider.select((state) => state.loadedIndexVersion), + ); + ref.watch( + localLibraryProvider.select((state) => state.loadedIndexVersion), + ); + final dbQuery = request.toDbQuery(); + if (request.filterMode == 'albums') { + final rows = await LibraryDatabase.instance.getQueueAlbumPage(dbQuery); + final groupedAlbums = <_GroupedAlbum>[]; + final groupedLocalAlbums = <_GroupedLocalAlbum>[]; + for (final row in rows) { + final source = row['queue_source'] as String? ?? ''; + final latestMillis = (row['sort_added'] as num?)?.toInt() ?? 0; + final latest = DateTime.fromMillisecondsSinceEpoch(latestMillis); + if (source == 'local') { + groupedLocalAlbums.add( + _GroupedLocalAlbum( + albumName: row['album_name'] as String? ?? '', + artistName: row['artist_name'] as String? ?? '', + coverPath: row['cover_path'] as String?, + tracks: const [], + trackCount: (row['track_count'] as num?)?.toInt() ?? 0, + latestScanned: latest, + ), + ); + } else if (source == 'downloaded') { + groupedAlbums.add( + _GroupedAlbum( + albumName: row['album_name'] as String? ?? '', + artistName: row['artist_name'] as String? ?? '', + coverUrl: row['cover_url'] as String?, + sampleFilePath: row['sample_file_path'] as String? ?? '', + tracks: const [], + trackCount: (row['track_count'] as num?)?.toInt() ?? 0, + latestDownload: latest, + ), + ); + } + } + return _QueueLibraryPageData( + groupedAlbums: groupedAlbums, + groupedLocalAlbums: groupedLocalAlbums, + ); + } + + final rows = await LibraryDatabase.instance.getQueueTrackPage(dbQuery); + final items = []; + final historyItems = []; + final localItems = []; + for (final row in rows) { + final source = row['source'] as String? ?? ''; + final itemJson = Map.from(row['item'] as Map); + if (source == 'local') { + final item = LocalLibraryItem.fromJson(itemJson); + localItems.add(item); + items.add(UnifiedLibraryItem.fromLocalLibrary(item)); + } else if (source == 'downloaded') { + final item = DownloadHistoryItem.fromJson(itemJson); + historyItems.add(item); + items.add(UnifiedLibraryItem.fromDownloadHistory(item)); + } + } + return _QueueLibraryPageData( + items: items, + historyItems: historyItems, + localItems: localItems, + ); + }); + +final _queueLibraryCountsProvider = + FutureProvider.family(( + ref, + request, + ) async { + ref.watch( + downloadHistoryProvider.select((state) => state.loadedIndexVersion), + ); + ref.watch( + localLibraryProvider.select((state) => state.loadedIndexVersion), + ); + return LibraryDatabase.instance.getQueueCounts(request.toDbQuery()); + }); + class _UnifiedCacheEntry { final List historyItems; final List localItems; @@ -290,59 +533,6 @@ class _QueueItemIdsSnapshot { int get hashCode => Object.hashAll(ids); } -class _QueueGroupedAlbumFilterRequest { - final String searchQuery; - final String? filterSource; - final String? filterQuality; - final String? filterFormat; - final String? filterMetadata; - final String sortMode; - - const _QueueGroupedAlbumFilterRequest({ - required this.searchQuery, - required this.filterSource, - required this.filterQuality, - required this.filterFormat, - required this.filterMetadata, - required this.sortMode, - }); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is _QueueGroupedAlbumFilterRequest && - searchQuery == other.searchQuery && - filterSource == other.filterSource && - filterQuality == other.filterQuality && - filterFormat == other.filterFormat && - filterMetadata == other.filterMetadata && - sortMode == other.sortMode; - - @override - int get hashCode => Object.hash( - searchQuery, - filterSource, - filterQuality, - filterFormat, - filterMetadata, - sortMode, - ); -} - -class _QueueHistoryStatsMemoEntry { - final List historyItems; - final List localItems; - final _HistoryStats stats; - - const _QueueHistoryStatsMemoEntry({ - required this.historyItems, - required this.localItems, - required this.stats, - }); -} - -_QueueHistoryStatsMemoEntry? _queueHistoryStatsMemo; - class _FileExistsListenableCache { static const int _maxCacheSize = 500; @@ -413,19 +603,6 @@ class _FileExistsListenableCache { } } -String _queueHistoryAlbumKey(String albumName, String artistName) { - return '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; -} - -String _queueFileExtLower(String filePath) { - final slashIndex = filePath.lastIndexOf('/'); - final dotIndex = filePath.lastIndexOf('.'); - if (dotIndex == -1 || dotIndex < slashIndex + 1) { - return ''; - } - return filePath.substring(dotIndex + 1).toLowerCase(); -} - bool _queueHasMetadataValue(String? value) { return value != null && value.trim().isNotEmpty; } @@ -583,554 +760,6 @@ int _queueCompareOptionalDate( return descending ? -comparison : comparison; } -DateTime? _queueGroupedAlbumReleaseDate(_GroupedAlbum album) { - for (final track in album.tracks) { - final releaseDate = _queueParseReleaseDate(track.releaseDate); - if (releaseDate != null) { - return releaseDate; - } - } - return null; -} - -DateTime? _queueGroupedLocalAlbumReleaseDate(_GroupedLocalAlbum album) { - for (final track in album.tracks) { - final releaseDate = _queueParseReleaseDate(track.releaseDate); - if (releaseDate != null) { - return releaseDate; - } - } - return null; -} - -String? _queueGroupedAlbumGenre(_GroupedAlbum album) { - for (final track in album.tracks) { - if (_queueHasMetadataValue(track.genre)) { - return track.genre; - } - } - return null; -} - -String? _queueGroupedLocalAlbumGenre(_GroupedLocalAlbum album) { - for (final track in album.tracks) { - if (_queueHasMetadataValue(track.genre)) { - return track.genre; - } - } - return null; -} - -String? _queueLocalQualityLabel(LocalLibraryItem item) { - if (item.bitrate != null && item.bitrate! > 0) { - return '${item.bitrate}kbps'; - } - if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) { - return null; - } - return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; -} - -bool _queuePassesQualityFilter(String? filterQuality, String? quality) { - if (filterQuality == null) return true; - if (quality == null) return filterQuality == 'lossy'; - final normalized = quality.toLowerCase(); - switch (filterQuality) { - case 'hires': - return normalized.startsWith('24'); - case 'cd': - return normalized.startsWith('16'); - case 'lossy': - return !normalized.startsWith('24') && !normalized.startsWith('16'); - default: - return true; - } -} - -bool _queuePassesFormatFilter(String? filterFormat, String filePath) { - if (filterFormat == null) return true; - return _queueFileExtLower(filePath) == filterFormat; -} - -_HistoryStats _buildQueueHistoryStats( - List items, [ - List localItems = const [], -]) { - final memo = _queueHistoryStatsMemo; - if (memo != null && - identical(memo.historyItems, items) && - identical(memo.localItems, localItems)) { - return memo.stats; - } - - final albumCounts = {}; - final albumMap = >{}; - for (final item in items) { - final key = _queueHistoryAlbumKey( - item.albumName, - item.albumArtist ?? item.artistName, - ); - albumCounts[key] = (albumCounts[key] ?? 0) + 1; - albumMap.putIfAbsent(key, () => []).add(item); - } - - var singleTracks = 0; - var albumCount = 0; - for (final count in albumCounts.values) { - if (count > 1) { - albumCount++; - } else { - singleTracks += count; - } - } - - final groupedAlbums = <_GroupedAlbum>[]; - albumMap.forEach((_, tracks) { - if (tracks.length <= 1) return; - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - groupedAlbums.add( - _GroupedAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverUrl: tracks.first.coverUrl, - sampleFilePath: tracks.first.filePath, - tracks: tracks, - latestDownload: tracks - .map((t) => t.downloadedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - ), - ); - }); - groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); - - final downloadedPathKeys = {}; - for (final item in items) { - downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); - } - - final dedupedLocalItems = localItems - .where((item) { - final localPathKeys = buildPathMatchKeys(item.filePath); - return !localPathKeys.any(downloadedPathKeys.contains); - }) - .toList(growable: false); - - final localAlbumCounts = {}; - final localAlbumMap = >{}; - for (final item in dedupedLocalItems) { - final key = _queueHistoryAlbumKey( - item.albumName, - item.albumArtist ?? item.artistName, - ); - localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; - localAlbumMap.putIfAbsent(key, () => []).add(item); - } - - var localAlbumCount = 0; - var localSingleTracks = 0; - for (final count in localAlbumCounts.values) { - if (count > 1) { - localAlbumCount++; - } else { - localSingleTracks++; - } - } - - final groupedLocalAlbums = <_GroupedLocalAlbum>[]; - localAlbumMap.forEach((_, tracks) { - if (tracks.length <= 1) return; - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - groupedLocalAlbums.add( - _GroupedLocalAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverPath: tracks - .firstWhere( - (t) => t.coverPath != null && t.coverPath!.isNotEmpty, - orElse: () => tracks.first, - ) - .coverPath, - tracks: tracks, - latestScanned: tracks - .map((t) => t.scannedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - ), - ); - }); - groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned)); - - final stats = _HistoryStats( - albumCounts: albumCounts, - localAlbumCounts: localAlbumCounts, - groupedAlbums: groupedAlbums, - groupedLocalAlbums: groupedLocalAlbums, - albumCount: albumCount, - singleTracks: singleTracks, - localAlbumCount: localAlbumCount, - localSingleTracks: localSingleTracks, - ); - _queueHistoryStatsMemo = _QueueHistoryStatsMemoEntry( - historyItems: items, - localItems: localItems, - stats: stats, - ); - return stats; -} - -List<_GroupedAlbum> _queueFilterGroupedAlbums( - List<_GroupedAlbum> albums, - _QueueGroupedAlbumFilterRequest request, -) { - if (request.filterSource == 'local') return const []; - if (request.filterSource == null && - request.filterQuality == null && - request.filterFormat == null && - request.filterMetadata == null && - request.searchQuery.isEmpty && - request.sortMode == 'latest') { - return albums; - } - - final result = <_GroupedAlbum>[]; - for (final album in albums) { - if (request.searchQuery.isNotEmpty && - !album.searchKey.contains(request.searchQuery)) { - continue; - } - - if (request.filterQuality != null || - request.filterFormat != null || - request.filterMetadata != null) { - var hasMatchingTrack = false; - for (final track in album.tracks) { - if (!_queuePassesQualityFilter(request.filterQuality, track.quality)) { - continue; - } - if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { - continue; - } - if (!_queueMatchesMetadataFilter( - filterMetadata: request.filterMetadata, - artistName: track.artistName, - albumArtist: track.albumArtist, - releaseDate: track.releaseDate, - genre: track.genre, - trackNumber: track.trackNumber, - discNumber: track.discNumber, - isrc: track.isrc, - label: track.label, - )) { - continue; - } - hasMatchingTrack = true; - break; - } - if (!hasMatchingTrack) continue; - } - - result.add(album); - } - - switch (request.sortMode) { - case 'oldest': - result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload)); - case 'artist-asc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - a.artistName, - b.artistName, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'artist-desc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - a.artistName, - b.artistName, - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'a-z': - result.sort( - (a, b) => - a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), - ); - case 'z-a': - result.sort( - (a, b) => - b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), - ); - case 'album-asc': - result.sort( - (a, b) => _queueCompareOptionalText(a.albumName, b.albumName), - ); - case 'album-desc': - result.sort( - (a, b) => _queueCompareOptionalText( - a.albumName, - b.albumName, - descending: true, - ), - ); - case 'release-oldest': - result.sort((a, b) { - final comparison = _queueCompareOptionalDate( - _queueGroupedAlbumReleaseDate(a), - _queueGroupedAlbumReleaseDate(b), - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'release-newest': - result.sort((a, b) { - final comparison = _queueCompareOptionalDate( - _queueGroupedAlbumReleaseDate(a), - _queueGroupedAlbumReleaseDate(b), - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'genre-asc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - _queueGroupedAlbumGenre(a), - _queueGroupedAlbumGenre(b), - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'genre-desc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - _queueGroupedAlbumGenre(a), - _queueGroupedAlbumGenre(b), - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - default: - break; - } - return result; -} - -List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( - List<_GroupedLocalAlbum> albums, - _QueueGroupedAlbumFilterRequest request, -) { - if (request.filterSource == 'downloaded') return const []; - if (request.filterSource == null && - request.filterQuality == null && - request.filterFormat == null && - request.filterMetadata == null && - request.searchQuery.isEmpty && - request.sortMode == 'latest') { - return albums; - } - - final result = <_GroupedLocalAlbum>[]; - for (final album in albums) { - if (request.searchQuery.isNotEmpty && - !album.searchKey.contains(request.searchQuery)) { - continue; - } - - if (request.filterQuality != null || - request.filterFormat != null || - request.filterMetadata != null) { - var hasMatchingTrack = false; - for (final track in album.tracks) { - if (!_queuePassesQualityFilter( - request.filterQuality, - _queueLocalQualityLabel(track), - )) { - continue; - } - if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { - continue; - } - if (!_queueMatchesMetadataFilter( - filterMetadata: request.filterMetadata, - artistName: track.artistName, - albumArtist: track.albumArtist, - releaseDate: track.releaseDate, - genre: track.genre, - trackNumber: track.trackNumber, - discNumber: track.discNumber, - isrc: track.isrc, - label: track.label, - )) { - continue; - } - hasMatchingTrack = true; - break; - } - if (!hasMatchingTrack) continue; - } - - result.add(album); - } - - switch (request.sortMode) { - case 'oldest': - result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned)); - case 'artist-asc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - a.artistName, - b.artistName, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'artist-desc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - a.artistName, - b.artistName, - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'a-z': - result.sort( - (a, b) => - a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()), - ); - case 'z-a': - result.sort( - (a, b) => - b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), - ); - case 'album-asc': - result.sort( - (a, b) => _queueCompareOptionalText(a.albumName, b.albumName), - ); - case 'album-desc': - result.sort( - (a, b) => _queueCompareOptionalText( - a.albumName, - b.albumName, - descending: true, - ), - ); - case 'release-oldest': - result.sort((a, b) { - final comparison = _queueCompareOptionalDate( - _queueGroupedLocalAlbumReleaseDate(a), - _queueGroupedLocalAlbumReleaseDate(b), - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'release-newest': - result.sort((a, b) { - final comparison = _queueCompareOptionalDate( - _queueGroupedLocalAlbumReleaseDate(a), - _queueGroupedLocalAlbumReleaseDate(b), - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'genre-asc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - _queueGroupedLocalAlbumGenre(a), - _queueGroupedLocalAlbumGenre(b), - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - case 'genre-desc': - result.sort((a, b) { - final comparison = _queueCompareOptionalText( - _queueGroupedLocalAlbumGenre(a), - _queueGroupedLocalAlbumGenre(b), - descending: true, - ); - if (comparison != 0) { - return comparison; - } - return _queueCompareOptionalText(a.albumName, b.albumName); - }); - default: - break; - } - return result; -} - -final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) { - final historyItems = ref.watch( - downloadHistoryProvider.select((s) => s.items), - ); - final localLibraryEnabled = ref.watch( - settingsProvider.select((s) => s.localLibraryEnabled), - ); - final localItems = localLibraryEnabled - ? ref - .watch(localLibraryAllItemsProvider) - .maybeWhen( - data: (items) => items, - orElse: () => const [], - ) - : const []; - return _buildQueueHistoryStats(historyItems, localItems); -}); - -final _queueFilteredAlbumsProvider = - Provider.family< - ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}), - _QueueGroupedAlbumFilterRequest - >((ref, request) { - final historyStats = ref.watch(_queueHistoryStatsProvider); - return ( - albums: _queueFilterGroupedAlbums(historyStats.groupedAlbums, request), - localAlbums: _queueFilterGroupedLocalAlbums( - historyStats.groupedLocalAlbums, - request, - ), - ); - }); - Map> _filterHistoryInIsolate(Map payload) { final entries = (payload['entries'] as List).cast>(); final albumCounts = Map.from(payload['albumCounts'] as Map); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 54b6c70f..0d65a524 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3,9 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/utils/file_access.dart'; @@ -25,6 +23,7 @@ import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; part 'track_metadata_edit_sheet.dart'; @@ -46,6 +45,7 @@ class TrackMetadataScreen extends ConsumerStatefulWidget { final List? historyNavigationItems; final List? localNavigationItems; final int? navigationIndex; + final String? coverHeroTag; const TrackMetadataScreen({ super.key, @@ -54,6 +54,7 @@ class TrackMetadataScreen extends ConsumerStatefulWidget { this.historyNavigationItems, this.localNavigationItems, this.navigationIndex, + this.coverHeroTag, }) : assert( item != null || localItem != null, 'Either item or localItem must be provided', @@ -684,7 +685,8 @@ class _TrackMetadataScreenState extends ConsumerState { String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; String get _coverHeroTag => - _isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId'; + widget.coverHeroTag ?? + (_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId'); String? get _coverUrl => _isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl); String? get _localCoverPath => @@ -1133,11 +1135,10 @@ class _TrackMetadataScreenState extends ConsumerState { errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) : _coverUrl != null - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: _coverUrl!, fit: BoxFit.cover, memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => Container(color: colorScheme.surface), ) diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index b63323df..7260f80c 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -5,12 +5,65 @@ import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/path_match_keys.dart'; final _log = AppLogger('HistoryDatabase'); final Future _prefs = SharedPreferences.getInstance(); String? _currentContainerPath; +class HistoryLookupRequest { + final String spotifyId; + final String? isrc; + final String trackName; + final String artistName; + + const HistoryLookupRequest({ + required this.spotifyId, + this.isrc, + required this.trackName, + required this.artistName, + }); + + String get lookupKey => + '${spotifyId.trim()}|${HistoryDatabase.normalizeIsrc(isrc)}|' + '${HistoryDatabase.matchKeyFor(trackName, artistName)}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HistoryLookupRequest && + spotifyId == other.spotifyId && + isrc == other.isrc && + trackName == other.trackName && + artistName == other.artistName; + + @override + int get hashCode => Object.hash(spotifyId, isrc, trackName, artistName); +} + +class HistoryBatchLookupRequest { + final List tracks; + + const HistoryBatchLookupRequest(this.tracks); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! HistoryBatchLookupRequest || + other.tracks.length != tracks.length) { + return false; + } + for (var i = 0; i < tracks.length; i++) { + if (tracks[i] != other.tracks[i]) return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll(tracks); +} + class HistoryDatabase { static final HistoryDatabase instance = HistoryDatabase._init(); static Database? _database; @@ -31,7 +84,7 @@ class HistoryDatabase { return await openDatabase( path, - version: 5, + version: 8, onConfigure: (db) async { await db.rawQuery('PRAGMA journal_mode = WAL'); await db.execute('PRAGMA synchronous = NORMAL'); @@ -74,7 +127,10 @@ class HistoryDatabase { genre TEXT, composer TEXT, label TEXT, - copyright TEXT + copyright TEXT, + spotify_id_norm TEXT, + isrc_norm TEXT, + match_key TEXT ) '''); @@ -86,6 +142,11 @@ class HistoryDatabase { await db.execute( 'CREATE INDEX idx_album ON history(album_name, album_artist)', ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)', + ); + await _createNormalizedIndexes(db); + await _createPathKeyTable(db); _log.i('Database schema created with indexes'); } @@ -126,6 +187,152 @@ class HistoryDatabase { await db.execute('ALTER TABLE history ADD COLUMN total_discs INTEGER'); } } + if (oldVersion < 6) { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)', + ); + } + if (oldVersion < 7) { + await _createPathKeyTable(db); + await _backfillPathKeys(db); + } + if (oldVersion < 8) { + await _addColumnIfMissing(db, 'history', 'spotify_id_norm', 'TEXT'); + await _addColumnIfMissing(db, 'history', 'isrc_norm', 'TEXT'); + await _addColumnIfMissing(db, 'history', 'match_key', 'TEXT'); + await _backfillNormalizedColumns(db); + await _createNormalizedIndexes(db); + } + } + + static String normalizeLookupText(String? value) { + return (value ?? '').trim().toLowerCase(); + } + + static String normalizeIsrc(String? value) { + return (value ?? '').trim().toUpperCase().replaceAll(RegExp(r'[-\s]'), ''); + } + + static String normalizeSpotifyId(String? value) { + return (value ?? '').trim().toLowerCase(); + } + + static String matchKeyFor(String? trackName, String? artistName) { + final track = normalizeLookupText(trackName); + if (track.isEmpty) return ''; + return '$track|${normalizeLookupText(artistName)}'; + } + + static List spotifyLookupCandidates(String? rawId) { + final trimmed = rawId?.trim() ?? ''; + if (trimmed.isEmpty) return const []; + final candidates = {trimmed}; + final lowered = trimmed.toLowerCase(); + if (lowered.startsWith('spotify:track:')) { + final compact = trimmed.split(':').last.trim(); + if (compact.isNotEmpty) candidates.add(compact); + } else if (!trimmed.contains(':')) { + candidates.add('spotify:track:$trimmed'); + } + return candidates.toList(growable: false); + } + + Future _addColumnIfMissing( + Database db, + String table, + String column, + String type, + ) async { + final columns = await db.rawQuery('PRAGMA table_info($table)'); + final exists = columns.any( + (row) => (row['name']?.toString().toLowerCase() ?? '') == column, + ); + if (!exists) { + await db.execute('ALTER TABLE $table ADD COLUMN $column $type'); + } + } + + Future _createNormalizedIndexes(DatabaseExecutor db) async { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)', + ); + } + + Future _backfillNormalizedColumns(Database db) async { + final rows = await db.query( + 'history', + columns: ['id', 'spotify_id', 'isrc', 'track_name', 'artist_name'], + ); + final batch = db.batch(); + for (final row in rows) { + batch.update( + 'history', + _normalizedColumns( + spotifyId: row['spotify_id'] as String?, + isrc: row['isrc'] as String?, + trackName: row['track_name'] as String?, + artistName: row['artist_name'] as String?, + ), + where: 'id = ?', + whereArgs: [row['id']], + ); + } + await batch.commit(noResult: true); + } + + Map _normalizedColumns({ + required String? spotifyId, + required String? isrc, + required String? trackName, + required String? artistName, + }) { + return { + 'spotify_id_norm': normalizeSpotifyId(spotifyId), + 'isrc_norm': normalizeIsrc(isrc), + 'match_key': matchKeyFor(trackName, artistName), + }; + } + + Future _createPathKeyTable(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS history_path_keys ( + item_id TEXT NOT NULL, + path_key TEXT NOT NULL, + PRIMARY KEY (item_id, path_key) + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_history_path_keys_key ON history_path_keys(path_key)', + ); + } + + Future _backfillPathKeys(Database db) async { + final rows = await db.query('history', columns: ['id', 'file_path']); + final batch = db.batch(); + for (final row in rows) { + _putPathKeysInBatch( + batch, + row['id'] as String, + row['file_path'] as String?, + ); + } + await batch.commit(noResult: true); + } + + void _putPathKeysInBatch(Batch batch, String id, String? filePath) { + batch.delete('history_path_keys', where: 'item_id = ?', whereArgs: [id]); + for (final key in buildPathMatchKeys(filePath)) { + batch.insert('history_path_keys', { + 'item_id': id, + 'path_key': key, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + } } static final _iosContainerPattern = RegExp( @@ -202,6 +409,7 @@ class HistoryDatabase { where: 'id = ?', whereArgs: [id], ); + _putPathKeysInBatch(batch, id, newPath); updatedCount++; } } @@ -253,6 +461,11 @@ class HistoryDatabase { _jsonToDbRow(map), conflictAlgorithm: ConflictAlgorithm.replace, ); + _putPathKeysInBatch( + batch, + map['id'] as String, + map['filePath'] as String?, + ); } await batch.commit(noResult: true); @@ -268,7 +481,7 @@ class HistoryDatabase { } Map _jsonToDbRow(Map json) { - return { + final row = { 'id': json['id'], 'track_name': json['trackName'], 'artist_name': json['artistName'], @@ -299,6 +512,15 @@ class HistoryDatabase { 'label': json['label'], 'copyright': json['copyright'], }; + row.addAll( + _normalizedColumns( + spotifyId: json['spotifyId'] as String?, + isrc: json['isrc'] as String?, + trackName: json['trackName'] as String?, + artistName: json['artistName'] as String?, + ), + ); + return row; } Map _dbRowToJson(Map row) { @@ -337,25 +559,41 @@ class HistoryDatabase { Future upsert(Map json) async { final db = await database; - await db.insert( - 'history', - _jsonToDbRow(json), - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.transaction((txn) async { + await txn.insert( + 'history', + _jsonToDbRow(json), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + final batch = txn.batch(); + _putPathKeysInBatch( + batch, + json['id'] as String, + json['filePath'] as String?, + ); + await batch.commit(noResult: true); + }); } Future upsertBatch(List> items) async { if (items.isEmpty) return; final db = await database; - final batch = db.batch(); - for (final json in items) { - batch.insert( - 'history', - _jsonToDbRow(json), - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - await batch.commit(noResult: true); + await db.transaction((txn) async { + final batch = txn.batch(); + for (final json in items) { + batch.insert( + 'history', + _jsonToDbRow(json), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + _putPathKeysInBatch( + batch, + json['id'] as String, + json['filePath'] as String?, + ); + } + await batch.commit(noResult: true); + }); } Future>> getAll({int? limit, int? offset}) async { @@ -369,6 +607,38 @@ class HistoryDatabase { return rows.map(_dbRowToJson).toList(); } + Future>> getAlbumTracks( + String albumName, + String artistName, + ) async { + final db = await database; + final rows = await db.query( + 'history', + where: + 'LOWER(album_name) = ? AND LOWER(COALESCE(album_artist, artist_name)) = ?', + whereArgs: [albumName.toLowerCase(), artistName.toLowerCase()], + orderBy: + 'COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name', + ); + return rows.map(_dbRowToJson).toList(growable: false); + } + + Future?> findByTrackAndArtist( + String trackName, + String artistName, + ) async { + final db = await database; + final rows = await db.query( + 'history', + where: 'LOWER(track_name) = ? AND LOWER(artist_name) = ?', + whereArgs: [trackName.toLowerCase(), artistName.toLowerCase()], + orderBy: 'downloaded_at DESC', + limit: 1, + ); + if (rows.isEmpty) return null; + return _dbRowToJson(rows.first); + } + Future?> getById(String id) async { final db = await database; final rows = await db.query( @@ -405,6 +675,117 @@ class HistoryDatabase { return _dbRowToJson(rows.first); } + Future existsTrack(HistoryLookupRequest request) async { + final row = await findExistingTrack(request, columns: ['id']); + return row != null; + } + + Future?> findExistingTrack( + HistoryLookupRequest request, { + List? columns, + }) async { + final db = await database; + final spotifyCandidates = spotifyLookupCandidates(request.spotifyId); + if (spotifyCandidates.isNotEmpty) { + final placeholders = List.filled(spotifyCandidates.length, '?').join(','); + final normalized = spotifyCandidates.map(normalizeSpotifyId).toList(); + final rows = await db.query( + 'history', + columns: columns, + where: + 'spotify_id IN ($placeholders) OR spotify_id_norm IN ($placeholders)', + whereArgs: [...spotifyCandidates, ...normalized], + orderBy: 'downloaded_at DESC', + limit: 1, + ); + if (rows.isNotEmpty) return _dbRowToJson(rows.first); + } + + final isrcNorm = normalizeIsrc(request.isrc); + if (isrcNorm.isNotEmpty) { + final rows = await db.query( + 'history', + columns: columns, + where: 'isrc_norm = ?', + whereArgs: [isrcNorm], + orderBy: 'downloaded_at DESC', + limit: 1, + ); + if (rows.isNotEmpty) return _dbRowToJson(rows.first); + } + + final matchKey = matchKeyFor(request.trackName, request.artistName); + if (matchKey.isNotEmpty) { + final rows = await db.query( + 'history', + columns: columns, + where: 'match_key = ?', + whereArgs: [matchKey], + orderBy: 'downloaded_at DESC', + limit: 1, + ); + if (rows.isNotEmpty) return _dbRowToJson(rows.first); + } + return null; + } + + Future> existingTrackKeys( + List requests, + ) async { + if (requests.isEmpty) return const {}; + final db = await database; + final found = {}; + final rawSpotifyToKeys = >{}; + final normSpotifyToKeys = >{}; + final isrcToKeys = >{}; + final matchToKeys = >{}; + + void add(Map> map, String value, String key) { + if (value.isEmpty) return; + map.putIfAbsent(value, () => {}).add(key); + } + + for (final request in requests) { + final key = request.lookupKey; + for (final candidate in spotifyLookupCandidates(request.spotifyId)) { + add(rawSpotifyToKeys, candidate, key); + add(normSpotifyToKeys, normalizeSpotifyId(candidate), key); + } + add(isrcToKeys, normalizeIsrc(request.isrc), key); + add(matchToKeys, matchKeyFor(request.trackName, request.artistName), key); + } + + Future queryColumn( + String column, + Map> keyMap, + ) async { + final values = keyMap.keys.toList(growable: false); + const chunkSize = 450; + for (var i = 0; i < values.length; i += chunkSize) { + final end = (i + chunkSize < values.length) + ? i + chunkSize + : values.length; + final chunk = values.sublist(i, end); + final placeholders = List.filled(chunk.length, '?').join(','); + final rows = await db.rawQuery( + 'SELECT DISTINCT $column AS lookup_value FROM history WHERE $column IN ($placeholders)', + chunk, + ); + for (final row in rows) { + final value = row['lookup_value'] as String?; + if (value == null) continue; + found.addAll(keyMap[value] ?? const {}); + } + } + } + + await queryColumn('spotify_id', rawSpotifyToKeys); + await queryColumn('spotify_id_norm', normSpotifyToKeys); + await queryColumn('isrc_norm', isrcToKeys); + await queryColumn('match_key', matchToKeys); + return found; + } + Future existsBySpotifyId(String spotifyId) async { final db = await database; final result = await db.rawQuery( @@ -424,17 +805,47 @@ class HistoryDatabase { Future deleteById(String id) async { final db = await database; - await db.delete('history', where: 'id = ?', whereArgs: [id]); + await db.transaction((txn) async { + await txn.delete( + 'history_path_keys', + where: 'item_id = ?', + whereArgs: [id], + ); + await txn.delete('history', where: 'id = ?', whereArgs: [id]); + }); } - Future deleteBySpotifyId(String spotifyId) async { + Future deleteBySpotifyId(String spotifyId) async { final db = await database; - await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]); + final rows = await db.query( + 'history', + columns: ['id'], + where: 'spotify_id = ?', + whereArgs: [spotifyId], + ); + final ids = rows.map((row) => row['id'] as String).toList(growable: false); + return db.transaction((txn) async { + for (final id in ids) { + await txn.delete( + 'history_path_keys', + where: 'item_id = ?', + whereArgs: [id], + ); + } + return txn.delete( + 'history', + where: 'spotify_id = ?', + whereArgs: [spotifyId], + ); + }); } Future clearAll() async { final db = await database; - await db.delete('history'); + await db.transaction((txn) async { + await txn.delete('history_path_keys'); + await txn.delete('history'); + }); _log.i('Cleared all history'); } @@ -444,6 +855,25 @@ class HistoryDatabase { return Sqflite.firstIntValue(result) ?? 0; } + Future> getGroupedCounts() async { + final db = await database; + final rows = await db.rawQuery(''' + SELECT + SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END) AS albums, + SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END) AS singles + FROM ( + SELECT COUNT(*) AS track_count + FROM history + GROUP BY LOWER(album_name), LOWER(COALESCE(album_artist, artist_name)) + ) + '''); + final row = rows.isEmpty ? const {} : rows.first; + return { + 'albums': (row['albums'] as num?)?.toInt() ?? 0, + 'singles': (row['singles'] as num?)?.toInt() ?? 0, + }; + } + Future?> findExisting({ String? spotifyId, String? isrc, @@ -506,7 +936,12 @@ class HistoryDatabase { values['sample_rate'] = newSampleRate; } } - await db.update('history', values, where: 'id = ?', whereArgs: [id]); + await db.transaction((txn) async { + await txn.update('history', values, where: 'id = ?', whereArgs: [id]); + final batch = txn.batch(); + _putPathKeysInBatch(batch, id, newFilePath); + await batch.commit(noResult: true); + }); } Future updateAudioMetadata( @@ -583,6 +1018,10 @@ class HistoryDatabase { final end = (i + chunkSize < ids.length) ? i + chunkSize : ids.length; final chunk = ids.sublist(i, end); final placeholders = List.filled(chunk.length, '?').join(','); + await db.rawDelete( + 'DELETE FROM history_path_keys WHERE item_id IN ($placeholders)', + chunk, + ); totalDeleted += await db.rawDelete( 'DELETE FROM history WHERE id IN ($placeholders)', chunk, diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 6f3702e8..310f9487 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -5,6 +5,8 @@ import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/services/history_database.dart'; +import 'package:spotiflac_android/utils/path_match_keys.dart'; final _log = AppLogger('LibraryDatabase'); @@ -143,17 +145,6 @@ class LocalLibraryPageRequest { this.format, }); - LocalLibraryPageRequest copyWithOffset(int nextOffset) { - return LocalLibraryPageRequest( - limit: limit, - offset: nextOffset, - sortMode: sortMode, - filterMode: filterMode, - searchQuery: searchQuery, - format: format, - ); - } - @override bool operator ==(Object other) { return other is LocalLibraryPageRequest && @@ -226,9 +217,48 @@ class LocalLibraryLookupIndex { }); } +class QueueLibraryDbQuery { + final int limit; + final int offset; + final String filterMode; + final String? searchQuery; + final String? source; + final String? quality; + final String? format; + final String? metadata; + final String sortMode; + final bool includeLocal; + + const QueueLibraryDbQuery({ + this.limit = 100, + this.offset = 0, + this.filterMode = 'all', + this.searchQuery, + this.source, + this.quality, + this.format, + this.metadata, + this.sortMode = 'latest', + this.includeLocal = true, + }); +} + +class QueueLibraryCounts { + final int allTrackCount; + final int albumCount; + final int singleTrackCount; + + const QueueLibraryCounts({ + required this.allTrackCount, + required this.albumCount, + required this.singleTrackCount, + }); +} + class LibraryDatabase { static final LibraryDatabase instance = LibraryDatabase._init(); static Database? _database; + bool _historyAttached = false; LibraryDatabase._init(); @@ -238,6 +268,23 @@ class LibraryDatabase { return _database!; } + Future _ensureHistoryAttached(Database db) async { + if (_historyAttached) return; + await HistoryDatabase.instance.database; + final dbPath = await getApplicationDocumentsDirectory(); + final historyPath = join(dbPath.path, 'history.db'); + try { + await db.execute('ATTACH DATABASE ? AS history_db', [historyPath]); + } catch (e) { + final message = e.toString().toLowerCase(); + if (!message.contains('already in use') && + !message.contains('already exists')) { + rethrow; + } + } + _historyAttached = true; + } + Future _initDB(String fileName) async { final dbPath = await getApplicationDocumentsDirectory(); final path = join(dbPath.path, fileName); @@ -246,7 +293,7 @@ class LibraryDatabase { return await openDatabase( path, - version: 7, + version: 8, onConfigure: (db) async { await db.rawQuery('PRAGMA journal_mode = WAL'); await db.execute('PRAGMA synchronous = NORMAL'); @@ -305,6 +352,7 @@ class LibraryDatabase { 'CREATE INDEX idx_library_file_path ON library(file_path)', ); await _createNormalizedIndexes(db); + await _createPathKeyTable(db); _log.i('Library database schema created with indexes'); } @@ -351,6 +399,47 @@ class LibraryDatabase { await _createNormalizedIndexes(db); _log.i('Added normalized local library lookup columns'); } + if (oldVersion < 8) { + await _createPathKeyTable(db); + await _backfillPathKeys(db); + _log.i('Added local library path-key lookup table'); + } + } + + Future _createPathKeyTable(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS library_path_keys ( + item_id TEXT NOT NULL, + path_key TEXT NOT NULL, + PRIMARY KEY (item_id, path_key) + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_path_keys_key ON library_path_keys(path_key)', + ); + } + + Future _backfillPathKeys(Database db) async { + final rows = await db.query('library', columns: ['id', 'file_path']); + final batch = db.batch(); + for (final row in rows) { + _putPathKeysInBatch( + batch, + row['id'] as String, + row['file_path'] as String?, + ); + } + await batch.commit(noResult: true); + } + + void _putPathKeysInBatch(Batch batch, String id, String? filePath) { + batch.delete('library_path_keys', where: 'item_id = ?', whereArgs: [id]); + for (final key in buildPathMatchKeys(filePath)) { + batch.insert('library_path_keys', { + 'item_id': id, + 'path_key': key, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + } } static String normalizeLookupText(String? value) { @@ -524,11 +613,20 @@ class LibraryDatabase { Future upsert(Map json) async { final db = await database; - await db.insert( - 'library', - _jsonToDbRow(json), - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.transaction((txn) async { + await txn.insert( + 'library', + _jsonToDbRow(json), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + final batch = txn.batch(); + _putPathKeysInBatch( + batch, + json['id'] as String, + json['filePath'] as String?, + ); + await batch.commit(noResult: true); + }); } Future upsertBatch(List> items) async { @@ -542,6 +640,11 @@ class LibraryDatabase { _jsonToDbRow(json), conflictAlgorithm: ConflictAlgorithm.replace, ); + _putPathKeysInBatch( + batch, + json['id'] as String, + json['filePath'] as String?, + ); } await batch.commit(noResult: true); }); @@ -551,6 +654,7 @@ class LibraryDatabase { Future replaceAll(List> items) async { final db = await database; await db.transaction((txn) async { + await txn.delete('library_path_keys'); await txn.delete('library'); if (items.isEmpty) { return; @@ -563,6 +667,11 @@ class LibraryDatabase { _jsonToDbRow(json), conflictAlgorithm: ConflictAlgorithm.replace, ); + _putPathKeysInBatch( + batch, + json['id'] as String, + json['filePath'] as String?, + ); } await batch.commit(noResult: true); }); @@ -713,13 +822,20 @@ class LibraryDatabase { ) { final query = normalizeLookupText(searchQuery); if (query.isEmpty) return; - final like = '%$query%'; + final like = '%${_escapeLikePattern(query)}%'; where.add( - '(track_name_norm LIKE ? OR artist_name_norm LIKE ? OR album_name_norm LIKE ? OR album_artist_norm LIKE ?)', + "(track_name_norm LIKE ? ESCAPE '\\' OR artist_name_norm LIKE ? ESCAPE '\\' OR album_name_norm LIKE ? ESCAPE '\\' OR album_artist_norm LIKE ? ESCAPE '\\')", ); whereArgs.addAll([like, like, like, like]); } + String _escapeLikePattern(String value) { + return value + .replaceAll('\\', r'\\') + .replaceAll('%', r'\%') + .replaceAll('_', r'\_'); + } + String _orderByForSort(LocalLibrarySortMode sortMode) { return switch (sortMode) { LocalLibrarySortMode.title => @@ -747,6 +863,685 @@ class LibraryDatabase { }; } + Future>> getQueueTrackPage( + QueueLibraryDbQuery request, + ) async { + final db = await database; + await _ensureHistoryAttached(db); + final args = []; + final unionSql = _queueTrackUnionSql(request, args); + final rows = await db.rawQuery( + ''' + SELECT * + FROM ($unionSql) + ORDER BY ${_queueTrackOrderBy(request.sortMode)} + LIMIT ? OFFSET ? + ''', + [...args, request.limit, request.offset], + ); + return rows.map(_queueTrackRowToJson).toList(growable: false); + } + + Future getQueueCounts(QueueLibraryDbQuery request) async { + final db = await database; + await _ensureHistoryAttached(db); + + 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 allRows = await db.rawQuery( + 'SELECT COUNT(*) AS count FROM ($allSql)', + 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, + ); + final singleRows = await db.rawQuery( + 'SELECT COUNT(*) AS count FROM ($singleSql)', + singleArgs, + ); + + final albumArgs = []; + final albumSql = _queueAlbumUnionSql(request, albumArgs); + final albumRows = await db.rawQuery( + 'SELECT COUNT(*) AS count FROM ($albumSql)', + albumArgs, + ); + + return QueueLibraryCounts( + allTrackCount: Sqflite.firstIntValue(allRows) ?? 0, + albumCount: Sqflite.firstIntValue(albumRows) ?? 0, + singleTrackCount: Sqflite.firstIntValue(singleRows) ?? 0, + ); + } + + Future>> getQueueAlbumPage( + QueueLibraryDbQuery request, + ) async { + final db = await database; + await _ensureHistoryAttached(db); + final args = []; + final unionSql = _queueAlbumUnionSql(request, args); + final rows = await db.rawQuery( + ''' + SELECT * + FROM ($unionSql) + ORDER BY ${_queueAlbumOrderBy(request.sortMode)} + LIMIT ? OFFSET ? + ''', + [...args, request.limit, request.offset], + ); + return rows.toList(growable: false); + } + + Future>> getQueueLocalAlbumTracks( + String albumName, + String artistName, + ) async { + final db = await database; + final rows = await db.query( + 'library', + where: + 'LOWER(album_name) = ? AND LOWER(COALESCE(album_artist, artist_name)) = ?', + whereArgs: [albumName.toLowerCase(), artistName.toLowerCase()], + orderBy: + 'COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name', + ); + return rows.map(_dbRowToJson).toList(growable: false); + } + + String _queueTrackUnionSql(QueueLibraryDbQuery request, List args) { + final parts = []; + if (request.source != 'local') { + final where = []; + _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)) + FROM history_db.history + GROUP BY LOWER(album_name), LOWER(COALESCE(album_artist, artist_name)) + HAVING COUNT(*) = 1 + ) + '''); + } + parts.add(''' + SELECT + 'downloaded' AS queue_source, + 'dl_' || h.id AS unified_id, + h.id, + h.track_name, + h.artist_name, + h.album_name, + h.album_artist, + h.cover_url, + h.file_path, + h.storage_mode, + h.download_tree_uri, + h.saf_relative_dir, + h.saf_file_name, + h.saf_repaired, + h.service, + h.downloaded_at, + h.isrc, + h.spotify_id, + h.track_number, + h.total_tracks, + h.disc_number, + h.total_discs, + h.duration, + h.release_date, + h.quality, + h.bit_depth, + h.sample_rate, + h.genre, + h.composer, + h.label, + h.copyright, + NULL AS cover_path, + NULL AS scanned_at, + NULL AS file_mod_time, + NULL AS bitrate, + NULL AS format, + LOWER(h.track_name) AS sort_track, + LOWER(h.artist_name) AS sort_artist, + LOWER(h.album_name) AS sort_album, + LOWER(COALESCE(h.genre, '')) AS sort_genre, + h.release_date AS sort_release, + CAST(strftime('%s', h.downloaded_at) AS INTEGER) * 1000 AS sort_added + FROM history_db.history h + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + '''); + } + + 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); + if (request.filterMode == 'singles') { + where.add(''' + l.album_key IN ( + SELECT album_key + FROM library + WHERE NOT EXISTS ( + SELECT 1 + FROM library_path_keys lpk + JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key + WHERE lpk.item_id = library.id + ) + GROUP BY album_key + HAVING COUNT(*) = 1 + ) + '''); + } + parts.add(''' + SELECT + 'local' AS queue_source, + 'local_' || l.id AS unified_id, + l.id, + l.track_name, + l.artist_name, + l.album_name, + l.album_artist, + NULL AS cover_url, + l.file_path, + NULL AS storage_mode, + NULL AS download_tree_uri, + NULL AS saf_relative_dir, + NULL AS saf_file_name, + 0 AS saf_repaired, + 'local' AS service, + NULL AS downloaded_at, + l.isrc, + NULL AS spotify_id, + l.track_number, + l.total_tracks, + l.disc_number, + l.total_discs, + l.duration, + l.release_date, + NULL AS quality, + l.bit_depth, + l.sample_rate, + l.genre, + l.composer, + l.label, + l.copyright, + l.cover_path, + l.scanned_at, + l.file_mod_time, + l.bitrate, + l.format, + l.track_name_norm AS sort_track, + l.artist_name_norm AS sort_artist, + l.album_name_norm AS sort_album, + LOWER(COALESCE(l.genre, '')) AS sort_genre, + l.release_date AS sort_release, + COALESCE(l.file_mod_time, CAST(strftime('%s', l.scanned_at) AS INTEGER) * 1000) AS sort_added + FROM library l + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + '''); + } + + if (parts.isEmpty) { + return ''' + SELECT + NULL AS queue_source, + NULL AS unified_id, + NULL AS id, + NULL AS track_name, + NULL AS artist_name, + NULL AS album_name, + NULL AS album_artist, + NULL AS cover_url, + NULL AS file_path, + NULL AS storage_mode, + NULL AS download_tree_uri, + NULL AS saf_relative_dir, + NULL AS saf_file_name, + NULL AS saf_repaired, + NULL AS service, + NULL AS downloaded_at, + NULL AS isrc, + NULL AS spotify_id, + NULL AS track_number, + NULL AS total_tracks, + NULL AS disc_number, + NULL AS total_discs, + NULL AS duration, + NULL AS release_date, + NULL AS quality, + NULL AS bit_depth, + NULL AS sample_rate, + NULL AS genre, + NULL AS composer, + NULL AS label, + NULL AS copyright, + NULL AS cover_path, + NULL AS scanned_at, + NULL AS file_mod_time, + NULL AS bitrate, + NULL AS format, + NULL AS sort_track, + NULL AS sort_artist, + NULL AS sort_album, + NULL AS sort_genre, + NULL AS sort_release, + NULL AS sort_added + WHERE 0 + '''; + } + return parts.join(' UNION ALL '); + } + + String _queueAlbumUnionSql(QueueLibraryDbQuery request, List args) { + final parts = []; + if (request.source != 'local') { + final where = []; + _appendQueueHistoryFilters(where, args, request); + parts.add(''' + SELECT + 'downloaded' AS queue_source, + c.album_key, + MIN(h.album_name) AS album_name, + COALESCE(NULLIF(MIN(h.album_artist), ''), MIN(h.artist_name)) AS artist_name, + MAX(CASE WHEN h.cover_url IS NOT NULL AND h.cover_url != '' THEN h.cover_url END) AS cover_url, + NULL AS cover_path, + MAX(h.file_path) AS sample_file_path, + COUNT(*) AS track_count, + c.latest_added AS sort_added, + MIN(LOWER(COALESCE(h.album_name, ''))) AS sort_album, + MIN(LOWER(COALESCE(h.album_artist, h.artist_name, ''))) AS sort_artist, + MAX(h.release_date) AS sort_release, + MAX(LOWER(COALESCE(h.genre, ''))) AS sort_genre + FROM history_db.history h + JOIN ( + SELECT + LOWER(album_name) || '|' || LOWER(COALESCE(album_artist, artist_name)) AS 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)) + HAVING COUNT(*) > 1 + ) c + ON c.album_key = LOWER(h.album_name) || '|' || LOWER(COALESCE(h.album_artist, h.artist_name)) + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + GROUP BY c.album_key + '''); + } + + 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 + 'local' AS queue_source, + c.album_key, + MIN(l.album_name) AS album_name, + COALESCE(NULLIF(MIN(l.album_artist), ''), MIN(l.artist_name)) AS artist_name, + NULL AS cover_url, + MAX(CASE WHEN l.cover_path IS NOT NULL AND l.cover_path != '' THEN l.cover_path END) AS cover_path, + MAX(l.file_path) AS sample_file_path, + COUNT(*) AS track_count, + c.latest_added AS sort_added, + MIN(l.album_name_norm) AS sort_album, + MIN(l.album_artist_norm) AS sort_artist, + MAX(l.release_date) AS sort_release, + MAX(LOWER(COALESCE(l.genre, ''))) AS sort_genre + FROM library l + JOIN ( + SELECT + album_key, + COUNT(*) AS track_count, + MAX(COALESCE(file_mod_time, CAST(strftime('%s', scanned_at) AS INTEGER) * 1000)) AS latest_added + FROM library + WHERE NOT EXISTS ( + SELECT 1 + FROM library_path_keys lpk + JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key + WHERE lpk.item_id = library.id + ) + GROUP BY album_key + HAVING COUNT(*) > 1 + ) c ON c.album_key = l.album_key + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + GROUP BY c.album_key + '''); + } + + if (parts.isEmpty) { + return ''' + SELECT + NULL AS queue_source, + NULL AS album_key, + NULL AS album_name, + NULL AS artist_name, + NULL AS cover_url, + NULL AS cover_path, + NULL AS sample_file_path, + NULL AS track_count, + NULL AS sort_added, + NULL AS sort_album, + NULL AS sort_artist, + NULL AS sort_release, + NULL AS sort_genre + WHERE 0 + '''; + } + return parts.join(' UNION ALL '); + } + + void _appendQueueHistoryFilters( + List where, + List args, + QueueLibraryDbQuery request, + ) { + 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]); + } + _appendQueueCommonFilters( + where, + args, + request, + filePathExpr: 'h.file_path', + qualityExpr: 'h.quality', + bitDepthExpr: 'h.bit_depth', + artistExpr: 'h.artist_name', + albumArtistExpr: 'h.album_artist', + releaseDateExpr: 'h.release_date', + genreExpr: 'h.genre', + trackNumberExpr: 'h.track_number', + discNumberExpr: 'h.disc_number', + isrcExpr: 'h.isrc', + labelExpr: 'h.label', + ); + } + + void _appendQueueLocalFilters( + List where, + List args, + QueueLibraryDbQuery request, + ) { + final query = normalizeLookupText(request.searchQuery); + if (query.isNotEmpty) { + final like = '%${_escapeLikePattern(query)}%'; + where.add(''' + ( + l.track_name_norm LIKE ? ESCAPE '\\' OR + l.artist_name_norm LIKE ? ESCAPE '\\' OR + l.album_name_norm LIKE ? ESCAPE '\\' OR + l.album_artist_norm LIKE ? ESCAPE '\\' + ) + '''); + args.addAll([like, like, like, like]); + } + _appendQueueCommonFilters( + where, + args, + request, + filePathExpr: 'l.file_path', + qualityExpr: 'NULL', + bitDepthExpr: 'l.bit_depth', + artistExpr: 'l.artist_name', + albumArtistExpr: 'l.album_artist', + releaseDateExpr: 'l.release_date', + genreExpr: 'l.genre', + trackNumberExpr: 'l.track_number', + discNumberExpr: 'l.disc_number', + isrcExpr: 'l.isrc', + labelExpr: 'l.label', + ); + } + + void _appendQueueCommonFilters( + List where, + List args, + QueueLibraryDbQuery request, { + required String filePathExpr, + required String qualityExpr, + required String bitDepthExpr, + required String artistExpr, + required String albumArtistExpr, + required String releaseDateExpr, + required String genreExpr, + required String trackNumberExpr, + required String discNumberExpr, + required String isrcExpr, + required String labelExpr, + }) { + final quality = request.quality?.trim().toLowerCase(); + if (quality != null && quality.isNotEmpty) { + final isHiRes = + '(COALESCE($bitDepthExpr, 0) >= 24 OR LOWER(COALESCE($qualityExpr, \'\')) LIKE \'24%\')'; + final isCd = + '(COALESCE($bitDepthExpr, 0) = 16 OR LOWER(COALESCE($qualityExpr, \'\')) LIKE \'16%\')'; + switch (quality) { + case 'hires': + where.add(isHiRes); + break; + case 'cd': + where.add(isCd); + break; + case 'lossy': + where.add('NOT ($isHiRes OR $isCd)'); + break; + } + } + + final format = request.format?.trim().toLowerCase(); + if (format != null && format.isNotEmpty) { + where.add('LOWER($filePathExpr) LIKE ?'); + args.add('%.$format'); + } + + final metadata = request.metadata?.trim(); + if (metadata == null || metadata.isEmpty) return; + final hasArtist = 'TRIM(COALESCE($artistExpr, \'\')) != \'\''; + final hasAlbumArtist = 'TRIM(COALESCE($albumArtistExpr, \'\')) != \'\''; + final hasReleaseDate = + 'TRIM(COALESCE($releaseDateExpr, \'\')) GLOB \'*[0-9][0-9][0-9][0-9]*\''; + final hasGenre = 'TRIM(COALESCE($genreExpr, \'\')) != \'\''; + final hasTrackNumber = 'COALESCE($trackNumberExpr, 0) > 0'; + final hasDiscNumber = 'COALESCE($discNumberExpr, 0) > 0'; + final hasLabel = 'TRIM(COALESCE($labelExpr, \'\')) != \'\''; + final normalizedIsrc = + 'REPLACE(REPLACE(UPPER(TRIM(COALESCE($isrcExpr, \'\'))), \'-\', \'\'), \' \', \'\')'; + final hasIncorrectIsrc = + 'TRIM(COALESCE($isrcExpr, \'\')) != \'\' AND LENGTH($normalizedIsrc) != 12'; + final isComplete = + '($hasArtist AND $hasAlbumArtist AND $hasReleaseDate AND $hasGenre AND $hasTrackNumber AND $hasDiscNumber AND $hasLabel AND NOT ($hasIncorrectIsrc))'; + + switch (metadata) { + case 'complete': + where.add(isComplete); + break; + case 'missing-any': + where.add('NOT $isComplete'); + break; + case 'missing-year': + where.add('NOT ($hasReleaseDate)'); + break; + case 'missing-genre': + where.add('NOT ($hasGenre)'); + break; + case 'missing-album-artist': + where.add('NOT ($hasAlbumArtist)'); + break; + case 'missing-track-number': + where.add('NOT ($hasTrackNumber)'); + break; + case 'missing-disc-number': + where.add('NOT ($hasDiscNumber)'); + break; + case 'missing-artist': + where.add('NOT ($hasArtist)'); + break; + case 'incorrect-isrc-format': + where.add('($hasIncorrectIsrc)'); + break; + case 'missing-label': + where.add('NOT ($hasLabel)'); + break; + } + } + + String _queueTrackOrderBy(String sortMode) { + return switch (sortMode) { + 'oldest' => 'sort_added ASC, sort_track ASC', + 'a-z' => 'sort_track ASC, sort_artist ASC', + 'z-a' => 'sort_track DESC, sort_artist DESC', + 'artist-asc' => 'sort_artist ASC, sort_track ASC', + 'artist-desc' => 'sort_artist DESC, sort_track ASC', + 'album-asc' => 'sort_album ASC, sort_track ASC', + 'album-desc' => 'sort_album DESC, sort_track ASC', + 'release-oldest' => 'sort_release ASC, sort_track ASC', + 'release-newest' => 'sort_release DESC, sort_track ASC', + 'genre-asc' => 'sort_genre ASC, sort_track ASC', + 'genre-desc' => 'sort_genre DESC, sort_track ASC', + _ => 'sort_added DESC, sort_track ASC', + }; + } + + String _queueAlbumOrderBy(String sortMode) { + return switch (sortMode) { + 'oldest' => 'sort_added ASC, sort_album ASC', + 'a-z' || 'album-asc' => 'sort_album ASC, sort_artist ASC', + 'z-a' || 'album-desc' => 'sort_album DESC, sort_artist DESC', + 'artist-asc' => 'sort_artist ASC, sort_album ASC', + 'artist-desc' => 'sort_artist DESC, sort_album ASC', + 'release-oldest' => 'sort_release ASC, sort_album ASC', + 'release-newest' => 'sort_release DESC, sort_album ASC', + 'genre-asc' => 'sort_genre ASC, sort_album ASC', + 'genre-desc' => 'sort_genre DESC, sort_album ASC', + _ => 'sort_added DESC, sort_album ASC', + }; + } + + Map _queueTrackRowToJson(Map 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'], + }, + }; + } + + return { + 'source': source, + 'item': { + 'id': row['id'], + 'trackName': row['track_name'], + 'artistName': row['artist_name'], + 'albumName': row['album_name'], + 'albumArtist': row['album_artist'], + 'coverUrl': row['cover_url'], + 'filePath': row['file_path'], + 'storageMode': row['storage_mode'], + 'downloadTreeUri': row['download_tree_uri'], + 'safRelativeDir': row['saf_relative_dir'], + 'safFileName': row['saf_file_name'], + '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'], + 'genre': row['genre'], + 'composer': row['composer'], + 'label': row['label'], + 'copyright': row['copyright'], + }, + }; + } + Future?> getById(String id) async { final db = await database; final rows = await db.query( @@ -902,7 +1697,27 @@ class LibraryDatabase { Future deleteByPath(String filePath) async { final db = await database; - await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]); + final rows = await db.query( + 'library', + columns: ['id'], + where: 'file_path = ?', + whereArgs: [filePath], + ); + final ids = rows.map((row) => row['id'] as String).toList(growable: false); + await db.transaction((txn) async { + for (final id in ids) { + await txn.delete( + 'library_path_keys', + where: 'item_id = ?', + whereArgs: [id], + ); + } + await txn.delete( + 'library', + where: 'file_path = ?', + whereArgs: [filePath], + ); + }); } Future replaceWithConvertedItem({ @@ -931,6 +1746,11 @@ class LibraryDatabase { } await db.transaction((txn) async { + await txn.delete( + 'library_path_keys', + where: 'item_id = ?', + whereArgs: [item.id], + ); await txn.delete( 'library', where: 'id = ? OR file_path = ?', @@ -941,6 +1761,13 @@ class LibraryDatabase { _jsonToDbRow(updated), conflictAlgorithm: ConflictAlgorithm.replace, ); + final batch = txn.batch(); + _putPathKeysInBatch( + batch, + updated['id'] as String, + updated['filePath'] as String?, + ); + await batch.commit(noResult: true); }); } @@ -972,7 +1799,14 @@ class LibraryDatabase { Future delete(String id) async { final db = await database; - await db.delete('library', where: 'id = ?', whereArgs: [id]); + await db.transaction((txn) async { + await txn.delete( + 'library_path_keys', + where: 'item_id = ?', + whereArgs: [id], + ); + await txn.delete('library', where: 'id = ?', whereArgs: [id]); + }); } Future cleanupMissingFiles() async { @@ -1012,6 +1846,10 @@ class LibraryDatabase { : missingIds.length; final idChunk = missingIds.sublist(i, end); final placeholders = List.filled(idChunk.length, '?').join(','); + await db.rawDelete( + 'DELETE FROM library_path_keys WHERE item_id IN ($placeholders)', + idChunk, + ); removed += await db.rawDelete( 'DELETE FROM library WHERE id IN ($placeholders)', idChunk, @@ -1026,7 +1864,10 @@ class LibraryDatabase { Future clearAll() async { final db = await database; - await db.delete('library'); + await db.transaction((txn) async { + await txn.delete('library_path_keys'); + await txn.delete('library'); + }); _log.i('Cleared all library data'); } @@ -1041,11 +1882,11 @@ class LibraryDatabase { int limit = 50, }) async { final db = await database; - final searchQuery = '%${query.toLowerCase()}%'; + final searchQuery = '%${_escapeLikePattern(query.toLowerCase())}%'; final rows = await db.query( 'library', where: - 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?', + "LOWER(track_name) LIKE ? ESCAPE '\\' OR LOWER(artist_name) LIKE ? ESCAPE '\\' OR LOWER(album_name) LIKE ? ESCAPE '\\'", whereArgs: [searchQuery, searchQuery, searchQuery], orderBy: 'track_name', limit: limit, @@ -1057,6 +1898,7 @@ class LibraryDatabase { final db = await database; await db.close(); _database = null; + _historyAttached = false; } Future> getFileModTimes() async { @@ -1131,6 +1973,20 @@ class LibraryDatabase { : filePaths.length; final chunk = filePaths.sublist(i, end); final placeholders = List.filled(chunk.length, '?').join(','); + final rows = await db.rawQuery( + 'SELECT id FROM library WHERE file_path IN ($placeholders)', + chunk, + ); + final ids = rows + .map((row) => row['id'] as String) + .toList(growable: false); + if (ids.isNotEmpty) { + final idPlaceholders = List.filled(ids.length, '?').join(','); + await db.rawDelete( + 'DELETE FROM library_path_keys WHERE item_id IN ($idPlaceholders)', + ids, + ); + } totalDeleted += await db.rawDelete( 'DELETE FROM library WHERE file_path IN ($placeholders)', chunk,