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 html
import os import os
import re import re
import shutil
import tempfile
import time import time
import tomllib import tomllib
import zipfile
from email.utils import parsedate_to_datetime, format_datetime from email.utils import parsedate_to_datetime, format_datetime
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -25,7 +28,7 @@ from typing import Optional
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
import httpx 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 fastapi.responses import Response, FileResponse, JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -74,6 +77,7 @@ _worker_started = False
# ════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════
class FeedIn(BaseModel): class FeedIn(BaseModel):
url: str url: str
auto_dl: bool = True
class OPMLIn(BaseModel): class OPMLIn(BaseModel):
@ -151,16 +155,34 @@ def parse_feed(xml_bytes: bytes) -> tuple[dict, list[dict]]:
return meta, episodes 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: async with httpx.AsyncClient(follow_redirects=True, timeout=30) as cli:
r = await cli.get(url, headers={"User-Agent": "SecuBox-Podcaster/1.0"}) r = await cli.get(url, headers={"User-Agent": "SecuBox-Podcaster/1.0"})
r.raise_for_status() r.raise_for_status()
meta, eps = parse_feed(r.content) 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) 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: for ep in eps:
store.upsert_episode(fid, ep) 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") 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}") @router.get("/media/{ep_id}")
async def media(ep_id: int): async def media(ep_id: int):
ep = store.get_episode(ep_id) ep = store.get_episode(ep_id)
@ -324,11 +393,21 @@ async def feeds():
async def add_feed(body: FeedIn): async def add_feed(body: FeedIn):
_ensure_worker() _ensure_worker()
try: 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: except Exception as e:
raise HTTPException(400, f"feed add failed: {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)]) @router.delete("/feeds/{fid}", dependencies=[Depends(require_jwt)])
async def del_feed(fid: int): async def del_feed(fid: int):
store.delete_feed(fid) store.delete_feed(fid)
@ -353,6 +432,68 @@ async def import_opml(body: OPMLIn):
return {"added": added, "total": len(urls)} 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)]) @router.get("/episodes", dependencies=[Depends(require_jwt)])
async def episodes(feed_id: Optional[int] = None, state: Optional[str] = None): async def episodes(feed_id: Optional[int] = None, state: Optional[str] = None):
_ensure_worker() _ensure_worker()

View File

@ -66,13 +66,13 @@ def init() -> None:
# ── feeds ────────────────────────────────────────────────────────── # ── 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: with _conn() as c:
cur = c.execute( cur = c.execute(
"INSERT OR IGNORE INTO feeds(url,title,description,image,site,added_ts) " "INSERT OR IGNORE INTO feeds(url,title,description,image,site,added_ts,auto_dl) "
"VALUES(?,?,?,?,?,?)", "VALUES(?,?,?,?,?,?,?)",
(url, meta.get("title"), meta.get("description"), (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: if cur.lastrowid:
return cur.lastrowid return cur.lastrowid
@ -80,6 +80,26 @@ def add_feed(url: str, meta: dict) -> int:
return row["id"] if row else 0 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: def update_feed_meta(feed_id: int, meta: dict) -> None:
with _conn() as c: with _conn() as c:
c.execute( 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 secubox-podcaster (1.0.0-1~bookworm1) bookworm; urgency=medium
* Initial release (#726). Modern podcast manager: * Initial release (#726). Modern podcast manager:

View File

@ -1,98 +1,99 @@
<!DOCTYPE html> <!DOCTYPE html>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 --> <!-- 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"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>SecuBox · Podcaster</title> <title>SecuBox · Podcaster</title>
<link rel="stylesheet" href="/shared/crt-light.css">
<link rel="stylesheet" href="/shared/sidebar-light.css">
<style> <style>
:root{ :root{
--cosmos-black:#0a0a0f; --gold-hermetic:#c9a84c; --cinnabar:#e63946; --cosmos-black:#0a0a0f; --gold-hermetic:#c9a84c; --cinnabar:#e63946;
--matrix-green:#00ff41; --void-purple:#6e40c9; --cyber-cyan:#00d4ff; --matrix-green:#00ff41; --void-purple:#6e40c9; --cyber-cyan:#00d4ff;
--text-primary:#e8e6d9; --text-muted:#6b6b7a; --panel:#13131c; --line:#23232f; --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); body{margin:0;background:var(--cosmos-black);color:var(--text-primary);
font-family:'JetBrains Mono',ui-monospace,Menlo,monospace;font-size:14px} font-family:'JetBrains Mono',ui-monospace,Menlo,monospace;font-size:14px;display:flex}
header{display:flex;align-items:center;gap:14px;padding:14px 20px; .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)} 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)} .topbar h1{font-size:18px;margin:0;letter-spacing:1px;color:var(--gold-hermetic)}
header .em{font-size:22px} .topbar .em{font-size:22px}
.stats{margin-left:auto;display:flex;gap:18px;color:var(--text-muted);font-size:12px} .stats{margin-left:auto;display:flex;gap:16px;color:var(--text-muted);font-size:12px}
.stats b{color:var(--cyber-cyan)} .stats b{color:var(--cyber-cyan)}
.wrap{display:grid;grid-template-columns:300px 1fr;gap:0;min-height:calc(100vh - 58px)} .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} aside.feeds{border-right:1px solid var(--line);background:var(--panel);padding:14px;overflow:auto}
main{padding:18px 22px;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} .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,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} input{flex:1;background:#0e0e16;color:var(--text-primary);padding:9px 11px}
button{background:#1a1a26;color:var(--text-primary);padding:9px 13px;cursor:pointer; button{background:#1a1a26;color:var(--text-primary);padding:9px 13px;cursor:pointer;transition:.15s}
transition:.15s;border:1px solid var(--line)}
button:hover{border-color:var(--cyber-cyan);color:var(--cyber-cyan)} 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{background:var(--void-purple);border-color:var(--void-purple);color:#fff}
button.go:hover{filter:brightness(1.2)} a.portal{color:var(--cyber-cyan);text-decoration:none;font-size:12px;border:1px solid var(--cyber-cyan);
.feed{display:flex;gap:10px;align-items:center;padding:9px;border-radius:8px;cursor:pointer; padding:7px 11px;border-radius:8px}
border:1px solid transparent} .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: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 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; .feed .t{flex:1;min-width:0}.feed .t b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px}
text-overflow:ellipsis;font-size:13px} .feed .t span{color:var(--text-muted);font-size:11px} .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 .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); .feed .ad{font-size:14px;cursor:pointer;opacity:.5}.feed .ad.on{opacity:1;color:var(--matrix-green)}
padding-bottom:8px;margin:0 0 14px} label.autodl{display:flex;gap:7px;align-items:center;color:var(--text-muted);font-size:12px;margin:-4px 0 12px;cursor:pointer}
.ep{display:flex;gap:12px;align-items:center;padding:12px;border:1px solid var(--line); h2{font-size:15px;color:var(--gold-hermetic);border-bottom:1px solid var(--line);padding-bottom:8px;margin:0 0 14px}
border-radius:10px;margin-bottom:10px;background:var(--panel)} .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{flex:1;min-width:0}.ep .meta b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ep .meta b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ep .meta span{color:var(--text-muted);font-size:11px} .ep .meta span{color:var(--text-muted);font-size:11px}
.ep .act{display:flex;gap:6px;align-items:center} .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{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.downloading{color:var(--cyber-cyan);border-color:var(--cyber-cyan)}
.badge.error{color:var(--cinnabar);border-color:var(--cinnabar)} .badge.error{color:var(--cinnabar);border-color:var(--cinnabar)}
.bar{height:4px;border-radius:3px;background:#0e0e16;overflow:hidden;width:90px} .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 i{display:block;height:100%;background:var(--cyber-cyan);width:0}
audio{height:34px} audio{height:34px}
.share{margin-top:6px;padding:12px;border:1px dashed var(--void-purple);border-radius:10px; .share{margin-top:6px;padding:12px;border:1px dashed var(--void-purple);border-radius:10px;color:var(--text-muted);font-size:12px}
color:var(--text-muted);font-size:12px}
.share a{color:var(--cyber-cyan)} .share a{color:var(--cyber-cyan)}
.muted{color:var(--text-muted)}.empty{color:var(--text-muted);padding:40px;text-align:center} .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); dialog{background:var(--panel);color:var(--text-primary);border:1px solid var(--void-purple);border-radius:12px;padding:20px;width:min(520px,92vw)}
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%}
dialog label{display:block;margin:10px 0 4px;color:var(--text-muted);font-size:12px} .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 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> </style>
</head> </head>
<body> <body>
<header> <nav class="sidebar" id="sidebar"></nav>
<main class="main">
<div class="topbar">
<span class="em">🎙️</span><h1>PODCASTER</h1> <span class="em">🎙️</span><h1>PODCASTER</h1>
<div class="stats" id="stats"></div> <div class="stats" id="stats"></div>
<a class="portal" href="/podcaster/portal/" target="_blank">🌐 Public portal</a>
<button onclick="openCfg()" title="Config & status">⚙️</button> <button onclick="openCfg()" title="Config & status">⚙️</button>
</header> </div>
<div class="wrap"> <div class="wrap">
<aside> <aside class="feeds">
<div class="row"> <div class="row">
<input id="feedUrl" placeholder="RSS feed URL…" onkeydown="if(event.key==='Enter')addFeed()"> <input id="feedUrl" placeholder="RSS feed URL…" onkeydown="if(event.key==='Enter')addFeed()">
<button class="go" onclick="addFeed()"></button> <button class="go" onclick="addFeed()"></button>
</div> </div>
<label class="autodl"><input type="checkbox" id="autodl" checked> auto-download new episodes</label>
<div class="row"> <div class="row">
<button onclick="document.getElementById('opml').click()">Import OPML</button> <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> <button onclick="loadAll()"></button>
</div> </div>
<input type="file" id="opml" accept=".opml,.xml" style="display:none" onchange="importOpml(this)"> <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 id="feeds"></div>
<div class="share" id="share"></div> <div class="share" id="share"></div>
</aside> </aside>
<main> <section class="eps">
<h2 id="title">Latest episodes</h2> <h2 id="title">Latest episodes</h2>
<div id="eps"><div class="empty">Loading…</div></div> <div id="eps"><div class="empty">Loading…</div></div>
</main> </section>
</div> </div>
</main>
<dialog id="cfg"> <dialog id="cfg">
<h2>Config &amp; status</h2> <h2>Config &amp; status</h2>
@ -109,91 +110,82 @@
</dialog> </dialog>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="/shared/sidebar.js"></script>
<script> <script>
const API="/api/v1/podcaster"; const API="/api/v1/podcaster";
let SEL=null, POLL=null; let SEL=null;
function tok(){return localStorage.getItem("secubox_token")||localStorage.getItem("token")||""} function tok(){return localStorage.getItem("sbx_token")||""}
async function api(p,opt={}){ 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; const t=tok(); if(t) opt.headers["Authorization"]="Bearer "+t;
opt.credentials="include";
const r=await fetch(API+p,opt); 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()); if(!r.ok) throw new Error(await r.text());
const ct=r.headers.get("content-type")||""; return ct.includes("json")?r.json():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"); function toast(m){const t=document.getElementById("toast");t.textContent=m;t.classList.add("on");clearTimeout(t._);t._=setTimeout(()=>t.classList.remove("on"),3000)}
clearTimeout(t._);t._=setTimeout(()=>t.classList.remove("on"),2600)}
function esc(s){return (s||"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]))} function esc(s){return (s||"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]))}
async function loadStatus(){ async function loadStatus(){
try{const s=await fetch(API+"/status").then(r=>r.json()); try{const s=await fetch(API+"/status").then(r=>r.json());
document.getElementById("stats").innerHTML= document.getElementById("stats").innerHTML=`feeds <b>${s.feeds}</b> · eps <b>${s.episodes}</b> · local <b>${s.downloaded}</b> · queue <b>${s.queued}</b>`;
`feeds <b>${s.feeds}</b> · episodes <b>${s.episodes}</b> · local <b>${s.downloaded}</b> · queue <b>${s.queued}</b>`;
const base=(s.public_base||location.origin); const base=(s.public_base||location.origin);
document.getElementById("share").innerHTML= document.getElementById("share").innerHTML=`🔗 Share feed:<br><a href="${API}/share/feed.xml" target="_blank">${esc(base)}/api/v1/podcaster/share/feed.xml</a>`;
`🔗 Share feed:<br><a href="${API}/share/feed.xml" target="_blank">${esc(base)}/api/v1/podcaster/share/feed.xml</a>`;
}catch(e){} }catch(e){}
} }
async function loadFeeds(){ async function loadFeeds(){
let d; try{d=await api("/feeds")}catch(e){document.getElementById("feeds").innerHTML= let d; try{d=await api("/feeds")}catch(e){return}
'<div class="muted" style="padding:12px">Log in via the Hub to manage feeds.</div>';return}
const el=document.getElementById("feeds"); 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} 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})"> 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'"> <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> <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(""); <span class="x" onclick="event.stopPropagation();delFeed(${f.id})"></span></div>`).join("");
} }
async function selFeed(id){SEL=id;loadFeeds();loadEps()} async function selFeed(id){SEL=id;loadFeeds();loadEps()}
async function loadEps(){ async function loadEps(){
const el=document.getElementById("eps"); const el=document.getElementById("eps");const q=SEL?`?feed_id=${SEL}`:"";
const q=SEL?`?feed_id=${SEL}`:"";
document.getElementById("title").textContent=SEL?"Episodes":"Latest episodes"; 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} if(!d.episodes.length){el.innerHTML='<div class="empty">No episodes.</div>';return}
el.innerHTML=d.episodes.map(e=>{ el.innerHTML=d.episodes.map(e=>{
const date=e.pubdate?new Date(e.pubdate*1000).toLocaleDateString():""; const date=e.pubdate?new Date(e.pubdate*1000).toLocaleDateString():"";
let act; let act;
if(e.state==="done") act=`<audio controls preload="none" src="${API}/media/${e.id}"></audio>`; if(e.state==="done") act=`<audio controls preload="none" src="${API}/media/${e.id}"></audio>`;
else if(e.state==="downloading"||e.state==="queued") 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>`;
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==="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>`; else act=`<button onclick="dl(${e.id})">⬇ download</button>`;
return `<div class="ep"><div class="meta"><b>${esc(e.title)}</b> 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>`;
<span>${esc(e.feed_title)} · ${date} ${e.duration?'· '+esc(e.duration):''}</span></div>
<div class="act">${act}</div></div>`;
}).join(""); }).join("");
} }
async function addFeed(){ async function addFeed(){const u=document.getElementById("feedUrl").value.trim();if(!u)return;
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})}); 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)`);loadAll()} document.getElementById("feedUrl").value="";
catch(e){toast("Add failed")} 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 delFeed(id){if(!confirm("Remove this feed?"))return; async function toggleAd(id,on){try{const r=await api(`/feeds/${id}/autodl?on=${on?'true':'false'}`,{method:"POST"});
await api("/feeds/"+id,{method:"DELETE"});if(SEL===id)SEL=null;loadAll()} toast(on?`auto-download on${r.queued?' · '+r.queued+' queued':''}`:"auto-download off");loadAll()}catch(e){toast("toggle failed")}}
async function dl(id){try{await api(`/episodes/${id}/download`,{method:"POST"});toast("Queued");loadEps()} async function delFeed(id){if(!confirm("Remove this feed?"))return;await api("/feeds/"+id,{method:"DELETE"});if(SEL===id)SEL=null;loadAll()}
catch(e){toast("Queue failed")}} 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(); 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})}); 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=""}
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); 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(()=>({})); 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`; 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; 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||""}
c_pub.value=d.public_base||"";c_title.value=d.share_title||""}
document.getElementById("cfg").showModal()} document.getElementById("cfg").showModal()}
async function saveCfg(){ 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")}}
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()} function loadAll(){loadStatus();loadFeeds();loadEps()}
loadAll(); loadAll();setInterval(()=>{loadStatus();loadEps()},4000);
POLL=setInterval(()=>{loadStatus();loadEps()},4000);
</script> </script>
</body> </body>
</html> </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>