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
)