Compare commits

...

3 Commits

Author SHA1 Message Date
0566672615 feat(podcaster): auto-download to keep portal/share feed synced (#726)
Feeds can auto-queue new episodes (auto_dl). New feeds default 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. Bump 1.0.3.
2026-06-24 12:15:36 +02:00
9f5bec6a87 feat(podcaster/portal): per-episode download + per-feed ZIP download (#726)
Public portal: ⬇ per-episode mp3 download + 'Download all (ZIP)' per feed via
new public GET /public/feed/{id}/zip (STORED zip to temp, streamed, cleaned up);
public/library now exposes feed_id. Bump 1.0.2.
2026-06-24 10:53:52 +02:00
560b8d8213 feat(podcaster): Hub navbar + sbx_token auth, public portal, audiobook ZIP import (#726)
- admin UI: shared /shared/sidebar.js navbar + correct sbx_token auth
  (401->/login.html) — fixes missing navbar + false login prompt
- public listener PORTAL at /podcaster/portal/ (no auth) + GET /public/library
- audiobook ZIP import: POST /audiobook/upload (raw body, streamed to temp,
  extracts audio tracks -> synthetic feed, published in library + share feed)
- bump 1.0.1
2026-06-24 10:45:58 +02:00
5 changed files with 410 additions and 117 deletions

View File

@ -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()

View File

@ -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(

View File

@ -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:

View File

@ -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}
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 .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 .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}
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>
<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>
</header>
</div>
<div class="wrap">
<aside>
<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>
<main>
<section class="eps">
<h2 id="title">Latest episodes</h2>
<div id="eps"><div class="empty">Loading…</div></div>
</main>
</section>
</div>
</main>
<dialog id="cfg">
<h2>Config &amp; 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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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>

View 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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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>