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) ---
|
# --- Rétention du cache (optionnel) ---
|
||||||
# Nombre de morceaux joués conservés sur disque avant éviction (LRU).
|
# Nombre de morceaux joués conservés sur disque avant éviction (LRU).
|
||||||
RADIEO_RETENTION_KEEP=20
|
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.
|
MusicBrainz MBID the feed already carries. Leave the variable empty to disable.
|
||||||
A local file path under `config/` also works for testing.
|
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
|
```sh
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
ingest:
|
ingest:
|
||||||
condition: service_healthy
|
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:
|
ports:
|
||||||
- "8000:8000" # flux HTTP : http://localhost:8000/radio.mp3
|
- "8000:8000" # flux HTTP : http://localhost:8000/radio.mp3
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ def annotate_uri(path: Path, track: Track) -> str:
|
||||||
# stream so the player can show a discreet source indicator.
|
# stream so the player can show a discreet source indicator.
|
||||||
f'origin="{esc(track.origin)}"',
|
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
|
# 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).
|
# source (see Track.page_url for how it's derived per backend).
|
||||||
if track.page_url is not None:
|
if track.page_url is not None:
|
||||||
|
|
|
||||||
|
|
@ -439,6 +439,38 @@
|
||||||
player.addEventListener("pause", () => { media.playbackState = "paused"; });
|
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() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/nowplaying", { cache: "no-store" });
|
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;
|
document.title = t ? (a ? `${t} — ${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME;
|
||||||
updateMediaSession(t, a);
|
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 */ }
|
} catch (e) { /* keep last known values */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -197,10 +197,15 @@ radio = mksafe(radio)
|
||||||
now_playing = ref([])
|
now_playing = ref([])
|
||||||
history = ref([])
|
history = ref([])
|
||||||
history_max = 25
|
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(
|
radio.on_metadata(
|
||||||
synchronous=false,
|
synchronous=false,
|
||||||
fun(m) -> begin
|
fun(m) -> begin
|
||||||
now_playing := m
|
now_playing := m
|
||||||
|
scrobbled := false
|
||||||
# `file` : nom de base du fichier à l'antenne, servant de jeton de
|
# `file` : nom de base du fichier à l'antenne, servant de jeton de
|
||||||
# téléchargement (/download?file=…). Vide si la métadonnée manque.
|
# 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"])}
|
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",
|
port=8000, method="GET", "/nowplaying",
|
||||||
fun(_, resp) -> begin
|
fun(_, resp) -> begin
|
||||||
m = now_playing()
|
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
|
end
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue