perf(autoplay): index listening history and reuse normalized metadata

This commit is contained in:
zarzet committed 2026-10-05 17:53:17 +07:00
1 parent a3102b3b34
commit 6d68ef00ab
2 files changed
+85 -18

No files matched your search

+28 -18
View File
@@ -96,46 +96,56 @@ List<PlayableMedia> 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 = <String, int>{};
final recentSongs = <String, int>{};
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 = <PlayableMedia>[];
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);
}
}
}
+57
View File
@@ -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);