171 lines
6.6 KiB
JavaScript
171 lines
6.6 KiB
JavaScript
import { listeningTracker } from './listening-tracker.js';
|
|
import { db } from './db.js';
|
|
|
|
class SmartRecommendations {
|
|
async getSmartSeeds(count = 50) {
|
|
const [history, favorites, playlists] = await Promise.all([
|
|
db.getHistory(),
|
|
db.getFavorites('track'),
|
|
db.getPlaylists(true),
|
|
]);
|
|
const playlistTracks = playlists.flatMap((p) => p.tracks || []);
|
|
|
|
const scoredTracks = new Map();
|
|
|
|
const addWithScore = (tracks, baseWeight) => {
|
|
for (const t of tracks) {
|
|
if (!t || !t.id) continue;
|
|
const signalScore = listeningTracker.getTrackScore(t.id);
|
|
const completionBonus = this._getCompletionBonus(t.id);
|
|
const score = baseWeight + signalScore + completionBonus;
|
|
const existing = scoredTracks.get(t.id);
|
|
if (existing) {
|
|
existing.score += score;
|
|
existing.track = t;
|
|
} else {
|
|
scoredTracks.set(t.id, { score, track: t });
|
|
}
|
|
}
|
|
};
|
|
|
|
addWithScore(favorites, 3);
|
|
addWithScore(playlistTracks, 2);
|
|
addWithScore(history, 1);
|
|
|
|
const sorted = [...scoredTracks.values()].sort((a, b) => b.score - a.score);
|
|
|
|
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
|
|
|
|
const filteredSeeds = sorted
|
|
.filter((s) => {
|
|
const t = s.track;
|
|
if (this._isTrackByDislikedArtist(t, dislikedArtistIds)) return false;
|
|
const signal = listeningTracker.getTrackSignal(t.id);
|
|
if (signal && signal.playCount >= 3 && signal.avgCompletionRatio < 0.2) return false;
|
|
return true;
|
|
})
|
|
.slice(0, count)
|
|
.map((s) => s.track);
|
|
|
|
const shuffle = (arr) => [...arr].sort(() => Math.random() - 0.5);
|
|
return shuffle(filteredSeeds);
|
|
}
|
|
|
|
_getCompletionBonus(trackId) {
|
|
const signal = listeningTracker.getTrackSignal(trackId);
|
|
if (!signal) return 0;
|
|
if (signal.avgCompletionRatio > 0.8) return 2;
|
|
if (signal.avgCompletionRatio > 0.5) return 1;
|
|
if (signal.avgCompletionRatio < 0.2 && signal.playCount >= 2) return -3;
|
|
return 0;
|
|
}
|
|
|
|
_isTrackByDislikedArtist(track, dislikedArtistIds) {
|
|
if (!track || dislikedArtistIds.size === 0) return false;
|
|
if (track.artist?.id && dislikedArtistIds.has(String(track.artist.id))) return true;
|
|
if (track.artists?.some((a) => a.id && dislikedArtistIds.has(String(a.id)))) return true;
|
|
return false;
|
|
}
|
|
|
|
filterRecommendations(tracks) {
|
|
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
|
|
const frequentlySkippedIds = new Set(listeningTracker.getFrequentlySkippedTrackIds(100));
|
|
const shortPlayIds = new Set(listeningTracker.getShortPlayTrackIds(100));
|
|
|
|
return tracks.filter((t) => {
|
|
if (!t || !t.id) return false;
|
|
if (frequentlySkippedIds.has(t.id)) return false;
|
|
if (shortPlayIds.has(t.id)) return false;
|
|
if (this._isTrackByDislikedArtist(t, dislikedArtistIds)) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
scoreRecommendation(track) {
|
|
if (!track) return 0;
|
|
let score = 0;
|
|
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
|
|
const topArtists = listeningTracker.getTopArtists(30);
|
|
const topArtistIds = new Set(topArtists.map((a) => a.id));
|
|
|
|
if (track.artist?.id && topArtistIds.has(String(track.artist.id))) {
|
|
const artist = topArtists.find((a) => a.id === String(track.artist.id));
|
|
score += artist ? Math.min(artist.affinity * 2, 5) : 1;
|
|
}
|
|
if (track.artists?.some((a) => a.id && topArtistIds.has(String(a.id)))) {
|
|
score += 1;
|
|
}
|
|
if (this._isTrackByDislikedArtist(track, dislikedArtistIds)) {
|
|
score -= 5;
|
|
}
|
|
const skipIds = new Set(listeningTracker.getFrequentlySkippedTrackIds(50));
|
|
if (skipIds.has(track.id)) score -= 3;
|
|
|
|
return score;
|
|
}
|
|
|
|
rankRecommendations(tracks) {
|
|
return tracks
|
|
.map((t) => ({ track: t, score: this.scoreRecommendation(t) }))
|
|
.sort((a, b) => b.score - a.score)
|
|
.map((t) => t.track);
|
|
}
|
|
|
|
async getAdaptiveQueueSeeds(currentQueueTracks, recentlyPlayedIds, count = 5) {
|
|
const topArtistIds = new Set(listeningTracker.getTopArtists(20).map((a) => a.id));
|
|
|
|
const queueArtistIds = new Set();
|
|
for (const t of currentQueueTracks) {
|
|
if (t.artist?.id) queueArtistIds.add(String(t.artist.id));
|
|
if (t.artists)
|
|
t.artists.forEach((a) => {
|
|
if (a.id) queueArtistIds.add(String(a.id));
|
|
});
|
|
}
|
|
|
|
const currentArtistIds = new Set();
|
|
for (const id of queueArtistIds) {
|
|
if (topArtistIds.has(id)) currentArtistIds.add(id);
|
|
}
|
|
|
|
const recentTrackIds = new Set(recentlyPlayedIds);
|
|
const dislikedArtistIds = new Set(listeningTracker.getDislikedArtistIds());
|
|
|
|
const scoredTracks = [];
|
|
for (const t of currentQueueTracks) {
|
|
if (!t || recentTrackIds.has(t.id)) continue;
|
|
if (this._isTrackByDislikedArtist(t, dislikedArtistIds)) continue;
|
|
const signal = listeningTracker.getTrackSignal(t.id);
|
|
const completionRatio = signal ? signal.avgCompletionRatio : 0.5;
|
|
const score = completionRatio;
|
|
scoredTracks.push({ track: t, score });
|
|
}
|
|
|
|
scoredTracks.sort((a, b) => b.score - a.score);
|
|
|
|
const bestSeeds = scoredTracks.slice(0, Math.ceil(count / 2)).map((s) => s.track);
|
|
|
|
if (bestSeeds.length < count) {
|
|
const smartSeeds = await this.getSmartSeeds(20);
|
|
const additional = smartSeeds.filter((s) => {
|
|
if (recentTrackIds.has(s.id)) return false;
|
|
return !bestSeeds.some((b) => b.id === s.id);
|
|
});
|
|
bestSeeds.push(...additional.slice(0, count - bestSeeds.length));
|
|
}
|
|
|
|
return bestSeeds.slice(0, count);
|
|
}
|
|
|
|
getKnownBadTrackIds() {
|
|
const skipped = new Set(listeningTracker.getFrequentlySkippedTrackIds(100));
|
|
const shortPlay = new Set(listeningTracker.getShortPlayTrackIds(100));
|
|
return new Set([...skipped, ...shortPlay]);
|
|
}
|
|
|
|
getKnownBadArtistIds() {
|
|
return new Set(listeningTracker.getDislikedArtistIds(30));
|
|
}
|
|
}
|
|
|
|
export const smartRecommendations = new SmartRecommendations();
|