mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
perf(playback): batch local playlist resolution
This commit is contained in:
@@ -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<PlaybackState> {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
|
||||
final resolvedPaths = await _resolveTrackPaths(orderedTracks);
|
||||
|
||||
if (await _useInternalPlayer()) {
|
||||
final queue = <PlayableMedia>[];
|
||||
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<PlaybackState> {
|
||||
}
|
||||
|
||||
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<PlaybackState> {
|
||||
];
|
||||
}
|
||||
|
||||
Future<String?> _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<List<String?>> _resolveTrackPaths(List<Track> 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<String?>.filled(tracks.length, null);
|
||||
final existsChecks = <String, Future<bool>>{};
|
||||
final invalidHistoryIds = <String>{};
|
||||
var next = 0;
|
||||
final workerCount = tracks.length < 4 ? tracks.length : 4;
|
||||
final workerCount = tracks.length < 8 ? tracks.length : 8;
|
||||
|
||||
Future<bool> pathExists(String path) =>
|
||||
existsChecks.putIfAbsent(path, () => fileExists(path));
|
||||
|
||||
Future<void> 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<LocalLibraryItem?> _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<DownloadHistoryItem?> _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<String> _spotifyIdLookupCandidates(String rawId) {
|
||||
final trimmed = rawId.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final candidates = <String>{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 <String>[];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <String>[];
|
||||
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<List<Map<String, dynamic>?>> findExistingTracks(
|
||||
List<HistoryLookupRequest> requests,
|
||||
) async {
|
||||
if (requests.isEmpty) return const [];
|
||||
final db = await database;
|
||||
final bySpotify = <String, Map<String, dynamic>>{};
|
||||
final bySpotifyNorm = <String, Map<String, dynamic>>{};
|
||||
final byIsrcNorm = <String, Map<String, dynamic>>{};
|
||||
final byMatchKey = <String, Map<String, dynamic>>{};
|
||||
|
||||
Future<void> loadColumn(
|
||||
String column,
|
||||
Iterable<String> rawValues,
|
||||
Map<String, Map<String, dynamic>> 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<Set<String>> existingTrackKeys(
|
||||
List<HistoryLookupRequest> requests,
|
||||
) async {
|
||||
|
||||
@@ -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<List<Map<String, dynamic>?>> findExistingBatch(
|
||||
List<LocalLibraryBatchLookupRequest> requests,
|
||||
) async {
|
||||
if (requests.isEmpty) return const [];
|
||||
final db = await database;
|
||||
final byId = <String, Map<String, dynamic>>{};
|
||||
final byIsrc = <String, Map<String, dynamic>>{};
|
||||
final byMatchKey = <String, Map<String, dynamic>>{};
|
||||
|
||||
Future<void> loadColumn(
|
||||
String column,
|
||||
Iterable<String> rawValues,
|
||||
Map<String, Map<String, dynamic>> 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<Set<String>> getAllIsrcs() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
|
||||
Reference in New Issue
Block a user