From 32fae3dbbae715c4c6c69a879c9780f23efe2490 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 3 Jul 2026 12:45:57 +0800 Subject: [PATCH 1/5] stream: play a scheduled jingle right after key times of day Adds special jingle folders (midi, gouter, bisous) that take priority over the default jingle rotation once per day, briefly after 11h00, 15h00, 16h30 and 21h00. Co-Authored-By: Claude Opus 4.8 --- stream/radio.liq | 60 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/stream/radio.liq b/stream/radio.liq index 5efec84..a738352 100644 --- a/stream/radio.liq +++ b/stream/radio.liq @@ -78,10 +78,64 @@ music = fallback(track_sensitive=true, [main, backup]) # reload_mode="watch" : un jingle ajouté/retiré est pris en compte à chaud. # Dossier vide → source jamais prête → le switch retombe simplement sur la # musique, sans jingle et sans plantage. +# file.ls (utilisé en interne par playlist pour lister un dossier) n'est pas +# récursif par défaut : les sous-dossiers spéciaux ci-dessous n'entrent donc +# pas dans la rotation par défaut. jingles = playlist( mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles" ) +# Jingles spéciaux, joués une seule fois juste après une heure donnée (au +# prochain jingle qui suit ce moment), plutôt que le jingle par défaut. +jingles_midi = playlist( + mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles/midi" +) +jingles_gouter = playlist( + mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles/gouter" +) +jingles_moment = playlist( + mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles/moment" +) + +# Fenêtre (en minutes) après l'heure cible pendant laquelle le jingle spécial +# reste éligible : assez courte pour rester "juste après", assez large pour +# laisser passer au moins une occasion de jingle (toutes les ~2 chansons). +special_jingle_window = 10 + +# Identifiant du jour courant (année * 366 + jour de l'année), pour ne +# déclencher chaque créneau spécial qu'une seule fois par jour. +def day_key() = + t = time.local() + t.year * 1000 + t.year_day +end + +# Construit un couple (prédicat, source) pour le switch : le prédicat devient +# vrai une seule fois par jour, à partir de hour:minute et pendant +# special_jingle_window minutes. +def make_special_slot(hour, minute, jingle_source) = + last_day = ref(-1) + slot_minutes = hour * 60 + minute + def due() = + t = time.local() + now_minutes = t.hour * 60 + t.min + in_window = now_minutes >= slot_minutes and now_minutes < slot_minutes + special_jingle_window + if in_window and last_day() != day_key() then + last_day := day_key() + true + else + false + end + end + (due, jingle_source) +end + +special_jingle_slots = [ + make_special_slot(11, 0, jingles_midi), + make_special_slot(15, 0, jingles_bisous), + make_special_slot(16, 30, jingles_gouter), + make_special_slot(21, 0, jingles_bisous), +] + # On compte les morceaux de musique réellement diffusés : on_track ne se # déclenche que sur la source effectivement tirée par le switch (les sources # non sélectionnées ne sont pas consommées), donc les jingles ne comptent pas. @@ -101,10 +155,12 @@ end # switch track_sensitive : la décision est prise aux frontières de morceaux, on # ne coupe donc jamais un titre. Si les jingles ne sont pas prêts (dossier vide), -# le switch enchaîne directement sur la musique. +# le switch enchaîne directement sur la musique. Les créneaux spéciaux sont +# testés en premier : un jingle "juste après" une heure donnée prend le pas sur +# le cycle normal des 2 chansons, une seule fois par jour. radio = switch( track_sensitive=true, - [(time_for_jingle, jingles), ({true}, music)] + list.append(special_jingle_slots, [(time_for_jingle, jingles), ({true}, music)]) ) # Transition douce entre les morceaux : fondu enchaîné de 3 s. La fin du From f065a0eda92136db7a29baa76e235b8cf0cec177 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 3 Jul 2026 12:48:45 +0800 Subject: [PATCH 2/5] ingest: resolve bandcamp label sources down to individual tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A yt-dlp source pointing at a label/artist page flat-extracts to a mix of /track/ and /album/ URLs. The provider used each verbatim as a locator, so an /album/ URL was handed to the fetcher as if it were a track: yt-dlp then (mis)downloaded the whole album into one file and tagged it from the playlist-level info, which carries the album title and no artist — surfacing as "Unknown artist" on the stream. Drill picked container entries down to a single track before emitting a locator, bounded by a small depth so nested containers (label -> album -> track) resolve while a real track (which flat-extracts to just itself) is the base case. Locators are now always downloadable tracks, so the existing tag/fetch path and anti-repeat keying work as intended. Also make guess_metadata trust the explicit artist tag over the "Artist - Title" title split: some label uploads double the artist into the title ("Artist - Artist - Title"), which the blind last-" - " split mis-parsed. When that artist prefixes the title we peel it off (repeatedly), falling back to the split only when there is no artist tag. Co-Authored-By: Claude Opus 4.8 --- ingest/radieo/providers/ytdlp.py | 59 ++++++++++++++++++++++++++++---- ingest/radieo/tagging.py | 33 +++++++++++------- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/ingest/radieo/providers/ytdlp.py b/ingest/radieo/providers/ytdlp.py index 77f1e3a..c106e1d 100644 --- a/ingest/radieo/providers/ytdlp.py +++ b/ingest/radieo/providers/ytdlp.py @@ -28,6 +28,11 @@ log = logging.getLogger("radieo.provider.ytdlp") class YtdlpProvider: name = "ytdlp" + # How many container levels (label -> album -> track) to expand while + # drilling down to a single track. One flat extraction of a label page is + # not enough: its /album/ entries are themselves containers. + _MAX_EXPAND_DEPTH = 3 + def __init__(self, urls_file: Path, db: Database, cache_dir: Path): self._urls_file = urls_file self._db = db @@ -77,26 +82,66 @@ class YtdlpProvider: def _resolve(self, src_url: str, recent: set[str]) -> Track | None: try: - entries = self._entries_for(src_url) + entry = self._pick_entry(src_url, recent, 0) except Exception as exc: # yt-dlp raises many extractor-specific errors log.warning("yt-dlp could not resolve %s: %s", src_url, exc) return None - if not entries: + if entry is None: return None - pool = [e for e in entries if e["url"] not in recent] or entries - entry = random.choice(pool) locator = entry["url"] meta = self._resolved_tags(locator) + if meta is not None: # a previous download already wrote real tags + artist, title, mbid = meta.artist, meta.title, meta.mbid + else: + # Split the entry's "Artist - Title" the same way the fetcher will + # after downloading, so the scheduler's canonical anti-repeat keys + # this provisional track the same before and after the download. + guess = tagging.guess_metadata( + {"title": entry.get("title"), "artist": entry.get("artist")} + ) + artist = guess.artist + title = guess.title if entry.get("title") else locator + mbid = None return Track( backend="ytdlp", locator=locator, - artist=(meta.artist if meta else entry.get("artist")) or "Unknown artist", - title=(meta.title if meta else entry.get("title")) or locator, + artist=artist, + title=title, origin=self.name, - mbid=meta.mbid if meta else None, + mbid=mbid, source_url=src_url if src_url != locator else None, ) + def _pick_entry(self, src_url: str, recent: set[str], depth: int) -> dict | None: + """Pick one *track* entry under ``src_url``, drilling through containers. + + A bandcamp label/artist page flat-extracts to a mix of ``/track/`` and + ``/album/`` URLs, and an ``/album/`` URL is itself a container. If such a + URL were emitted as a locator, the fetcher would (mis)download the whole + album into a single file and tag it with album-level, artist-less + metadata — the "Unknown artist" bug. So when the picked entry is itself a + container we recurse into it, bounded by ``_MAX_EXPAND_DEPTH``, until the + locator is a single downloadable track. + + A real track flat-extracts to just itself, which is the recursion's base + case. That leaf extraction is a full (non-flat) metadata fetch, so the + entry carries the track's real ``title``/``artist``; it is cached, so the + cost is paid once per refresh window. + """ + entries = self._entries_for(src_url) + if not entries: + return None + 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) + url = entry["url"] + if url == src_url or depth >= self._MAX_EXPAND_DEPTH: + return entry + # The picked entry may itself be a container (an album); drill into it. + # Fall back to the entry as-is if that turns up nothing usable. + return self._pick_entry(url, recent, depth + 1) or entry + def _resolved_tags(self, locator: str): """Corrected identity a previous download wrote into the cached file. diff --git a/ingest/radieo/tagging.py b/ingest/radieo/tagging.py index 7e9a1ce..c0941f8 100644 --- a/ingest/radieo/tagging.py +++ b/ingest/radieo/tagging.py @@ -41,21 +41,28 @@ def is_bandcamp(info: dict, locator: str) -> bool: def guess_metadata(info: dict, source_url: str | None = None) -> TagMeta: """Derive tags from a yt-dlp *download* info dict. - Priority mirrors the user's shell heuristic: the track title usually carries - ``"Artist - Title"`` (split on the *last* ``" - "``), which is more reliable - than the uploader on label accounts. When the title has no separator we fall - back to the explicit ``artist`` field, preferring it over the uploader/label. + On a bandcamp download the reliable signal is the explicit ``artist`` tag + (the performing artist, *not* the label the way ``uploader`` is). Label + uploads name the ``title`` ``"Artist - Title"`` — sometimes with the artist + doubled (``"Artist - Artist - Title"``) — so when that artist tag prefixes + the title we peel it off rather than guessing where to split. Only when + there is no such artist tag do we fall back to splitting the title on the + last ``" - "`` (and then to the uploader/label as a last resort). """ raw_title = (info.get("title") or "").strip() - artist, title = _split_artist_title(raw_title) - if not artist: - artist = ( - info.get("artist") - or info.get("creator") - or info.get("uploader") - or info.get("channel") - or "" - ).strip() + reliable = (info.get("artist") or info.get("creator") or "").strip() + prefix = f"{reliable} - " + if reliable and raw_title.startswith(prefix): + artist = reliable + title = raw_title + while title.startswith(prefix): # peel a doubled "Artist - " prefix too + title = title[len(prefix):].strip() + else: + artist, title = _split_artist_title(raw_title) + if not artist: + artist = reliable or ( + info.get("uploader") or info.get("channel") or "" + ).strip() if not title: title = raw_title or (info.get("track") or "").strip() From 6d2bc963db258f01cf3bc1d1008a2093b67ff353 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 3 Jul 2026 14:07:46 +0800 Subject: [PATCH 3/5] stream: add a synthwave favicon Co-Authored-By: Claude Opus 4.8 --- stream/Dockerfile | 1 + stream/favicon.svg | 52 ++++++++++++++++++++++++++++++++++++++++++++++ stream/index.html | 1 + stream/radio.liq | 9 ++++++++ 4 files changed, 63 insertions(+) create mode 100644 stream/favicon.svg diff --git a/stream/Dockerfile b/stream/Dockerfile index 643f4f0..c18cd9f 100644 --- a/stream/Dockerfile +++ b/stream/Dockerfile @@ -2,5 +2,6 @@ FROM savonet/liquidsoap:v2.4.5 COPY radio.liq /etc/liquidsoap/radio.liq COPY index.html /etc/liquidsoap/index.html +COPY favicon.svg /etc/liquidsoap/favicon.svg CMD ["/etc/liquidsoap/radio.liq"] diff --git a/stream/favicon.svg b/stream/favicon.svg new file mode 100644 index 0000000..4f9b6fd --- /dev/null +++ b/stream/favicon.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/stream/index.html b/stream/index.html index ee1ac6f..6925240 100644 --- a/stream/index.html +++ b/stream/index.html @@ -4,6 +4,7 @@ + +
Préchargement
@@ -107,6 +121,33 @@ document.getElementById("stationName").textContent = "◈ " + STATION_NAME; document.getElementById("pageTitle").textContent = STATION_NAME; + // 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, + // qui retire l'extension du nom listé. On précharge l'image avant de + // l'afficher pour éviter tout flash, et on ignore silencieusement les + // échecs (le dégradé du body reste alors le fond). + (async () => { + const BASE = "https://www.navidrome.org/images/"; + try { + const r = await fetch(BASE + "index.yml", { cache: "no-store" }); + const names = (await r.text()) + .split("\n") + .map((l) => l.replace(/^\s*-\s*/, "").trim()) + .filter(Boolean); + if (!names.length) return; + const name = names[Math.floor(Math.random() * names.length)]; + const url = BASE + name.replace(/\.[^.]+$/, "") + ".webp"; + const img = new Image(); + img.onload = () => { + const bg = document.getElementById("bg"); + bg.style.backgroundImage = `url("${url}")`; + bg.classList.add("loaded"); + }; + img.src = url; + } catch (e) { /* pas de fond : on garde le dégradé */ } + })(); + const titleEl = document.getElementById("title"); const artistEl = document.getElementById("artist"); const npLabel = document.getElementById("npLabel");