stream: link the now-playing title to its source page
For yt-dlp tracks the locator is the original web page, so pass it as a url annotation, carry it through the stream metadata and history, and turn the track title into a link back to that page (both live and in the history). Subsonic ids are opaque and stay plain text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
fa1be6df77
commit
c12e522fee
3 changed files with 37 additions and 22 deletions
|
|
@ -26,10 +26,13 @@ def annotate_uri(path: Path, track: Track) -> str:
|
||||||
def esc(value: str) -> str:
|
def esc(value: str) -> str:
|
||||||
return value.replace("\\", "\\\\").replace('"', '\\"')
|
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
return (
|
fields = [f'title="{esc(track.title)}"', f'artist="{esc(track.artist)}"']
|
||||||
f'annotate:title="{esc(track.title)}",artist="{esc(track.artist)}"'
|
# Web page the track was pulled from, so the player can link back to the
|
||||||
f":{path}"
|
# source. Only http(s) locators qualify (yt-dlp tracks); a Subsonic song id
|
||||||
)
|
# is opaque and points at no public page.
|
||||||
|
if track.locator.startswith(("http://", "https://")):
|
||||||
|
fields.append(f'url="{esc(track.locator)}"')
|
||||||
|
return f'annotate:{",".join(fields)}:{path}'
|
||||||
|
|
||||||
|
|
||||||
class IngestServer(ThreadingHTTPServer):
|
class IngestServer(ThreadingHTTPServer):
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@
|
||||||
color: #7d768f; margin-bottom: .5rem; }
|
color: #7d768f; margin-bottom: .5rem; }
|
||||||
.title { font-size: 1.55rem; font-weight: 650; line-height: 1.25;
|
.title { font-size: 1.55rem; font-weight: 650; line-height: 1.25;
|
||||||
word-wrap: break-word; }
|
word-wrap: break-word; }
|
||||||
|
.title a { color: inherit; text-decoration: none; transition: color .15s; }
|
||||||
|
.title a:hover { color: #9b8cff; text-decoration: underline; }
|
||||||
.artist { margin-top: .35rem; font-size: 1.05rem; color: #b8b2c8; }
|
.artist { margin-top: .35rem; font-size: 1.05rem; color: #b8b2c8; }
|
||||||
audio { width: 100%; margin-top: 2rem; }
|
audio { width: 100%; margin-top: 2rem; }
|
||||||
.share { display: flex; gap: .5rem; margin-top: 1rem; }
|
.share { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||||
|
|
@ -56,16 +58,16 @@
|
||||||
color: #7d768f; margin: 0 0 .4rem; font-weight: 600; }
|
color: #7d768f; margin: 0 0 .4rem; font-weight: 600; }
|
||||||
.history ul { list-style: none; margin: 0; padding: 0; }
|
.history ul { list-style: none; margin: 0; padding: 0; }
|
||||||
.history li { border-top: 1px solid rgba(255,255,255,.06); }
|
.history li { border-top: 1px solid rgba(255,255,255,.06); }
|
||||||
.history li > :not(a) { padding: .45rem 0; }
|
.history .h-row {
|
||||||
.history .h-dl {
|
|
||||||
display: flex; align-items: center; gap: .6rem; padding: .45rem 0;
|
display: flex; align-items: center; gap: .6rem; padding: .45rem 0;
|
||||||
text-decoration: none; color: inherit;
|
|
||||||
}
|
}
|
||||||
.history .h-dl > div { flex: 1; min-width: 0; }
|
.history .h-meta { flex: 1; min-width: 0; }
|
||||||
.history .h-icon { color: #7d768f; font-size: .95rem; transition: color .15s; }
|
.history .h-act { color: #7d768f; font-size: .95rem; text-decoration: none;
|
||||||
.history .h-dl:hover .h-icon { color: #9b8cff; }
|
transition: color .15s; }
|
||||||
.history .h-dl:hover .h-title { color: #fff; }
|
.history .h-act:hover { color: #9b8cff; }
|
||||||
.history .h-title { color: #e8e4f2; font-size: .92rem; }
|
.history .h-title { color: #e8e4f2; font-size: .92rem; }
|
||||||
|
.history .h-title a { color: inherit; text-decoration: none; transition: color .15s; }
|
||||||
|
.history .h-title a:hover { color: #9b8cff; text-decoration: underline; }
|
||||||
.history .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; }
|
.history .h-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; }
|
||||||
.history .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; }
|
.history .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; }
|
||||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
|
@ -163,7 +165,13 @@
|
||||||
const m = await r.json();
|
const m = await r.json();
|
||||||
const t = (m.title || "").trim();
|
const t = (m.title || "").trim();
|
||||||
const a = (m.artist || "").trim();
|
const a = (m.artist || "").trim();
|
||||||
titleEl.textContent = t || "—";
|
// Le titre devient un lien vers la page d'origine (yt-dlp) quand elle
|
||||||
|
// est connue ; sinon simple texte.
|
||||||
|
const u = (m.url || "").trim();
|
||||||
|
const label = t || "—";
|
||||||
|
titleEl.innerHTML = u
|
||||||
|
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
|
||||||
|
: escapeHtml(label);
|
||||||
artistEl.textContent = a;
|
artistEl.textContent = a;
|
||||||
document.title = t ? (a ? `${t} — ${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME;
|
document.title = t ? (a ? `${t} — ${a} · ${STATION_NAME}` : `${t} · ${STATION_NAME}`) : STATION_NAME;
|
||||||
} catch (e) { /* keep last known values */ }
|
} catch (e) { /* keep last known values */ }
|
||||||
|
|
@ -188,14 +196,18 @@
|
||||||
const t = escapeHtml((m.title || "").trim() || "—");
|
const t = escapeHtml((m.title || "").trim() || "—");
|
||||||
const a = (m.artist || "").trim();
|
const a = (m.artist || "").trim();
|
||||||
const artist = a ? `<div class="h-artist">${escapeHtml(a)}</div>` : "";
|
const artist = a ? `<div class="h-artist">${escapeHtml(a)}</div>` : "";
|
||||||
const meta = `<div class="h-title">${t}</div>${artist}`;
|
// Le titre porte le lien vers la page d'origine (yt-dlp) quand elle
|
||||||
// Lien de téléchargement si le fichier est encore dans le cache.
|
// existe ; le téléchargement reste une action distincte à côté.
|
||||||
|
const u = (m.url || "").trim();
|
||||||
|
const titleHtml = u
|
||||||
|
? `<a href="${escapeHtml(u)}" target="_blank" rel="noopener">${t}</a>`
|
||||||
|
: t;
|
||||||
|
const meta = `<div class="h-meta"><div class="h-title">${titleHtml}</div>${artist}</div>`;
|
||||||
const f = (m.file || "").trim();
|
const f = (m.file || "").trim();
|
||||||
if (f) {
|
const dl = f
|
||||||
const href = "/download?file=" + encodeURIComponent(f);
|
? `<a class="h-act" href="/download?file=${encodeURIComponent(f)}" download title="Télécharger">⬇</a>`
|
||||||
return `<li><a class="h-dl" href="${href}" download>${meta}<span class="h-icon">⬇</span></a></li>`;
|
: "";
|
||||||
}
|
return `<li><div class="h-row">${meta}${dl}</div></li>`;
|
||||||
return `<li>${meta}</li>`;
|
|
||||||
}).join("");
|
}).join("");
|
||||||
} catch (e) { /* keep last known values */ }
|
} catch (e) { /* keep last known values */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,8 +127,8 @@ radio.on_metadata(
|
||||||
now_playing := m
|
now_playing := m
|
||||||
# `file` : nom de base du fichier à l'antenne, servant de jeton de
|
# `file` : nom de base du fichier à l'antenne, servant de jeton de
|
||||||
# téléchargement (/download?file=…). Vide si la métadonnée manque.
|
# téléchargement (/download?file=…). Vide si la métadonnée manque.
|
||||||
entry = {title=m["title"], artist=m["artist"], file=path.basename(m["filename"])}
|
entry = {title=m["title"], artist=m["artist"], url=m["url"], file=path.basename(m["filename"])}
|
||||||
head = list.hd(default={title="", artist="", file=""}, history())
|
head = list.hd(default={title="", artist="", url="", file=""}, history())
|
||||||
is_dup = head.title == entry.title and head.artist == entry.artist
|
is_dup = head.title == entry.title and head.artist == entry.artist
|
||||||
if not is_dup and (entry.title != "" or entry.artist != "") then
|
if not is_dup and (entry.title != "" or entry.artist != "") then
|
||||||
history := list.prefix(history_max, list.add(entry, history()))
|
history := list.prefix(history_max, list.add(entry, history()))
|
||||||
|
|
@ -159,7 +159,7 @@ harbor.http.register(
|
||||||
port=8000, method="GET", "/nowplaying",
|
port=8000, method="GET", "/nowplaying",
|
||||||
fun(_, resp) -> begin
|
fun(_, resp) -> begin
|
||||||
m = now_playing()
|
m = now_playing()
|
||||||
resp.json({title=m["title"], artist=m["artist"]})
|
resp.json({title=m["title"], artist=m["artist"], url=m["url"]})
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue