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..c822509 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,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 +158,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 +205,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 +215,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