The fallback played the whole /cache directory, which at cold start holds only the 2-3 tracks being pre-fetched — so it looped them until the request.dynamic buffer filled. Restrict the fallback to tracks already aired: the ingest daemon exposes them at GET /fallback.m3u (played_at set, still on disk), and the stream fetches that into a local /tmp/fallback.m3u that playlist watches. Cold start is now silent (assumed) instead of a tight loop, and a mid-stream drain degrades across the whole listening history. A local file (not a remote playlist URL) is used to avoid Liquidsoap's http resolver mis-sniffing the response as text/html; mime_type is forced so an empty header-only m3u still parses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
4.2 KiB
Text
113 lines
4.2 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é.
|
|
now_playing = ref([])
|
|
radio.on_metadata(synchronous=false, fun(m) -> now_playing := m)
|
|
|
|
# --- 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
|
|
)
|