stream: scrobble listened tracks to ListenBrainz
All checks were successful
continuous-integration/drone/push Build is passing

The web player decides when a track counts as listened (caught near its
start and heard to ~90%, capped at 4 min) and triggers POST /scrobble.
The token stays server-side (RADIEO_LISTENBRAINZ_TOKEN), submitting the
listen with the canonical MusicBrainz MBID when available. Each airing is
deduplicated so multiple tabs submit it once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-07-04 17:51:41 +08:00
commit d302cf1c88
7 changed files with 163 additions and 2 deletions

View file

@ -439,6 +439,38 @@
player.addEventListener("pause", () => { media.playbackState = "paused"; });
}
// --- Scrobbling ListenBrainz ------------------------------------------
// Le serveur détient le token (dans .env) ; le player ne fait que décider
// QUAND un morceau est vraiment « écouté » et le déclencher. Règle : pris
// ~au début (position serveur < 10 s à la découverte) ET réellement écouté
// ≥ 90 % de sa durée (plafonné à 4 min, comme la règle ListenBrainz). Un
// morceau qu'on « next » ou qu'on rejoint en cours n'est donc pas scrobblé.
const SCROBBLE_START_MAX = 10; // s : position max au moment de la découverte
const SCROBBLE_MIN_RATIO = 0.9; // fraction de la durée à écouter
const SCROBBLE_CAP = 240; // s : plafond (4 min) pour les longs morceaux
let sc = null; // état du morceau courant vis-à-vis du scrobble
function scrobble(type) {
if (!sc || !sc.file) return;
const q = "/scrobble?file=" + encodeURIComponent(sc.file) +
(type ? "&type=" + type : "");
fetch(q, { method: "POST" }).catch(() => { /* best effort */ });
}
// Cumul du temps RÉELLEMENT écouté : une seconde par tick tant qu'on joue.
// Une pause renvoie au direct via goLive() (on ne rattrape pas le live), donc
// après une pause on n'atteindra pas les 90 % → pas de scrobble abusif.
setInterval(() => {
if (!sc || player.paused || !sc.music) return;
if (!sc.playingNowSent) { sc.playingNowSent = true; scrobble("playing_now"); }
sc.heard += 1;
if (!sc.scrobbled && sc.duration > 0 && sc.startPos < SCROBBLE_START_MAX &&
sc.heard >= Math.min(sc.duration * SCROBBLE_MIN_RATIO, SCROBBLE_CAP)) {
sc.scrobbled = true;
scrobble(); // listen définitif
}
}, 1000);
async function poll() {
try {
const r = await fetch("/nowplaying", { cache: "no-store" });
@ -463,6 +495,19 @@
}
document.title = t ? (a ? `${t} — ${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME;
updateMediaSession(t, a);
// Suivi du morceau courant pour le scrobble. Le jeton `file` identifie le
// passage ; on repart de zéro à chaque nouveau titre.
const file = (m.file || "").trim();
const dur = Number(m.duration) || 0;
const pos = Number(m.position) || 0;
if (file && (!sc || sc.file !== file)) {
sc = { file, duration: dur, startPos: pos, heard: 0,
scrobbled: false, playingNowSent: false, music: !!(t && a) };
} else if (sc && sc.file === file) {
// Les métadonnées peuvent se compléter après coup (durée, tags tardifs).
if (!sc.music && t && a) sc.music = true;
if (sc.duration <= 0 && dur > 0) sc.duration = dur;
}
} catch (e) { /* keep last known values */ }
}