stream: scrobble listened tracks to ListenBrainz
All checks were successful
continuous-integration/drone/push Build is passing
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:
parent
b04e717f40
commit
d302cf1c88
7 changed files with 163 additions and 2 deletions
|
|
@ -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=
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue