Compare commits

...

6 commits

Author SHA1 Message Date
112a4b0c89 ingest: bias yt-dlp picks towards a discography's newest releases
Some checks are pending
continuous-integration/drone/push Build is running
Bandcamp label/artist pages list releases newest-first, so give the first
YTDLP_RECENT_COUNT entries a YTDLP_RECENT_BOOST multiplier when picking,
leaning the radio towards fresh music. Flat extraction carries no dates, so
list position is used as the recency proxy rather than a real date lookup.

The boost is gated on the source URL being a bandcamp discography listing:
single /album/ and /track/ pages (whether a URL-file line or one the pick
recursed into) list tracks in track order, not by recency, and non-bandcamp
sources have unverified ordering, so all of those keep a uniform pick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:02:37 +08:00
49783218d8 ingest: rotate recently-played tracks oldest-first when anti-repeat is exhausted
When every candidate is within the anti-repeat window, the fallback replayed
at random, ignoring how long ago each was heard. With a small feed (or a
window larger than a source's pool) this is the *normal* path, and random
picking clusters the same tracks together.

Play the least-recently-heard candidate instead, so tracks rotate at the
widest spacing the pool allows.

- db: add last_played_at(keys) -> key -> most-recent play timestamp.
- providers/listenbrainz: sort the exhausted pool oldest-first.
- scheduler: on exhaustion, return the oldest-played of the drawn candidates
  rather than the last one drawn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:41:43 +08:00
40061446c9 ingest: support multiple weighted OpenSubsonic playlists
RADIEO_SUBSONIC_PLAYLIST now accepts a comma-separated list of playlist
names or ids, each optionally weighted with a '=<number>' suffix (e.g.
'Chill=3, Focus, Party=2'); default weight is 1 and 0 disables an entry.
The provider caches each playlist independently and walks them in
weighted-random order on every pick, falling through past empty, renamed
or unreachable playlists so the source never stalls. Picking the
playlist first (by weight) then a song uniformly gives each its
configured share regardless of length.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:30:21 +08:00
5dd50f37b7 ingest: weight yt-dlp sources with an optional WEIGHT:URL prefix
Source lines may now start with a numeric weight (e.g. 3:https://…) to
bias how often each is drawn. The prefix is unambiguous since a URL
scheme can never be numeric; unprefixed lines default to weight 1 and a
weight of 0 disables the line. Selection uses Efraimidis-Spirakis
weighted sampling without replacement, preserving the fall-through to
the next source when one fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:16:04 +08:00
8054c98dd1 stream: split radio.liq into pipeline, web and ingest-proxy parts
Extract the HTTP surface out of radio.liq into two included files: web.liq
(static assets, PWA, local player API) and ingest_proxy.liq (relays to the
ingest daemon). radio.liq keeps only the streaming pipeline and ends with the
%include directives, evaluated after the pipeline so the handlers see radio,
now_playing, history, etc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:11:56 +08:00
c73b71d32f stream: make the web player installable as a PWA
Wire up a web manifest, service worker and icon routes so the player can
be installed on mobile. The static manifest stays generic; the page
regenerates it at runtime from STATION_NAME so an instance keeps its name.
The service worker only caches the app shell, never the live stream or the
playback APIs. Icons (192/512, maskable and apple-touch) are rasterized
from the favicon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:09:08 +08:00
22 changed files with 771 additions and 296 deletions

View file

@ -7,7 +7,10 @@
RADIEO_SUBSONIC_URL=https://subsonic.example.org
RADIEO_SUBSONIC_USER=monuser
RADIEO_SUBSONIC_PASSWORD=monmotdepasse
# Nom OU identifiant de la playlist à diffuser.
# Playlist(s) à diffuser : nom OU identifiant, séparés par des virgules.
# Chaque playlist peut être pondérée avec un suffixe '=<nombre>' (défaut : 1)
# pour ajuster sa fréquence de tirage ; un poids de 0 la désactive.
# RADIEO_SUBSONIC_PLAYLIST=Chill=3, Focus, Party=2
RADIEO_SUBSONIC_PLAYLIST=Radio
# --- Source yt-dlp ---

View file

@ -68,7 +68,9 @@ cp .env.example .env
Fill in `.env`:
- **OpenSubsonic server**: `RADIEO_SUBSONIC_URL` / `USER` / `PASSWORD` and the
playlist to broadcast in `RADIEO_SUBSONIC_PLAYLIST` (name or id). Works with
playlist(s) to broadcast in `RADIEO_SUBSONIC_PLAYLIST` — a comma-separated
list of names or ids, each optionally weighted with a `=<number>` suffix
(e.g. `Chill=3, Focus, Party=2`; default weight 1, `0` disables). Works with
any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Leave empty
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
@ -89,6 +91,14 @@ Add one URL per line: a single track, or a playlist/album/label/artist page to
pick from. The file is mounted read-only, so you can edit it without rebuilding.
A missing file just disables the yt-dlp source.
To favour fresh music, set `RADIEO_YTDLP_RECENT_BOOST` (in `.env`) above `1.0`:
when picking from a Bandcamp label/artist/discography page — which lists
releases newest-first — the newest `RADIEO_YTDLP_RECENT_COUNT` releases (default
`5`) get that multiplier on their odds (e.g. `2.0` makes them twice as likely).
The default `1.0` disables it. The boost only applies to such discography
listings; single `/album/` and `/track/` pages and non-Bandcamp sources keep a
uniform pick.
### 4. ListenBrainz suggestions
Point `RADIEO_LISTENBRAINZ_URL` (in `.env`) at your recommendations syndication

View file

@ -7,8 +7,16 @@
# -> radieo y pioche un morceau au hasard à chaque tour,
# en évitant ceux joués récemment.
#
# Poids (optionnel) : préfixer la ligne par `POIDS:URL` pour ajuster la
# fréquence de tirage d'une source. Sans préfixe, le poids vaut 1. Le poids est
# relatif aux autres lignes ; un poids de 0 désactive la ligne. La lecture est
# non ambiguë : un schéma d'URL ne peut jamais être un nombre.
# 3:https://… -> tirée 3× plus souvent qu'une ligne de poids 1
# 0.5:https://… -> tirée 2× moins souvent
# 0:https://… -> désactivée
#
# Exemples (à remplacer par les tiens) :
# https://www.youtube.com/watch?v=dQw4w9WgXcQ
# https://soundcloud.com/artiste/un-morceau
# 2:https://soundcloud.com/artiste/un-morceau
# https://artiste.bandcamp.com/album/un-album
# https://www.youtube.com/playlist?list=PLxxxxxxxx
# 3:https://www.youtube.com/playlist?list=PLxxxxxxxx

View file

@ -43,12 +43,12 @@ def _build_pipeline(db: Database, canonicalizer):
subsonic_client = SubsonicClient(
config.SUBSONIC_URL, config.SUBSONIC_USER, config.SUBSONIC_PASSWORD
)
provider = SubsonicProvider(subsonic_client, config.SUBSONIC_PLAYLIST, db)
provider = SubsonicProvider(subsonic_client, config.SUBSONIC_PLAYLISTS, db)
providers.append((provider, config.SOURCE_WEIGHTS.get("subsonic", 0)))
fetchers["subsonic"] = SubsonicFetcher(subsonic_client, config.CACHE_DIR)
log.info(
"OpenSubsonic source enabled (playlist=%r, weight=%d)",
config.SUBSONIC_PLAYLIST,
"OpenSubsonic source enabled (playlists=%r, weight=%d)",
config.SUBSONIC_PLAYLISTS,
config.SOURCE_WEIGHTS.get("subsonic", 0),
)
else:

View file

@ -55,21 +55,65 @@ USER_AGENT = os.environ.get(
SUBSONIC_URL = os.environ.get("RADIEO_SUBSONIC_URL", "").strip()
SUBSONIC_USER = os.environ.get("RADIEO_SUBSONIC_USER", "").strip()
SUBSONIC_PASSWORD = os.environ.get("RADIEO_SUBSONIC_PASSWORD", "")
SUBSONIC_PLAYLIST = os.environ.get("RADIEO_SUBSONIC_PLAYLIST", "").strip()
def _parse_playlists(raw: str) -> list[tuple[str, float]]:
"""Parse a comma-separated 'ref[=weight]' playlist list.
Each item is a playlist name or id, with an optional relative weight after
'=' (e.g. 'Chill Vibes=3'). Refs are freeform they may contain digits or
colons so the weight is a trailing '=<number>' rather than a prefix. An
item whose '=' tail is not numeric is taken as part of the name (no weight,
default 1). A non-positive weight disables the playlist.
"""
out: list[tuple[str, float]] = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
ref, sep, tail = item.rpartition("=")
if sep:
try:
weight = float(tail.strip())
except ValueError:
ref, weight = item, 1.0 # '=' belongs to the name, not a weight
else:
ref = ref.strip()
else:
ref, weight = item, 1.0
if ref and weight > 0:
out.append((ref, weight))
return out
# Comma-separated list of playlists, each optionally weighted with '=<number>'.
SUBSONIC_PLAYLISTS = _parse_playlists(
os.environ.get("RADIEO_SUBSONIC_PLAYLIST", "")
)
# How often to reload the playlist contents, in seconds.
PLAYLIST_REFRESH = float(os.environ.get("RADIEO_PLAYLIST_REFRESH", "300"))
SUBSONIC_ENABLED = bool(
SUBSONIC_URL and SUBSONIC_USER and SUBSONIC_PASSWORD and SUBSONIC_PLAYLIST
SUBSONIC_URL and SUBSONIC_USER and SUBSONIC_PASSWORD and SUBSONIC_PLAYLISTS
)
# --- yt-dlp source ---
# Plain text file (one URL per line, '#' comments) mounted into the container.
# May contain direct track URLs or container URLs (playlist/album/label/artist),
# from which one track is picked at random. Left absent disables the source.
# from which one track is picked at random. A line may carry an optional
# 'WEIGHT:URL' prefix to bias how often it is drawn. Left absent disables it.
YTDLP_URLS_FILE = Path(os.environ.get("RADIEO_YTDLP_URLS_FILE", "/config/urls.txt"))
# How often to reload the URL list and re-expand container URLs, in seconds.
YTDLP_REFRESH = float(os.environ.get("RADIEO_YTDLP_REFRESH", "300"))
# Recency boost. Bandcamp label/artist/discography pages list releases
# newest-first, so the first entries are the freshest. When picking a release
# from such a listing, the newest RADIEO_YTDLP_RECENT_COUNT entries get this
# multiplier on their selection weight, biasing the radio towards new music.
# 1.0 disables the boost (uniform pick); 2.0 makes fresh releases twice as
# likely per slot. Applied only at the top level of a source (a discography):
# once drilled into an album, entry order is track order, not recency.
YTDLP_RECENT_BOOST = float(os.environ.get("RADIEO_YTDLP_RECENT_BOOST", "1.0"))
YTDLP_RECENT_COUNT = int(os.environ.get("RADIEO_YTDLP_RECENT_COUNT", "5"))
# --- ListenBrainz suggestions source ---
# Atom recommendations feed. May be an http(s) URL (the real syndication feed)

View file

@ -79,6 +79,26 @@ class Database:
).fetchall()
return {r["track_key"] for r in rows}
def last_played_at(self, keys: set[str]) -> dict[str, float]:
"""Map each of ``keys`` to the timestamp of its most recent play.
Keys never played are absent from the result (treat as played "at 0",
i.e. longest ago). Used by the anti-repeat fallback to play the
least-recently-heard candidate instead of a random one when every
candidate is within the recent window.
"""
if not keys:
return {}
placeholders = ",".join("?" * len(keys))
with self._lock:
rows = self._conn.execute(
"SELECT track_key, MAX(played_at) AS last"
f" FROM history WHERE track_key IN ({placeholders})"
" GROUP BY track_key",
tuple(keys),
).fetchall()
return {r["track_key"]: r["last"] for r in rows}
def recent_locators(self, limit: int) -> set[str]:
"""Raw backend locators recently played (providers' cheap local filter)."""
with self._lock:

View file

@ -103,8 +103,16 @@ class ListenBrainzProvider:
if not recs:
return None
recent = self._db.recent_keys(config.ANTIREPEAT_WINDOW)
pool = [r for r in recs if f"mbid:{r['mbid']}" not in recent] or list(recs)
random.shuffle(pool)
fresh = [r for r in recs if f"mbid:{r['mbid']}" not in recent]
if fresh:
random.shuffle(fresh) # genuinely unheard recently: order is free
pool = fresh
else:
# Every rec is within the recent window (small feed / large window):
# don't replay at random, march through them least-recently-heard
# first so each recurs at the widest spacing the feed allows.
last = self._db.last_played_at({f"mbid:{r['mbid']}" for r in recs})
pool = sorted(recs, key=lambda r: last.get(f"mbid:{r['mbid']}", 0.0))
for rec in pool:
track = self._resolve(rec)
if track is not None:

View file

@ -1,11 +1,17 @@
"""SubsonicProvider: picks tracks from an OpenSubsonic playlist.
"""SubsonicProvider: picks tracks from one or more OpenSubsonic playlists.
Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic).
Emits ``subsonic`` tracks (locator = song id). The playlist is cached in
memory and refreshed periodically. A cheap local anti-repeat filters out songs
whose id was played recently; if that empties the pool (short playlist), the
filter is dropped so playback never stalls. The authoritative, source-agnostic
anti-repeat lives in the Scheduler (on the canonical key).
Emits ``subsonic`` tracks (locator = song id). Each configured playlist is
cached in memory and refreshed periodically. On every pick the provider walks
its playlists in weighted-random order (per-playlist weights bias which one is
drawn from, independently of playlist length) and returns a song from the first
that yields one so an empty, renamed or briefly-unreachable playlist never
stalls the source.
A cheap local anti-repeat filters out songs whose id was played recently; if
that empties a playlist's pool (short playlist), the filter is dropped for it so
playback never stalls. The authoritative, source-agnostic anti-repeat lives in
the Scheduler (on the canonical key).
"""
import logging
@ -22,49 +28,73 @@ from ..subsonic import SubsonicClient, SubsonicError
log = logging.getLogger("radieo.provider.subsonic")
class _Playlist:
"""Cached state for a single configured playlist."""
def __init__(self, ref: str, weight: float):
self.ref = ref
self.weight = weight
self.id: str | None = None
self.songs: list[dict] = []
self.loaded_at = 0.0
class SubsonicProvider:
name = "subsonic"
def __init__(self, client: SubsonicClient, playlist_ref: str, db: Database):
def __init__(
self, client: SubsonicClient, playlists: list[tuple[str, float]], db: Database
):
self._client = client
self._playlist_ref = playlist_ref
self._playlists = [_Playlist(ref, weight) for ref, weight in playlists]
self._db = db
self._playlist_id: str | None = None
self._songs: list[dict] = []
self._loaded_at = 0.0
def _ensure_songs(self) -> None:
def _ensure_songs(self, pl: _Playlist) -> None:
now = time.time()
if self._songs and now - self._loaded_at < config.PLAYLIST_REFRESH:
if pl.songs and now - pl.loaded_at < config.PLAYLIST_REFRESH:
return
if self._playlist_id is None:
self._playlist_id = self._client.resolve_playlist_id(
self._playlist_ref
)
songs = self._client.get_playlist_songs(self._playlist_id)
self._songs = songs
self._loaded_at = now
log.info("loaded %d songs from playlist %r", len(songs), self._playlist_ref)
if pl.id is None:
pl.id = self._client.resolve_playlist_id(pl.ref)
songs = self._client.get_playlist_songs(pl.id)
pl.songs = songs
pl.loaded_at = now
log.info("loaded %d songs from playlist %r", len(songs), pl.ref)
def _weighted_order(self) -> list[_Playlist]:
"""Playlists ordered by weighted-random sampling without replacement.
Same EfraimidisSpirakis key as the yt-dlp provider
(``random() ** (1 / weight)``): a heavier playlist tends to come first
while staying probabilistic, and the ordering lets ``next`` fall through
to the following playlist when one is empty or fails to load.
"""
keyed = [
(random.random() ** (1.0 / pl.weight), i, pl)
for i, pl in enumerate(self._playlists)
]
keyed.sort(reverse=True)
return [pl for _, _, pl in keyed]
def next(self) -> Track | None:
try:
self._ensure_songs()
except (SubsonicError, httpx.HTTPError, OSError) as exc:
log.warning("could not load playlist: %s", exc)
return None
if not self._songs:
return None
recent = self._db.recent_locators(config.ANTIREPEAT_WINDOW)
candidates = [
s for s in self._songs if str(s["id"]) not in recent
] or self._songs
song = random.choice(candidates)
return Track(
backend="subsonic",
locator=str(song["id"]),
artist=song.get("artist", "Unknown artist"),
title=song.get("title", str(song["id"])),
origin=self.name,
source_ext=song.get("suffix"),
)
for pl in self._weighted_order():
try:
self._ensure_songs(pl)
except (SubsonicError, httpx.HTTPError, OSError) as exc:
log.warning("could not load playlist %r: %s", pl.ref, exc)
continue
if not pl.songs:
continue
candidates = [
s for s in pl.songs if str(s["id"]) not in recent
] or pl.songs
song = random.choice(candidates)
return Track(
backend="subsonic",
locator=str(song["id"]),
artist=song.get("artist", "Unknown artist"),
title=song.get("title", str(song["id"])),
origin=self.name,
source_ext=song.get("suffix"),
)
return None

View file

@ -6,6 +6,17 @@ be either a direct track URL or a *container* URL (playlist, album, label,
artist page); container URLs are expanded with a flat yt-dlp extraction and one
entry is picked at random, honouring the anti-repeat window.
A line may carry an optional relative weight as a ``WEIGHT:URL`` prefix (e.g.
``3:https://``). The parse is unambiguous because URL schemes must start with a
letter, so a numeric part before the first ``:`` can only be a weight. Lines
without a prefix default to weight ``1``; a weight of ``0`` disables the line.
Weights bias the *order* in which sources are tried each pick.
Within a discography (a label/artist page), the newest releases can be given a
selection boost (``YTDLP_RECENT_BOOST``): bandcamp lists releases newest-first,
so the first ``YTDLP_RECENT_COUNT`` entries are favoured, keeping the radio
leaning towards fresh music.
The provider only *resolves* references it emits ``ytdlp`` tracks whose
locator is the chosen media URL. The actual download happens in the matching
fetcher (milestone 4). Flat extraction is cached per source URL to avoid
@ -15,6 +26,7 @@ re-hitting the network on every pick.
import logging
import random
import time
import urllib.parse
from pathlib import Path
from .. import config, tagging
@ -37,7 +49,8 @@ class YtdlpProvider:
self._urls_file = urls_file
self._db = db
self._cache_dir = cache_dir
self._urls: list[str] = []
# (url, weight) pairs; weight > 0, disabled lines are dropped on load.
self._urls: list[tuple[str, float]] = []
self._loaded_at = 0.0
# source URL -> (entries, loaded_at); entries are normalized dicts.
self._expanded: dict[str, tuple[list[dict], float]] = {}
@ -57,13 +70,39 @@ class YtdlpProvider:
urls = []
for line in lines:
line = line.strip()
if line and not line.startswith("#"):
urls.append(line)
if not line or line.startswith("#"):
continue
url, weight = self._parse_line(line)
if weight > 0:
urls.append((url, weight))
if urls != self._urls:
log.info("loaded %d yt-dlp source URL(s)", len(urls))
self._urls = urls
self._loaded_at = now
@staticmethod
def _parse_line(line: str) -> tuple[str, float]:
"""Split an optional ``WEIGHT:URL`` prefix off a source line.
The part before the first ``:`` is a weight only if it parses as a
number; a URL scheme can never be purely numeric (RFC 3986 requires a
leading letter), so there is no ambiguity. Unprefixed lines default to
weight ``1``; a non-positive or malformed weight is logged and the line
is disabled (weight ``0``).
"""
head, sep, rest = line.partition(":")
if not sep:
return line, 1.0
try:
weight = float(head)
except ValueError:
return line, 1.0 # no numeric prefix -> the whole line is the URL
rest = rest.strip()
if weight < 0 or not rest:
log.warning("ignoring yt-dlp source line with bad weight: %r", line)
return rest, 0.0
return rest, weight
# --- resolution -------------------------------------------------------
def next(self) -> Track | None:
@ -71,15 +110,27 @@ class YtdlpProvider:
if not self._urls:
return None
recent = self._db.recent_locators(config.ANTIREPEAT_WINDOW)
# Try source lines in random order until one yields a usable track.
candidates = list(self._urls)
random.shuffle(candidates)
for src in candidates:
# Try source lines in weighted-random order until one yields a track.
for src in self._weighted_order():
track = self._resolve(src, recent)
if track is not None:
return track
return None
def _weighted_order(self) -> list[str]:
"""Source URLs ordered by weighted-random sampling without replacement.
Uses the EfraimidisSpirakis key ``random() ** (1 / weight)``: a higher
weight makes a larger key likely, so heavier sources tend to come first
while still being probabilistic. Sampling without replacement keeps the
current "fall through to the next source when one fails" behaviour.
"""
keyed = [
(random.random() ** (1.0 / weight), url) for url, weight in self._urls
]
keyed.sort(reverse=True)
return [url for _, url in keyed]
def _resolve(self, src_url: str, recent: set[str]) -> Track | None:
try:
entry = self._pick_entry(src_url, recent, 0)
@ -134,7 +185,7 @@ class YtdlpProvider:
if len(entries) == 1 and entries[0]["url"] == src_url:
return entries[0] # leaf: a track resolves to itself
pool = [e for e in entries if e["url"] not in recent] or entries
entry = random.choice(pool)
entry = self._choose(src_url, pool, entries)
url = entry["url"]
if url == src_url or depth >= self._MAX_EXPAND_DEPTH:
return entry
@ -142,6 +193,45 @@ class YtdlpProvider:
# Fall back to the entry as-is if that turns up nothing usable.
return self._pick_entry(url, recent, depth + 1) or entry
@staticmethod
def _choose(src_url: str, pool: list[dict], entries: list[dict]) -> dict:
"""Pick one entry, favouring the newest releases of a discography.
Bandcamp label/artist/discography pages return releases newest-first, so
the first ``YTDLP_RECENT_COUNT`` entries are the freshest; they get
``YTDLP_RECENT_BOOST`` times the weight of the rest, biasing selection
towards new music without needing a per-release date lookup (flat
extraction carries no dates).
The boost only applies when ``src_url`` is such a discography listing
(see ``_boostable``). For a single ``/album/`` or ``/track/`` source
whether it is a line in the URL file or one the pick recursed into the
entries are tracks in track order, where position says nothing about
recency, so the pick stays uniform, as it does when the boost is
disabled (``== 1.0``).
"""
boost = config.YTDLP_RECENT_BOOST
if boost == 1.0 or not YtdlpProvider._boostable(src_url):
return random.choice(pool)
newest = {e["url"] for e in entries[: config.YTDLP_RECENT_COUNT]}
weights = [boost if e["url"] in newest else 1.0 for e in pool]
return random.choices(pool, weights=weights, k=1)[0]
@staticmethod
def _boostable(src_url: str) -> bool:
"""True when ``src_url`` lists releases newest-first (a discography).
The recency boost relies on bandcamp ordering a label/artist/music page
newest-first. That does not hold for a single ``/album/`` or ``/track/``
page (those list tracks in track order) nor for non-bandcamp sources
whose ordering we have not verified, so the boost is limited to bandcamp
discography URLs.
"""
if not tagging.is_bandcamp({}, src_url):
return False
path = urllib.parse.urlsplit(src_url).path
return "/album/" not in path and "/track/" not in path
def _resolved_tags(self, locator: str):
"""Corrected identity a previous download wrote into the cached file.

View file

@ -34,7 +34,7 @@ class Scheduler:
if not self._entries:
return None
recent = self._db.recent_keys(config.ANTIREPEAT_WINDOW)
last = None
drawn = []
for _ in range(config.SCHEDULER_MAX_TRIES):
track = self._pick()
if track is None:
@ -42,10 +42,14 @@ class Scheduler:
track = self._canonicalizer.canonicalize(track)
if track.key not in recent:
return track
last = track # recently played; try another
drawn.append(track) # recently played; try another
log.debug("skipping recent %s", track)
# Everything drawn was recent (e.g. tiny library): play the last anyway.
return last
# Every draw was recent (e.g. tiny library): don't just replay the last
# one drawn — play whichever of them we've gone longest without hearing.
if not drawn:
return None
last = self._db.last_played_at({t.key for t in drawn})
return min(drawn, key=lambda t: last.get(t.key, 0.0))
def _pick(self):
"""Weighted provider draw, falling through to the others when empty."""

View file

@ -1,7 +1,15 @@
FROM savonet/liquidsoap:v2.4.5
COPY radio.liq /etc/liquidsoap/radio.liq
COPY web.liq /etc/liquidsoap/web.liq
COPY ingest_proxy.liq /etc/liquidsoap/ingest_proxy.liq
COPY index.html /etc/liquidsoap/index.html
COPY favicon.svg /etc/liquidsoap/favicon.svg
COPY manifest.webmanifest /etc/liquidsoap/manifest.webmanifest
COPY sw.js /etc/liquidsoap/sw.js
COPY icon-192.png /etc/liquidsoap/icon-192.png
COPY icon-512.png /etc/liquidsoap/icon-512.png
COPY icon-maskable-512.png /etc/liquidsoap/icon-maskable-512.png
COPY apple-touch-icon.png /etc/liquidsoap/apple-touch-icon.png
CMD ["/etc/liquidsoap/radio.liq"]

BIN
stream/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
stream/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
stream/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

49
stream/icon-maskable.svg Normal file
View file

@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- Variante « full-bleed » du favicon, destinée aux icônes d'application
(PWA maskable + apple-touch-icon). Le fond couvre tout le carré (pas de
coins arrondis : Android/iOS appliquent leur propre masque) et le motif
est ramené dans la zone de sécurité centrale (~80%) pour ne pas être
rogné par un masque circulaire. -->
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1a1030"/>
<stop offset="1" stop-color="#3a1145"/>
</linearGradient>
<linearGradient id="sun" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffe15a"/>
<stop offset="0.5" stop-color="#ff5c8a"/>
<stop offset="1" stop-color="#9b4dff"/>
</linearGradient>
<linearGradient id="grid" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#9b8cff" stop-opacity="0"/>
<stop offset="1" stop-color="#ff4fd8" stop-opacity="0.9"/>
</linearGradient>
</defs>
<rect width="64" height="64" fill="url(#sky)"/>
<!-- Motif ramené dans la zone de sécurité : translation + échelle 0.72. -->
<g transform="translate(9 9) scale(0.72)" clip-path="none">
<g>
<circle cx="32" cy="27" r="16" fill="url(#sun)"/>
<g fill="#1a1030">
<rect x="14" y="30" width="36" height="1.6"/>
<rect x="14" y="33.5" width="36" height="2.2"/>
<rect x="14" y="37.5" width="36" height="3"/>
<rect x="14" y="42" width="36" height="4"/>
</g>
</g>
<rect x="0" y="45" width="64" height="1.4" fill="#ff4fd8"/>
<g stroke="url(#grid)" stroke-width="1">
<line x1="32" y1="46" x2="-8" y2="66"/>
<line x1="32" y1="46" x2="8" y2="66"/>
<line x1="32" y1="46" x2="24" y2="66"/>
<line x1="32" y1="46" x2="40" y2="66"/>
<line x1="32" y1="46" x2="56" y2="66"/>
<line x1="32" y1="46" x2="72" y2="66"/>
<line x1="0" y1="50" x2="64" y2="50"/>
<line x1="0" y1="55" x2="64" y2="55"/>
<line x1="0" y1="61" x2="64" y2="61"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -5,6 +5,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title id="pageTitle"></title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- Application installable (PWA). Le manifeste décrit nom, icônes et mode
d'affichage ; les balises Apple font l'équivalent sur iOS, qui n'exploite
pas le manifeste pour l'ajout à l'écran d'accueil. La couleur de thème
teinte la barre système en mode autonome. -->
<link rel="manifest" href="/manifest.webmanifest" id="manifestLink">
<meta name="theme-color" content="#0d0b14">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="" id="appleTitle">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
@ -235,6 +246,39 @@
document.getElementById("stationName").textContent = "◈ " + STATION_NAME;
document.getElementById("pageTitle").textContent = STATION_NAME;
// Le manifeste statique reste générique (« Radieo ») pour être réutilisable ;
// on le régénère ici à partir de STATION_NAME, l'unique réglage de la station,
// afin que l'app installée porte le nom de l'instance. Le manifeste est servi
// en data URL, donc toutes les URLs qu'il contient doivent être absolues.
(async () => {
try {
const link = document.getElementById("manifestLink");
const base = await fetch(link.href, { cache: "no-store" }).then((r) => r.json());
const abs = (p) => new URL(p, location.origin).href;
const manifest = {
...base,
name: STATION_NAME,
short_name: STATION_NAME,
start_url: abs(base.start_url || "/"),
scope: abs(base.scope || "/"),
icons: (base.icons || []).map((ic) => ({ ...ic, src: abs(ic.src) })),
};
const blob = new Blob([JSON.stringify(manifest)], { type: "application/manifest+json" });
link.href = URL.createObjectURL(blob);
} catch (e) { /* on garde le manifeste statique générique */ }
// iOS n'exploite pas le manifeste : on lui donne le nom via sa balise dédiée.
document.getElementById("appleTitle").setAttribute("content", STATION_NAME);
})();
// Enregistrement du service worker : condition pour que la webapp soit
// « installable » sur mobile (Android/Chrome) et pour ouvrir la fenêtre
// autonome depuis le cache. Échec silencieux si non supporté.
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}
// Fond aléatoire repris de l'écran de connexion Navidrome : on récupère la
// liste de leur galerie (index.yml, CORS ouvert), on tire un nom au hasard
// et on sert la version .webp depuis leur CDN — même logique que Navidrome,

117
stream/ingest_proxy.liq Normal file
View file

@ -0,0 +1,117 @@
# radieo — reverse-proxy vers le daemon d'ingestion.
#
# Inclus par radio.liq. Le player n'a pas d'accès direct au réseau interne
# (l'ingest n'est joignable que depuis les autres conteneurs) : on relaie donc
# ces quelques endpoints à travers le harbor du flux (port 8000). Ces routes
# sont autonomes — elles ne dépendent que de `http`/`url`, jamais du pipeline —
# et renvoient une valeur neutre plutôt qu'une erreur si l'ingest est
# injoignable, pour ne pas casser le player.
# File d'attente des prochains morceaux, relayée depuis le daemon d'ingestion.
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 {ready, prefetch}. Si le daemon est injoignable on
# renvoie un objet neutre plutôt qu'une erreur, pour ne pas casser le player.
ingest_status_url = "http://ingest:8080/status"
harbor.http.register(
port=8000, method="GET", "/ingest/status",
fun(_, resp) -> begin
resp.content_type("application/json; charset=utf-8")
body = http.get(ingest_status_url, timeout=5.0)
if body.status_code == 200 then
resp.data(string.trim(body) ^ "\n")
else
resp.data("{}")
end
end
)
# Mettre une URL yt-dlp en file d'attente (piste seule, ou playlist/album
# entier). On relaie la demande vers l'ingest, qui résout l'URL et la place en
# file prioritaire (le prochain /next la servira). On renvoie tel quel son code
# et son corps JSON ({queued: N} ou une erreur). Timeout large : résoudre une
# grosse playlist peut prendre du temps. NB : la variable locale s'appelle
# `link`, pas `url`, pour ne pas masquer le module `url` (url.encode).
ingest_enqueue_url = "http://ingest:8080/enqueue"
harbor.http.register(
port=8000, method="POST", "/enqueue",
fun(req, resp) -> begin
link = list.assoc(default="", "url", req.query)
if link == "" then
resp.status_code(400)
resp.data("missing url")
else
body = http.post(
data="", timeout=60.0, "#{ingest_enqueue_url}?url=#{url.encode(link)}"
)
resp.status_code(body.status_code)
resp.content_type("application/json; charset=utf-8")
resp.data(string.trim(body) ^ "\n")
end
end
)
# Retirer un morceau de la file d'attente. Symétrique de /enqueue : on relaie la
# demande (l'`id` opaque fourni par /queue) vers l'ingest, qui retire l'entrée
# correspondante. On renvoie tel quel son code et son corps JSON ({removed: true}
# ou une erreur).
ingest_dequeue_url = "http://ingest:8080/dequeue"
harbor.http.register(
port=8000, method="POST", "/dequeue",
fun(req, resp) -> begin
id = list.assoc(default="", "id", req.query)
if id == "" then
resp.status_code(400)
resp.data("missing id")
else
body = http.post(
data="", timeout=10.0, "#{ingest_dequeue_url}?id=#{url.encode(id)}"
)
resp.status_code(body.status_code)
resp.content_type("application/json; charset=utf-8")
resp.data(string.trim(body) ^ "\n")
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
)

View file

@ -0,0 +1,18 @@
{
"name": "Radieo",
"short_name": "Radieo",
"description": "Radio synthwave en direct.",
"lang": "fr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0d0b14",
"theme_color": "#0d0b14",
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

View file

@ -220,234 +220,10 @@ output.harbor(
radio
)
# --- 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")
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", "/nowplaying",
fun(_, resp) -> begin
m = now_playing()
resp.json({title=m["title"], artist=m["artist"], url=m["url"], origin=m["origin"]})
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())
)
# 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
# on renvoie un objet neutre plutôt qu'une erreur, pour ne pas casser le player.
ingest_status_url = "http://ingest:8080/status"
harbor.http.register(
port=8000, method="GET", "/ingest/status",
fun(_, resp) -> begin
resp.content_type("application/json; charset=utf-8")
body = http.get(ingest_status_url, timeout=5.0)
if body.status_code == 200 then
resp.data(string.trim(body) ^ "\n")
else
resp.data("{}")
end
end
)
# Mettre une URL yt-dlp en file d'attente (piste seule, ou playlist/album
# entier). Le player n'a pas accès au réseau interne : on relaie la demande vers
# l'ingest, qui résout l'URL et la place en file prioritaire (le prochain /next
# la servira). On renvoie tel quel son code et son corps JSON ({queued: N} ou
# une erreur). Timeout large : résoudre une grosse playlist peut prendre du
# temps. NB : la variable locale s'appelle `link`, pas `url`, pour ne pas
# masquer le module `url` (url.encode).
ingest_enqueue_url = "http://ingest:8080/enqueue"
harbor.http.register(
port=8000, method="POST", "/enqueue",
fun(req, resp) -> begin
link = list.assoc(default="", "url", req.query)
if link == "" then
resp.status_code(400)
resp.data("missing url")
else
body = http.post(
data="", timeout=60.0, "#{ingest_enqueue_url}?url=#{url.encode(link)}"
)
resp.status_code(body.status_code)
resp.content_type("application/json; charset=utf-8")
resp.data(string.trim(body) ^ "\n")
end
end
)
# Retirer un morceau de la file d'attente. Symétrique de /enqueue : le player
# n'a pas accès au réseau interne, on relaie donc la demande (l'`id` opaque
# fourni par /queue) vers l'ingest, qui retire l'entrée correspondante. On
# renvoie tel quel son code et son corps JSON ({removed: true} ou une erreur).
ingest_dequeue_url = "http://ingest:8080/dequeue"
harbor.http.register(
port=8000, method="POST", "/dequeue",
fun(req, resp) -> begin
id = list.assoc(default="", "id", req.query)
if id == "" then
resp.status_code(400)
resp.data("missing id")
else
body = http.post(
data="", timeout=10.0, "#{ingest_dequeue_url}?id=#{url.encode(id)}"
)
resp.status_code(body.status_code)
resp.content_type("application/json; charset=utf-8")
resp.data(string.trim(body) ^ "\n")
end
end
)
# 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=<nom>` :
# 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
)
# 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
)
# --- 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"

61
stream/sw.js Normal file
View file

@ -0,0 +1,61 @@
// Service worker minimal : son seul rôle indispensable est de rendre la webapp
// « installable » (critère PWA d'Android/Chrome) en présentant un gestionnaire
// de requêtes. Accessoirement, il met en cache la coquille de l'application
// (page, icônes, manifest) pour que la fenêtre autonome s'ouvre instantanément,
// même hors ligne.
//
// Rien de ce qui est « vivant » ne passe par le cache : le flux audio
// (/radio.mp3) et toutes les API de lecture (/nowplaying, /history, /queue,
// /skip…) vont toujours directement au réseau. On se contente donc de gérer les
// quelques ressources statiques de la coquille.
const CACHE = "nemufm-shell-v1";
const SHELL = [
"/",
"/manifest.webmanifest",
"/favicon.svg",
"/icon-192.png",
"/icon-512.png",
"/icon-maskable-512.png",
"/apple-touch-icon.png",
];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener("activate", (e) => {
// Purge des anciennes versions de coquille.
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (e) => {
const req = e.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
// Hors périmètre (CDN d'arrière-plan…) ou ressources vivantes : on laisse le
// navigateur faire, sans interception ni cache.
if (url.origin !== location.origin) return;
const shellPaths = new Set(SHELL);
const isShell = url.pathname === "/" ? true : shellPaths.has(url.pathname);
if (!isShell) return; // flux et API : réseau direct, pas de cache.
// Coquille : réseau d'abord (pour récupérer une version fraîche), repli sur le
// cache si le réseau manque, avec mise à jour du cache au passage.
e.respondWith(
fetch(req)
.then((resp) => {
const copy = resp.clone();
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
return resp;
})
.catch(() => caches.match(req).then((r) => r || caches.match("/")))
);
});

185
stream/web.liq Normal file
View file

@ -0,0 +1,185 @@
# 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()
resp.json({title=m["title"], artist=m["artist"], url=m["url"], origin=m["origin"]})
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=<nom>` :
# 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
)