radieo/stream/web.liq
Pierre-Olivier Mercier d302cf1c88
All checks were successful
continuous-integration/drone/push Build is passing
stream: scrobble listened tracks to ListenBrainz
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>
2026-07-04 17:51:41 +08:00

261 lines
9.7 KiB
Text

# radieo — couche web : ressources statiques et API de lecture locale.
#
# Inclus par radio.liq APRÈS la définition du pipeline : ce fichier ne fait
# qu'enregistrer des routes sur le harbor du flux (port 8000) et s'appuie sur les
# symboles du pipeline (`radio`, `now_playing`, `history`, `requeue`,
# `song_count`, `audio_ext`). Les routes qui relaient le daemon d'ingestion sont
# à part, dans ingest_proxy.liq.
# --- Page web et API de lecture (mêmes port/harbor que le flux) ---
home_html = file.contents("/etc/liquidsoap/index.html")
favicon_svg = file.contents("/etc/liquidsoap/favicon.svg")
# Ressources de la webapp installable (PWA) : manifeste, service worker et jeu
# d'icônes. Chargées une fois au démarrage puis servies telles quelles.
manifest_json = file.contents("/etc/liquidsoap/manifest.webmanifest")
sw_js = file.contents("/etc/liquidsoap/sw.js")
icon_192 = file.contents("/etc/liquidsoap/icon-192.png")
icon_512 = file.contents("/etc/liquidsoap/icon-512.png")
icon_maskable_512 = file.contents("/etc/liquidsoap/icon-maskable-512.png")
apple_touch_icon = file.contents("/etc/liquidsoap/apple-touch-icon.png")
harbor.http.register(
port=8000, method="GET", "/",
fun(_, resp) -> begin
resp.content_type("text/html; charset=utf-8")
resp.html(home_html)
end
)
harbor.http.register(
port=8000, method="GET", "/favicon.svg",
fun(_, resp) -> begin
resp.content_type("image/svg+xml; charset=utf-8")
resp.data(favicon_svg)
end
)
harbor.http.register(
port=8000, method="GET", "/manifest.webmanifest",
fun(_, resp) -> begin
resp.content_type("application/manifest+json; charset=utf-8")
resp.data(manifest_json)
end
)
# Le service worker doit être servi depuis la racine pour couvrir tout le site
# (portée par défaut = son emplacement). Pas de cache HTTP agressif : le
# navigateur revérifie régulièrement le script.
harbor.http.register(
port=8000, method="GET", "/sw.js",
fun(_, resp) -> begin
resp.content_type("text/javascript; charset=utf-8")
resp.header("Cache-Control", "no-cache")
resp.data(sw_js)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-192.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_192)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-512.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_512)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-maskable-512.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_maskable_512)
end
)
harbor.http.register(
port=8000, method="GET", "/apple-touch-icon.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(apple_touch_icon)
end
)
harbor.http.register(
port=8000, method="GET", "/nowplaying",
fun(_, resp) -> begin
m = now_playing()
# `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
)
# Historique des titres passés (le plus récent en tête, morceau courant inclus).
harbor.http.register(
port=8000, method="GET", "/history",
fun(_, resp) -> resp.json(history())
)
# Passer au morceau suivant : on saute le morceau en cours sur la source
# diffusée. request.dynamic a déjà préchargé le suivant, donc l'enchaînement
# est immédiat (le prochain /next est demandé au daemon dans la foulée).
harbor.http.register(
port=8000, method="POST", "/skip",
fun(_, resp) -> begin
source.skip(radio)
resp.json({skipped=true})
end
)
# Rejouer le morceau courant depuis le début, pour TOUTE l'antenne (le flux est
# partagé : il n'existe pas de position par auditeur). On repousse le fichier
# courant — pris dans le cache, donc forcément local et présent — en tête via la
# file de rejeu, puis on saute le morceau en cours pour l'y enchaîner aussitôt.
# On remet le compteur de chansons à zéro pour qu'un jingle ne vole pas la place
# du rejeu à cette frontière. Un morceau introuvable (jingle en cours, cache
# évincé) renvoie 409.
harbor.http.register(
port=8000, method="POST", "/restart-track",
fun(_, resp) -> begin
base = path.basename(list.assoc(default="", "filename", now_playing()))
full = "/cache/#{base}"
if base != "" and file.exists(full) then
song_count := 0
requeue.push.uri(full)
source.skip(radio)
resp.json({restarted=true})
else
resp.status_code(409)
resp.json({restarted=false})
end
end
)
# Télécharger un morceau. Sans paramètre : le titre en cours. Avec `?file=<nom>` :
# n'importe quel fichier encore présent dans le cache (les titres passés listés
# par /history exposent ce jeton). Le cache étant borné par le LRU, un morceau
# évincé renvoie simplement 404.
def content_type_of(name) =
low = string.case(lower=true, name)
if string.contains(suffix=".flac", low) then "audio/flac"
elsif string.contains(suffix=".ogg", low) then "audio/ogg"
elsif string.contains(suffix=".opus", low) then "audio/opus"
elsif string.contains(suffix=".m4a", low) or string.contains(suffix=".aac", low) then "audio/mp4"
elsif string.contains(suffix=".wav", low) then "audio/wav"
else "audio/mpeg"
end
end
# Sert un fichier en pièce jointe après validation. `path.basename` neutralise
# toute tentative de remontée de répertoire (…/…) ; on n'autorise que de vrais
# fichiers audio du cache, jamais les fichiers cachés (.part en cours, .gitkeep).
def serve_attachment(resp, name) =
base = path.basename(name)
low = string.case(lower=true, base)
is_audio = list.exists(fun(e) -> string.contains(suffix=e, low), audio_ext)
full = "/cache/#{base}"
if base == "" or string.contains(prefix=".", base) or not is_audio
or not file.exists(full) then
resp.status_code(404)
resp.data("track not available")
else
resp.content_type(content_type_of(base))
resp.header("Content-Disposition", "attachment; filename=\"#{base}\"")
resp.data(file.contents(full))
end
end
harbor.http.register(
port=8000, method="GET", "/download",
fun(req, resp) -> begin
requested = list.assoc(default="", "file", req.query)
name =
if requested != "" then
requested
else
list.assoc(default="", "filename", now_playing())
end
serve_attachment(resp, name)
end
)