Compare commits
5 commits
4b35695624
...
c9c2218e14
| Author | SHA1 | Date | |
|---|---|---|---|
| c9c2218e14 | |||
| 9ca500a3e0 | |||
| 6d2bc963db | |||
| f065a0eda9 | |||
| 32fae3dbba |
6 changed files with 239 additions and 24 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
52
stream/favicon.svg
Normal file
52
stream/favicon.svg
Normal 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 |
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title id="pageTitle"></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
|
|
@ -13,6 +14,19 @@
|
|||
background: radial-gradient(circle at 30% 20%, #2a2140, #0d0b14 70%);
|
||||
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 {
|
||||
width: min(90vw, 420px); padding: 2.5rem 2rem;
|
||||
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08);
|
||||
|
|
@ -81,6 +95,7 @@
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg" id="bg"></div>
|
||||
<main class="card">
|
||||
<div class="logo" id="stationName">◈</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("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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue