Compare commits
6 commits
976f009297
...
112a4b0c89
| Author | SHA1 | Date | |
|---|---|---|---|
| 112a4b0c89 | |||
| 49783218d8 | |||
| 40061446c9 | |||
| 5dd50f37b7 | |||
| 8054c98dd1 | |||
| c73b71d32f |
22 changed files with 771 additions and 296 deletions
|
|
@ -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 ---
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 Efraimidis–Spirakis 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
|
||||
|
|
|
|||
|
|
@ -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 Efraimidis–Spirakis 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
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
BIN
stream/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
stream/icon-512.png
Normal file
BIN
stream/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
stream/icon-maskable-512.png
Normal file
BIN
stream/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
49
stream/icon-maskable.svg
Normal file
49
stream/icon-maskable.svg
Normal 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 |
|
|
@ -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
117
stream/ingest_proxy.liq
Normal 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
|
||||
)
|
||||
18
stream/manifest.webmanifest
Normal file
18
stream/manifest.webmanifest
Normal 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" }
|
||||
]
|
||||
}
|
||||
238
stream/radio.liq
238
stream/radio.liq
|
|
@ -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
61
stream/sw.js
Normal 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
185
stream/web.liq
Normal 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
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue