diff --git a/README.md b/README.md index db7aae0..ab62d10 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,10 @@ simultaneous listeners), not for public broadcasting. jingles (noon, snack time, ...) played once when their time comes. - **Smooth playback**: a 3 s crossfade between tracks. - **Built-in web player** at `http://localhost:8000/`: now playing (linked to - its source page), track history, skip button, per-track download, volume - memory, live auto-reconnect, prefetch progress, and a synthwave look. + its source page — a Bandcamp/YouTube page for yt-dlp tracks, or an on-demand + Subsonic share for library tracks), track history and the upcoming queue, + skip/restart controls, per-track download, volume memory, live auto-reconnect, + prefetch progress, and a synthwave look. - **OS media controls**: the player exposes current track metadata and play/pause/next through the Media Session API, so it wires into system media controls (MPRIS on Linux, macOS Control Center, Windows, mobile lock screen) @@ -68,7 +70,9 @@ Fill in `.env`: - **OpenSubsonic server**: `RADIEO_SUBSONIC_URL` / `USER` / `PASSWORD` and the playlist to broadcast in `RADIEO_SUBSONIC_PLAYLIST` (name or id). Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Leave empty - to disable this source. + to disable this source. To make the player's "source" link work for library + tracks, enable sharing on the server (Navidrome: `ND_ENABLESHARING=true`); the + link then mints a public share on demand, only when clicked. - **Mix**: `RADIEO_WEIGHT_SUBSONIC` / `RADIEO_WEIGHT_YTDLP` / `RADIEO_WEIGHT_LISTENBRAINZ` set the relative draw weight of each source (`0` disables one). @@ -137,14 +141,17 @@ time, and serves it over an internal HTTP API: - A *prefetch queue* keeps a few ready tracks; a SQLite database under `state/` holds play history, the MBID cache, and LRU cache-file retention. - API: `GET /next` (annotated Liquidsoap URI), `/fallback.m3u` (already-aired - tracks), `/status` (prefetch progress), `/healthz`. + tracks), `/status` (prefetch progress), `/queue` (upcoming tracks), + `/healthz`, and `POST /share?id=` (mint a Subsonic share on demand). **`stream`** (Liquidsoap) pulls `/next` via `request.dynamic`, inserts jingles (a `switch` that also handles the time-of-day slots), applies the crossfade and `mksafe`, and outputs the MP3. On the same harbor port 8000 it also serves the -web player and its API: `/nowplaying`, `/history`, `/skip`, `/download`, plus -`/ingest/status`. +web player and its API: `/nowplaying`, `/history`, `/skip`, `/restart-track`, +`/download`, and — proxied from `ingest` — `/queue`, `/ingest/status`, and +`/share` (which forwards to `ingest`, then redirects to the created share). Only port **8000** is published to the host. The browser never talks to `ingest` directly — the Liquidsoap harbor acts as a small reverse proxy for the data the -player needs (e.g. `/ingest/status`), keeping everything on a single origin. +player needs (e.g. `/ingest/status`, `/queue`, `/share`), keeping everything on a +single origin. diff --git a/ingest/radieo/__main__.py b/ingest/radieo/__main__.py index 5ee0dd9..ddf01fb 100644 --- a/ingest/radieo/__main__.py +++ b/ingest/radieo/__main__.py @@ -105,7 +105,7 @@ def _build_pipeline(db: Database, canonicalizer): if not providers: log.warning("no source active: the stream plays its local cache only.") - return providers, fetchers + return providers, fetchers, subsonic_client def _sweep_temp_files() -> None: @@ -152,12 +152,14 @@ def main() -> None: canonicalizer = _NullCanonicalizer() log.info("Canonicalizer disabled: tracks keyed by (artist, title).") - providers, fetchers = _build_pipeline(db, canonicalizer) + providers, fetchers, subsonic_client = _build_pipeline(db, canonicalizer) scheduler = Scheduler(providers, canonicalizer, db) queue = TrackQueue(scheduler, fetchers, db) queue.start() - server = IngestServer((config.HTTP_HOST, config.HTTP_PORT), queue, db) + server = IngestServer( + (config.HTTP_HOST, config.HTTP_PORT), queue, db, subsonic=subsonic_client + ) log.info( "ingest listening on %s:%d (cache=%s, state=%s)", config.HTTP_HOST, diff --git a/ingest/radieo/api.py b/ingest/radieo/api.py index cacc9d6..bea71fd 100644 --- a/ingest/radieo/api.py +++ b/ingest/radieo/api.py @@ -7,6 +7,8 @@ Endpoints: net; empty (→ silence) until something has played. GET /status -> JSON prefetch state {ready, prefetch}, surfaced to the player (proxied by the stream) so it can show buffering. + GET /queue -> JSON list of the upcoming (prefetched) tracks, oldest + first, surfaced to the player (proxied by the stream). GET /healthz -> "ok" """ @@ -14,11 +16,13 @@ import json import logging from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from urllib.parse import parse_qs, urlsplit from . import config from .db import Database from .models import Track from .queue import TrackQueue +from .subsonic import SubsonicClient log = logging.getLogger("radieo.api") @@ -29,20 +33,32 @@ def annotate_uri(path: Path, track: Track) -> str: def esc(value: str) -> str: return value.replace("\\", "\\\\").replace('"', '\\"') - fields = [f'title="{esc(track.title)}"', f'artist="{esc(track.artist)}"'] + fields = [ + f'title="{esc(track.title)}"', + f'artist="{esc(track.artist)}"', + # Provider that produced the track (subsonic, ytdlp…), surfaced by the + # stream so the player can show a discreet source indicator. + f'origin="{esc(track.origin)}"', + ] # Web page the track was pulled from, so the player can link back to the - # source. Only http(s) locators qualify (yt-dlp tracks); a Subsonic song id - # is opaque and points at no public page. - if track.locator.startswith(("http://", "https://")): - fields.append(f'url="{esc(track.locator)}"') + # source (see Track.page_url for how it's derived per backend). + if track.page_url is not None: + fields.append(f'url="{esc(track.page_url)}"') return f'annotate:{",".join(fields)}:{path}' class IngestServer(ThreadingHTTPServer): - def __init__(self, address, queue: TrackQueue, db: Database): + def __init__( + self, + address, + queue: TrackQueue, + db: Database, + subsonic: SubsonicClient | None = None, + ): super().__init__(address, _Handler) self.queue = queue self.db = db + self.subsonic = subsonic class _Handler(BaseHTTPRequestHandler): @@ -55,11 +71,42 @@ class _Handler(BaseHTTPRequestHandler): self._serve_fallback() elif self.path == "/status": self._serve_status() + elif self.path == "/queue": + self._serve_queue() elif self.path == "/healthz": self._text(200, "ok\n") else: self._text(404, "not found\n") + def do_POST(self): # noqa: N802 (name imposed by BaseHTTPRequestHandler) + parsed = urlsplit(self.path) + if parsed.path == "/share": + self._serve_share(parse_qs(parsed.query)) + else: + self._text(404, "not found\n") + + def _serve_share(self, query: dict[str, list[str]]): + # Mint a public Subsonic share for one song id, on demand. Called by the + # stream when a listener clicks a subsonic track's source link, so no + # share is created for tracks nobody opens. + client = self.server.subsonic + if client is None: + self._text(503, "subsonic not configured\n") + return + song_id = (query.get("id") or [""])[0] + if not song_id: + self._text(400, "missing id\n") + return + try: + url = client.create_share(song_id) + except Exception as exc: # sharing disabled, network error, bad id… + log.warning("createShare failed for %s: %s", song_id, exc) + self._text(502, "share unavailable\n") + return + self._text( + 200, json.dumps({"url": url}) + "\n", "application/json; charset=utf-8" + ) + def _serve_next(self): result = self.server.queue.pop_next() if result is None: @@ -87,6 +134,11 @@ class _Handler(BaseHTTPRequestHandler): }) self._text(200, body + "\n", "application/json; charset=utf-8") + def _serve_queue(self): + # Upcoming prefetched tracks, next first. A peek, nothing consumed. + body = json.dumps(self.server.queue.snapshot()) + self._text(200, body + "\n", "application/json; charset=utf-8") + def _text(self, code: int, body: str, ctype: str = "text/plain; charset=utf-8"): data = body.encode("utf-8") self.send_response(code) diff --git a/ingest/radieo/fetchers/subsonic.py b/ingest/radieo/fetchers/subsonic.py index c4af567..76e540a 100644 --- a/ingest/radieo/fetchers/subsonic.py +++ b/ingest/radieo/fetchers/subsonic.py @@ -44,4 +44,6 @@ class SubsonicFetcher: raise log.info("downloaded %s -> %s", track, dest.name) # Subsonic files are already tagged by the library server; pass through. + # The source link is minted lazily when a listener clicks it — see the + # /share endpoint — so no share is created here. return dest, track diff --git a/ingest/radieo/fetchers/ytdlp.py b/ingest/radieo/fetchers/ytdlp.py index 95cb534..bb404bb 100644 --- a/ingest/radieo/fetchers/ytdlp.py +++ b/ingest/radieo/fetchers/ytdlp.py @@ -22,6 +22,22 @@ from ..models import Track log = logging.getLogger("radieo.fetcher.ytdlp") +def _media_url(info: dict) -> str | None: + """The concrete media page URL yt-dlp actually resolved. + + A ``ytsearch1:`` query resolves to a playlist whose single entry is the + chosen video; its ``webpage_url`` is the real, linkable page (the search + query string itself is not). Returns None when no http(s) URL is available. + """ + entries = info.get("entries") + if entries: + info = entries[0] or {} + url = info.get("webpage_url") or info.get("original_url") + if url and url.startswith(("http://", "https://")): + return url + return None + + def cache_stem(locator: str) -> str: """Cache filename stem for a yt-dlp locator (shared with the provider).""" h = hashlib.sha1(locator.encode()).hexdigest()[:16] @@ -79,7 +95,16 @@ class YtdlpFetcher: leftover.unlink(missing_ok=True) raise log.info("downloaded %s -> %s", track, dest.name) - return dest, self._retag(dest, info, track) + result = self._retag(dest, info, track) + # A ``ytsearch1:`` locator (ListenBrainz picks resolved to yt-dlp) is not + # a linkable page. yt-dlp did resolve a concrete video, though, so record + # its real URL for the player's "source" link — but only when the locator + # itself isn't already an http page (direct URLs link to themselves). + if not track.locator.startswith(("http://", "https://")): + resolved = _media_url(info) + if resolved is not None: + result = replace(result, source_url=resolved) + return dest, result def _retag(self, dest: Path, info: dict, track: Track) -> Track: """For bandcamp downloads, ensure sane ID3 tags and refine the Track. diff --git a/ingest/radieo/models.py b/ingest/radieo/models.py index 6d8f41c..7bf8d77 100644 --- a/ingest/radieo/models.py +++ b/ingest/radieo/models.py @@ -8,6 +8,7 @@ de-duplication and anti-repeat. import re from dataclasses import dataclass +from urllib.parse import quote _WS = re.compile(r"\s+") @@ -43,5 +44,23 @@ class Track: return f"mbid:{self.mbid}" return f"name:{norm_name(self.artist)}|{norm_name(self.title)}" + @property + def page_url(self) -> str | None: + """A link a listener can open for this track, or None. + + - a direct yt-dlp URL is its own page (the locator); + - a ListenBrainz pick resolved via ``ytsearch1:`` links to the resolved + video URL the fetcher recorded in ``source_url``; + - a Subsonic song id is opaque, so it links to the stream's ``/share`` + endpoint, which mints a public share on demand (only when clicked) and + redirects to it — avoiding a share per played track. + """ + for candidate in (self.locator, self.source_url): + if candidate and candidate.startswith(("http://", "https://")): + return candidate + if self.backend == "subsonic": + return f"/share?song={quote(self.locator, safe='')}" + return None + def __str__(self) -> str: return f"{self.artist} — {self.title} [{self.origin}]" diff --git a/ingest/radieo/queue.py b/ingest/radieo/queue.py index 6b75f29..2d4d17f 100644 --- a/ingest/radieo/queue.py +++ b/ingest/radieo/queue.py @@ -81,6 +81,27 @@ class TrackQueue: with self._lock: return len(self._ready) + def snapshot(self) -> list[dict]: + """Display metadata of the upcoming tracks, oldest (next) first. + + A peek at the prefetch buffer for the player's "up next" view; it does + not consume anything. Mirrors the fields exposed for the current track + (see ``annotate_uri``): a source ``url`` only for http(s) locators. + """ + with self._lock: + ready = list(self._ready) + items = [] + for _path, track in ready: + entry = { + "title": track.title, + "artist": track.artist, + "origin": track.origin, + } + if track.page_url is not None: + entry["url"] = track.page_url + items.append(entry) + return items + # --- serving ---------------------------------------------------------- def pop_next(self) -> tuple[Path, Track] | None: diff --git a/ingest/radieo/subsonic.py b/ingest/radieo/subsonic.py index a23b521..fcf55e9 100644 --- a/ingest/radieo/subsonic.py +++ b/ingest/radieo/subsonic.py @@ -103,6 +103,20 @@ class SubsonicClient: ) return body.get("searchResult3", {}).get("song", []) + def create_share(self, song_id: str) -> str: + """Create a public share for a song and return its web URL. + + Needs sharing enabled on the server (Navidrome: ``ND_ENABLESHARING=true``); + otherwise the server replies with an error, raised as ``SubsonicError``. + Each call creates a *new* share, so callers should reuse the returned URL + rather than re-sharing the same song. + """ + body = self._get_json("createShare", id=song_id) + shares = body.get("shares", {}).get("share", []) + if not shares or not shares[0].get("url"): + raise SubsonicError(f"createShare {song_id}: no share url returned") + return shares[0]["url"] + def download(self, song_id: str, dest: Path, hint_ext: str | None = None) -> str: """Download a song to ``dest``; return the file extension used. diff --git a/stream/index.html b/stream/index.html index fa9b90f..b7207dd 100644 --- a/stream/index.html +++ b/stream/index.html @@ -27,7 +27,40 @@ content: ""; position: absolute; inset: 0; background: radial-gradient(circle at 30% 20%, rgba(20,16,34,.72), rgba(13,11,20,.9) 75%); } + /* Ambiance cathodique synthwave, purement décorative (au-dessus de tout, + mais transparent aux clics). Deux couches superposées : + - .scanlines : fines lignes horizontales fixes, comme la trame d'un + vieux moniteur, à peine visibles ; + - .scanbeam : une bande lumineuse qui balaie l'écran de haut en bas en + boucle, évoquant le rafraîchissement d'un tube cathodique. */ + .crt { position: fixed; inset: 0; z-index: 0; pointer-events: none; + overflow: hidden; } + .scanlines { + position: absolute; inset: 0; opacity: .35; + background: repeating-linear-gradient( + 180deg, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px, + rgba(0,0,0,.25) 3px, rgba(0,0,0,0) 4px); + } + .scanbeam { + position: absolute; left: 0; right: 0; top: 0; height: 22vh; + background: linear-gradient( + 180deg, rgba(155,140,255,0) 0%, + rgba(155,140,255,.06) 45%, rgba(214,153,255,.14) 50%, + rgba(155,140,255,.06) 55%, rgba(155,140,255,0) 100%); + mix-blend-mode: screen; + animation: scan 16s linear infinite; + } + /* Le faisceau part au-dessus du cadre et repasse sous le bas, pour un + balayage continu sans saut visible. */ + @keyframes scan { + 0% { transform: translateY(-22vh); } + 100% { transform: translateY(100vh); } + } + @media (prefers-reduced-motion: reduce) { + .scanbeam { animation: none; opacity: 0; } + } .card { + position: relative; z-index: 1; width: min(90vw, 420px); padding: 2.5rem 2rem; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08); border-radius: 18px; box-shadow: 0 20px 60px rgba(0,0,0,.45); @@ -37,6 +70,10 @@ color: #9b8cff; margin-bottom: 1.75rem; } .np-label { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; color: #7d768f; margin-bottom: .5rem; } + /* Provenance discrète du morceau : d'où vient la piste (OpenSubsonic, + YouTube…). Ton effacé, glissée à la suite de l'état « en cours ». */ + .provider { color: #6b6480; } + .provider::before { content: "·"; margin: 0 .35em; color: #4a4560; } .title { font-size: 1.55rem; font-weight: 650; line-height: 1.25; word-wrap: break-word; } .title a { color: inherit; text-decoration: none; transition: color .15s; } @@ -67,11 +104,41 @@ } .actions button:hover, .actions a:hover { background: rgba(155,140,255,.3); } .actions button:disabled { opacity: .5; cursor: default; } - .history { margin-top: 1.75rem; text-align: left; } - .history h2 { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; - color: #7d768f; margin: 0 0 .4rem; font-weight: 600; } - .history ul { list-style: none; margin: 0; padding: 0; } - .history li { border-top: 1px solid rgba(255,255,255,.06); } + /* Onglets « glossy » pour basculer entre file d'attente et historique : + une barre pilule au fond translucide, l'onglet actif surligné par un + dégradé lumineux et un léger reflet en haut pour l'effet vernis. */ + .tabs { margin-top: 1.75rem; text-align: left; } + .tab-bar { + display: flex; gap: .35rem; padding: .3rem; + background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08); + border-radius: 12px; box-shadow: inset 0 1px 0 rgba(255,255,255,.06); + } + .tab-btn { + flex: 1; position: relative; overflow: hidden; cursor: pointer; + padding: .55rem .7rem; font-size: .7rem; font-weight: 600; + letter-spacing: .15em; text-transform: uppercase; color: #8b849c; + background: transparent; border: 1px solid transparent; border-radius: 9px; + transition: color .2s, background .2s, box-shadow .2s, border-color .2s; + } + .tab-btn:hover { color: #cfc9de; } + .tab-btn.active { + color: #f2f0f7; + background: linear-gradient(180deg, rgba(155,140,255,.45), rgba(123,110,220,.22)); + border-color: rgba(155,140,255,.5); + box-shadow: inset 0 1px 0 rgba(255,255,255,.35), + 0 4px 14px rgba(123,110,220,.35); + } + /* Reflet vitreux : une bande claire sur la moitié haute de l'onglet actif. */ + .tab-btn.active::before { + content: ""; position: absolute; inset: 0 0 50%; + background: linear-gradient(180deg, rgba(255,255,255,.28), transparent); + pointer-events: none; + } + .tab-panel { margin-top: 1rem; text-align: left; } + .tab-panel[hidden] { display: none; } + .history, .queue { text-align: left; } + .history ul, .queue ul { list-style: none; margin: 0; padding: 0; } + .history li, .queue li { border-top: 1px solid rgba(255,255,255,.06); } .history .h-row { display: flex; align-items: center; gap: .6rem; padding: .45rem 0; } @@ -79,11 +146,17 @@ .history .h-act { color: #7d768f; font-size: .95rem; text-decoration: none; transition: color .15s; } .history .h-act:hover { color: #9b8cff; } - .history .h-title { color: #e8e4f2; font-size: .92rem; } - .history .h-title a { color: inherit; text-decoration: none; transition: color .15s; } - .history .h-title a:hover { color: #9b8cff; text-decoration: underline; } - .history .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; } - .history .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; } + .history .h-title, .queue .h-title { color: #e8e4f2; font-size: .92rem; } + .history .h-title a, .queue .h-title a { color: inherit; text-decoration: none; transition: color .15s; } + .history .h-title a:hover, .queue .h-title a:hover { color: #9b8cff; text-decoration: underline; } + .history .h-artist, .queue .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; } + .history .empty, .queue .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; } + /* File d'attente : rang discret devant chaque titre à venir. */ + .queue li { padding: .45rem 0; } + .queue .q-item { display: flex; align-items: baseline; gap: .6rem; } + .queue .q-num { color: #6b6480; font-size: .8rem; font-variant-numeric: tabular-nums; + min-width: 1.2em; text-align: right; } + .queue .q-meta { flex: 1; min-width: 0; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4ade80; margin-right: .4rem; vertical-align: middle; box-shadow: 0 0 0 0 rgba(74,222,128,.6); animation: pulse 2s infinite; } @@ -96,9 +169,10 @@
+
-
Préchargement
+
Connexion en cours
@@ -110,10 +184,18 @@ -
-

Historique

-
    -
    +
    +
    + + +
    +
    +
      +
      + +
      diff --git a/stream/radio.liq b/stream/radio.liq index 40ac29b..cae432d 100644 --- a/stream/radio.liq +++ b/stream/radio.liq @@ -71,6 +71,11 @@ 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 @@ -198,8 +203,8 @@ radio.on_metadata( 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"], file=path.basename(m["filename"])} - head = list.hd(default={title="", artist="", url="", file=""}, history()) + 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())) @@ -239,7 +244,7 @@ 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"]}) + resp.json({title=m["title"], artist=m["artist"], url=m["url"], origin=m["origin"]}) end ) @@ -249,6 +254,24 @@ harbor.http.register( 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 @@ -349,3 +372,33 @@ harbor.http.register( 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 +)