diff --git a/lib/providers/library_search_provider.dart b/lib/providers/library_search_provider.dart index 3f601aca..2e702881 100644 --- a/lib/providers/library_search_provider.dart +++ b/lib/providers/library_search_provider.dart @@ -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), diff --git a/lib/services/library_playlist_search.dart b/lib/services/library_playlist_search.dart new file mode 100644 index 00000000..9dffa0c3 --- /dev/null +++ b/lib/services/library_playlist_search.dart @@ -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 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> searchLibraryPlaylists({ + required List 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 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); +} diff --git a/test/library_playlist_search_test.dart b/test/library_playlist_search_test.dart new file mode 100644 index 00000000..8d91cd0c --- /dev/null +++ b/test/library_playlist_search_test.dart @@ -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 _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, + ); + }, + ); +}