radieo/stream/radio.liq
2026-07-03 10:15:25 +08:00

176 lines
6.4 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 ---
settings.harbor.bind_addrs := ["0.0.0.0"]
# 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
)
# fallback préfère la source principale et bascule sur le cache si elle n'a
# rien de prêt. track_sensitive=true : on ne coupe pas un morceau en cours.
radio = fallback(track_sensitive=true, [main, backup])
# 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
radio.on_metadata(
synchronous=false,
fun(m) -> begin
now_playing := m
entry = {title=m["title"], artist=m["artist"]}
head = list.hd(default={title="", artist=""}, 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
)
# --- Page web et API de lecture (mêmes port/harbor que le flux) ---
home_html = file.contents("/etc/liquidsoap/index.html")
harbor.http.register(
port=8000, method="GET", "/",
fun(_, resp) -> begin
resp.content_type("text/html; charset=utf-8")
resp.html(home_html)
end
)
harbor.http.register(
port=8000, method="GET", "/nowplaying",
fun(_, resp) -> begin
m = now_playing()
resp.json({title=m["title"], artist=m["artist"]})
end
)
# Historique des titres passés (le plus récent en tête, morceau courant inclus).
harbor.http.register(
port=8000, method="GET", "/history",
fun(_, resp) -> resp.json(history())
)
# Passer au morceau suivant : on saute le morceau en cours sur la source
# diffusée. request.dynamic a déjà préchargé le suivant, donc l'enchaînement
# est immédiat (le prochain /next est demandé au daemon dans la foulée).
harbor.http.register(
port=8000, method="POST", "/skip",
fun(_, resp) -> begin
source.skip(radio)
resp.json({skipped=true})
end
)
# Télécharger le fichier du morceau en cours. Le chemin vient de la métadonnée
# `filename` de la source diffusée : c'est bien le fichier à l'antenne, et il est
# forcément encore sur le disque tant qu'il joue (pas encore évincé par le LRU).
def content_type_of(name) =
low = string.case(lower=true, name)
if string.contains(suffix=".flac", low) then "audio/flac"
elsif string.contains(suffix=".ogg", low) then "audio/ogg"
elsif string.contains(suffix=".opus", low) then "audio/opus"
elsif string.contains(suffix=".m4a", low) or string.contains(suffix=".aac", low) then "audio/mp4"
elsif string.contains(suffix=".wav", low) then "audio/wav"
else "audio/mpeg"
end
end
harbor.http.register(
port=8000, method="GET", "/download",
fun(_, resp) -> begin
m = now_playing()
fname = m["filename"]
if fname == "" or not file.exists(fname) then
resp.status_code(404)
resp.data("no track currently available")
else
base = path.basename(fname)
resp.content_type(content_type_of(base))
resp.header("Content-Disposition", "attachment; filename=\"#{base}\"")
resp.data(file.contents(fname))
end
end
)