diff --git a/stream/index.html b/stream/index.html
index eb10981..2d18439 100644
--- a/stream/index.html
+++ b/stream/index.html
@@ -55,7 +55,16 @@
.history h2 { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase;
color: #7d768f; margin: 0 0 .4rem; font-weight: 600; }
.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-artist { color: #8b849c; font-size: .8rem; margin-top: .1rem; }
.history .empty { color: #6b6480; font-size: .85rem; padding: .45rem 0; }
@@ -142,7 +151,14 @@
const t = escapeHtml((m.title || "").trim() || "—");
const a = (m.artist || "").trim();
const artist = a ? `
${escapeHtml(a)}
` : "";
- return `${t}
${artist}`;
+ const meta = `${t}
${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 `${meta}⬇`;
+ }
+ return `${meta}`;
}).join("");
} catch (e) { /* keep last known values */ }
}
diff --git a/stream/radio.liq b/stream/radio.liq
index 4a5ab4d..9fb6f32 100644
--- a/stream/radio.liq
+++ b/stream/radio.liq
@@ -125,8 +125,10 @@ radio.on_metadata(
synchronous=false,
fun(m) -> begin
now_playing := m
- entry = {title=m["title"], artist=m["artist"]}
- head = list.hd(default={title="", artist=""}, history())
+ # `file` : nom de base du fichier à l'antenne, servant de jeton de
+ # 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
if not is_dup and (entry.title != "" or entry.artist != "") then
history := list.prefix(history_max, list.add(entry, history()))
@@ -178,9 +180,10 @@ harbor.http.register(
end
)
-# Télécharger le fichier du morceau en cours. Le chemin vient de la métadonnée
-# `filename` de la source diffusée : c'est bien le fichier à l'antenne, et il est
-# forcément encore sur le disque tant qu'il joue (pas encore évincé par le LRU).
+# Télécharger un morceau. Sans paramètre : le titre en cours. Avec `?file=` :
+# n'importe quel fichier encore présent dans le cache (les titres passés listés
+# par /history exposent ce jeton). Le cache étant borné par le LRU, un morceau
+# évincé renvoie simplement 404.
def content_type_of(name) =
low = string.case(lower=true, name)
if string.contains(suffix=".flac", low) then "audio/flac"
@@ -192,19 +195,35 @@ def content_type_of(name) =
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(
port=8000, method="GET", "/download",
- fun(_, resp) -> begin
- m = now_playing()
- fname = m["filename"]
- if fname == "" or not file.exists(fname) then
- resp.status_code(404)
- resp.data("no track currently available")
- else
- base = path.basename(fname)
- resp.content_type(content_type_of(base))
- resp.header("Content-Disposition", "attachment; filename=\"#{base}\"")
- resp.data(file.contents(fname))
- end
+ fun(req, resp) -> begin
+ requested = list.assoc(default="", "file", req.query)
+ name =
+ if requested != "" then
+ requested
+ else
+ list.assoc(default="", "filename", now_playing())
+ end
+ serve_attachment(resp, name)
end
)