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

@ -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
)