perf(library): rank playlist search pages on bounded workers

This commit is contained in:
zarzet committed 2026-10-07 03:15:57 +07:00
1 parent 8b2ba446e3
commit 0c6ad3b96a
3 files changed
+257 -20

No files matched your search

+7 -20
View File
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/providers/local_library_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/library_playlist_search.dart';
import 'package:spotiflac_android/services/library_search.dart';
typedef LibrarySearchRequest = ({
@@ -46,26 +47,12 @@ final librarySearchProvider = FutureProvider.autoDispose
final playlists = ref.watch(
libraryCollectionsProvider.select((state) => state.playlists),
);
final matches = playlists.where((p) => query.matches(p.name)).toList()
..sort((a, b) {
final relevance = query.rank(a.name).compareTo(query.rank(b.name));
if (relevance != 0) return relevance;
final title = a.name.toLowerCase().compareTo(b.name.toLowerCase());
return title != 0 ? title : a.id.compareTo(b.id);
});
return matches
.skip(request.offset)
.take(request.limit)
.map((p) {
return LibrarySearchHit(
kind: LibrarySearchKind.playlists,
id: p.id,
title: p.name,
cover: p.coverImagePath ?? p.previewCover,
trackCount: p.trackCount,
);
})
.toList(growable: false);
return searchLibraryPlaylists(
playlists: playlists,
query: request.query,
limit: request.limit,
offset: request.offset,
);
}
final includeLocal = ref.watch(
settingsProvider.select((s) => s.localLibraryEnabled),
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter/foundation.dart';
import 'package:spotiflac_android/models/library_collections.dart';
import 'package:spotiflac_android/services/library_search.dart';
typedef PlaylistSearchEntry = ({
String id,
String name,
String? cover,
int trackCount,
});
typedef PlaylistSearchRequest = ({
List<PlaylistSearchEntry> entries,
String query,
int limit,
int offset,
});
/// Only lightweight metadata is sent to the worker, never loaded tracks or
/// playlist membership sets. Retain just the requested ranked prefix.
Future<List<LibrarySearchHit>> searchLibraryPlaylists({
required List<UserPlaylistCollection> playlists,
required String query,
required int limit,
required int offset,
}) async {
final request = (
entries: [
for (final playlist in playlists)
(
id: playlist.id,
name: playlist.name,
cover: playlist.coverImagePath ?? playlist.previewCover,
trackCount: playlist.trackCount,
),
],
query: query,
limit: limit,
offset: offset,
);
if (playlists.length < 256) return rankPlaylistSearchPage(request);
return compute(
rankPlaylistSearchPage,
request,
debugLabel: 'library playlist search',
);
}
typedef _RankedPlaylist = ({PlaylistSearchEntry entry, String title, int rank});
List<LibrarySearchHit> rankPlaylistSearchPage(PlaylistSearchRequest request) {
final query = LibrarySearchQuery(request.query);
if (query.terms.isEmpty) return const [];
RangeError.checkNotNegative(request.offset, 'offset');
RangeError.checkNotNegative(request.limit, 'limit');
if (request.limit == 0) return const [];
final capacity = request.offset + request.limit;
final heap = <_RankedPlaylist>[];
for (final entry in request.entries) {
final title = entry.name.toLowerCase();
if (!query.terms.every(title.contains)) continue;
final trimmed = title.trim();
final rank = trimmed == query.text
? 0
: trimmed.startsWith(query.text)
? 1
: trimmed.contains(query.text)
? 2
: 3;
final candidate = (entry: entry, title: title, rank: rank);
if (heap.length < capacity) {
heap.add(candidate);
var child = heap.length - 1;
while (child > 0) {
final parent = (child - 1) ~/ 2;
if (_compareRankedPlaylists(heap[parent], candidate) >= 0) break;
heap[child] = heap[parent];
child = parent;
}
heap[child] = candidate;
} else if (_compareRankedPlaylists(candidate, heap.first) < 0) {
var parent = 0;
while (parent * 2 + 1 < heap.length) {
var child = parent * 2 + 1;
if (child + 1 < heap.length &&
_compareRankedPlaylists(heap[child + 1], heap[child]) > 0) {
child++;
}
if (_compareRankedPlaylists(candidate, heap[child]) >= 0) break;
heap[parent] = heap[child];
parent = child;
}
heap[parent] = candidate;
}
}
heap.sort(_compareRankedPlaylists);
return [
for (final candidate in heap.skip(request.offset))
LibrarySearchHit(
kind: LibrarySearchKind.playlists,
id: candidate.entry.id,
title: candidate.entry.name,
cover: candidate.entry.cover,
trackCount: candidate.entry.trackCount,
),
];
}
int _compareRankedPlaylists(_RankedPlaylist a, _RankedPlaylist b) {
final rank = a.rank.compareTo(b.rank);
if (rank != 0) return rank;
final title = a.title.compareTo(b.title);
return title != 0 ? title : a.entry.id.compareTo(b.entry.id);
}
+136
View File
@@ -0,0 +1,136 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/library_collections.dart';
import 'package:spotiflac_android/services/library_playlist_search.dart';
import 'package:spotiflac_android/services/library_search.dart';
List<PlaylistSearchEntry> _referencePage(PlaylistSearchRequest request) {
final query = LibrarySearchQuery(request.query);
final matches =
request.entries.where((entry) => query.matches(entry.name)).toList()
..sort((a, b) {
final rank = query.rank(a.name).compareTo(query.rank(b.name));
if (rank != 0) return rank;
final title = a.name.toLowerCase().compareTo(b.name.toLowerCase());
return title != 0 ? title : a.id.compareTo(b.id);
});
return matches.skip(request.offset).take(request.limit).toList();
}
void main() {
test(
'bounded search has identical ranking and pages across Unicode/ties',
() {
final random = Random(620);
const names = [
' rock ',
'ROCK',
'rock mix',
'my rock',
'mix rock',
'Röck café',
'歌 music',
'rock % mix',
'mix, rock',
'not a match',
];
final entries = List.generate(
4000,
(index) => (
id: '$index',
name: names[random.nextInt(names.length)],
cover: 'cover-$index',
trackCount: index,
),
);
for (final query in [
'rock',
'rock mix',
'mix rock',
'röck',
'歌',
'missing',
]) {
for (final offset in [0, 1, 30, 400, 5000]) {
for (final limit in [0, 1, 40, 5000]) {
final request = (
entries: entries,
query: query,
offset: offset,
limit: limit,
);
final expected = _referencePage(request);
final actual = rankPlaylistSearchPage(request);
expect(
actual.map((hit) => hit.id),
expected.map((entry) => entry.id),
);
expect(
actual.map((hit) => hit.title),
expected.map((entry) => entry.name),
);
expect(
actual.map((hit) => hit.cover),
expected.map((entry) => entry.cover),
);
expect(
actual.map((hit) => hit.trackCount),
expected.map((entry) => entry.trackCount),
);
}
}
}
},
);
test(
'playlist worker transfers only summary and preserves cover precedence',
() async {
final playlists = List.generate(
300,
(index) => UserPlaylistCollection(
id: '$index',
name: 'Playlist $index',
createdAt: DateTime.utc(2026),
updatedAt: DateTime.utc(2026),
tracks: const [],
tracksLoaded: false,
trackKeys: {'example:$index'},
previewCover: 'preview-$index',
coverImagePath: index.isEven ? 'custom-$index' : null,
),
);
final hits = await searchLibraryPlaylists(
playlists: playlists,
query: 'playlist',
offset: 0,
limit: 300,
);
expect(hits.length, 300);
for (final hit in hits) {
final index = int.parse(hit.id);
expect(hit.trackCount, 1);
expect(hit.cover, index.isEven ? 'custom-$index' : 'preview-$index');
}
expect(
() => rankPlaylistSearchPage((
entries: const [],
query: 'x',
offset: -1,
limit: 1,
)),
throwsRangeError,
);
expect(
rankPlaylistSearchPage((
entries: const [],
query: '',
offset: -1,
limit: -1,
)),
isEmpty,
);
},
);
}