diff --git a/.env.example b/.env.example index d8d543e..d4f153e 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index e330b68..603e5a3 100644 --- a/README.md +++ b/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 ) to scrobble what you listen to: + +``` +RADIEO_LISTENBRAINZ_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 diff --git a/docker-compose.yml b/docker-compose.yml index 8fd80b7..5f89506 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/ingest/radieo/api.py b/ingest/radieo/api.py index 8816018..66ecca3 100644 --- a/ingest/radieo/api.py +++ b/ingest/radieo/api.py @@ -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: diff --git a/stream/index.html b/stream/index.html index 351cf3f..7c66b34 100644 --- a/stream/index.html +++ b/stream/index.html @@ -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 */ } } diff --git a/stream/radio.liq b/stream/radio.liq index 1d18bb7..12d5bb3 100644 --- a/stream/radio.liq +++ b/stream/radio.liq @@ -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"])} diff --git a/stream/web.liq b/stream/web.liq index 0dfccf5..bbe017e 100644 --- a/stream/web.liq +++ b/stream/web.liq @@ -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=[&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 )