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

@ -47,3 +47,9 @@ RADIEO_USER_AGENT=radieo/0.1 (personal music radio)
# --- Rétention du cache (optionnel) ---
# Nombre de morceaux joués conservés sur disque avant éviction (LRU).
RADIEO_RETENTION_KEEP=20
# --- Scrobbling ListenBrainz (optionnel) ---
# Le player scrobble vers TON compte ListenBrainz les morceaux réellement
# écoutés (démarrés au début et entendus à ~90 %). Token utilisateur : voir
# https://listenbrainz.org/settings/ (« User token »). Vide = désactivé.
RADIEO_LISTENBRAINZ_TOKEN=

View file

@ -113,7 +113,25 @@ ListenBrainz only *names* tracks; each suggestion is resolved to a concrete file
MusicBrainz MBID the feed already carries. Leave the variable empty to disable.
A local file path under `config/` also works for testing.
### 5. Run it
### 5. Scrobbling to ListenBrainz (optional)
Set `RADIEO_LISTENBRAINZ_TOKEN` (in `.env`) to your ListenBrainz *user token*
(from <https://listenbrainz.org/settings/>) to scrobble what you listen to:
```
RADIEO_LISTENBRAINZ_TOKEN=<your-user-token>
```
The web player decides when a track counts as *listened* — caught near its
start (server position < 10 s) and actually heard to ~90 % of its length (capped
at 4 min, ListenBrainz's own rule) — then triggers `POST /scrobble`. The token
stays server-side (never exposed to the browser); the stream submits the listen,
using the canonical MusicBrainz MBID when the canonicalizer found one. Only
listeners on the web page scrobble (external players like VLC don't), and each
airing is submitted once even with several tabs open. Leave the variable empty
to disable.
### 6. Run it
```sh
docker compose up -d

View file

@ -45,6 +45,11 @@ services:
depends_on:
ingest:
condition: service_healthy
environment:
# Scrobbling ListenBrainz : token utilisateur (profil → « User token »).
# Le player déclenche /scrobble ; le token reste ici, jamais exposé au
# navigateur. Laisser vide désactive le scrobbling.
- RADIEO_LISTENBRAINZ_TOKEN=${RADIEO_LISTENBRAINZ_TOKEN:-}
ports:
- "8000:8000" # flux HTTP : http://localhost:8000/radio.mp3
volumes:

View file

@ -46,6 +46,12 @@ def annotate_uri(path: Path, track: Track) -> str:
# stream so the player can show a discreet source indicator.
f'origin="{esc(track.origin)}"',
]
# Canonical MusicBrainz recording MBID when the Canonicalizer found one.
# Surfaced on the stream so listeners can scrobble the exact recording to
# ListenBrainz (see stream/web.liq /scrobble). Passed explicitly rather than
# relying on Liquidsoap re-reading the file tags, which is format-dependent.
if track.mbid:
fields.append(f'musicbrainz_trackid="{esc(track.mbid)}"')
# Web page the track was pulled from, so the player can link back to the
# source (see Track.page_url for how it's derived per backend).
if track.page_url is not None:

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
)