# 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() # `file` : jeton du morceau courant (nom de base), que le player renvoie à # /scrobble pour prouver qu'il parle bien du titre à l'antenne. # `duration`/`position` (secondes, arrondies) permettent au player de décider # d'un scrobble « écouté à 90 %, démarré au début ». duration=0 si inconnue. # Ces valeurs sont pré-calculées dans le thread d'horloge (voir cur_duration/ # cur_started dans radio.liq) : on ne doit JAMAIS appeler source.duration/ # source.elapsed ici, ça figerait l'antenne pendant un crossfade. resp.json({ title=m["title"], artist=m["artist"], url=m["url"], origin=m["origin"], file=path.basename(m["filename"]), duration=int_of_float(cur_duration()), position=int_of_float(time() - cur_started()) }) end ) # --- Scrobbling ListenBrainz (déclenché par le player) ---------------------- # Le player décide QUAND un morceau est vraiment « écouté » (démarré au début et # entendu à ~90 %) et poste ici. Le token reste côté serveur (jamais exposé au # navigateur) : on lit RADIEO_LISTENBRAINZ_TOKEN dans l'environnement. Vide = # fonction désactivée. On soumet vers ListenBrainz avec le MBID canonique quand # l'ingest en a trouvé un (métadonnée musicbrainz_trackid), sinon artiste+titre. listenbrainz_submit_url = "https://api.listenbrainz.org/1/submit-listens" # Envoie un « listen » (single) ou un « playing_now » pour les métadonnées `m`. # json.stringify(s) échappe proprement une chaîne (guillemets, unicode…). def submit_listen(playing_now, m) = token = environment.get(default="", "RADIEO_LISTENBRAINZ_TOKEN") title = m["title"] artist = m["artist"] mbid = m["musicbrainz_trackid"] if token != "" and title != "" and artist != "" then info = "\"submission_client\":\"radieo\"" info = if mbid != "" then "#{info},\"recording_mbid\":#{json.stringify(mbid)}" else info end track_meta = "{\"artist_name\":#{json.stringify(artist)},\"track_name\":#{json.stringify(title)},\"additional_info\":{#{info}}}" body = if playing_now then "{\"listen_type\":\"playing_now\",\"payload\":[{\"track_metadata\":#{track_meta}}]}" else "{\"listen_type\":\"single\",\"payload\":[{\"listened_at\":#{int_of_float(time())},\"track_metadata\":#{track_meta}}]}" end ignore(http.post( data=body, timeout=8.0, headers=[("Authorization", "Token #{token}"), ("Content-Type", "application/json")], listenbrainz_submit_url )) end end # POST /scrobble?file=[&type=playing_now] # - file doit correspondre au titre courant (sinon 409 : le titre a changé) ; # - un titre sans artiste/titre (jingle, inconnu) est ignoré silencieusement ; # - type=playing_now : « en cours d'écoute », renvoyé à chaque nouveau titre ; # - sinon (single) : scrobble définitif, dédupliqué par le flag `scrobbled` # pour ne compter le passage qu'une fois même si plusieurs auditeurs postent. harbor.http.register( port=8000, method="POST", "/scrobble", fun(req, resp) -> begin m = now_playing() cur = path.basename(m["filename"]) want = list.assoc(default="", "file", req.query) kind = list.assoc(default="single", "type", req.query) playing_now = kind == "playing_now" if environment.get(default="", "RADIEO_LISTENBRAINZ_TOKEN") == "" then resp.json({scrobbled=false, reason="disabled"}) elsif want == "" or want != cur then resp.status_code(409) resp.json({scrobbled=false, reason="stale"}) elsif m["title"] == "" or m["artist"] == "" then resp.json({scrobbled=false, reason="ignored"}) elsif playing_now then submit_listen(true, m) resp.json({scrobbled=true, playing_now=true}) elsif scrobbled() then resp.json({scrobbled=false, reason="duplicate"}) else scrobbled := true submit_listen(false, m) resp.json({scrobbled=true}) end 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 )