glm hope you did a good job fixing recommendations
This commit is contained in:
parent
17c382cb93
commit
e2b9e7772f
3 changed files with 453 additions and 0 deletions
BIN
bun.lockb
Executable file
BIN
bun.lockb
Executable file
Binary file not shown.
282
js/listening-tracker.js
Normal file
282
js/listening-tracker.js
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
const STORAGE_KEY = 'monochrome-listening-data';
|
||||||
|
const MAX_TRACKS = 2000;
|
||||||
|
const MAX_ARTISTS = 500;
|
||||||
|
const SKIP_THRESHOLD_S = 5;
|
||||||
|
const COMPLETION_RATIO_THRESHOLD = 0.3;
|
||||||
|
|
||||||
|
class ListeningTracker {
|
||||||
|
constructor() {
|
||||||
|
this._data = null;
|
||||||
|
this._currentTrackId = null;
|
||||||
|
this._playStartTime = null;
|
||||||
|
this._lastTimeUpdate = 0;
|
||||||
|
this._accumulatedPlayTime = 0;
|
||||||
|
this._trackDuration = 0;
|
||||||
|
this._flushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_load() {
|
||||||
|
if (this._data) return this._data;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
this._data = raw ? JSON.parse(raw) : this._empty();
|
||||||
|
} catch {
|
||||||
|
this._data = this._empty();
|
||||||
|
}
|
||||||
|
return this._data;
|
||||||
|
}
|
||||||
|
|
||||||
|
_empty() {
|
||||||
|
return { tracks: {}, artists: {}, version: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
_save() {
|
||||||
|
try {
|
||||||
|
const d = this._data || this._load();
|
||||||
|
const trackEntries = Object.entries(d.tracks);
|
||||||
|
if (trackEntries.length > MAX_TRACKS) {
|
||||||
|
trackEntries.sort((a, b) => (b[1].lastPlayed || 0) - (a[1].lastPlayed || 0));
|
||||||
|
d.tracks = Object.fromEntries(trackEntries.slice(0, MAX_TRACKS));
|
||||||
|
}
|
||||||
|
const artistEntries = Object.entries(d.artists);
|
||||||
|
if (artistEntries.length > MAX_ARTISTS) {
|
||||||
|
artistEntries.sort((a, b) => (b[1].affinity || 0) - (a[1].affinity || 0));
|
||||||
|
d.artists = Object.fromEntries(artistEntries.slice(0, MAX_ARTISTS));
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(d));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('ListeningTracker: save failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_flush() {
|
||||||
|
if (this._flushTimer) return;
|
||||||
|
this._flushTimer = setTimeout(() => {
|
||||||
|
this._save();
|
||||||
|
this._flushTimer = null;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTrackStart(track) {
|
||||||
|
if (!track || !track.id) return;
|
||||||
|
this._finalizeCurrent();
|
||||||
|
this._currentTrackId = track.id;
|
||||||
|
this._playStartTime = Date.now();
|
||||||
|
this._lastTimeUpdate = 0;
|
||||||
|
this._accumulatedPlayTime = 0;
|
||||||
|
this._trackDuration = (track.duration || 0) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTimeUpdate(currentTime, duration) {
|
||||||
|
if (!this._currentTrackId || this._playStartTime === null) return;
|
||||||
|
if (duration > 0) this._trackDuration = duration;
|
||||||
|
if (this._lastTimeUpdate > 0 && currentTime > this._lastTimeUpdate) {
|
||||||
|
const delta = currentTime - this._lastTimeUpdate;
|
||||||
|
if (delta < 5) {
|
||||||
|
this._accumulatedPlayTime += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._lastTimeUpdate = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTrackEnd() {
|
||||||
|
this._finalizeCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSkip() {
|
||||||
|
if (!this._currentTrackId || this._playStartTime === null) return;
|
||||||
|
const elapsed = this._accumulatedPlayTime;
|
||||||
|
this._recordTrackSignal(this._currentTrackId, elapsed, this._trackDuration, true);
|
||||||
|
if (this._currentTrackId) {
|
||||||
|
const currentData = this._load();
|
||||||
|
const trackMeta = this._findTrackMeta(this._currentTrackId);
|
||||||
|
if (trackMeta) {
|
||||||
|
this._updateArtistAffinityFromData(currentData, trackMeta, elapsed, this._trackDuration, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._currentTrackId = null;
|
||||||
|
this._playStartTime = null;
|
||||||
|
this._accumulatedPlayTime = 0;
|
||||||
|
this._lastTimeUpdate = 0;
|
||||||
|
this._flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
_finalizeCurrent() {
|
||||||
|
if (!this._currentTrackId || this._playStartTime === null) return;
|
||||||
|
const elapsed = this._accumulatedPlayTime;
|
||||||
|
this._recordTrackSignal(this._currentTrackId, elapsed, this._trackDuration, false);
|
||||||
|
if (this._currentTrackId) {
|
||||||
|
const currentData = this._load();
|
||||||
|
const trackMeta = this._findTrackMeta(this._currentTrackId);
|
||||||
|
if (trackMeta) {
|
||||||
|
this._updateArtistAffinityFromData(currentData, trackMeta, elapsed, this._trackDuration, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._currentTrackId = null;
|
||||||
|
this._playStartTime = null;
|
||||||
|
this._accumulatedPlayTime = 0;
|
||||||
|
this._lastTimeUpdate = 0;
|
||||||
|
this._flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
_findTrackMeta(_trackId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordTrackSignal(trackId, playTimeS, durationS, wasSkipped) {
|
||||||
|
const d = this._load();
|
||||||
|
if (!d.tracks[trackId]) {
|
||||||
|
d.tracks[trackId] = {
|
||||||
|
playCount: 0,
|
||||||
|
skipCount: 0,
|
||||||
|
totalPlayTime: 0,
|
||||||
|
completionCount: 0,
|
||||||
|
lastPlayed: 0,
|
||||||
|
avgCompletionRatio: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const t = d.tracks[trackId];
|
||||||
|
t.playCount++;
|
||||||
|
t.totalPlayTime += playTimeS;
|
||||||
|
t.lastPlayed = Date.now();
|
||||||
|
|
||||||
|
const completionRatio = durationS > 0 ? Math.min(playTimeS / durationS, 1) : 0;
|
||||||
|
t.avgCompletionRatio =
|
||||||
|
t.avgCompletionRatio === 0 ? completionRatio : t.avgCompletionRatio * 0.8 + completionRatio * 0.2;
|
||||||
|
|
||||||
|
if (wasSkipped || playTimeS < SKIP_THRESHOLD_S) {
|
||||||
|
t.skipCount++;
|
||||||
|
} else if (playTimeS >= durationS * 0.9 || completionRatio >= 0.9) {
|
||||||
|
t.completionCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateArtistAffinity(track, playTimeS, durationS, wasSkipped) {
|
||||||
|
if (!track) return;
|
||||||
|
const d = this._load();
|
||||||
|
this._updateArtistAffinityFromData(d, track, playTimeS, durationS, wasSkipped);
|
||||||
|
this._flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateArtistAffinityFromData(d, track, playTimeS, durationS, wasSkipped) {
|
||||||
|
const artistIds = [];
|
||||||
|
if (track.artist && track.artist.id) artistIds.push(track.artist.id);
|
||||||
|
if (track.artists && Array.isArray(track.artists)) {
|
||||||
|
for (const a of track.artists) {
|
||||||
|
if (a.id) artistIds.push(a.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (artistIds.length === 0) return;
|
||||||
|
|
||||||
|
const completionRatio = durationS > 0 ? Math.min(playTimeS / durationS, 1) : 0;
|
||||||
|
const weight = wasSkipped
|
||||||
|
? -0.5
|
||||||
|
: completionRatio > 0.8
|
||||||
|
? 1.0
|
||||||
|
: completionRatio > 0.5
|
||||||
|
? 0.5
|
||||||
|
: completionRatio > COMPLETION_RATIO_THRESHOLD
|
||||||
|
? 0.2
|
||||||
|
: -0.2;
|
||||||
|
|
||||||
|
for (const artistId of artistIds) {
|
||||||
|
const name = track.artists?.find((a) => a.id === artistId)?.name || track.artist?.name || '';
|
||||||
|
if (!d.artists[artistId]) {
|
||||||
|
d.artists[artistId] = { name, affinity: 0, playCount: 0, skipCount: 0, totalPlayTime: 0 };
|
||||||
|
}
|
||||||
|
const a = d.artists[artistId];
|
||||||
|
a.affinity = a.affinity * 0.9 + weight;
|
||||||
|
a.playCount++;
|
||||||
|
a.totalPlayTime += playTimeS;
|
||||||
|
if (wasSkipped) a.skipCount++;
|
||||||
|
if (name) a.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrackSignal(trackId) {
|
||||||
|
const d = this._load();
|
||||||
|
return d.tracks[trackId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrackScore(trackId) {
|
||||||
|
const signal = this.getTrackSignal(trackId);
|
||||||
|
if (!signal) return 0;
|
||||||
|
const skipRate = signal.playCount > 0 ? signal.skipCount / signal.playCount : 0;
|
||||||
|
const completionRate = signal.playCount > 0 ? signal.completionCount / signal.playCount : 0;
|
||||||
|
return (
|
||||||
|
signal.avgCompletionRatio * 2 + completionRate * 3 - skipRate * 4 + Math.log2(signal.playCount + 1) * 0.5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getArtistAffinity(artistId) {
|
||||||
|
const d = this._load();
|
||||||
|
return d.artists[artistId]?.affinity || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTopArtists(limit = 20) {
|
||||||
|
const d = this._load();
|
||||||
|
return Object.entries(d.artists)
|
||||||
|
.filter(([, v]) => v.playCount >= 2)
|
||||||
|
.sort((a, b) => b[1].affinity - a[1].affinity)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([id, v]) => ({ id, name: v.name, affinity: v.affinity, playCount: v.playCount }));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDislikedArtists(limit = 20) {
|
||||||
|
const d = this._load();
|
||||||
|
return Object.entries(d.artists)
|
||||||
|
.filter(([, v]) => v.playCount >= 2 && v.affinity < -0.3)
|
||||||
|
.sort((a, b) => a[1].affinity - b[1].affinity)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([id, v]) => ({ id, name: v.name, affinity: v.affinity }));
|
||||||
|
}
|
||||||
|
|
||||||
|
getHighlyPlayedTracks(limit = 50) {
|
||||||
|
const d = this._load();
|
||||||
|
return Object.entries(d.tracks)
|
||||||
|
.filter(([, v]) => v.playCount >= 2 && v.avgCompletionRatio > 0.6)
|
||||||
|
.sort((a, b) => b[1].playCount - a[1].playCount)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([id]) => id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFrequentlySkippedTrackIds(limit = 50) {
|
||||||
|
const d = this._load();
|
||||||
|
return Object.entries(d.tracks)
|
||||||
|
.filter(([, v]) => v.playCount >= 2 && v.skipCount / v.playCount > 0.5)
|
||||||
|
.sort((a, b) => b[1].skipCount / b[1].playCount - a[1].skipCount / a[1].playCount)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([id]) => id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getShortPlayTrackIds(limit = 50) {
|
||||||
|
const d = this._load();
|
||||||
|
return Object.entries(d.tracks)
|
||||||
|
.filter(([, v]) => v.playCount >= 2 && v.avgCompletionRatio < COMPLETION_RATIO_THRESHOLD)
|
||||||
|
.sort((a, b) => a[1].avgCompletionRatio - b[1].avgCompletionRatio)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([id]) => id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDislikedArtistIds() {
|
||||||
|
return this.getDislikedArtists(30).map((a) => a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionSignals() {
|
||||||
|
return {
|
||||||
|
currentTrackId: this._currentTrackId,
|
||||||
|
accumulatedPlayTime: this._accumulatedPlayTime,
|
||||||
|
trackDuration: this._trackDuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
forceFlush() {
|
||||||
|
if (this._flushTimer) {
|
||||||
|
clearTimeout(this._flushTimer);
|
||||||
|
this._flushTimer = null;
|
||||||
|
}
|
||||||
|
this._save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listeningTracker = new ListeningTracker();
|
||||||
171
js/smart-recommendations.js
Normal file
171
js/smart-recommendations.js
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
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();
|
||||||
Loading…
Reference in a new issue