Compare commits
6 commits
96a1ba89e6
...
ef1a19504e
| Author | SHA1 | Date | |
|---|---|---|---|
| ef1a19504e | |||
| efd7307cc6 | |||
| d30f687185 | |||
| 62302ac21d | |||
| 126cb8f8ac | |||
| bfa7cc1046 |
10 changed files with 386 additions and 40 deletions
21
README.md
21
README.md
|
|
@ -30,8 +30,10 @@ simultaneous listeners), not for public broadcasting.
|
||||||
jingles (noon, snack time, ...) played once when their time comes.
|
jingles (noon, snack time, ...) played once when their time comes.
|
||||||
- **Smooth playback**: a 3 s crossfade between tracks.
|
- **Smooth playback**: a 3 s crossfade between tracks.
|
||||||
- **Built-in web player** at `http://localhost:8000/`: now playing (linked to
|
- **Built-in web player** at `http://localhost:8000/`: now playing (linked to
|
||||||
its source page), track history, skip button, per-track download, volume
|
its source page — a Bandcamp/YouTube page for yt-dlp tracks, or an on-demand
|
||||||
memory, live auto-reconnect, prefetch progress, and a synthwave look.
|
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
|
- **OS media controls**: the player exposes current track metadata and
|
||||||
play/pause/next through the Media Session API, so it wires into system media
|
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)
|
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
|
- **OpenSubsonic server**: `RADIEO_SUBSONIC_URL` / `USER` / `PASSWORD` and the
|
||||||
playlist to broadcast in `RADIEO_SUBSONIC_PLAYLIST` (name or id). Works with
|
playlist to broadcast in `RADIEO_SUBSONIC_PLAYLIST` (name or id). Works with
|
||||||
any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Leave empty
|
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` /
|
- **Mix**: `RADIEO_WEIGHT_SUBSONIC` / `RADIEO_WEIGHT_YTDLP` /
|
||||||
`RADIEO_WEIGHT_LISTENBRAINZ` set the relative draw weight of each source
|
`RADIEO_WEIGHT_LISTENBRAINZ` set the relative draw weight of each source
|
||||||
(`0` disables one).
|
(`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/`
|
- A *prefetch queue* keeps a few ready tracks; a SQLite database under `state/`
|
||||||
holds play history, the MBID cache, and LRU cache-file retention.
|
holds play history, the MBID cache, and LRU cache-file retention.
|
||||||
- API: `GET /next` (annotated Liquidsoap URI), `/fallback.m3u` (already-aired
|
- 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=<songId>` (mint a Subsonic share on demand).
|
||||||
|
|
||||||
**`stream`** (Liquidsoap) pulls `/next` via `request.dynamic`, inserts jingles
|
**`stream`** (Liquidsoap) pulls `/next` via `request.dynamic`, inserts jingles
|
||||||
(a `switch` that also handles the time-of-day slots), applies the crossfade and
|
(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
|
`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
|
web player and its API: `/nowplaying`, `/history`, `/skip`, `/restart-track`,
|
||||||
`/ingest/status`.
|
`/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`
|
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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ def _build_pipeline(db: Database, canonicalizer):
|
||||||
|
|
||||||
if not providers:
|
if not providers:
|
||||||
log.warning("no source active: the stream plays its local cache only.")
|
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:
|
def _sweep_temp_files() -> None:
|
||||||
|
|
@ -152,12 +152,14 @@ def main() -> None:
|
||||||
canonicalizer = _NullCanonicalizer()
|
canonicalizer = _NullCanonicalizer()
|
||||||
log.info("Canonicalizer disabled: tracks keyed by (artist, title).")
|
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)
|
scheduler = Scheduler(providers, canonicalizer, db)
|
||||||
queue = TrackQueue(scheduler, fetchers, db)
|
queue = TrackQueue(scheduler, fetchers, db)
|
||||||
queue.start()
|
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(
|
log.info(
|
||||||
"ingest listening on %s:%d (cache=%s, state=%s)",
|
"ingest listening on %s:%d (cache=%s, state=%s)",
|
||||||
config.HTTP_HOST,
|
config.HTTP_HOST,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ Endpoints:
|
||||||
net; empty (→ silence) until something has played.
|
net; empty (→ silence) until something has played.
|
||||||
GET /status -> JSON prefetch state {ready, prefetch}, surfaced to the
|
GET /status -> JSON prefetch state {ready, prefetch}, surfaced to the
|
||||||
player (proxied by the stream) so it can show buffering.
|
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"
|
GET /healthz -> "ok"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -14,11 +16,13 @@ import json
|
||||||
import logging
|
import logging
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
from .db import Database
|
from .db import Database
|
||||||
from .models import Track
|
from .models import Track
|
||||||
from .queue import TrackQueue
|
from .queue import TrackQueue
|
||||||
|
from .subsonic import SubsonicClient
|
||||||
|
|
||||||
log = logging.getLogger("radieo.api")
|
log = logging.getLogger("radieo.api")
|
||||||
|
|
||||||
|
|
@ -29,20 +33,32 @@ def annotate_uri(path: Path, track: Track) -> str:
|
||||||
def esc(value: str) -> str:
|
def esc(value: str) -> str:
|
||||||
return value.replace("\\", "\\\\").replace('"', '\\"')
|
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
|
# 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
|
# source (see Track.page_url for how it's derived per backend).
|
||||||
# is opaque and points at no public page.
|
if track.page_url is not None:
|
||||||
if track.locator.startswith(("http://", "https://")):
|
fields.append(f'url="{esc(track.page_url)}"')
|
||||||
fields.append(f'url="{esc(track.locator)}"')
|
|
||||||
return f'annotate:{",".join(fields)}:{path}'
|
return f'annotate:{",".join(fields)}:{path}'
|
||||||
|
|
||||||
|
|
||||||
class IngestServer(ThreadingHTTPServer):
|
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)
|
super().__init__(address, _Handler)
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self.subsonic = subsonic
|
||||||
|
|
||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
|
@ -55,11 +71,42 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
self._serve_fallback()
|
self._serve_fallback()
|
||||||
elif self.path == "/status":
|
elif self.path == "/status":
|
||||||
self._serve_status()
|
self._serve_status()
|
||||||
|
elif self.path == "/queue":
|
||||||
|
self._serve_queue()
|
||||||
elif self.path == "/healthz":
|
elif self.path == "/healthz":
|
||||||
self._text(200, "ok\n")
|
self._text(200, "ok\n")
|
||||||
else:
|
else:
|
||||||
self._text(404, "not found\n")
|
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):
|
def _serve_next(self):
|
||||||
result = self.server.queue.pop_next()
|
result = self.server.queue.pop_next()
|
||||||
if result is None:
|
if result is None:
|
||||||
|
|
@ -87,6 +134,11 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
})
|
})
|
||||||
self._text(200, body + "\n", "application/json; charset=utf-8")
|
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"):
|
def _text(self, code: int, body: str, ctype: str = "text/plain; charset=utf-8"):
|
||||||
data = body.encode("utf-8")
|
data = body.encode("utf-8")
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
|
|
|
||||||
|
|
@ -44,4 +44,6 @@ class SubsonicFetcher:
|
||||||
raise
|
raise
|
||||||
log.info("downloaded %s -> %s", track, dest.name)
|
log.info("downloaded %s -> %s", track, dest.name)
|
||||||
# Subsonic files are already tagged by the library server; pass through.
|
# 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
|
return dest, track
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,22 @@ from ..models import Track
|
||||||
log = logging.getLogger("radieo.fetcher.ytdlp")
|
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:
|
def cache_stem(locator: str) -> str:
|
||||||
"""Cache filename stem for a yt-dlp locator (shared with the provider)."""
|
"""Cache filename stem for a yt-dlp locator (shared with the provider)."""
|
||||||
h = hashlib.sha1(locator.encode()).hexdigest()[:16]
|
h = hashlib.sha1(locator.encode()).hexdigest()[:16]
|
||||||
|
|
@ -79,7 +95,16 @@ class YtdlpFetcher:
|
||||||
leftover.unlink(missing_ok=True)
|
leftover.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
log.info("downloaded %s -> %s", track, dest.name)
|
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:
|
def _retag(self, dest: Path, info: dict, track: Track) -> Track:
|
||||||
"""For bandcamp downloads, ensure sane ID3 tags and refine the Track.
|
"""For bandcamp downloads, ensure sane ID3 tags and refine the Track.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ de-duplication and anti-repeat.
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
_WS = re.compile(r"\s+")
|
_WS = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
@ -43,5 +44,23 @@ class Track:
|
||||||
return f"mbid:{self.mbid}"
|
return f"mbid:{self.mbid}"
|
||||||
return f"name:{norm_name(self.artist)}|{norm_name(self.title)}"
|
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:
|
def __str__(self) -> str:
|
||||||
return f"{self.artist} — {self.title} [{self.origin}]"
|
return f"{self.artist} — {self.title} [{self.origin}]"
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,27 @@ class TrackQueue:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return len(self._ready)
|
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 ----------------------------------------------------------
|
# --- serving ----------------------------------------------------------
|
||||||
|
|
||||||
def pop_next(self) -> tuple[Path, Track] | None:
|
def pop_next(self) -> tuple[Path, Track] | None:
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,20 @@ class SubsonicClient:
|
||||||
)
|
)
|
||||||
return body.get("searchResult3", {}).get("song", [])
|
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:
|
def download(self, song_id: str, dest: Path, hint_ext: str | None = None) -> str:
|
||||||
"""Download a song to ``dest``; return the file extension used.
|
"""Download a song to ``dest``; return the file extension used.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,40 @@
|
||||||
content: ""; position: absolute; inset: 0;
|
content: ""; position: absolute; inset: 0;
|
||||||
background: radial-gradient(circle at 30% 20%, rgba(20,16,34,.72), rgba(13,11,20,.9) 75%);
|
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 {
|
.card {
|
||||||
|
position: relative; z-index: 1;
|
||||||
width: min(90vw, 420px); padding: 2.5rem 2rem;
|
width: min(90vw, 420px); padding: 2.5rem 2rem;
|
||||||
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08);
|
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);
|
border-radius: 18px; box-shadow: 0 20px 60px rgba(0,0,0,.45);
|
||||||
|
|
@ -37,6 +70,10 @@
|
||||||
color: #9b8cff; margin-bottom: 1.75rem; }
|
color: #9b8cff; margin-bottom: 1.75rem; }
|
||||||
.np-label { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase;
|
.np-label { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase;
|
||||||
color: #7d768f; margin-bottom: .5rem; }
|
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;
|
.title { font-size: 1.55rem; font-weight: 650; line-height: 1.25;
|
||||||
word-wrap: break-word; }
|
word-wrap: break-word; }
|
||||||
.title a { color: inherit; text-decoration: none; transition: color .15s; }
|
.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:hover, .actions a:hover { background: rgba(155,140,255,.3); }
|
||||||
.actions button:disabled { opacity: .5; cursor: default; }
|
.actions button:disabled { opacity: .5; cursor: default; }
|
||||||
.history { margin-top: 1.75rem; text-align: left; }
|
/* Onglets « glossy » pour basculer entre file d'attente et historique :
|
||||||
.history h2 { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase;
|
une barre pilule au fond translucide, l'onglet actif surligné par un
|
||||||
color: #7d768f; margin: 0 0 .4rem; font-weight: 600; }
|
dégradé lumineux et un léger reflet en haut pour l'effet vernis. */
|
||||||
.history ul { list-style: none; margin: 0; padding: 0; }
|
.tabs { margin-top: 1.75rem; text-align: left; }
|
||||||
.history li { border-top: 1px solid rgba(255,255,255,.06); }
|
.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 {
|
.history .h-row {
|
||||||
display: flex; align-items: center; gap: .6rem; padding: .45rem 0;
|
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;
|
.history .h-act { color: #7d768f; font-size: .95rem; text-decoration: none;
|
||||||
transition: color .15s; }
|
transition: color .15s; }
|
||||||
.history .h-act:hover { color: #9b8cff; }
|
.history .h-act:hover { color: #9b8cff; }
|
||||||
.history .h-title { color: #e8e4f2; font-size: .92rem; }
|
.history .h-title, .queue .h-title { color: #e8e4f2; font-size: .92rem; }
|
||||||
.history .h-title a { color: inherit; text-decoration: none; transition: color .15s; }
|
.history .h-title a, .queue .h-title a { color: inherit; text-decoration: none; transition: color .15s; }
|
||||||
.history .h-title a:hover { color: #9b8cff; text-decoration: underline; }
|
.history .h-title a:hover, .queue .h-title a:hover { color: #9b8cff; text-decoration: underline; }
|
||||||
.history .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; }
|
.history .h-artist, .queue .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; }
|
||||||
.history .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; }
|
.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%;
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||||
background: #4ade80; margin-right: .4rem; vertical-align: middle;
|
background: #4ade80; margin-right: .4rem; vertical-align: middle;
|
||||||
box-shadow: 0 0 0 0 rgba(74,222,128,.6); animation: pulse 2s infinite; }
|
box-shadow: 0 0 0 0 rgba(74,222,128,.6); animation: pulse 2s infinite; }
|
||||||
|
|
@ -96,9 +169,10 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bg" id="bg"></div>
|
<div class="bg" id="bg"></div>
|
||||||
|
<div class="crt" aria-hidden="true"><div class="scanlines"></div><div class="scanbeam"></div></div>
|
||||||
<main class="card">
|
<main class="card">
|
||||||
<div class="logo" id="stationName">◈</div>
|
<div class="logo" id="stationName">◈</div>
|
||||||
<div class="np-label"><span class="dot"></span><span id="npLabel">Préchargement</span></div>
|
<div class="np-label"><span class="dot"></span><span id="npLabel">Connexion en cours</span><span id="provider" class="provider" hidden></span></div>
|
||||||
<div class="title" id="title">—</div>
|
<div class="title" id="title">—</div>
|
||||||
<div class="artist" id="artist"></div>
|
<div class="artist" id="artist"></div>
|
||||||
<audio id="player" controls autoplay preload="none"></audio>
|
<audio id="player" controls autoplay preload="none"></audio>
|
||||||
|
|
@ -110,10 +184,18 @@
|
||||||
<input id="streamUrl" type="text" readonly>
|
<input id="streamUrl" type="text" readonly>
|
||||||
<button id="copyBtn" type="button">Copier</button>
|
<button id="copyBtn" type="button">Copier</button>
|
||||||
</div>
|
</div>
|
||||||
<section class="history">
|
<div class="tabs">
|
||||||
<h2>Historique</h2>
|
<div class="tab-bar" role="tablist">
|
||||||
<ul id="historyList"></ul>
|
<button class="tab-btn active" id="tabHistory" type="button" role="tab" aria-selected="true" aria-controls="panelHistory">Historique</button>
|
||||||
</section>
|
<button class="tab-btn" id="tabQueue" type="button" role="tab" aria-selected="false" aria-controls="panelQueue">File d'attente</button>
|
||||||
|
</div>
|
||||||
|
<section class="tab-panel history" id="panelHistory" role="tabpanel" aria-labelledby="tabHistory">
|
||||||
|
<ul id="historyList"></ul>
|
||||||
|
</section>
|
||||||
|
<section class="tab-panel queue" id="panelQueue" role="tabpanel" aria-labelledby="tabQueue" hidden>
|
||||||
|
<ul id="queueList"></ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
// Nom de la station, modifiable ici (repris dans le logo et le titre de l'onglet).
|
// Nom de la station, modifiable ici (repris dans le logo et le titre de l'onglet).
|
||||||
|
|
@ -151,8 +233,20 @@
|
||||||
const titleEl = document.getElementById("title");
|
const titleEl = document.getElementById("title");
|
||||||
const artistEl = document.getElementById("artist");
|
const artistEl = document.getElementById("artist");
|
||||||
const npLabel = document.getElementById("npLabel");
|
const npLabel = document.getElementById("npLabel");
|
||||||
|
const providerEl = document.getElementById("provider");
|
||||||
const player = document.getElementById("player");
|
const player = document.getElementById("player");
|
||||||
|
|
||||||
|
// Noms d'affichage des providers d'ingestion (champ `origin` du morceau).
|
||||||
|
// Inconnu → on réutilise tel quel, faute de mieux.
|
||||||
|
const PROVIDER_NAMES = {
|
||||||
|
subsonic: "OpenSubsonic",
|
||||||
|
ytdlp: "YouTube",
|
||||||
|
listenbrainz: "ListenBrainz",
|
||||||
|
// Filet de secours : morceau rejoué depuis le cache local (déjà diffusé),
|
||||||
|
// quand l'ingest n'a rien à proposer (démarrage, panne…).
|
||||||
|
cache: "le cache local",
|
||||||
|
};
|
||||||
|
|
||||||
// Tant que le buffer de préchargement (PREFETCH côté ingest) n'est pas
|
// Tant que le buffer de préchargement (PREFETCH côté ingest) n'est pas
|
||||||
// rempli, on affiche « Préchargement N/M » plutôt que « en cours ». L'info
|
// rempli, on affiche « Préchargement N/M » plutôt que « en cours ». L'info
|
||||||
// vient du daemon d'ingestion, relayée par le stream via /ingest/status.
|
// vient du daemon d'ingestion, relayée par le stream via /ingest/status.
|
||||||
|
|
@ -166,12 +260,14 @@
|
||||||
const r = await fetch("/ingest/status", { cache: "no-store" });
|
const r = await fetch("/ingest/status", { cache: "no-store" });
|
||||||
const s = await r.json();
|
const s = await r.json();
|
||||||
const ready = Number(s.ready) || 0;
|
const ready = Number(s.ready) || 0;
|
||||||
const prefetch = Number(s.prefetch) || 0;
|
const prefetch = Number.isFinite(Number(s.prefetch)) ? Number(s.prefetch) : -1;
|
||||||
if (prefetch > 0 && ready >= prefetch) {
|
if (prefetch >= 0 && ready >= prefetch) {
|
||||||
bufferFull = true;
|
bufferFull = true;
|
||||||
npLabel.textContent = "en cours";
|
npLabel.textContent = "en cours";
|
||||||
|
} else if (prefetch >= 0) {
|
||||||
|
npLabel.textContent = `Préchargement ${ready}/${prefetch}`;
|
||||||
} else {
|
} else {
|
||||||
npLabel.textContent = `Préchargement ${ready}/${prefetch || "…"}`;
|
npLabel.textContent = "Connexion en cours";
|
||||||
}
|
}
|
||||||
} catch (e) { /* keep last known label */ }
|
} catch (e) { /* keep last known label */ }
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +375,14 @@
|
||||||
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
|
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
|
||||||
: escapeHtml(label);
|
: escapeHtml(label);
|
||||||
artistEl.textContent = a;
|
artistEl.textContent = a;
|
||||||
|
// Provenance discrète : nom lisible du provider, masqué s'il est absent.
|
||||||
|
const origin = (m.origin || "").trim();
|
||||||
|
if (origin) {
|
||||||
|
providerEl.textContent = "via " + (PROVIDER_NAMES[origin] || origin);
|
||||||
|
providerEl.hidden = false;
|
||||||
|
} else {
|
||||||
|
providerEl.hidden = true;
|
||||||
|
}
|
||||||
document.title = t ? (a ? `${t} — ${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME;
|
document.title = t ? (a ? `${t} — ${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME;
|
||||||
updateMediaSession(t, a);
|
updateMediaSession(t, a);
|
||||||
} catch (e) { /* keep last known values */ }
|
} catch (e) { /* keep last known values */ }
|
||||||
|
|
@ -318,6 +422,34 @@
|
||||||
}).join("");
|
}).join("");
|
||||||
} catch (e) { /* keep last known values */ }
|
} catch (e) { /* keep last known values */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File d'attente : les prochains morceaux déjà préchargés par le daemon
|
||||||
|
// d'ingestion, relayés par le stream via /queue (le plus proche en tête).
|
||||||
|
// La liste est courte (bornée par PREFETCH) et peut être vide au démarrage.
|
||||||
|
const queueList = document.getElementById("queueList");
|
||||||
|
async function pollQueue() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/queue", { cache: "no-store" });
|
||||||
|
const items = await r.json();
|
||||||
|
const next = Array.isArray(items) ? items : [];
|
||||||
|
if (next.length === 0) {
|
||||||
|
queueList.innerHTML = '<li class="empty">—</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueList.innerHTML = next.map((m, i) => {
|
||||||
|
const t = escapeHtml((m.title || "").trim() || "—");
|
||||||
|
const a = (m.artist || "").trim();
|
||||||
|
const artist = a ? `<div class="h-artist">${escapeHtml(a)}</div>` : "";
|
||||||
|
// Titre cliquable vers la page d'origine (yt-dlp) quand elle existe.
|
||||||
|
const u = (m.url || "").trim();
|
||||||
|
const titleHtml = u
|
||||||
|
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${t}</a>`
|
||||||
|
: t;
|
||||||
|
const meta = `<div class="q-meta"><div class="h-title">${titleHtml}</div>${artist}</div>`;
|
||||||
|
return `<li><div class="q-item"><span class="q-num">${i + 1}</span>${meta}</div></li>`;
|
||||||
|
}).join("");
|
||||||
|
} catch (e) { /* keep last known values */ }
|
||||||
|
}
|
||||||
// Lien nu du flux, à ouvrir dans un lecteur externe (VLC…).
|
// Lien nu du flux, à ouvrir dans un lecteur externe (VLC…).
|
||||||
const shareUrl = location.origin + "/radio.mp3";
|
const shareUrl = location.origin + "/radio.mp3";
|
||||||
const urlEl = document.getElementById("streamUrl");
|
const urlEl = document.getElementById("streamUrl");
|
||||||
|
|
@ -341,13 +473,32 @@
|
||||||
skipBtn.disabled = true;
|
skipBtn.disabled = true;
|
||||||
try { await fetch("/skip", { method: "POST" }); } catch (e) { /* ignore */ }
|
try { await fetch("/skip", { method: "POST" }); } catch (e) { /* ignore */ }
|
||||||
// Laisser le temps à la bascule, puis rafraîchir l'affichage.
|
// Laisser le temps à la bascule, puis rafraîchir l'affichage.
|
||||||
setTimeout(() => { skipBtn.disabled = false; poll(); pollHistory(); }, 900);
|
setTimeout(() => { skipBtn.disabled = false; poll(); pollHistory(); pollQueue(); }, 900);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Onglets File d'attente / Historique : l'historique est affiché par
|
||||||
|
// défaut. On bascule l'état actif et la visibilité des panneaux ; les deux
|
||||||
|
// continuent d'être rafraîchis en arrière-plan, seul l'affichage change.
|
||||||
|
const tabs = [
|
||||||
|
{ btn: document.getElementById("tabHistory"), panel: document.getElementById("panelHistory") },
|
||||||
|
{ btn: document.getElementById("tabQueue"), panel: document.getElementById("panelQueue") },
|
||||||
|
];
|
||||||
|
tabs.forEach(({ btn }, i) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
tabs.forEach(({ btn: b, panel: p }, j) => {
|
||||||
|
const on = i === j;
|
||||||
|
b.classList.toggle("active", on);
|
||||||
|
b.setAttribute("aria-selected", on ? "true" : "false");
|
||||||
|
p.hidden = !on;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
poll();
|
poll();
|
||||||
pollHistory();
|
pollHistory();
|
||||||
|
pollQueue();
|
||||||
pollStatus();
|
pollStatus();
|
||||||
setInterval(() => { poll(); pollHistory(); pollStatus(); }, 5000);
|
setInterval(() => { poll(); pollHistory(); pollQueue(); pollStatus(); }, 5000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,11 @@ backup = playlist(
|
||||||
mode="randomize", reload_mode="watch", mime_type="audio/x-mpegurl",
|
mode="randomize", reload_mode="watch", mime_type="audio/x-mpegurl",
|
||||||
check_next=audio_only, fallback_file
|
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
|
# 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
|
# endpoint /restart-track y pousse le morceau courant pour le rejouer depuis le
|
||||||
|
|
@ -198,8 +203,8 @@ radio.on_metadata(
|
||||||
now_playing := m
|
now_playing := m
|
||||||
# `file` : nom de base du fichier à l'antenne, servant de jeton de
|
# `file` : nom de base du fichier à l'antenne, servant de jeton de
|
||||||
# téléchargement (/download?file=…). Vide si la métadonnée manque.
|
# 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"])}
|
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="", file=""}, history())
|
head = list.hd(default={title="", artist="", url="", origin="", file=""}, history())
|
||||||
is_dup = head.title == entry.title and head.artist == entry.artist
|
is_dup = head.title == entry.title and head.artist == entry.artist
|
||||||
if not is_dup and (entry.title != "" or entry.artist != "") then
|
if not is_dup and (entry.title != "" or entry.artist != "") then
|
||||||
history := list.prefix(history_max, list.add(entry, history()))
|
history := list.prefix(history_max, list.add(entry, history()))
|
||||||
|
|
@ -239,7 +244,7 @@ harbor.http.register(
|
||||||
port=8000, method="GET", "/nowplaying",
|
port=8000, method="GET", "/nowplaying",
|
||||||
fun(_, resp) -> begin
|
fun(_, resp) -> begin
|
||||||
m = now_playing()
|
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
|
end
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -249,6 +254,24 @@ harbor.http.register(
|
||||||
fun(_, resp) -> resp.json(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) :
|
# É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
|
# 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
|
# {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)
|
serve_attachment(resp, name)
|
||||||
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
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue