radieo/stream/radio.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

234 lines
9.9 KiB
Text

#!/usr/bin/liquidsoap
# radieo — couche diffusion.
# La source principale est pilotée par le daemon d'ingestion via GET /next.
# Le dossier /cache sert de secours quand le daemon n'a rien à proposer
# (daemon indisponible, file momentanément vide…). Si tout est vide : silence.
# Le harbor sert aussi une petite page web (/) et le titre courant (/nowplaying).
# --- Journalisation : tout sur la sortie standard (pratique en conteneur) ---
settings.log.stdout := true
settings.log.file := false
settings.log.level := 3
# --- Harbor : écoute sur toutes les interfaces du conteneur (IPv4 + IPv6) ---
# `::` seul suffit : sous Linux (bindv6only=0 par défaut) la socket IPv6 accepte
# aussi les clients IPv4 (adresses mappées). Ajouter "0.0.0.0" en plus ferait
# double bind sur le même port IPv4 → EADDRINUSE.
settings.harbor.bind_addrs := ["::"]
# URL du daemon d'ingestion (nom de service résolu par docker-compose).
ingest_url = "http://ingest:8080/next"
# Callback appelé par request.dynamic pour obtenir le prochain morceau.
# Renvoie une requête à jouer, ou null si rien n'est disponible (→ secours).
def next_track() =
resp = http.get(ingest_url, timeout=5.0)
body = string.trim(resp)
if resp.status_code == 200 and body != "" then
request.create(body)
else
null
end
end
# Source principale : pilotée par le daemon. prefetch=1 pour anticiper le
# prochain morceau ; retry_delay pour ne pas marteler le daemon en cas de vide.
main = request.dynamic(next_track, prefetch=1, retry_delay=1.0)
# Filtre du secours : ne garder que les vrais fichiers audio et ignorer les
# fichiers cachés (.gitkeep, téléchargements .part en cours). Ceinture et
# bretelles : la m3u ne liste déjà que des fichiers audio réels.
audio_ext = [".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wav"]
def audio_only(r) =
u = string.case(lower=true, request.uri(r))
base = path.basename(u)
is_audio = list.exists(fun(e) -> string.contains(suffix=e, u), audio_ext)
is_audio and not string.contains(prefix=".", base)
end
# Secours : uniquement les morceaux DÉJÀ passés à l'antenne. Le daemon les
# expose en m3u (/fallback.m3u) ; on la récupère périodiquement dans un fichier
# LOCAL que playlist surveille. Le buffer de préchargement (morceaux pas encore
# diffusés) en est exclu : au démarrage à froid la liste est vide → silence
# assumé plutôt qu'une boucle sur 2-3 titres.
# Passer par un fichier local (plutôt qu'une URL directe dans playlist) évite la
# détection de type hasardeuse du résolveur http, et garde la dernière liste
# valide si l'ingest devient injoignable.
fallback_url = "http://ingest:8080/fallback.m3u"
fallback_file = "/tmp/fallback.m3u"
file.write(data="#EXTM3U\n", atomic=true, fallback_file) # amorce vide au boot
def refresh_fallback() =
resp = http.get(fallback_url, timeout=5.0)
if resp.status_code == 200 then
file.write(data=resp, atomic=true, fallback_file)
end
end
thread.run(fast=false, every={30.}, refresh_fallback)
backup = playlist(
mode="randomize", reload_mode="watch", mime_type="audio/x-mpegurl",
check_next=audio_only, fallback_file
)
# Les morceaux du secours sont rejoués depuis le cache local (déjà diffusés) et
# n'ont pas d'annotation d'origine. On les étiquette explicitement pour que le
# player affiche « via le cache local » plutôt que rien quand on retombe sur ce
# filet (ingest injoignable, file vide pendant le préchargement…).
backup = metadata.map(fun(_) -> [("origin", "cache")], backup)
# File de rejeu ponctuel : normalement vide (donc non prête, transparente). Le
# endpoint /restart-track y pousse le morceau courant pour le rejouer depuis le
# début à l'antenne. Placée en tête du fallback, elle préempte la source
# principale dès qu'un morceau y est poussé.
requeue = request.queue(id="requeue")
# fallback préfère la file de rejeu, puis la source principale, et bascule sur le
# cache si rien n'est prêt. track_sensitive=true : on ne coupe pas un morceau en
# cours (le rejeu ne prend donc effet qu'à la prochaine frontière, provoquée
# explicitement par source.skip dans /restart-track).
music = fallback(track_sensitive=true, [requeue, main, backup])
# --- Jingles : intercalés toutes les 2 chansons -----------------------------
# Le dossier /jingles (monté depuis ./jingles) est parcouru et lu au hasard.
# reload_mode="watch" : un jingle ajouté/retiré est pris en compte à chaud.
# Dossier vide → source jamais prête → le switch retombe simplement sur la
# musique, sans jingle et sans plantage.
# ATTENTION : playlist explore récursivement les sous-dossiers. Pour la rotation
# par défaut on ne veut QUE les fichiers directement dans /jingles ; les
# sous-dossiers spéciaux (midi, gouter, moment) sont gérés à part. On
# filtre donc sur le dossier parent en plus du test audio habituel.
def jingle_top_level(r) =
audio_only(r) and path.dirname(request.uri(r)) == "/jingles"
end
jingles = playlist(
mode="randomize", reload_mode="watch", check_next=jingle_top_level, "/jingles"
)
# Jingles spéciaux, joués une seule fois juste après une heure donnée (au
# prochain jingle qui suit ce moment), plutôt que le jingle par défaut.
jingles_midi = playlist(
mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles/midi"
)
jingles_gouter = playlist(
mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles/gouter"
)
jingles_moment = playlist(
mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles/moment"
)
# Fenêtre (en minutes) après l'heure cible pendant laquelle le jingle spécial
# reste éligible : assez courte pour rester "juste après", assez large pour
# laisser passer au moins une occasion de jingle (toutes les ~2 chansons).
special_jingle_window = 10
# Identifiant du jour courant (année * 366 + jour de l'année), pour ne
# déclencher chaque créneau spécial qu'une seule fois par jour.
def day_key() =
t = time.local()
t.year * 1000 + t.year_day
end
# Construit un couple (prédicat, source) pour le switch : le prédicat devient
# vrai une seule fois par jour, à partir de hour:minute et pendant
# special_jingle_window minutes.
def make_special_slot(hour, minute, jingle_source) =
last_day = ref(-1)
slot_minutes = hour * 60 + minute
def due() =
t = time.local()
now_minutes = t.hour * 60 + t.min
in_window = now_minutes >= slot_minutes and now_minutes < slot_minutes + special_jingle_window
if in_window and last_day() != day_key() then
last_day := day_key()
true
else
false
end
end
(due, jingle_source)
end
special_jingle_slots = [
make_special_slot(11, 0, jingles_midi),
make_special_slot(15, 0, jingles_moment),
make_special_slot(16, 30, jingles_gouter),
make_special_slot(21, 0, jingles_moment),
]
# On compte les morceaux de musique réellement diffusés : on_track ne se
# déclenche que sur la source effectivement tirée par le switch (les sources
# non sélectionnées ne sont pas consommées), donc les jingles ne comptent pas.
song_count = ref(0)
music.on_track(synchronous=false, fun(_) -> song_count := song_count() + 1)
# Prédicat du switch : vrai quand 2 chansons ont été diffusées depuis le dernier
# jingle. Il remet le compteur à zéro au passage pour repartir sur un cycle neuf.
def time_for_jingle() =
if song_count() >= 2 then
song_count := 0
true
else
false
end
end
# switch track_sensitive : la décision est prise aux frontières de morceaux, on
# ne coupe donc jamais un titre. Si les jingles ne sont pas prêts (dossier vide),
# le switch enchaîne directement sur la musique. Les créneaux spéciaux sont
# testés en premier : un jingle "juste après" une heure donnée prend le pas sur
# le cycle normal des 2 chansons, une seule fois par jour.
radio = switch(
track_sensitive=true,
list.append(special_jingle_slots, [(time_for_jingle, jingles), ({true}, music)])
)
# Transition douce entre les morceaux : fondu enchaîné de 3 s. La fin du
# morceau courant se fond dans le début du suivant.
radio = crossfade(duration=3.0, fade_in=3.0, fade_out=3.0, radio)
# mksafe garantit un flux continu : silence plutôt que plantage si tout est vide.
radio = mksafe(radio)
# --- Métadonnées « en cours de lecture » -----------------------------------
# On mémorise les dernières métadonnées vues sur le flux réellement diffusé,
# ainsi qu'un historique borné des titres passés à l'antenne (le plus récent
# en tête). L'historique reflète ce qui a vraiment été diffusé.
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"])}
head = list.hd(default={title="", artist="", url="", origin="", file=""}, history())
is_dup = head.title == entry.title and head.artist == entry.artist
if not is_dup and (entry.title != "" or entry.artist != "") then
history := list.prefix(history_max, list.add(entry, history()))
end
end
)
# --- Sortie : flux MP3 sur http://<hote>:8000/radio.mp3 ---
output.harbor(
%mp3(bitrate=192),
port=8000,
mount="radio.mp3",
radio
)
# --- Surface HTTP (mêmes port/harbor que le flux) ---------------------------
# Enregistrée APRÈS le pipeline pour que les handlers voient `radio`,
# `now_playing`, `history`, etc. Deux volets séparés :
# - web.liq : page, PWA/assets statiques, API de lecture locale ;
# - ingest_proxy.liq : relais des endpoints du daemon d'ingestion.
%include "web.liq"
%include "ingest_proxy.liq"