mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
perf(library): rank playlist search pages on bounded workers
This commit is contained in:
1 parent
8b2ba446e3
commit
0c6ad3b96a
3 files changed
+257
-20
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user