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()
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..47b515f 100644
--- a/stream/index.html
+++ b/stream/index.html
@@ -4,6 +4,7 @@
+
+
◈
Préchargement
@@ -106,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");
diff --git a/stream/radio.liq b/stream/radio.liq
index 5efec84..058af6b 100644
--- a/stream/radio.liq
+++ b/stream/radio.liq
@@ -11,8 +11,11 @@ settings.log.stdout := true
settings.log.file := false
settings.log.level := 3
-# --- Harbor : écoute sur toutes les interfaces du conteneur ---
-settings.harbor.bind_addrs := ["0.0.0.0"]
+# --- Harbor : écoute sur toutes les interfaces du conteneur (IPv4 + IPv6) ---
+# `::` seul suffit : sous Linux (bindv6only=0 par défaut) la socket IPv6 accepte
+# aussi les clients IPv4 (adresses mappées). Ajouter "0.0.0.0" en plus ferait
+# double bind sur le même port IPv4 → EADDRINUSE.
+settings.harbor.bind_addrs := ["::"]
# URL du daemon d'ingestion (nom de service résolu par docker-compose).
ingest_url = "http://ingest:8080/next"
@@ -78,10 +81,68 @@ 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.
+# ATTENTION : playlist explore récursivement les sous-dossiers. Pour la rotation
+# par défaut on ne veut QUE les fichiers directement dans /jingles ; les
+# sous-dossiers spéciaux (midi, gouter, moment) sont gérés à part. On
+# filtre donc sur le dossier parent en plus du test audio habituel.
+def jingle_top_level(r) =
+ audio_only(r) and path.dirname(request.uri(r)) == "/jingles"
+end
jingles = playlist(
- mode="randomize", reload_mode="watch", check_next=audio_only, "/jingles"
+ mode="randomize", reload_mode="watch", check_next=jingle_top_level, "/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_moment),
+ make_special_slot(16, 30, jingles_gouter),
+ make_special_slot(21, 0, jingles_moment),
+]
+
# 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 +162,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
@@ -146,6 +209,7 @@ output.harbor(
# --- 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", "/",
@@ -155,6 +219,14 @@ harbor.http.register(
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