#!/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 (IPv4 + IPv6) --- # `::` seul suffit : sous Linux (bindv6only=0 par défaut) la socket IPv6 accepte # aussi les clients IPv4 (adresses mappées). Ajouter "0.0.0.0" en plus ferait # double bind sur le même port IPv4 → EADDRINUSE. settings.harbor.bind_addrs := ["::"] # 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 ) # Les morceaux du secours sont rejoués depuis le cache local (déjà diffusés) et # n'ont pas d'annotation d'origine. On les étiquette explicitement pour que le # player affiche « via le cache local » plutôt que rien quand on retombe sur ce # filet (ingest injoignable, file vide pendant le préchargement…). backup = metadata.map(fun(_) -> [("origin", "cache")], backup) # File de rejeu ponctuel : normalement vide (donc non prête, transparente). Le # endpoint /restart-track y pousse le morceau courant pour le rejouer depuis le # début à l'antenne. Placée en tête du fallback, elle préempte la source # principale dès qu'un morceau y est poussé. requeue = request.queue(id="requeue") # fallback préfère la file de rejeu, puis la source principale, et bascule sur le # cache si rien n'est prêt. track_sensitive=true : on ne coupe pas un morceau en # cours (le rejeu ne prend donc effet qu'à la prochaine frontière, provoquée # explicitement par source.skip dans /restart-track). music = fallback(track_sensitive=true, [requeue, main, backup]) # --- Jingles : intercalés toutes les 2 chansons ----------------------------- # Le dossier /jingles (monté depuis ./jingles) est parcouru et lu au hasard. # reload_mode="watch" : un jingle ajouté/retiré est pris en compte à chaud. # Dossier vide → source jamais prête → le switch retombe simplement sur la # musique, sans jingle et sans plantage. # ATTENTION : playlist explore récursivement les sous-dossiers. Pour la rotation # par défaut on ne veut QUE les fichiers directement dans /jingles ; les # sous-dossiers spéciaux (midi, gouter, moment) sont gérés à part. On # filtre donc sur le dossier parent en plus du test audio habituel. def jingle_top_level(r) = audio_only(r) and path.dirname(request.uri(r)) == "/jingles" end jingles = playlist( mode="randomize", reload_mode="watch", check_next=jingle_top_level, "/jingles" ) # Jingles spéciaux, joués une seule fois juste après une heure donnée (au # prochain jingle qui suit ce moment), plutôt que le jingle par défaut. jingles_midi = playlist( mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles/midi" ) jingles_gouter = playlist( mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles/gouter" ) jingles_moment = playlist( mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles/moment" ) # Fenêtre (en minutes) après l'heure cible pendant laquelle le jingle spécial # reste éligible : assez courte pour rester "juste après", assez large pour # laisser passer au moins une occasion de jingle (toutes les ~2 chansons). special_jingle_window = 10 # Identifiant du jour courant (année * 366 + jour de l'année), pour ne # déclencher chaque créneau spécial qu'une seule fois par jour. def day_key() = t = time.local() t.year * 1000 + t.year_day end # Construit un couple (prédicat, source) pour le switch : le prédicat devient # vrai une seule fois par jour, à partir de hour:minute et pendant # special_jingle_window minutes. def make_special_slot(hour, minute, jingle_source) = last_day = ref(-1) slot_minutes = hour * 60 + minute def due() = t = time.local() now_minutes = t.hour * 60 + t.min in_window = now_minutes >= slot_minutes and now_minutes < slot_minutes + special_jingle_window if in_window and last_day() != day_key() then last_day := day_key() true else false end end (due, jingle_source) end special_jingle_slots = [ make_special_slot(11, 0, jingles_midi), make_special_slot(15, 0, jingles_moment), make_special_slot(16, 30, jingles_gouter), make_special_slot(21, 0, jingles_moment), ] # On compte les morceaux de musique réellement diffusés : on_track ne se # déclenche que sur la source effectivement tirée par le switch (les sources # non sélectionnées ne sont pas consommées), donc les jingles ne comptent pas. song_count = ref(0) music.on_track(synchronous=false, fun(_) -> song_count := song_count() + 1) # Prédicat du switch : vrai quand 2 chansons ont été diffusées depuis le dernier # jingle. Il remet le compteur à zéro au passage pour repartir sur un cycle neuf. def time_for_jingle() = if song_count() >= 2 then song_count := 0 true else false end end # switch track_sensitive : la décision est prise aux frontières de morceaux, on # ne coupe donc jamais un titre. Si les jingles ne sont pas prêts (dossier vide), # le switch enchaîne directement sur la musique. Les créneaux spéciaux sont # testés en premier : un jingle "juste après" une heure donnée prend le pas sur # le cycle normal des 2 chansons, une seule fois par jour. radio = switch( track_sensitive=true, list.append(special_jingle_slots, [(time_for_jingle, jingles), ({true}, music)]) ) # 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 # `file` : nom de base du fichier à l'antenne, servant de jeton de # téléchargement (/download?file=…). Vide si la métadonnée manque. entry = {title=m["title"], artist=m["artist"], url=m["url"], origin=m["origin"], file=path.basename(m["filename"])} head = list.hd(default={title="", artist="", url="", origin="", file=""}, 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 ) # --- 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"