perf(playback): batch local playlist resolution

This commit is contained in:
zarzet
2026-07-16 08:09:12 +07:00
parent ca8a6a8849
commit efa3ff3d66
3 changed files with 203 additions and 114 deletions
+92
View File
@@ -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 {
+61
View File
@@ -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(