#!/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://: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 )