diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index 9c0f0824..ef18d22f 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -1,10 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; -import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -144,11 +144,11 @@ class PlaybackController extends Notifier { if (tracks.isEmpty) return; final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex); + final resolvedPaths = await _resolveTrackPaths(orderedTracks); if (await _useInternalPlayer()) { final queue = []; var skippedCueVirtualTrack = false; - final resolvedPaths = await _resolveTrackPaths(orderedTracks); for (var index = 0; index < orderedTracks.length; index++) { final track = orderedTracks[index]; final resolvedPath = resolvedPaths[index]; @@ -186,8 +186,9 @@ class PlaybackController extends Notifier { } var skippedCueVirtualTrack = false; - for (final track in orderedTracks) { - final resolvedPath = await _resolveTrackPath(track); + for (var index = 0; index < orderedTracks.length; index++) { + final track = orderedTracks[index]; + final resolvedPath = resolvedPaths[index]; if (resolvedPath == null) { continue; } @@ -225,130 +226,65 @@ class PlaybackController extends Notifier { ]; } - Future _resolveTrackPath(Track track) async { - final historyState = ref.read(downloadHistoryProvider); - final historyNotifier = ref.read(downloadHistoryProvider.notifier); - - final localItem = await _findLocalLibraryItemForTrack(track); - if (localItem != null && await fileExists(localItem.filePath)) { - return localItem.filePath; - } - - final historyItem = await _findDownloadHistoryItemForTrack( - track, - historyState, - ); - if (historyItem != null) { - if (await fileExists(historyItem.filePath)) { - return historyItem.filePath; - } - historyNotifier.removeFromHistory(historyItem.id); - } - - return null; - } - Future> _resolveTrackPaths(List tracks) async { if (tracks.isEmpty) return const []; + final localFuture = LibraryDatabase.instance.findExistingBatch([ + for (final track in tracks) + LocalLibraryBatchLookupRequest( + id: (track.source ?? '').toLowerCase() == 'local' ? track.id : null, + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ), + ]); + final historyFuture = HistoryDatabase.instance.findExistingTracks([ + for (final track in tracks) + HistoryLookupRequest( + spotifyId: track.id, + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ), + ]); + final localMatches = await localFuture; + final historyMatches = await historyFuture; + final results = List.filled(tracks.length, null); + final existsChecks = >{}; + final invalidHistoryIds = {}; var next = 0; - final workerCount = tracks.length < 4 ? tracks.length : 4; + final workerCount = tracks.length < 8 ? tracks.length : 8; + + Future pathExists(String path) => + existsChecks.putIfAbsent(path, () => fileExists(path)); + Future worker() async { while (true) { final index = next++; if (index >= tracks.length) return; - results[index] = await _resolveTrackPath(tracks[index]); + final localPath = localMatches[index]?['filePath']?.toString() ?? ''; + if (localPath.isNotEmpty && await pathExists(localPath)) { + results[index] = localPath; + continue; + } + + final historyMatch = historyMatches[index]; + final historyPath = historyMatch?['filePath']?.toString() ?? ''; + if (historyPath.isNotEmpty && await pathExists(historyPath)) { + results[index] = historyPath; + continue; + } + final historyId = historyMatch?['id']?.toString() ?? ''; + if (historyId.isNotEmpty) invalidHistoryIds.add(historyId); } } await Future.wait(List.generate(workerCount, (_) => worker())); - return results; - } - - Future _findLocalLibraryItemForTrack(Track track) async { - final isLocalSource = (track.source ?? '').toLowerCase() == 'local'; - if (isLocalSource) { - final byId = await ref - .read(localLibraryProvider.notifier) - .getById(track.id); - if (byId != null) return byId; - } - - final isrc = track.isrc?.trim(); - return ref - .read(localLibraryProvider.notifier) - .findExistingAsync( - isrc: isrc, - trackName: track.name, - artistName: track.artistName, - ); - } - - 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; - } + for (final id in invalidHistoryIds) { + historyNotifier.removeFromHistory(id); } - - final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) { - final byIsrc = historyState.getByIsrc(isrc); - if (byIsrc != null) { - return byIsrc; - } - final byIsrcAsync = await historyNotifier.getByIsrcAsync(isrc); - if (byIsrcAsync != null) { - return byIsrcAsync; - } - } - - return historyNotifier.findByTrackAndArtistAsync( - track.name, - track.artistName, - ); - } - - List _spotifyIdLookupCandidates(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'); - } - - final uri = Uri.tryParse(trimmed); - final segments = uri?.pathSegments ?? const []; - final trackIndex = segments.indexOf('track'); - if (trackIndex >= 0 && trackIndex + 1 < segments.length) { - final pathId = segments[trackIndex + 1].trim(); - if (pathId.isNotEmpty) { - candidates.add(pathId); - candidates.add('spotify:track:$pathId'); - } - } - - return candidates.toList(growable: false); + return results; } } diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 6d67dcb7..13f89d49 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -231,6 +231,16 @@ class HistoryDatabase { } else if (!trimmed.contains(':')) { candidates.add('spotify:track:$trimmed'); } + final uri = Uri.tryParse(trimmed); + final segments = uri?.pathSegments ?? const []; + final trackIndex = segments.indexOf('track'); + if (trackIndex >= 0 && trackIndex + 1 < segments.length) { + final pathId = segments[trackIndex + 1].trim(); + if (pathId.isNotEmpty) { + candidates.add(pathId); + candidates.add('spotify:track:$pathId'); + } + } return candidates.toList(growable: false); } @@ -716,6 +726,88 @@ class HistoryDatabase { return null; } + /// Batch variant used by playlist playback. Four indexed scans resolve the + /// complete list while preserving the same Spotify -> ISRC -> match-key + /// priority as [findExistingTrack]. + Future?>> findExistingTracks( + List requests, + ) async { + if (requests.isEmpty) return const []; + final db = await database; + final bySpotify = >{}; + final bySpotifyNorm = >{}; + final byIsrcNorm = >{}; + final byMatchKey = >{}; + + Future loadColumn( + String column, + Iterable rawValues, + Map> destination, + ) async { + final values = rawValues + .where((value) => value.isNotEmpty) + .toSet() + .toList(); + const chunkSize = 450; + for (var start = 0; start < values.length; start += chunkSize) { + final end = start + chunkSize < values.length + ? start + chunkSize + : values.length; + final chunk = values.sublist(start, end); + final placeholders = List.filled(chunk.length, '?').join(','); + final rows = await db.rawQuery( + 'SELECT * FROM history WHERE $column IN ($placeholders) ' + 'ORDER BY downloaded_at DESC', + chunk, + ); + for (final row in rows) { + final key = row[column] as String?; + if (key != null && key.isNotEmpty) { + destination.putIfAbsent(key, () => _dbRowToJson(row)); + } + } + } + } + + final spotifyCandidates = requests.expand( + (request) => spotifyLookupCandidates(request.spotifyId), + ); + await Future.wait([ + loadColumn('spotify_id', spotifyCandidates, bySpotify), + loadColumn( + 'spotify_id_norm', + spotifyCandidates.map(normalizeSpotifyId), + bySpotifyNorm, + ), + loadColumn( + 'isrc_norm', + requests.map((request) => normalizeIsrc(request.isrc)), + byIsrcNorm, + ), + loadColumn( + 'match_key', + requests.map( + (request) => matchKeyFor(request.trackName, request.artistName), + ), + byMatchKey, + ), + ]); + + return requests + .map((request) { + for (final candidate in spotifyLookupCandidates(request.spotifyId)) { + final match = + bySpotify[candidate] ?? + bySpotifyNorm[normalizeSpotifyId(candidate)]; + if (match != null) return match; + } + final byIsrc = byIsrcNorm[normalizeIsrc(request.isrc)]; + if (byIsrc != null) return byIsrc; + return byMatchKey[matchKeyFor(request.trackName, request.artistName)]; + }) + .toList(growable: false); + } + Future> existingTrackKeys( List requests, ) async { diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 56e7b86a..2404d01a 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -1638,6 +1638,67 @@ class LibraryDatabase { return null; } + /// Resolves a track list with a bounded number of indexed queries instead + /// of issuing up to three SQLite calls for every track. + Future?>> findExistingBatch( + List requests, + ) async { + if (requests.isEmpty) return const []; + final db = await database; + final byId = >{}; + final byIsrc = >{}; + final byMatchKey = >{}; + + Future loadColumn( + String column, + Iterable rawValues, + Map> destination, + ) async { + final values = rawValues + .where((value) => value.isNotEmpty) + .toSet() + .toList(); + const chunkSize = 450; + for (var start = 0; start < values.length; start += chunkSize) { + final end = (start + chunkSize).clamp(0, values.length); + final chunk = values.sublist(start, end); + final placeholders = List.filled(chunk.length, '?').join(','); + final rows = await db.rawQuery( + 'SELECT * FROM library WHERE $column IN ($placeholders)', + chunk, + ); + for (final row in rows) { + final key = row[column] as String?; + if (key != null && key.isNotEmpty) { + destination.putIfAbsent(key, () => _dbRowToJson(row)); + } + } + } + } + + await Future.wait([ + loadColumn('id', requests.map((request) => request.id ?? ''), byId), + loadColumn('isrc', requests.map((request) => request.isrc ?? ''), byIsrc), + loadColumn( + 'match_key', + requests.map( + (request) => matchKeyFor(request.trackName, request.artistName), + ), + byMatchKey, + ), + ]); + + return requests + .map((request) { + final id = request.id?.trim() ?? ''; + if (id.isNotEmpty && byId[id] != null) return byId[id]; + final isrc = request.isrc?.trim() ?? ''; + if (isrc.isNotEmpty && byIsrc[isrc] != null) return byIsrc[isrc]; + return byMatchKey[matchKeyFor(request.trackName, request.artistName)]; + }) + .toList(growable: false); + } + Future> getAllIsrcs() async { final db = await database; final rows = await db.rawQuery(