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 )