stream: let listeners download any track still in the cache
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
/download now accepts ?file=<name> to fetch any cached file, not just the current track. History entries carry that filename token (via /history), so the web UI renders each aired track as a download link. A shared serve_attachment helper validates the request (basename-only, real audio file, no hidden/.part files) before streaming it; LRU-evicted tracks return 404. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
453ecd1353
commit
aad3c9d0f7
2 changed files with 54 additions and 19 deletions
|
|
@ -55,7 +55,16 @@
|
||||||
.history h2 { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase;
|
.history h2 { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase;
|
||||||
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 { padding: .45rem 0; 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-dl {
|
||||||
|
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-icon { color: #7d768f; font-size: .95rem; transition: color .15s; }
|
||||||
|
.history .h-dl:hover .h-icon { color: #9b8cff; }
|
||||||
|
.history .h-dl:hover .h-title { color: #fff; }
|
||||||
.history .h-title { color: #e8e4f2; font-size: .92rem; }
|
.history .h-title { color: #e8e4f2; font-size: .92rem; }
|
||||||
.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; }
|
||||||
|
|
@ -142,7 +151,14 @@
|
||||||
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>` : "";
|
||||||
return `<li><div class="h-title">${t}</div>${artist}</li>`;
|
const meta = `<div class="h-title">${t}</div>${artist}`;
|
||||||
|
// Lien de téléchargement si le fichier est encore dans le cache.
|
||||||
|
const f = (m.file || "").trim();
|
||||||
|
if (f) {
|
||||||
|
const href = "/download?file=" + encodeURIComponent(f);
|
||||||
|
return `<li><a class="h-dl" href="${href}" download>${meta}<span class="h-icon">⬇</span></a></li>`;
|
||||||
|
}
|
||||||
|
return `<li>${meta}</li>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
} catch (e) { /* keep last known values */ }
|
} catch (e) { /* keep last known values */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,8 +125,10 @@ radio.on_metadata(
|
||||||
synchronous=false,
|
synchronous=false,
|
||||||
fun(m) -> begin
|
fun(m) -> begin
|
||||||
now_playing := m
|
now_playing := m
|
||||||
entry = {title=m["title"], artist=m["artist"]}
|
# `file` : nom de base du fichier à l'antenne, servant de jeton de
|
||||||
head = list.hd(default={title="", artist=""}, history())
|
# téléchargement (/download?file=…). Vide si la métadonnée manque.
|
||||||
|
entry = {title=m["title"], artist=m["artist"], file=path.basename(m["filename"])}
|
||||||
|
head = list.hd(default={title="", artist="", 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()))
|
||||||
|
|
@ -178,9 +180,10 @@ harbor.http.register(
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
|
|
||||||
# Télécharger le fichier du morceau en cours. Le chemin vient de la métadonnée
|
# Télécharger un morceau. Sans paramètre : le titre en cours. Avec `?file=<nom>` :
|
||||||
# `filename` de la source diffusée : c'est bien le fichier à l'antenne, et il est
|
# n'importe quel fichier encore présent dans le cache (les titres passés listés
|
||||||
# forcément encore sur le disque tant qu'il joue (pas encore évincé par le LRU).
|
# par /history exposent ce jeton). Le cache étant borné par le LRU, un morceau
|
||||||
|
# évincé renvoie simplement 404.
|
||||||
def content_type_of(name) =
|
def content_type_of(name) =
|
||||||
low = string.case(lower=true, name)
|
low = string.case(lower=true, name)
|
||||||
if string.contains(suffix=".flac", low) then "audio/flac"
|
if string.contains(suffix=".flac", low) then "audio/flac"
|
||||||
|
|
@ -192,19 +195,35 @@ def content_type_of(name) =
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Sert un fichier en pièce jointe après validation. `path.basename` neutralise
|
||||||
|
# toute tentative de remontée de répertoire (…/…) ; on n'autorise que de vrais
|
||||||
|
# fichiers audio du cache, jamais les fichiers cachés (.part en cours, .gitkeep).
|
||||||
|
def serve_attachment(resp, name) =
|
||||||
|
base = path.basename(name)
|
||||||
|
low = string.case(lower=true, base)
|
||||||
|
is_audio = list.exists(fun(e) -> string.contains(suffix=e, low), audio_ext)
|
||||||
|
full = "/cache/#{base}"
|
||||||
|
if base == "" or string.contains(prefix=".", base) or not is_audio
|
||||||
|
or not file.exists(full) then
|
||||||
|
resp.status_code(404)
|
||||||
|
resp.data("track not available")
|
||||||
|
else
|
||||||
|
resp.content_type(content_type_of(base))
|
||||||
|
resp.header("Content-Disposition", "attachment; filename=\"#{base}\"")
|
||||||
|
resp.data(file.contents(full))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
harbor.http.register(
|
harbor.http.register(
|
||||||
port=8000, method="GET", "/download",
|
port=8000, method="GET", "/download",
|
||||||
fun(_, resp) -> begin
|
fun(req, resp) -> begin
|
||||||
m = now_playing()
|
requested = list.assoc(default="", "file", req.query)
|
||||||
fname = m["filename"]
|
name =
|
||||||
if fname == "" or not file.exists(fname) then
|
if requested != "" then
|
||||||
resp.status_code(404)
|
requested
|
||||||
resp.data("no track currently available")
|
else
|
||||||
else
|
list.assoc(default="", "filename", now_playing())
|
||||||
base = path.basename(fname)
|
end
|
||||||
resp.content_type(content_type_of(base))
|
serve_attachment(resp, name)
|
||||||
resp.header("Content-Disposition", "attachment; filename=\"#{base}\"")
|
|
||||||
resp.data(file.contents(fname))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue