stream: split radio.liq into pipeline, web and ingest-proxy parts

Extract the HTTP surface out of radio.liq into two included files: web.liq
(static assets, PWA, local player API) and ingest_proxy.liq (relays to the
ingest daemon). radio.liq keeps only the streaming pipeline and ends with the
%include directives, evaluated after the pipeline so the handlers see radio,
now_playing, history, etc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-07-04 16:11:56 +08:00
commit 8054c98dd1
4 changed files with 311 additions and 292 deletions

View file

@ -1,6 +1,8 @@
FROM savonet/liquidsoap:v2.4.5
COPY radio.liq /etc/liquidsoap/radio.liq
COPY web.liq /etc/liquidsoap/web.liq
COPY ingest_proxy.liq /etc/liquidsoap/ingest_proxy.liq
COPY index.html /etc/liquidsoap/index.html
COPY favicon.svg /etc/liquidsoap/favicon.svg
COPY manifest.webmanifest /etc/liquidsoap/manifest.webmanifest

117
stream/ingest_proxy.liq Normal file
View file

@ -0,0 +1,117 @@
# radieo — reverse-proxy vers le daemon d'ingestion.
#
# Inclus par radio.liq. Le player n'a pas d'accès direct au réseau interne
# (l'ingest n'est joignable que depuis les autres conteneurs) : on relaie donc
# ces quelques endpoints à travers le harbor du flux (port 8000). Ces routes
# sont autonomes — elles ne dépendent que de `http`/`url`, jamais du pipeline —
# et renvoient une valeur neutre plutôt qu'une erreur si l'ingest est
# injoignable, pour ne pas casser le player.
# File d'attente des prochains morceaux, relayée depuis le daemon d'ingestion.
ingest_queue_url = "http://ingest:8080/queue"
harbor.http.register(
port=8000, method="GET", "/queue",
fun(_, resp) -> begin
resp.content_type("application/json; charset=utf-8")
body = http.get(ingest_queue_url, timeout=5.0)
if body.status_code == 200 then
resp.data(string.trim(body) ^ "\n")
else
resp.data("[]")
end
end
)
# État du préchargement {ready, prefetch}. Si le daemon est injoignable on
# renvoie un objet neutre plutôt qu'une erreur, pour ne pas casser le player.
ingest_status_url = "http://ingest:8080/status"
harbor.http.register(
port=8000, method="GET", "/ingest/status",
fun(_, resp) -> begin
resp.content_type("application/json; charset=utf-8")
body = http.get(ingest_status_url, timeout=5.0)
if body.status_code == 200 then
resp.data(string.trim(body) ^ "\n")
else
resp.data("{}")
end
end
)
# Mettre une URL yt-dlp en file d'attente (piste seule, ou playlist/album
# entier). On relaie la demande vers l'ingest, qui résout l'URL et la place en
# file prioritaire (le prochain /next la servira). On renvoie tel quel son code
# et son corps JSON ({queued: N} ou une erreur). Timeout large : résoudre une
# grosse playlist peut prendre du temps. NB : la variable locale s'appelle
# `link`, pas `url`, pour ne pas masquer le module `url` (url.encode).
ingest_enqueue_url = "http://ingest:8080/enqueue"
harbor.http.register(
port=8000, method="POST", "/enqueue",
fun(req, resp) -> begin
link = list.assoc(default="", "url", req.query)
if link == "" then
resp.status_code(400)
resp.data("missing url")
else
body = http.post(
data="", timeout=60.0, "#{ingest_enqueue_url}?url=#{url.encode(link)}"
)
resp.status_code(body.status_code)
resp.content_type("application/json; charset=utf-8")
resp.data(string.trim(body) ^ "\n")
end
end
)
# Retirer un morceau de la file d'attente. Symétrique de /enqueue : on relaie la
# demande (l'`id` opaque fourni par /queue) vers l'ingest, qui retire l'entrée
# correspondante. On renvoie tel quel son code et son corps JSON ({removed: true}
# ou une erreur).
ingest_dequeue_url = "http://ingest:8080/dequeue"
harbor.http.register(
port=8000, method="POST", "/dequeue",
fun(req, resp) -> begin
id = list.assoc(default="", "id", req.query)
if id == "" then
resp.status_code(400)
resp.data("missing id")
else
body = http.post(
data="", timeout=10.0, "#{ingest_dequeue_url}?id=#{url.encode(id)}"
)
resp.status_code(body.status_code)
resp.content_type("application/json; charset=utf-8")
resp.data(string.trim(body) ^ "\n")
end
end
)
# Partage Subsonic à la demande. Un morceau de la bibliothèque Subsonic n'a pas
# d'URL publique : son lien « source » pointe ici avec l'id du morceau. On
# demande alors à l'ingest (qui détient les identifiants Subsonic) de créer un
# partage public via createShare, puis on redirige l'auditeur vers l'URL
# renvoyée. Le partage n'est donc créé que si quelqu'un clique réellement sur le
# lien — jamais à chaque morceau joué. 404 si l'id manque, 502 si l'ingest ne
# peut pas partager (partage désactivé côté serveur, injoignable…).
ingest_share_url = "http://ingest:8080/share"
harbor.http.register(
port=8000, method="GET", "/share",
fun(req, resp) -> begin
song = list.assoc(default="", "song", req.query)
if song == "" then
resp.status_code(404)
resp.data("missing song id")
else
body = http.post(data="", timeout=10.0, "#{ingest_share_url}?id=#{url.encode(song)}")
share = json.parse(default={url=""}, string.trim(body))
if body.status_code == 200 and share.url != "" then
resp.status_code(302)
resp.header("Location", share.url)
resp.data("")
else
resp.status_code(502)
resp.data("share unavailable")
end
end
end
)

View file

@ -220,295 +220,10 @@ output.harbor(
radio
)
# --- Page web et API de lecture (mêmes port/harbor que le flux) ---
home_html = file.contents("/etc/liquidsoap/index.html")
favicon_svg = file.contents("/etc/liquidsoap/favicon.svg")
# Ressources de la webapp installable (PWA) : manifeste, service worker et jeu
# d'icônes. Chargées une fois au démarrage puis servies telles quelles.
manifest_json = file.contents("/etc/liquidsoap/manifest.webmanifest")
sw_js = file.contents("/etc/liquidsoap/sw.js")
icon_192 = file.contents("/etc/liquidsoap/icon-192.png")
icon_512 = file.contents("/etc/liquidsoap/icon-512.png")
icon_maskable_512 = file.contents("/etc/liquidsoap/icon-maskable-512.png")
apple_touch_icon = file.contents("/etc/liquidsoap/apple-touch-icon.png")
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", "/favicon.svg",
fun(_, resp) -> begin
resp.content_type("image/svg+xml; charset=utf-8")
resp.data(favicon_svg)
end
)
harbor.http.register(
port=8000, method="GET", "/manifest.webmanifest",
fun(_, resp) -> begin
resp.content_type("application/manifest+json; charset=utf-8")
resp.data(manifest_json)
end
)
# Le service worker doit être servi depuis la racine pour couvrir tout le site
# (portée par défaut = son emplacement). Pas de cache HTTP agressif : le
# navigateur revérifie régulièrement le script.
harbor.http.register(
port=8000, method="GET", "/sw.js",
fun(_, resp) -> begin
resp.content_type("text/javascript; charset=utf-8")
resp.header("Cache-Control", "no-cache")
resp.data(sw_js)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-192.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_192)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-512.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_512)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-maskable-512.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_maskable_512)
end
)
harbor.http.register(
port=8000, method="GET", "/apple-touch-icon.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(apple_touch_icon)
end
)
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"]})
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())
)
# File d'attente des prochains morceaux, relayée depuis le daemon d'ingestion
# (le player n'a pas d'accès direct au réseau interne). Comme /ingest/status, on
# renvoie une valeur neutre — ici une liste vide — si le daemon est injoignable,
# pour ne pas casser le player.
ingest_queue_url = "http://ingest:8080/queue"
harbor.http.register(
port=8000, method="GET", "/queue",
fun(_, resp) -> begin
resp.content_type("application/json; charset=utf-8")
body = http.get(ingest_queue_url, timeout=5.0)
if body.status_code == 200 then
resp.data(string.trim(body) ^ "\n")
else
resp.data("[]")
end
end
)
# État du préchargement, relayé depuis le daemon d'ingestion (reverse proxy) :
# le player n'a pas accès direct au réseau interne, on lui expose donc l'info
# {ready, prefetch} via le même harbor que le flux. Si le daemon est injoignable
# on renvoie un objet neutre plutôt qu'une erreur, pour ne pas casser le player.
ingest_status_url = "http://ingest:8080/status"
harbor.http.register(
port=8000, method="GET", "/ingest/status",
fun(_, resp) -> begin
resp.content_type("application/json; charset=utf-8")
body = http.get(ingest_status_url, timeout=5.0)
if body.status_code == 200 then
resp.data(string.trim(body) ^ "\n")
else
resp.data("{}")
end
end
)
# Mettre une URL yt-dlp en file d'attente (piste seule, ou playlist/album
# entier). Le player n'a pas accès au réseau interne : on relaie la demande vers
# l'ingest, qui résout l'URL et la place en file prioritaire (le prochain /next
# la servira). On renvoie tel quel son code et son corps JSON ({queued: N} ou
# une erreur). Timeout large : résoudre une grosse playlist peut prendre du
# temps. NB : la variable locale s'appelle `link`, pas `url`, pour ne pas
# masquer le module `url` (url.encode).
ingest_enqueue_url = "http://ingest:8080/enqueue"
harbor.http.register(
port=8000, method="POST", "/enqueue",
fun(req, resp) -> begin
link = list.assoc(default="", "url", req.query)
if link == "" then
resp.status_code(400)
resp.data("missing url")
else
body = http.post(
data="", timeout=60.0, "#{ingest_enqueue_url}?url=#{url.encode(link)}"
)
resp.status_code(body.status_code)
resp.content_type("application/json; charset=utf-8")
resp.data(string.trim(body) ^ "\n")
end
end
)
# Retirer un morceau de la file d'attente. Symétrique de /enqueue : le player
# n'a pas accès au réseau interne, on relaie donc la demande (l'`id` opaque
# fourni par /queue) vers l'ingest, qui retire l'entrée correspondante. On
# renvoie tel quel son code et son corps JSON ({removed: true} ou une erreur).
ingest_dequeue_url = "http://ingest:8080/dequeue"
harbor.http.register(
port=8000, method="POST", "/dequeue",
fun(req, resp) -> begin
id = list.assoc(default="", "id", req.query)
if id == "" then
resp.status_code(400)
resp.data("missing id")
else
body = http.post(
data="", timeout=10.0, "#{ingest_dequeue_url}?id=#{url.encode(id)}"
)
resp.status_code(body.status_code)
resp.content_type("application/json; charset=utf-8")
resp.data(string.trim(body) ^ "\n")
end
end
)
# 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
)
# Rejouer le morceau courant depuis le début, pour TOUTE l'antenne (le flux est
# partagé : il n'existe pas de position par auditeur). On repousse le fichier
# courant — pris dans le cache, donc forcément local et présent — en tête via la
# file de rejeu, puis on saute le morceau en cours pour l'y enchaîner aussitôt.
# On remet le compteur de chansons à zéro pour qu'un jingle ne vole pas la place
# du rejeu à cette frontière. Un morceau introuvable (jingle en cours, cache
# évincé) renvoie 409.
harbor.http.register(
port=8000, method="POST", "/restart-track",
fun(_, resp) -> begin
base = path.basename(list.assoc(default="", "filename", now_playing()))
full = "/cache/#{base}"
if base != "" and file.exists(full) then
song_count := 0
requeue.push.uri(full)
source.skip(radio)
resp.json({restarted=true})
else
resp.status_code(409)
resp.json({restarted=false})
end
end
)
# Télécharger un morceau. Sans paramètre : le titre en cours. Avec `?file=<nom>` :
# n'importe quel fichier encore présent dans le cache (les titres passés listés
# par /history exposent ce jeton). Le cache étant borné par le LRU, un morceau
# évincé renvoie simplement 404.
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
# Sert un fichier en pièce jointe après validation. `path.basename` neutralise
# toute tentative de remontée de répertoire (…/…) ; on n'autorise que de vrais
# fichiers audio du cache, jamais les fichiers cachés (.part en cours, .gitkeep).
def serve_attachment(resp, name) =
base = path.basename(name)
low = string.case(lower=true, base)
is_audio = list.exists(fun(e) -> string.contains(suffix=e, low), audio_ext)
full = "/cache/#{base}"
if base == "" or string.contains(prefix=".", base) or not is_audio
or not file.exists(full) then
resp.status_code(404)
resp.data("track not available")
else
resp.content_type(content_type_of(base))
resp.header("Content-Disposition", "attachment; filename=\"#{base}\"")
resp.data(file.contents(full))
end
end
harbor.http.register(
port=8000, method="GET", "/download",
fun(req, resp) -> begin
requested = list.assoc(default="", "file", req.query)
name =
if requested != "" then
requested
else
list.assoc(default="", "filename", now_playing())
end
serve_attachment(resp, name)
end
)
# Partage Subsonic à la demande. Un morceau de la bibliothèque Subsonic n'a pas
# d'URL publique : son lien « source » pointe ici avec l'id du morceau. On
# demande alors à l'ingest (qui détient les identifiants Subsonic) de créer un
# partage public via createShare, puis on redirige l'auditeur vers l'URL
# renvoyée. Le partage n'est donc créé que si quelqu'un clique réellement sur le
# lien — jamais à chaque morceau joué. 404 si l'id manque, 502 si l'ingest ne
# peut pas partager (partage désactivé côté serveur, injoignable…).
ingest_share_url = "http://ingest:8080/share"
harbor.http.register(
port=8000, method="GET", "/share",
fun(req, resp) -> begin
song = list.assoc(default="", "song", req.query)
if song == "" then
resp.status_code(404)
resp.data("missing song id")
else
body = http.post(data="", timeout=10.0, "#{ingest_share_url}?id=#{url.encode(song)}")
share = json.parse(default={url=""}, string.trim(body))
if body.status_code == 200 and share.url != "" then
resp.status_code(302)
resp.header("Location", share.url)
resp.data("")
else
resp.status_code(502)
resp.data("share unavailable")
end
end
end
)
# --- 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"

185
stream/web.liq Normal file
View file

@ -0,0 +1,185 @@
# radieo — couche web : ressources statiques et API de lecture locale.
#
# Inclus par radio.liq APRÈS la définition du pipeline : ce fichier ne fait
# qu'enregistrer des routes sur le harbor du flux (port 8000) et s'appuie sur les
# symboles du pipeline (`radio`, `now_playing`, `history`, `requeue`,
# `song_count`, `audio_ext`). Les routes qui relaient le daemon d'ingestion sont
# à part, dans ingest_proxy.liq.
# --- Page web et API de lecture (mêmes port/harbor que le flux) ---
home_html = file.contents("/etc/liquidsoap/index.html")
favicon_svg = file.contents("/etc/liquidsoap/favicon.svg")
# Ressources de la webapp installable (PWA) : manifeste, service worker et jeu
# d'icônes. Chargées une fois au démarrage puis servies telles quelles.
manifest_json = file.contents("/etc/liquidsoap/manifest.webmanifest")
sw_js = file.contents("/etc/liquidsoap/sw.js")
icon_192 = file.contents("/etc/liquidsoap/icon-192.png")
icon_512 = file.contents("/etc/liquidsoap/icon-512.png")
icon_maskable_512 = file.contents("/etc/liquidsoap/icon-maskable-512.png")
apple_touch_icon = file.contents("/etc/liquidsoap/apple-touch-icon.png")
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", "/favicon.svg",
fun(_, resp) -> begin
resp.content_type("image/svg+xml; charset=utf-8")
resp.data(favicon_svg)
end
)
harbor.http.register(
port=8000, method="GET", "/manifest.webmanifest",
fun(_, resp) -> begin
resp.content_type("application/manifest+json; charset=utf-8")
resp.data(manifest_json)
end
)
# Le service worker doit être servi depuis la racine pour couvrir tout le site
# (portée par défaut = son emplacement). Pas de cache HTTP agressif : le
# navigateur revérifie régulièrement le script.
harbor.http.register(
port=8000, method="GET", "/sw.js",
fun(_, resp) -> begin
resp.content_type("text/javascript; charset=utf-8")
resp.header("Cache-Control", "no-cache")
resp.data(sw_js)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-192.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_192)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-512.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_512)
end
)
harbor.http.register(
port=8000, method="GET", "/icon-maskable-512.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(icon_maskable_512)
end
)
harbor.http.register(
port=8000, method="GET", "/apple-touch-icon.png",
fun(_, resp) -> begin
resp.content_type("image/png")
resp.data(apple_touch_icon)
end
)
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"]})
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
)
# Rejouer le morceau courant depuis le début, pour TOUTE l'antenne (le flux est
# partagé : il n'existe pas de position par auditeur). On repousse le fichier
# courant — pris dans le cache, donc forcément local et présent — en tête via la
# file de rejeu, puis on saute le morceau en cours pour l'y enchaîner aussitôt.
# On remet le compteur de chansons à zéro pour qu'un jingle ne vole pas la place
# du rejeu à cette frontière. Un morceau introuvable (jingle en cours, cache
# évincé) renvoie 409.
harbor.http.register(
port=8000, method="POST", "/restart-track",
fun(_, resp) -> begin
base = path.basename(list.assoc(default="", "filename", now_playing()))
full = "/cache/#{base}"
if base != "" and file.exists(full) then
song_count := 0
requeue.push.uri(full)
source.skip(radio)
resp.json({restarted=true})
else
resp.status_code(409)
resp.json({restarted=false})
end
end
)
# Télécharger un morceau. Sans paramètre : le titre en cours. Avec `?file=<nom>` :
# n'importe quel fichier encore présent dans le cache (les titres passés listés
# par /history exposent ce jeton). Le cache étant borné par le LRU, un morceau
# évincé renvoie simplement 404.
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
# Sert un fichier en pièce jointe après validation. `path.basename` neutralise
# toute tentative de remontée de répertoire (…/…) ; on n'autorise que de vrais
# fichiers audio du cache, jamais les fichiers cachés (.part en cours, .gitkeep).
def serve_attachment(resp, name) =
base = path.basename(name)
low = string.case(lower=true, base)
is_audio = list.exists(fun(e) -> string.contains(suffix=e, low), audio_ext)
full = "/cache/#{base}"
if base == "" or string.contains(prefix=".", base) or not is_audio
or not file.exists(full) then
resp.status_code(404)
resp.data("track not available")
else
resp.content_type(content_type_of(base))
resp.header("Content-Disposition", "attachment; filename=\"#{base}\"")
resp.data(file.contents(full))
end
end
harbor.http.register(
port=8000, method="GET", "/download",
fun(req, resp) -> begin
requested = list.assoc(default="", "file", req.query)
name =
if requested != "" then
requested
else
list.assoc(default="", "filename", now_playing())
end
serve_attachment(resp, name)
end
)