diff --git a/stream/Dockerfile b/stream/Dockerfile index af8f79a..2d8c9c6 100644 --- a/stream/Dockerfile +++ b/stream/Dockerfile @@ -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 diff --git a/stream/ingest_proxy.liq b/stream/ingest_proxy.liq new file mode 100644 index 0000000..351e107 --- /dev/null +++ b/stream/ingest_proxy.liq @@ -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 +) diff --git a/stream/radio.liq b/stream/radio.liq index 92a7044..1d18bb7 100644 --- a/stream/radio.liq +++ b/stream/radio.liq @@ -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=` : -# 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" diff --git a/stream/web.liq b/stream/web.liq new file mode 100644 index 0000000..0dfccf5 --- /dev/null +++ b/stream/web.liq @@ -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=` : +# 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 +)