Compare commits

...

5 commits

Author SHA1 Message Date
85cd5d1b74 stream: show a random Navidrome-style background image
All checks were successful
continuous-integration/drone/push Build is passing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:42:51 +08:00
dcdfda2fdb stream: listen on IPv6 as well as IPv4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:42:51 +08:00
534ade0ba5 stream: add a synthwave favicon
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:42:51 +08:00
032a9b86b8 ingest: resolve bandcamp label sources down to individual tracks
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 <noreply@anthropic.com>
2026-07-03 18:42:51 +08:00
a65cc61ccd 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 <noreply@anthropic.com>
2026-07-03 18:42:51 +08:00
6 changed files with 244 additions and 25 deletions

View file

@ -28,6 +28,11 @@ log = logging.getLogger("radieo.provider.ytdlp")
class YtdlpProvider: class YtdlpProvider:
name = "ytdlp" 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): def __init__(self, urls_file: Path, db: Database, cache_dir: Path):
self._urls_file = urls_file self._urls_file = urls_file
self._db = db self._db = db
@ -77,26 +82,66 @@ class YtdlpProvider:
def _resolve(self, src_url: str, recent: set[str]) -> Track | None: def _resolve(self, src_url: str, recent: set[str]) -> Track | None:
try: 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 except Exception as exc: # yt-dlp raises many extractor-specific errors
log.warning("yt-dlp could not resolve %s: %s", src_url, exc) log.warning("yt-dlp could not resolve %s: %s", src_url, exc)
return None return None
if not entries: if entry is None:
return None return None
pool = [e for e in entries if e["url"] not in recent] or entries
entry = random.choice(pool)
locator = entry["url"] locator = entry["url"]
meta = self._resolved_tags(locator) 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( return Track(
backend="ytdlp", backend="ytdlp",
locator=locator, locator=locator,
artist=(meta.artist if meta else entry.get("artist")) or "Unknown artist", artist=artist,
title=(meta.title if meta else entry.get("title")) or locator, title=title,
origin=self.name, origin=self.name,
mbid=meta.mbid if meta else None, mbid=mbid,
source_url=src_url if src_url != locator else None, 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): def _resolved_tags(self, locator: str):
"""Corrected identity a previous download wrote into the cached file. """Corrected identity a previous download wrote into the cached file.

View file

@ -41,21 +41,28 @@ def is_bandcamp(info: dict, locator: str) -> bool:
def guess_metadata(info: dict, source_url: str | None = None) -> TagMeta: def guess_metadata(info: dict, source_url: str | None = None) -> TagMeta:
"""Derive tags from a yt-dlp *download* info dict. """Derive tags from a yt-dlp *download* info dict.
Priority mirrors the user's shell heuristic: the track title usually carries On a bandcamp download the reliable signal is the explicit ``artist`` tag
``"Artist - Title"`` (split on the *last* ``" - "``), which is more reliable (the performing artist, *not* the label the way ``uploader`` is). Label
than the uploader on label accounts. When the title has no separator we fall uploads name the ``title`` ``"Artist - Title"`` sometimes with the artist
back to the explicit ``artist`` field, preferring it over the uploader/label. 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() raw_title = (info.get("title") or "").strip()
artist, title = _split_artist_title(raw_title) reliable = (info.get("artist") or info.get("creator") or "").strip()
if not artist: prefix = f"{reliable} - "
artist = ( if reliable and raw_title.startswith(prefix):
info.get("artist") artist = reliable
or info.get("creator") title = raw_title
or info.get("uploader") while title.startswith(prefix): # peel a doubled "Artist - " prefix too
or info.get("channel") title = title[len(prefix):].strip()
or "" else:
).strip() 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: if not title:
title = raw_title or (info.get("track") or "").strip() title = raw_title or (info.get("track") or "").strip()

View file

@ -2,5 +2,6 @@ FROM savonet/liquidsoap:v2.4.5
COPY radio.liq /etc/liquidsoap/radio.liq COPY radio.liq /etc/liquidsoap/radio.liq
COPY index.html /etc/liquidsoap/index.html COPY index.html /etc/liquidsoap/index.html
COPY favicon.svg /etc/liquidsoap/favicon.svg
CMD ["/etc/liquidsoap/radio.liq"] CMD ["/etc/liquidsoap/radio.liq"]

52
stream/favicon.svg Normal file
View file

@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<!-- Ciel synthwave : violet profond vers magenta -->
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1a1030"/>
<stop offset="1" stop-color="#3a1145"/>
</linearGradient>
<!-- Soleil dégradé jaune / rose néon -->
<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>
<!-- Masque : bandes horizontales qui tranchent le soleil -->
<clipPath id="round"><rect x="0" y="0" width="64" height="64" rx="12"/></clipPath>
</defs>
<g clip-path="url(#round)">
<rect width="64" height="64" fill="url(#sky)"/>
<!-- Soleil avec les stries synthwave classiques -->
<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>
<!-- Horizon néon -->
<rect x="0" y="45" width="64" height="1.4" fill="#ff4fd8"/>
<!-- Grille en perspective -->
<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 KiB

View file

@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title id="pageTitle"></title> <title id="pageTitle"></title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@ -13,6 +14,19 @@
background: radial-gradient(circle at 30% 20%, #2a2140, #0d0b14 70%); background: radial-gradient(circle at 30% 20%, #2a2140, #0d0b14 70%);
color: #f2f0f7; color: #f2f0f7;
} }
/* Fond façon écran de connexion Navidrome : une image aléatoire tirée de
leur galerie, posée derrière la carte avec un voile sombre pour garder
le texte lisible. Le dégradé du body reste visible tant que l'image
n'est pas chargée (ou en cas d'échec réseau). */
.bg {
position: fixed; inset: 0; z-index: -1; background-size: cover;
background-position: center; opacity: 0; transition: opacity .8s ease;
}
.bg.loaded { opacity: 1; }
.bg::after {
content: ""; position: absolute; inset: 0;
background: radial-gradient(circle at 30% 20%, rgba(20,16,34,.72), rgba(13,11,20,.9) 75%);
}
.card { .card {
width: min(90vw, 420px); padding: 2.5rem 2rem; width: min(90vw, 420px); padding: 2.5rem 2rem;
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08);
@ -81,6 +95,7 @@
</style> </style>
</head> </head>
<body> <body>
<div class="bg" id="bg"></div>
<main class="card"> <main class="card">
<div class="logo" id="stationName"></div> <div class="logo" id="stationName"></div>
<div class="np-label"><span class="dot"></span><span id="npLabel">Préchargement</span></div> <div class="np-label"><span class="dot"></span><span id="npLabel">Préchargement</span></div>
@ -106,6 +121,33 @@
document.getElementById("stationName").textContent = "◈ " + STATION_NAME; document.getElementById("stationName").textContent = "◈ " + STATION_NAME;
document.getElementById("pageTitle").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 titleEl = document.getElementById("title");
const artistEl = document.getElementById("artist"); const artistEl = document.getElementById("artist");
const npLabel = document.getElementById("npLabel"); const npLabel = document.getElementById("npLabel");

View file

@ -11,8 +11,11 @@ settings.log.stdout := true
settings.log.file := false settings.log.file := false
settings.log.level := 3 settings.log.level := 3
# --- Harbor : écoute sur toutes les interfaces du conteneur --- # --- Harbor : écoute sur toutes les interfaces du conteneur (IPv4 + IPv6) ---
settings.harbor.bind_addrs := ["0.0.0.0"] # `::` 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). # URL du daemon d'ingestion (nom de service résolu par docker-compose).
ingest_url = "http://ingest:8080/next" 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. # reload_mode="watch" : un jingle ajouté/retiré est pris en compte à chaud.
# Dossier vide → source jamais prête → le switch retombe simplement sur la # Dossier vide → source jamais prête → le switch retombe simplement sur la
# musique, sans jingle et sans plantage. # 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( 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 # 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 # 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. # 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 # 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), # 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( radio = switch(
track_sensitive=true, 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 # 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) --- # --- Page web et API de lecture (mêmes port/harbor que le flux) ---
home_html = file.contents("/etc/liquidsoap/index.html") home_html = file.contents("/etc/liquidsoap/index.html")
favicon_svg = file.contents("/etc/liquidsoap/favicon.svg")
harbor.http.register( harbor.http.register(
port=8000, method="GET", "/", port=8000, method="GET", "/",
@ -155,6 +219,14 @@ harbor.http.register(
end 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( harbor.http.register(
port=8000, method="GET", "/nowplaying", port=8000, method="GET", "/nowplaying",
fun(_, resp) -> begin fun(_, resp) -> begin