mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 10:08:36 +00:00
Compare commits
3 Commits
7da61e8fd5
...
0566672615
| Author | SHA1 | Date | |
|---|---|---|---|
| 0566672615 | |||
| 9f5bec6a87 | |||
| 560b8d8213 |
|
|
@ -16,8 +16,11 @@ import asyncio
|
|||
import html
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import tomllib
|
||||
import zipfile
|
||||
from email.utils import parsedate_to_datetime, format_datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -25,7 +28,7 @@ from typing import Optional
|
|||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import FastAPI, APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response, FileResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -74,6 +77,7 @@ _worker_started = False
|
|||
# ════════════════════════════════════════════════════════════════════
|
||||
class FeedIn(BaseModel):
|
||||
url: str
|
||||
auto_dl: bool = True
|
||||
|
||||
|
||||
class OPMLIn(BaseModel):
|
||||
|
|
@ -151,16 +155,34 @@ def parse_feed(xml_bytes: bytes) -> tuple[dict, list[dict]]:
|
|||
return meta, episodes
|
||||
|
||||
|
||||
async def fetch_and_store(url: str) -> dict:
|
||||
async def fetch_and_store(url: str, auto_dl: Optional[bool] = None) -> dict:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=30) as cli:
|
||||
r = await cli.get(url, headers={"User-Agent": "SecuBox-Podcaster/1.0"})
|
||||
r.raise_for_status()
|
||||
meta, eps = parse_feed(r.content)
|
||||
fid = store.add_feed(url, meta)
|
||||
fid = store.add_feed(url, meta, auto_dl=1 if auto_dl else 0)
|
||||
store.update_feed_meta(fid, meta)
|
||||
if auto_dl is not None: # explicit add/toggle; None = refresh (keep current)
|
||||
store.set_feed_autodl(fid, 1 if auto_dl else 0)
|
||||
for ep in eps:
|
||||
store.upsert_episode(fid, ep)
|
||||
return {"feed_id": fid, "title": meta.get("title"), "episodes": len(eps)}
|
||||
queued = await _autoqueue(fid)
|
||||
return {"feed_id": fid, "title": meta.get("title"),
|
||||
"episodes": len(eps), "queued": queued}
|
||||
|
||||
|
||||
async def _autoqueue(fid: int) -> int:
|
||||
"""If a feed has auto_dl, enqueue its not-yet-downloaded episodes (newest
|
||||
first, capped by keep_per_feed to avoid unbounded disk use)."""
|
||||
if not store.feed_autodl(fid):
|
||||
return 0
|
||||
cap = int(CFG.get("keep_per_feed", 0) or 0)
|
||||
n = 0
|
||||
for ep_id in store.pending_episode_ids(fid, cap):
|
||||
store.set_episode(ep_id, state="queued", progress=0, error=None)
|
||||
await _queue.put(ep_id)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -299,6 +321,53 @@ async def share_feed(request: Request):
|
|||
return Response(content=rss, media_type="application/rss+xml")
|
||||
|
||||
|
||||
@router.get("/public/library")
|
||||
async def public_library():
|
||||
"""Public listener library — downloaded/shared episodes only (no auth).
|
||||
Feeds the external portal frontend; exposes nothing un-shared."""
|
||||
_ensure_worker()
|
||||
eps = store.downloaded_episodes()
|
||||
feeds: dict = {}
|
||||
for e in eps:
|
||||
k = e.get("feed_title") or "Podcast"
|
||||
feeds[k] = feeds.get(k, 0) + 1
|
||||
return {
|
||||
"title": CFG.get("share_title", "SecuBox Podcaster"),
|
||||
"episodes": [{
|
||||
"id": e["id"], "title": e.get("title"), "feed": e.get("feed_title"),
|
||||
"feed_id": e.get("feed_id"),
|
||||
"pubdate": e.get("pubdate"), "duration": e.get("duration"),
|
||||
"mime": e.get("mime"), "bytes": e.get("bytes"),
|
||||
"media": f"/api/v1/podcaster/media/{e['id']}",
|
||||
} for e in eps],
|
||||
"feeds": feeds,
|
||||
"share": "/api/v1/podcaster/share/feed.xml",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/feed/{fid}/zip")
|
||||
async def public_feed_zip(fid: int, background: BackgroundTasks):
|
||||
"""Public: download all downloaded episodes of a feed as one ZIP.
|
||||
mp3/audio are already compressed → STORED (no recompress). Built to a temp
|
||||
file then streamed; cleaned up after send."""
|
||||
_ensure_worker()
|
||||
eps = [e for e in store.downloaded_episodes(limit=2000)
|
||||
if e.get("feed_id") == fid and e.get("local_path")
|
||||
and Path(e["local_path"]).exists()]
|
||||
if not eps:
|
||||
raise HTTPException(404, "no downloaded episodes for this feed")
|
||||
name = re.sub(r"[^A-Za-z0-9._-]+", "_", (eps[0].get("feed_title") or f"feed{fid}"))[:60] or f"feed{fid}"
|
||||
tmp = Path(tempfile.mkstemp(suffix=".zip", dir="/var/lib/secubox/podcaster")[1])
|
||||
eps.sort(key=lambda e: e.get("pubdate") or 0)
|
||||
with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_STORED) as z:
|
||||
for i, e in enumerate(eps):
|
||||
p = Path(e["local_path"])
|
||||
z.write(p, arcname=_safe_name(f"{i+1:03d}_{e.get('title') or p.stem}", p.suffix))
|
||||
background.add_task(lambda: tmp.unlink(missing_ok=True))
|
||||
return FileResponse(tmp, media_type="application/zip", filename=f"{name}.zip",
|
||||
background=background)
|
||||
|
||||
|
||||
@router.get("/media/{ep_id}")
|
||||
async def media(ep_id: int):
|
||||
ep = store.get_episode(ep_id)
|
||||
|
|
@ -324,11 +393,21 @@ async def feeds():
|
|||
async def add_feed(body: FeedIn):
|
||||
_ensure_worker()
|
||||
try:
|
||||
return await fetch_and_store(body.url.strip())
|
||||
return await fetch_and_store(body.url.strip(), auto_dl=body.auto_dl)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"feed add failed: {e}")
|
||||
|
||||
|
||||
@router.post("/feeds/{fid}/autodl", dependencies=[Depends(require_jwt)])
|
||||
async def toggle_autodl(fid: int, on: bool = True):
|
||||
"""Toggle auto-download for a feed. Turning it on also queues any episodes
|
||||
not yet downloaded (so the portal/share feed sync immediately)."""
|
||||
_ensure_worker()
|
||||
store.set_feed_autodl(fid, 1 if on else 0)
|
||||
queued = await _autoqueue(fid) if on else 0
|
||||
return {"ok": True, "auto_dl": on, "queued": queued}
|
||||
|
||||
|
||||
@router.delete("/feeds/{fid}", dependencies=[Depends(require_jwt)])
|
||||
async def del_feed(fid: int):
|
||||
store.delete_feed(fid)
|
||||
|
|
@ -353,6 +432,68 @@ async def import_opml(body: OPMLIn):
|
|||
return {"added": added, "total": len(urls)}
|
||||
|
||||
|
||||
_AUDIO_EXT = {".mp3", ".m4a", ".m4b", ".aac", ".ogg", ".opus", ".flac", ".wav", ".mp4"}
|
||||
_AUDIO_MIME = {".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4",
|
||||
".aac": "audio/aac", ".ogg": "audio/ogg", ".opus": "audio/opus",
|
||||
".flac": "audio/flac", ".wav": "audio/wav", ".mp4": "audio/mp4"}
|
||||
|
||||
|
||||
@router.post("/audiobook/upload", dependencies=[Depends(require_jwt)])
|
||||
async def audiobook_upload(request: Request, title: str = "Audiobook"):
|
||||
"""Stream an uploaded ZIP to a temp file, extract its audio tracks, and
|
||||
publish them as a self-contained (synthetic) feed — already 'done' so they
|
||||
show in the library + share feed. Raw body (no python-multipart dep)."""
|
||||
_ensure_worker()
|
||||
title = (title or "Audiobook").strip() or "Audiobook"
|
||||
tmp = Path(tempfile.mkstemp(suffix=".zip", dir="/var/lib/secubox/podcaster")[1])
|
||||
try:
|
||||
with open(tmp, "wb") as fh:
|
||||
async for chunk in request.stream():
|
||||
fh.write(chunk)
|
||||
if not zipfile.is_zipfile(tmp):
|
||||
raise HTTPException(400, "not a valid ZIP")
|
||||
# synthetic feed
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "audiobook"
|
||||
fid = store.add_feed(f"audiobook:{slug}:{int(time.time())}",
|
||||
{"title": title, "description": f"Audiobook: {title}"})
|
||||
fdir = MEDIA / str(fid)
|
||||
fdir.mkdir(parents=True, exist_ok=True)
|
||||
tracks = 0
|
||||
with zipfile.ZipFile(tmp) as z:
|
||||
names = [n for n in z.namelist()
|
||||
if os.path.splitext(n)[1].lower() in _AUDIO_EXT
|
||||
and not n.endswith("/")]
|
||||
names.sort() # chapter order
|
||||
base = int(time.time())
|
||||
for i, n in enumerate(names):
|
||||
ext = os.path.splitext(n)[1].lower()
|
||||
tname = _safe_name(f"{i+1:03d}_{os.path.basename(n)}", ext)
|
||||
dest = fdir / tname
|
||||
with z.open(n) as src, open(dest, "wb") as out:
|
||||
shutil.copyfileobj(src, out, 1024 * 256)
|
||||
store.upsert_episode(fid, {
|
||||
"guid": f"ab:{fid}:{i}", "title": f"{i+1:02d} · {os.path.splitext(os.path.basename(n))[0]}",
|
||||
"description": title, "pubdate": base + i,
|
||||
"enclosure": f"local:{dest}", "mime": _AUDIO_MIME.get(ext, "audio/mpeg"),
|
||||
"bytes": dest.stat().st_size,
|
||||
})
|
||||
# mark immediately downloaded (local file already present)
|
||||
ep = store.list_episodes(feed_id=fid)
|
||||
for e in ep:
|
||||
if e["guid"] == f"ab:{fid}:{i}":
|
||||
store.set_episode(e["id"], state="done", progress=100,
|
||||
local_path=str(dest))
|
||||
break
|
||||
tracks += 1
|
||||
if not tracks:
|
||||
store.delete_feed(fid)
|
||||
raise HTTPException(400, "no audio files found in ZIP")
|
||||
log.info(f"audiobook '{title}' -> feed {fid}, {tracks} tracks")
|
||||
return {"title": title, "feed_id": fid, "tracks": tracks}
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.get("/episodes", dependencies=[Depends(require_jwt)])
|
||||
async def episodes(feed_id: Optional[int] = None, state: Optional[str] = None):
|
||||
_ensure_worker()
|
||||
|
|
|
|||
|
|
@ -66,13 +66,13 @@ def init() -> None:
|
|||
|
||||
|
||||
# ── feeds ──────────────────────────────────────────────────────────
|
||||
def add_feed(url: str, meta: dict) -> int:
|
||||
def add_feed(url: str, meta: dict, auto_dl: int = 0) -> int:
|
||||
with _conn() as c:
|
||||
cur = c.execute(
|
||||
"INSERT OR IGNORE INTO feeds(url,title,description,image,site,added_ts) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
"INSERT OR IGNORE INTO feeds(url,title,description,image,site,added_ts,auto_dl) "
|
||||
"VALUES(?,?,?,?,?,?,?)",
|
||||
(url, meta.get("title"), meta.get("description"),
|
||||
meta.get("image"), meta.get("site"), int(time.time())),
|
||||
meta.get("image"), meta.get("site"), int(time.time()), int(auto_dl)),
|
||||
)
|
||||
if cur.lastrowid:
|
||||
return cur.lastrowid
|
||||
|
|
@ -80,6 +80,26 @@ def add_feed(url: str, meta: dict) -> int:
|
|||
return row["id"] if row else 0
|
||||
|
||||
|
||||
def set_feed_autodl(feed_id: int, on: int) -> None:
|
||||
with _conn() as c:
|
||||
c.execute("UPDATE feeds SET auto_dl=? WHERE id=?", (int(on), feed_id))
|
||||
|
||||
|
||||
def feed_autodl(feed_id: int) -> int:
|
||||
with _conn() as c:
|
||||
r = c.execute("SELECT auto_dl FROM feeds WHERE id=?", (feed_id,)).fetchone()
|
||||
return int(r["auto_dl"]) if r else 0
|
||||
|
||||
|
||||
def pending_episode_ids(feed_id: int, limit: int = 0) -> list[int]:
|
||||
"""Episode ids in state 'new' for a feed (newest first); for auto-download."""
|
||||
q = "SELECT id FROM episodes WHERE feed_id=? AND state='new' ORDER BY pubdate DESC"
|
||||
if limit and limit > 0:
|
||||
q += f" LIMIT {int(limit)}"
|
||||
with _conn() as c:
|
||||
return [r["id"] for r in c.execute(q, (feed_id,)).fetchall()]
|
||||
|
||||
|
||||
def update_feed_meta(feed_id: int, meta: dict) -> None:
|
||||
with _conn() as c:
|
||||
c.execute(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,36 @@
|
|||
secubox-podcaster (1.0.3-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Auto-download: feeds can auto-queue new episodes so the portal + share feed
|
||||
stay synced without manual downloads. New feeds default auto_dl=on (UI
|
||||
checkbox); per-feed toggle (POST /feeds/{id}/autodl, ⏬ in admin). The
|
||||
periodic refresher auto-queues newly published episodes; bounded by
|
||||
keep_per_feed. (#726)
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Wed, 24 Jun 2026 14:20:00 +0000
|
||||
|
||||
secubox-podcaster (1.0.2-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Public portal: per-episode download (⬇, mp3/audio) + per-feed
|
||||
"Download all (ZIP)" via new public GET /public/feed/{id}/zip (STORED, no
|
||||
recompress; built to temp then streamed + cleaned up). public/library now
|
||||
carries feed_id so the portal can zip by feed.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Wed, 24 Jun 2026 13:40:00 +0000
|
||||
|
||||
secubox-podcaster (1.0.1-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Admin WebUI: integrate the shared Hub sidebar (/shared/sidebar.js) + correct
|
||||
auth token (sbx_token, 401 -> /login.html) — was missing the navbar and
|
||||
falsely prompting to log in.
|
||||
* Public listener PORTAL (no auth) at /podcaster/portal/ — standalone
|
||||
C3BOX page driven by a new public GET /public/library endpoint; subscribe
|
||||
(RSS) + per-feed filter + inline players. Meant to be exposed externally.
|
||||
* Audiobook ZIP import: POST /audiobook/upload (raw body, no python-multipart)
|
||||
streams the ZIP to a temp file, extracts audio tracks, and publishes them as
|
||||
a self-contained synthetic feed (already 'done' -> in library + share feed).
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Wed, 24 Jun 2026 13:00:00 +0000
|
||||
|
||||
secubox-podcaster (1.0.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Initial release (#726). Modern podcast manager:
|
||||
|
|
|
|||
|
|
@ -1,98 +1,99 @@
|
|||
<!DOCTYPE html>
|
||||
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
|
||||
<!-- SecuBox-Deb :: Podcaster WebUI — CyberMind https://cybermind.fr -->
|
||||
<!-- SecuBox-Deb :: Podcaster admin WebUI — CyberMind https://cybermind.fr -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SecuBox · Podcaster</title>
|
||||
<link rel="stylesheet" href="/shared/crt-light.css">
|
||||
<link rel="stylesheet" href="/shared/sidebar-light.css">
|
||||
<style>
|
||||
:root{
|
||||
--cosmos-black:#0a0a0f; --gold-hermetic:#c9a84c; --cinnabar:#e63946;
|
||||
--matrix-green:#00ff41; --void-purple:#6e40c9; --cyber-cyan:#00d4ff;
|
||||
--text-primary:#e8e6d9; --text-muted:#6b6b7a; --panel:#13131c; --line:#23232f;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--cosmos-black);color:var(--text-primary);
|
||||
font-family:'JetBrains Mono',ui-monospace,Menlo,monospace;font-size:14px}
|
||||
header{display:flex;align-items:center;gap:14px;padding:14px 20px;
|
||||
font-family:'JetBrains Mono',ui-monospace,Menlo,monospace;font-size:14px;display:flex}
|
||||
.main{flex:1;margin-left:220px;min-height:100vh}
|
||||
@media(max-width:900px){.sidebar{display:none}.main{margin-left:0}}
|
||||
.topbar{display:flex;align-items:center;gap:14px;padding:14px 20px;
|
||||
border-bottom:1px solid var(--line);background:linear-gradient(90deg,#0a0a0f,#13131c)}
|
||||
header h1{font-size:18px;margin:0;letter-spacing:1px;color:var(--gold-hermetic)}
|
||||
header .em{font-size:22px}
|
||||
.stats{margin-left:auto;display:flex;gap:18px;color:var(--text-muted);font-size:12px}
|
||||
.topbar h1{font-size:18px;margin:0;letter-spacing:1px;color:var(--gold-hermetic)}
|
||||
.topbar .em{font-size:22px}
|
||||
.stats{margin-left:auto;display:flex;gap:16px;color:var(--text-muted);font-size:12px}
|
||||
.stats b{color:var(--cyber-cyan)}
|
||||
.wrap{display:grid;grid-template-columns:300px 1fr;gap:0;min-height:calc(100vh - 58px)}
|
||||
aside{border-right:1px solid var(--line);background:var(--panel);padding:14px;overflow:auto}
|
||||
main{padding:18px 22px;overflow:auto}
|
||||
aside.feeds{border-right:1px solid var(--line);background:var(--panel);padding:14px;overflow:auto}
|
||||
section.eps{padding:18px 22px;overflow:auto}
|
||||
@media(max-width:760px){.wrap{grid-template-columns:1fr}aside.feeds{border-right:0;border-bottom:1px solid var(--line)}}
|
||||
.row{display:flex;gap:8px;margin-bottom:12px}
|
||||
input,button,select{font-family:inherit;font-size:13px;border-radius:8px;border:1px solid var(--line)}
|
||||
input{flex:1;background:#0e0e16;color:var(--text-primary);padding:9px 11px}
|
||||
button{background:#1a1a26;color:var(--text-primary);padding:9px 13px;cursor:pointer;
|
||||
transition:.15s;border:1px solid var(--line)}
|
||||
button{background:#1a1a26;color:var(--text-primary);padding:9px 13px;cursor:pointer;transition:.15s}
|
||||
button:hover{border-color:var(--cyber-cyan);color:var(--cyber-cyan)}
|
||||
button.go{background:var(--void-purple);border-color:var(--void-purple);color:#fff}
|
||||
button.go:hover{filter:brightness(1.2)}
|
||||
.feed{display:flex;gap:10px;align-items:center;padding:9px;border-radius:8px;cursor:pointer;
|
||||
border:1px solid transparent}
|
||||
.feed:hover{background:#0e0e16} .feed.sel{background:#0e0e16;border-color:var(--void-purple)}
|
||||
a.portal{color:var(--cyber-cyan);text-decoration:none;font-size:12px;border:1px solid var(--cyber-cyan);
|
||||
padding:7px 11px;border-radius:8px}
|
||||
.feed{display:flex;gap:10px;align-items:center;padding:9px;border-radius:8px;cursor:pointer;border:1px solid transparent}
|
||||
.feed:hover{background:#0e0e16}.feed.sel{background:#0e0e16;border-color:var(--void-purple)}
|
||||
.feed img{width:40px;height:40px;border-radius:6px;object-fit:cover;background:#222}
|
||||
.feed .t{flex:1;min-width:0} .feed .t b{display:block;white-space:nowrap;overflow:hidden;
|
||||
text-overflow:ellipsis;font-size:13px} .feed .t span{color:var(--text-muted);font-size:11px}
|
||||
.feed .x{color:var(--text-muted);opacity:0;font-size:16px} .feed:hover .x{opacity:1}
|
||||
h2{font-size:15px;color:var(--gold-hermetic);border-bottom:1px solid var(--line);
|
||||
padding-bottom:8px;margin:0 0 14px}
|
||||
.ep{display:flex;gap:12px;align-items:center;padding:12px;border:1px solid var(--line);
|
||||
border-radius:10px;margin-bottom:10px;background:var(--panel)}
|
||||
.ep .meta{flex:1;min-width:0}
|
||||
.ep .meta b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.feed .t{flex:1;min-width:0}.feed .t b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px}
|
||||
.feed .t span{color:var(--text-muted);font-size:11px}
|
||||
.feed .x{color:var(--text-muted);opacity:0;font-size:16px}.feed:hover .x{opacity:1}
|
||||
.feed .ad{font-size:14px;cursor:pointer;opacity:.5}.feed .ad.on{opacity:1;color:var(--matrix-green)}
|
||||
label.autodl{display:flex;gap:7px;align-items:center;color:var(--text-muted);font-size:12px;margin:-4px 0 12px;cursor:pointer}
|
||||
h2{font-size:15px;color:var(--gold-hermetic);border-bottom:1px solid var(--line);padding-bottom:8px;margin:0 0 14px}
|
||||
.ep{display:flex;gap:12px;align-items:center;padding:12px;border:1px solid var(--line);border-radius:10px;margin-bottom:10px;background:var(--panel)}
|
||||
.ep .meta{flex:1;min-width:0}.ep .meta b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ep .meta span{color:var(--text-muted);font-size:11px}
|
||||
.ep .act{display:flex;gap:6px;align-items:center}
|
||||
.badge{font-size:10px;padding:2px 7px;border-radius:20px;border:1px solid var(--line);color:var(--text-muted)}
|
||||
.badge.done{color:var(--matrix-green);border-color:var(--matrix-green)}
|
||||
.badge.downloading{color:var(--cyber-cyan);border-color:var(--cyber-cyan)}
|
||||
.badge.error{color:var(--cinnabar);border-color:var(--cinnabar)}
|
||||
.bar{height:4px;border-radius:3px;background:#0e0e16;overflow:hidden;width:90px}
|
||||
.bar i{display:block;height:100%;background:var(--cyber-cyan);width:0}
|
||||
.bar{height:4px;border-radius:3px;background:#0e0e16;overflow:hidden;width:90px}.bar i{display:block;height:100%;background:var(--cyber-cyan);width:0}
|
||||
audio{height:34px}
|
||||
.share{margin-top:6px;padding:12px;border:1px dashed var(--void-purple);border-radius:10px;
|
||||
color:var(--text-muted);font-size:12px}
|
||||
.share{margin-top:6px;padding:12px;border:1px dashed var(--void-purple);border-radius:10px;color:var(--text-muted);font-size:12px}
|
||||
.share a{color:var(--cyber-cyan)}
|
||||
.muted{color:var(--text-muted)} .empty{color:var(--text-muted);padding:40px;text-align:center}
|
||||
dialog{background:var(--panel);color:var(--text-primary);border:1px solid var(--void-purple);
|
||||
border-radius:12px;padding:20px;width:min(520px,92vw)}
|
||||
dialog label{display:block;margin:10px 0 4px;color:var(--text-muted);font-size:12px}
|
||||
dialog input{width:100%}
|
||||
.toast{position:fixed;bottom:18px;right:18px;background:#13131c;border:1px solid var(--cyber-cyan);
|
||||
color:var(--cyber-cyan);padding:10px 14px;border-radius:8px;opacity:0;transition:.2s;font-size:12px}
|
||||
.toast.on{opacity:1}
|
||||
.muted{color:var(--text-muted)}.empty{color:var(--text-muted);padding:40px;text-align:center}
|
||||
dialog{background:var(--panel);color:var(--text-primary);border:1px solid var(--void-purple);border-radius:12px;padding:20px;width:min(520px,92vw)}
|
||||
dialog label{display:block;margin:10px 0 4px;color:var(--text-muted);font-size:12px}dialog input{width:100%}
|
||||
.toast{position:fixed;bottom:18px;right:18px;background:#13131c;border:1px solid var(--cyber-cyan);color:var(--cyber-cyan);padding:10px 14px;border-radius:8px;opacity:0;transition:.2s;font-size:12px}.toast.on{opacity:1}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="em">🎙️</span><h1>PODCASTER</h1>
|
||||
<div class="stats" id="stats"></div>
|
||||
<button onclick="openCfg()" title="Config & status">⚙️</button>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<aside>
|
||||
<div class="row">
|
||||
<input id="feedUrl" placeholder="RSS feed URL…" onkeydown="if(event.key==='Enter')addFeed()">
|
||||
<button class="go" onclick="addFeed()">+</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button onclick="document.getElementById('opml').click()">Import OPML</button>
|
||||
<button onclick="loadAll()">↻</button>
|
||||
</div>
|
||||
<input type="file" id="opml" accept=".opml,.xml" style="display:none" onchange="importOpml(this)">
|
||||
<div id="feeds"></div>
|
||||
<div class="share" id="share"></div>
|
||||
</aside>
|
||||
<main>
|
||||
<h2 id="title">Latest episodes</h2>
|
||||
<div id="eps"><div class="empty">Loading…</div></div>
|
||||
</main>
|
||||
</div>
|
||||
<nav class="sidebar" id="sidebar"></nav>
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<span class="em">🎙️</span><h1>PODCASTER</h1>
|
||||
<div class="stats" id="stats"></div>
|
||||
<a class="portal" href="/podcaster/portal/" target="_blank">🌐 Public portal</a>
|
||||
<button onclick="openCfg()" title="Config & status">⚙️</button>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<aside class="feeds">
|
||||
<div class="row">
|
||||
<input id="feedUrl" placeholder="RSS feed URL…" onkeydown="if(event.key==='Enter')addFeed()">
|
||||
<button class="go" onclick="addFeed()">+</button>
|
||||
</div>
|
||||
<label class="autodl"><input type="checkbox" id="autodl" checked> auto-download new episodes</label>
|
||||
<div class="row">
|
||||
<button onclick="document.getElementById('opml').click()">Import OPML</button>
|
||||
<button onclick="document.getElementById('abzip').click()" title="Upload an audiobook ZIP">📚 Audiobook ZIP</button>
|
||||
<button onclick="loadAll()">↻</button>
|
||||
</div>
|
||||
<input type="file" id="opml" accept=".opml,.xml" style="display:none" onchange="importOpml(this)">
|
||||
<input type="file" id="abzip" accept=".zip" style="display:none" onchange="uploadZip(this)">
|
||||
<div id="feeds"></div>
|
||||
<div class="share" id="share"></div>
|
||||
</aside>
|
||||
<section class="eps">
|
||||
<h2 id="title">Latest episodes</h2>
|
||||
<div id="eps"><div class="empty">Loading…</div></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<dialog id="cfg">
|
||||
<h2>Config & status</h2>
|
||||
|
|
@ -109,91 +110,82 @@
|
|||
</dialog>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/shared/sidebar.js"></script>
|
||||
<script>
|
||||
const API="/api/v1/podcaster";
|
||||
let SEL=null, POLL=null;
|
||||
function tok(){return localStorage.getItem("secubox_token")||localStorage.getItem("token")||""}
|
||||
let SEL=null;
|
||||
function tok(){return localStorage.getItem("sbx_token")||""}
|
||||
async function api(p,opt={}){
|
||||
opt.headers=Object.assign({"Content-Type":"application/json"},opt.headers||{});
|
||||
opt.headers=Object.assign({},opt.headers||{});
|
||||
if(!(opt.body instanceof FormData) && !opt.headers["Content-Type"]) opt.headers["Content-Type"]="application/json";
|
||||
const t=tok(); if(t) opt.headers["Authorization"]="Bearer "+t;
|
||||
opt.credentials="include";
|
||||
const r=await fetch(API+p,opt);
|
||||
if(r.status===401){toast("Login via the Hub first");throw new Error("401")}
|
||||
if(r.status===401){window.location="/login.html";throw new Error("401")}
|
||||
if(!r.ok) throw new Error(await r.text());
|
||||
const ct=r.headers.get("content-type")||""; return ct.includes("json")?r.json():r.text();
|
||||
}
|
||||
function toast(m){const t=document.getElementById("toast");t.textContent=m;t.classList.add("on");
|
||||
clearTimeout(t._);t._=setTimeout(()=>t.classList.remove("on"),2600)}
|
||||
function toast(m){const t=document.getElementById("toast");t.textContent=m;t.classList.add("on");clearTimeout(t._);t._=setTimeout(()=>t.classList.remove("on"),3000)}
|
||||
function esc(s){return (s||"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))}
|
||||
|
||||
async function loadStatus(){
|
||||
try{const s=await fetch(API+"/status").then(r=>r.json());
|
||||
document.getElementById("stats").innerHTML=
|
||||
`feeds <b>${s.feeds}</b> · episodes <b>${s.episodes}</b> · local <b>${s.downloaded}</b> · queue <b>${s.queued}</b>`;
|
||||
document.getElementById("stats").innerHTML=`feeds <b>${s.feeds}</b> · eps <b>${s.episodes}</b> · local <b>${s.downloaded}</b> · queue <b>${s.queued}</b>`;
|
||||
const base=(s.public_base||location.origin);
|
||||
document.getElementById("share").innerHTML=
|
||||
`🔗 Share feed:<br><a href="${API}/share/feed.xml" target="_blank">${esc(base)}/api/v1/podcaster/share/feed.xml</a>`;
|
||||
document.getElementById("share").innerHTML=`🔗 Share feed:<br><a href="${API}/share/feed.xml" target="_blank">${esc(base)}/api/v1/podcaster/share/feed.xml</a>`;
|
||||
}catch(e){}
|
||||
}
|
||||
async function loadFeeds(){
|
||||
let d; try{d=await api("/feeds")}catch(e){document.getElementById("feeds").innerHTML=
|
||||
'<div class="muted" style="padding:12px">Log in via the Hub to manage feeds.</div>';return}
|
||||
let d; try{d=await api("/feeds")}catch(e){return}
|
||||
const el=document.getElementById("feeds");
|
||||
if(!d.feeds.length){el.innerHTML='<div class="muted" style="padding:12px">No feeds yet — add one above.</div>';return}
|
||||
el.innerHTML=d.feeds.map(f=>`<div class="feed ${SEL===f.id?'sel':''}" onclick="selFeed(${f.id})">
|
||||
<img src="${esc(f.image||'')}" onerror="this.style.visibility='hidden'">
|
||||
<div class="t"><b>${esc(f.title||f.url)}</b><span>${f.downloaded}/${f.episodes} local</span></div>
|
||||
<span class="ad ${f.auto_dl?'on':''}" title="auto-download ${f.auto_dl?'on':'off'}" onclick="event.stopPropagation();toggleAd(${f.id},${f.auto_dl?0:1})">⏬</span>
|
||||
<span class="x" onclick="event.stopPropagation();delFeed(${f.id})">✕</span></div>`).join("");
|
||||
}
|
||||
async function selFeed(id){SEL=id;loadFeeds();loadEps()}
|
||||
async function loadEps(){
|
||||
const el=document.getElementById("eps");
|
||||
const q=SEL?`?feed_id=${SEL}`:"";
|
||||
const el=document.getElementById("eps");const q=SEL?`?feed_id=${SEL}`:"";
|
||||
document.getElementById("title").textContent=SEL?"Episodes":"Latest episodes";
|
||||
let d; try{d=await api("/episodes"+q)}catch(e){el.innerHTML='<div class="empty">Log in via the Hub to see episodes.</div>';return}
|
||||
let d; try{d=await api("/episodes"+q)}catch(e){return}
|
||||
if(!d.episodes.length){el.innerHTML='<div class="empty">No episodes.</div>';return}
|
||||
el.innerHTML=d.episodes.map(e=>{
|
||||
const date=e.pubdate?new Date(e.pubdate*1000).toLocaleDateString():"";
|
||||
let act;
|
||||
if(e.state==="done") act=`<audio controls preload="none" src="${API}/media/${e.id}"></audio>`;
|
||||
else if(e.state==="downloading"||e.state==="queued")
|
||||
act=`<span class="badge ${e.state}">${e.state}</span><div class="bar"><i style="width:${e.progress||0}%"></i></div>`;
|
||||
else if(e.state==="error") act=`<span class="badge error" title="${esc(e.error)}">error</span>
|
||||
<button onclick="dl(${e.id})">retry</button>`;
|
||||
else if(e.state==="downloading"||e.state==="queued") act=`<span class="badge downloading">${e.state}</span><div class="bar"><i style="width:${e.progress||0}%"></i></div>`;
|
||||
else if(e.state==="error") act=`<span class="badge error" title="${esc(e.error)}">error</span><button onclick="dl(${e.id})">retry</button>`;
|
||||
else act=`<button onclick="dl(${e.id})">⬇ download</button>`;
|
||||
return `<div class="ep"><div class="meta"><b>${esc(e.title)}</b>
|
||||
<span>${esc(e.feed_title)} · ${date} ${e.duration?'· '+esc(e.duration):''}</span></div>
|
||||
<div class="act">${act}</div></div>`;
|
||||
return `<div class="ep"><div class="meta"><b>${esc(e.title)}</b><span>${esc(e.feed_title)} · ${date} ${e.duration?'· '+esc(e.duration):''}</span></div><div class="act">${act}</div></div>`;
|
||||
}).join("");
|
||||
}
|
||||
async function addFeed(){
|
||||
const u=document.getElementById("feedUrl").value.trim(); if(!u)return;
|
||||
try{const r=await api("/feeds",{method:"POST",body:JSON.stringify({url:u})});
|
||||
document.getElementById("feedUrl").value="";toast(`Added ${r.title||u} (${r.episodes} eps)`);loadAll()}
|
||||
catch(e){toast("Add failed")}
|
||||
}
|
||||
async function delFeed(id){if(!confirm("Remove this feed?"))return;
|
||||
await api("/feeds/"+id,{method:"DELETE"});if(SEL===id)SEL=null;loadAll()}
|
||||
async function dl(id){try{await api(`/episodes/${id}/download`,{method:"POST"});toast("Queued");loadEps()}
|
||||
catch(e){toast("Queue failed")}}
|
||||
async function addFeed(){const u=document.getElementById("feedUrl").value.trim();if(!u)return;
|
||||
const ad=document.getElementById("autodl").checked;
|
||||
try{const r=await api("/feeds",{method:"POST",body:JSON.stringify({url:u,auto_dl:ad})});
|
||||
document.getElementById("feedUrl").value="";
|
||||
toast(`Added ${r.title||u} (${r.episodes} eps${r.queued?', '+r.queued+' queued':''})`);loadAll()}
|
||||
catch(e){toast("Add failed: "+e.message.slice(0,60))}}
|
||||
async function toggleAd(id,on){try{const r=await api(`/feeds/${id}/autodl?on=${on?'true':'false'}`,{method:"POST"});
|
||||
toast(on?`auto-download on${r.queued?' · '+r.queued+' queued':''}`:"auto-download off");loadAll()}catch(e){toast("toggle failed")}}
|
||||
async function delFeed(id){if(!confirm("Remove this feed?"))return;await api("/feeds/"+id,{method:"DELETE"});if(SEL===id)SEL=null;loadAll()}
|
||||
async function dl(id){try{await api(`/episodes/${id}/download`,{method:"POST"});toast("Queued");loadEps()}catch(e){toast("Queue failed")}}
|
||||
async function importOpml(inp){const f=inp.files[0];if(!f)return;const opml=await f.text();
|
||||
try{const r=await api("/feeds/import-opml",{method:"POST",body:JSON.stringify({opml})});
|
||||
toast(`Imported ${r.added}/${r.total}`);loadAll()}catch(e){toast("OPML import failed")}inp.value=""}
|
||||
try{const r=await api("/feeds/import-opml",{method:"POST",body:JSON.stringify({opml})});toast(`Imported ${r.added}/${r.total}`);loadAll()}catch(e){toast("OPML import failed")}inp.value=""}
|
||||
async function uploadZip(inp){const f=inp.files[0];if(!f)return;
|
||||
const title=prompt("Audiobook title:",f.name.replace(/\.zip$/i,""));if(title===null){inp.value="";return}
|
||||
toast("Uploading "+f.name+" …");
|
||||
try{const r=await api("/audiobook/upload?title="+encodeURIComponent(title),
|
||||
{method:"POST",headers:{"Content-Type":"application/zip"},body:f});
|
||||
toast(`Published "${r.title}" — ${r.tracks} tracks`);loadAll()}
|
||||
catch(e){toast("Upload failed: "+e.message.slice(0,60))}inp.value=""}
|
||||
async function openCfg(){const d=await api("/config").then(r=>r.config).catch(()=>null);
|
||||
const s=await fetch(API+"/status").then(r=>r.json()).catch(()=>({}));
|
||||
document.getElementById("svc").innerHTML=`service <b style="color:var(--matrix-green)">${s.service||'?'}</b> · worker ${s.worker?'on':'off'} · ${s.downloaded||0} local`;
|
||||
if(d){c_media.value=d.media_path||"";c_par.value=d.max_parallel||2;c_ref.value=d.refresh_minutes||60;
|
||||
c_pub.value=d.public_base||"";c_title.value=d.share_title||""}
|
||||
if(d){c_media.value=d.media_path||"";c_par.value=d.max_parallel||2;c_ref.value=d.refresh_minutes||60;c_pub.value=d.public_base||"";c_title.value=d.share_title||""}
|
||||
document.getElementById("cfg").showModal()}
|
||||
async function saveCfg(){
|
||||
try{await api("/config",{method:"POST",body:JSON.stringify({
|
||||
media_path:c_media.value,max_parallel:+c_par.value,refresh_minutes:+c_ref.value,
|
||||
public_base:c_pub.value,share_title:c_title.value})});
|
||||
toast("Saved — restart to apply");document.getElementById("cfg").close()}
|
||||
catch(e){toast("Save failed")}}
|
||||
async function saveCfg(){try{await api("/config",{method:"POST",body:JSON.stringify({media_path:c_media.value,max_parallel:+c_par.value,refresh_minutes:+c_ref.value,public_base:c_pub.value,share_title:c_title.value})});toast("Saved — restart to apply");document.getElementById("cfg").close()}catch(e){toast("Save failed")}}
|
||||
function loadAll(){loadStatus();loadFeeds();loadEps()}
|
||||
loadAll();
|
||||
POLL=setInterval(()=>{loadStatus();loadEps()},4000);
|
||||
loadAll();setInterval(()=>{loadStatus();loadEps()},4000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
107
packages/secubox-podcaster/www/podcaster/portal/index.html
Normal file
107
packages/secubox-podcaster/www/podcaster/portal/index.html
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<!DOCTYPE html>
|
||||
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
|
||||
<!-- SecuBox-Deb :: Podcaster PUBLIC portal (no auth) — CyberMind https://cybermind.fr -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Podcasts · SecuBox</title>
|
||||
<style>
|
||||
:root{
|
||||
--cosmos-black:#0a0a0f; --gold-hermetic:#c9a84c; --void-purple:#6e40c9;
|
||||
--cyber-cyan:#00d4ff; --matrix-green:#00ff41; --text-primary:#e8e6d9;
|
||||
--text-muted:#6b6b7a; --panel:#13131c; --line:#23232f;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:radial-gradient(1200px 600px at 50% -10%,#15101f,#0a0a0f 60%);
|
||||
color:var(--text-primary);font-family:'JetBrains Mono',ui-monospace,Menlo,monospace}
|
||||
header{max-width:1000px;margin:0 auto;padding:38px 20px 18px;text-align:center}
|
||||
header .em{font-size:42px}
|
||||
header h1{margin:8px 0 4px;font-size:30px;letter-spacing:2px;color:var(--gold-hermetic)}
|
||||
header p{color:var(--text-muted);margin:0}
|
||||
.subs{display:inline-flex;gap:10px;margin-top:16px;flex-wrap:wrap;justify-content:center}
|
||||
.subs a{color:var(--cyber-cyan);text-decoration:none;border:1px solid var(--cyber-cyan);
|
||||
padding:8px 14px;border-radius:24px;font-size:13px}
|
||||
.subs a:hover{background:rgba(0,212,255,.1)}
|
||||
.filters{max-width:1000px;margin:18px auto 0;padding:0 20px;display:flex;gap:8px;flex-wrap:wrap}
|
||||
.chip{font-size:12px;padding:6px 12px;border-radius:20px;border:1px solid var(--line);
|
||||
color:var(--text-muted);cursor:pointer}
|
||||
.chip.on{color:var(--void-purple);border-color:var(--void-purple);background:rgba(110,64,201,.12)}
|
||||
main{max-width:1000px;margin:18px auto 60px;padding:0 20px}
|
||||
.ep{display:flex;gap:14px;align-items:center;padding:14px;border:1px solid var(--line);
|
||||
border-radius:12px;margin-bottom:12px;background:var(--panel)}
|
||||
.ep .n{font-size:20px;color:var(--void-purple);width:30px;text-align:center}
|
||||
.ep .meta{flex:1;min-width:0}
|
||||
.ep .meta b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:15px}
|
||||
.ep .meta span{color:var(--text-muted);font-size:12px}
|
||||
audio{height:38px;max-width:300px}
|
||||
.ep a.dl{color:var(--matrix-green);text-decoration:none;border:1px solid var(--matrix-green);
|
||||
border-radius:8px;padding:7px 10px;font-size:13px;white-space:nowrap}
|
||||
.ep a.dl:hover{background:rgba(0,255,65,.1)}
|
||||
.zipbar{max-width:1000px;margin:14px auto 0;padding:0 20px}
|
||||
.zipbar a{color:var(--gold-hermetic);text-decoration:none;border:1px solid var(--gold-hermetic);
|
||||
border-radius:24px;padding:8px 16px;font-size:13px}
|
||||
.zipbar a:hover{background:rgba(201,168,76,.12)}
|
||||
.empty{text-align:center;color:var(--text-muted);padding:60px}
|
||||
footer{text-align:center;color:var(--text-muted);font-size:11px;padding:24px}
|
||||
footer a{color:var(--text-muted)}
|
||||
@media(max-width:680px){.ep{flex-wrap:wrap}audio{max-width:100%;width:100%}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="em">🎙️</div>
|
||||
<h1 id="title">Podcasts</h1>
|
||||
<p id="sub">Relayed locally by SecuBox · listen freely</p>
|
||||
<div class="subs">
|
||||
<a id="rss" href="/api/v1/podcaster/share/feed.xml" target="_blank">📡 Subscribe (RSS)</a>
|
||||
<a id="copy" href="#" onclick="copyRss(event)">🔗 Copy feed URL</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="filters" id="filters"></div>
|
||||
<div class="zipbar" id="zipbar"></div>
|
||||
<main><div id="list" class="empty">Loading…</div></main>
|
||||
<footer>SecuBox Podcaster · <a href="https://cybermind.fr" target="_blank">CyberMind</a></footer>
|
||||
|
||||
<script>
|
||||
const API="/api/v1/podcaster";
|
||||
let DATA={episodes:[],feeds:{}}, FILT=null;
|
||||
function esc(s){return (s||"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))}
|
||||
function rssUrl(){return location.origin+API+"/share/feed.xml"}
|
||||
function copyRss(e){e.preventDefault();navigator.clipboard.writeText(rssUrl()).then(()=>{
|
||||
const a=document.getElementById("copy");a.textContent="✓ Copied";setTimeout(()=>a.textContent="🔗 Copy feed URL",1800)})}
|
||||
async function load(){
|
||||
let d; try{d=await fetch(API+"/public/library").then(r=>r.json())}
|
||||
catch(e){document.getElementById("list").innerHTML='<div class="empty">Portal unavailable.</div>';return}
|
||||
DATA=d;
|
||||
document.getElementById("title").textContent=d.title||"Podcasts";
|
||||
document.getElementById("rss").href=API+"/share/feed.xml";
|
||||
const fl=document.getElementById("filters");
|
||||
const feeds=Object.keys(d.feeds||{});
|
||||
fl.innerHTML=(feeds.length>1?[`<span class="chip ${FILT===null?'on':''}" onclick="setF(null)">All</span>`]:[])
|
||||
.concat(feeds.map(f=>`<span class="chip ${FILT===f?'on':''}" onclick="setF('${esc(f).replace(/'/g,"")}')">${esc(f)} · ${d.feeds[f]}</span>`)).join("");
|
||||
render();
|
||||
}
|
||||
function setF(f){FILT=f;load._render?render():render()}
|
||||
function render(){
|
||||
const list=document.getElementById("list");
|
||||
let eps=DATA.episodes||[];
|
||||
if(FILT) eps=eps.filter(e=>e.feed===FILT);
|
||||
// ZIP-all button when a single feed is in view
|
||||
const zb=document.getElementById("zipbar");
|
||||
const fid=eps.length?eps[0].feed_id:null;
|
||||
zb.innerHTML=(FILT&&fid!=null)
|
||||
? `<a href="${API}/public/feed/${fid}/zip">⬇ Download all (ZIP) · ${esc(FILT)}</a>` : "";
|
||||
if(!eps.length){list.innerHTML='<div class="empty">No episodes published yet.</div>';return}
|
||||
list.innerHTML=eps.map((e,i)=>{
|
||||
const date=e.pubdate?new Date(e.pubdate*1000).toLocaleDateString():"";
|
||||
return `<div class="ep"><div class="n">${i+1}</div>
|
||||
<div class="meta"><b>${esc(e.title)}</b><span>${esc(e.feed)} · ${date} ${e.duration?'· '+esc(e.duration):''}</span></div>
|
||||
<audio controls preload="none" src="${API}/media/${e.id}"></audio>
|
||||
<a class="dl" href="${API}/media/${e.id}" download title="Download">⬇</a></div>`;
|
||||
}).join("");
|
||||
}
|
||||
load();setInterval(load,15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user