From 6d68ef00ab1e22df9c38da711a7b4ccb7adc6d38 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:53:17 +0700 Subject: [PATCH] perf(autoplay): index listening history and reuse normalized metadata --- lib/services/music_player_autoplay.dart | 46 ++++++++++++-------- test/music_player_autoplay_test.dart | 57 +++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/lib/services/music_player_autoplay.dart b/lib/services/music_player_autoplay.dart index efc627d1..68ff81da 100644 --- a/lib/services/music_player_autoplay.dart +++ b/lib/services/music_player_autoplay.dart @@ -96,46 +96,56 @@ List selectAutoplayTracks({ ...dismissedSources, }; final songs = {songKey(seed), ...upcoming.map(songKey)}; - final scored = <(PlayableMedia, double)>[]; + final seedArtist = normalized(seed.artist); + final seedAlbum = normalized(seed.album); + final seedGenre = normalized(seed.genre); + final recentSources = {}; + final recentSongs = {}; + for (var i = 0; i < recent.length; i++) { + recentSources[recent[i].source] = i; + recentSongs[songKey(recent[i])] = i; + } + final scored = <(PlayableMedia, double, String)>[]; for (final item in candidates) { + final key = songKey(item); if (item.source.trim().isEmpty || item.source.startsWith('http://') || item.source.startsWith('https://') || sources.contains(item.source) || - songs.contains(songKey(item))) { + songs.contains(key)) { continue; } sources.add(item.source); - songs.add(songKey(item)); + songs.add(key); + final artist = normalized(item.artist); var score = random.nextDouble() * 2; - if (normalized(seed.artist).isNotEmpty && - normalized(seed.artist) == normalized(item.artist)) { + if (seedArtist.isNotEmpty && seedArtist == artist) { score += 5; } - if (normalized(seed.album).isNotEmpty && - normalized(seed.album) == normalized(item.album)) { + if (seedAlbum.isNotEmpty && seedAlbum == normalized(item.album)) { score += 3; } - if (normalized(seed.genre).isNotEmpty && - normalized(seed.genre) == normalized(item.genre)) { + if (seedGenre.isNotEmpty && seedGenre == normalized(item.genre)) { score += 4; } - final played = recent.lastIndexWhere( - (previous) => - previous.source == item.source || songKey(previous) == songKey(item), + // Match the latest source OR normalized song occurrence without walking + // the entire listening history again for every candidate. + final played = max( + recentSources[item.source] ?? -1, + recentSongs[key] ?? -1, ); if (played >= 0) score -= 20 + played.toDouble(); - scored.add((item, score)); + scored.add((item, score, artist)); } final selected = []; while (selected.length < limit && scored.isNotEmpty) { scored.sort((a, b) => b.$2.compareTo(a.$2)); - final next = scored.removeAt(0).$1; - selected.add(next); + final next = scored.removeAt(0); + selected.add(next.$1); for (var i = 0; i < scored.length; i++) { - final (item, score) = scored[i]; - if (normalized(item.artist) == normalized(next.artist)) { - scored[i] = (item, score - 4); + final (item, score, artist) = scored[i]; + if (artist == next.$3) { + scored[i] = (item, score - 4, artist); } } } diff --git a/test/music_player_autoplay_test.dart b/test/music_player_autoplay_test.dart index 2c45ace8..b15deb51 100644 --- a/test/music_player_autoplay_test.dart +++ b/test/music_player_autoplay_test.dart @@ -17,6 +17,15 @@ PlayableMedia track( genre: genre, ); +class _NoNoise implements Random { + @override + double nextDouble() => 0; + @override + int nextInt(int max) => 0; + @override + bool nextBool() => false; +} + void main() { test('Autoplay prefers related unplayed Library tracks and deduplicates', () { final seed = track('seed', genre: 'Rock'); @@ -58,6 +67,54 @@ void main() { ); }); + test('recency uses the latest source or normalized song match', () { + final source = track('source-match', artist: 'A', source: '/same.flac'); + final song = track(' Song ', artist: ' ARTIST '); + final older = track('older', artist: 'D'); + final unplayed = track('new', artist: 'E'); + final selected = selectAutoplayTracks( + seed: track('seed', artist: 'Seed'), + candidates: [source, song, older, unplayed], + upcoming: [], + recent: [ + track('source-match', artist: ' a ', source: '/copy.flac'), + older, + track(' song ', artist: 'artist', source: '/other.flac'), + track('renamed', artist: 'different', source: '/same.flac'), + ], + dismissedSources: {}, + random: _NoNoise(), + ); + expect(selected, [unplayed, older, song, source]); + }); + + test('bounded ranking retains its seeded ordering with a full history', () { + PlayableMedia song(int id) => PlayableMedia( + id: '$id', + source: '/music/$id.flac', + title: ' Song $id ', + artist: ' Artist ${id % 24} ', + album: ' Album ${id % 35} ', + genre: ' Genre ${id % 8} ', + ); + final selected = selectAutoplayTracks( + seed: song(1000), + candidates: List.generate(384, song), + upcoming: [song(16), song(17)], + recent: List.generate(200, (i) => song(i + 192)), + dismissedSources: {'/music/18.flac'}, + random: Random(1), + ); + expect(selected.map((item) => item.id), [ + '160', + '40', + '96', + '80', + '90', + '20', + ]); + }); + test('Autoplay preference and recommendation provenance survive restore', () { expect(AppSettings.fromJson({}).autoplay, isFalse); final settings = const AppSettings().copyWith(autoplay: true);