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:
parent
c73b71d32f
commit
8054c98dd1
4 changed files with 311 additions and 292 deletions
185
stream/web.liq
Normal file
185
stream/web.liq
Normal 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
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue