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 */ }
}

View file

@ -197,10 +197,15 @@ radio = mksafe(radio)
now_playing = ref([])
history = ref([])
history_max = 25
# Vrai une fois que le passage courant a été scrobblé vers ListenBrainz, pour ne
# pas l'envoyer plusieurs fois (plusieurs onglets/auditeurs déclenchent /scrobble
# pour la même diffusion). Remis à false à chaque nouveau titre. Voir web.liq.
scrobbled = ref(false)
radio.on_metadata(
synchronous=false,
fun(m) -> begin
now_playing := m
scrobbled := false
# `file` : nom de base du fichier à l'antenne, servant de jeton de
# téléchargement (/download?file=…). Vide si la métadonnée manque.
entry = {title=m["title"], artist=m["artist"], url=m["url"], origin=m["origin"], file=path.basename(m["filename"])}

View file

@ -91,7 +91,83 @@ harbor.http.register(
port=8000, method="GET", "/nowplaying",
fun(_, resp) -> begin
m = now_playing()
resp.json({title=m["title"], artist=m["artist"], url=m["url"], origin=m["origin"]})
# `file` : jeton du morceau courant (nom de base), que le player renvoie à
# /scrobble pour prouver qu'il parle bien du titre à l'antenne.
# `duration`/`position` (secondes, arrondies) permettent au player de décider
# d'un scrobble « écouté à 90 %, démarré au début ». duration=0 si inconnue.
resp.json({
title=m["title"], artist=m["artist"], url=m["url"], origin=m["origin"],
file=path.basename(m["filename"]),
duration=int_of_float(source.duration(radio)),
position=int_of_float(source.elapsed(radio))
})
end
)
# --- Scrobbling ListenBrainz (déclenché par le player) ----------------------
# Le player décide QUAND un morceau est vraiment « écouté » (démarré au début et
# entendu à ~90 %) et poste ici. Le token reste côté serveur (jamais exposé au
# navigateur) : on lit RADIEO_LISTENBRAINZ_TOKEN dans l'environnement. Vide =
# fonction désactivée. On soumet vers ListenBrainz avec le MBID canonique quand
# l'ingest en a trouvé un (métadonnée musicbrainz_trackid), sinon artiste+titre.
listenbrainz_submit_url = "https://api.listenbrainz.org/1/submit-listens"
# Envoie un « listen » (single) ou un « playing_now » pour les métadonnées `m`.
# json.stringify(s) échappe proprement une chaîne (guillemets, unicode…).
def submit_listen(playing_now, m) =
token = environment.get(default="", "RADIEO_LISTENBRAINZ_TOKEN")
title = m["title"]
artist = m["artist"]
mbid = m["musicbrainz_trackid"]
if token != "" and title != "" and artist != "" then
info = "\"submission_client\":\"radieo\""
info = if mbid != "" then "#{info},\"recording_mbid\":#{json.stringify(mbid)}" else info end
track_meta = "{\"artist_name\":#{json.stringify(artist)},\"track_name\":#{json.stringify(title)},\"additional_info\":{#{info}}}"
body =
if playing_now then
"{\"listen_type\":\"playing_now\",\"payload\":[{\"track_metadata\":#{track_meta}}]}"
else
"{\"listen_type\":\"single\",\"payload\":[{\"listened_at\":#{int_of_float(time())},\"track_metadata\":#{track_meta}}]}"
end
ignore(http.post(
data=body, timeout=8.0,
headers=[("Authorization", "Token #{token}"), ("Content-Type", "application/json")],
listenbrainz_submit_url
))
end
end
# POST /scrobble?file=<jeton>[&type=playing_now]
# - file doit correspondre au titre courant (sinon 409 : le titre a changé) ;
# - un titre sans artiste/titre (jingle, inconnu) est ignoré silencieusement ;
# - type=playing_now : « en cours d'écoute », renvoyé à chaque nouveau titre ;
# - sinon (single) : scrobble définitif, dédupliqué par le flag `scrobbled`
# pour ne compter le passage qu'une fois même si plusieurs auditeurs postent.
harbor.http.register(
port=8000, method="POST", "/scrobble",
fun(req, resp) -> begin
m = now_playing()
cur = path.basename(m["filename"])
want = list.assoc(default="", "file", req.query)
kind = list.assoc(default="single", "type", req.query)
playing_now = kind == "playing_now"
if environment.get(default="", "RADIEO_LISTENBRAINZ_TOKEN") == "" then
resp.json({scrobbled=false, reason="disabled"})
elsif want == "" or want != cur then
resp.status_code(409)
resp.json({scrobbled=false, reason="stale"})
elsif m["title"] == "" or m["artist"] == "" then
resp.json({scrobbled=false, reason="ignored"})
elsif playing_now then
submit_listen(true, m)
resp.json({scrobbled=true, playing_now=true})
elsif scrobbled() then
resp.json({scrobbled=false, reason="duplicate"})
else
scrobbled := true
submit_listen(false, m)
resp.json({scrobbled=true})
end
end
)