Compare commits

..

6 Commits

Author SHA1 Message Date
ff6fd7632f Merge feature/726 — secubox-podcaster (subscribe/download/relay, portal, audiobook ZIP, auto-download)
Some checks are pending
License Headers / check (push) Waiting to run
2026-06-24 12:15:40 +02:00
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
7da61e8fd5 fix(podcaster): use python3 -m uvicorn + drop proxy directives dup'd by snippet (#726)
- /usr/bin/uvicorn doesn't exist on the board (it's /usr/local/bin); use the
  robust /usr/bin/python3 -m uvicorn form (status=203/EXEC fix).
- secubox-proxy.conf already sets proxy_buffering off + proxy_read_timeout;
  remove the duplicates (nginx -t emerg 'directive is duplicate').
2026-06-24 10:26:26 +02:00
f839c9260e feat(podcaster): new module — subscribe/download/relay podcasts (#726)
secubox-podcaster v1: FastAPI on /run/secubox/podcaster.sock, SQLite store,
pure-stdlib RSS/OPML parsing, asyncio+httpx download queue with progress,
generated shareable RSS (/share/feed.xml, LAN or public via secubox-exposure),
in-UI service status + TOML config, C3BOX WebUI with inline player. nginx route
shipped to the active secubox-routes.d/ include; never touches the shared
/run/secubox parent (#494). Lyrion link deferred (standalone first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:24:16 +02:00
16 changed files with 1322 additions and 0 deletions

View File

@ -0,0 +1,58 @@
# secubox-podcaster
Modern podcast manager for SecuBox — subscribe, download locally, and relay a
shareable RSS feed.
## What it does (v1, #726)
- **Subscribe** by RSS URL or **OPML import** (pure-stdlib feed parsing — no
`feedparser` dependency).
- **Download locally** into `media_path` via an asyncio + httpx queue with
per-episode progress.
- **Relay / share**: a generated RSS of the local library at
`/api/v1/podcaster/share/feed.xml`. LAN by default; set `public_base` to your
exposed vhost to publish externally.
- **In-UI service status + TOML config**; modern C3BOX WebUI with inline player.
Lyrion integration is deferred to a follow-up (standalone first).
## Layout
| Path | Role |
|------|------|
| `/usr/share/secubox/podcaster/api` | FastAPI app (uvicorn WorkingDirectory) |
| `/run/secubox/podcaster.sock` | Unix socket |
| `/var/lib/secubox/podcaster/podcaster.db` | SQLite store |
| `/var/lib/secubox/podcaster/media/<feed_id>/` | downloaded episodes |
| `/etc/secubox/podcaster.toml` | config |
| `/etc/nginx/secubox-routes.d/podcaster.conf` | nginx route (active include) |
## API
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health`, `/status` | public | service + counts |
| GET | `/share/feed.xml` | public | shareable RSS of local library |
| GET | `/media/{id}` | public | stream a downloaded episode |
| GET/POST/DELETE | `/feeds*` | JWT | manage feeds (+ `/feeds/import-opml`) |
| GET | `/episodes` | JWT | list (optional `feed_id`, `state`) |
| POST | `/episodes/{id}/download` | JWT | enqueue download |
| GET/POST | `/config` | JWT | TOML config |
## Public exposure (relay to the web)
Publish the share feed externally via HAProxy TLS → mitmproxy (never bypass the
WAF):
```bash
haproxyctl vhost add podcast.gk2.secubox.in # backend = mitmproxy_inspector
# add the route to BOTH mitmproxy routes files -> 127.0.0.1:<nginx>
systemctl restart mitmproxy
```
Then set `public_base = "https://podcast.gk2.secubox.in"` in
`/etc/secubox/podcaster.toml` and restart `secubox-podcaster` so the generated
feed's enclosure URLs are absolute.
---
*CyberMind — Gérald Kerma. LicenseRef-CMSD-1.0.*

View File

@ -0,0 +1,547 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Podcaster
CyberMind https://cybermind.fr
Modern podcast manager: subscribe to feeds, download episodes locally, and
re-publish a shareable RSS. FastAPI on a Unix socket; SQLite store; an asyncio
download worker (background task, fire-and-forget queue). Feed parsing is pure
stdlib (xml.etree) to keep Debian deps minimal only httpx is required.
"""
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
from typing import Optional
from xml.etree import ElementTree as ET
import httpx
from fastapi import FastAPI, APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import Response, FileResponse, JSONResponse
from pydantic import BaseModel
from secubox_core.auth import router as auth_router, require_jwt
from secubox_core.logger import get_logger
from . import store
log = get_logger("podcaster")
CONFIG_FILE = Path("/etc/secubox/podcaster.toml")
DEFAULT_CONFIG = {
"media_path": "/var/lib/secubox/podcaster/media",
"max_parallel": 2,
"refresh_minutes": 60,
"public_base": "", # e.g. https://podcast.gk2.secubox.in (exposure); empty → LAN/relative
"share_title": "SecuBox Podcaster",
"keep_per_feed": 0, # 0 = unlimited
}
def load_config() -> dict:
cfg = dict(DEFAULT_CONFIG)
try:
if CONFIG_FILE.exists():
cfg.update(tomllib.loads(CONFIG_FILE.read_text()))
except Exception as e: # pragma: no cover - defensive
log.error(f"config load failed: {e}")
return cfg
CFG = load_config()
MEDIA = Path(CFG["media_path"])
app = FastAPI(title="secubox-podcaster", version="1.0.0", root_path="/api/v1/podcaster")
app.include_router(auth_router, prefix="/auth")
router = APIRouter()
# ── download queue (in-process, persisted state in SQLite) ──────────
_queue: "asyncio.Queue[int]" = asyncio.Queue()
_worker_started = False
# ════════════════════════════════════════════════════════════════════
# Models
# ════════════════════════════════════════════════════════════════════
class FeedIn(BaseModel):
url: str
auto_dl: bool = True
class OPMLIn(BaseModel):
opml: str
class ConfigIn(BaseModel):
media_path: Optional[str] = None
max_parallel: Optional[int] = None
refresh_minutes: Optional[int] = None
public_base: Optional[str] = None
share_title: Optional[str] = None
keep_per_feed: Optional[int] = None
# ════════════════════════════════════════════════════════════════════
# Feed parsing (stdlib)
# ════════════════════════════════════════════════════════════════════
_NS = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd",
"atom": "http://www.w3.org/2005/Atom"}
def _ts(s: Optional[str]) -> int:
if not s:
return 0
try:
return int(parsedate_to_datetime(s).timestamp())
except Exception:
return 0
def parse_feed(xml_bytes: bytes) -> tuple[dict, list[dict]]:
"""Return (feed_meta, [episode, ...]) from RSS 2.0 bytes."""
root = ET.fromstring(xml_bytes)
chan = root.find("channel")
if chan is None: # not RSS we understand
return {}, []
def t(el, tag):
x = el.find(tag)
return x.text.strip() if x is not None and x.text else None
img = None
ic = chan.find("itunes:image", _NS)
if ic is not None:
img = ic.get("href")
if not img:
im = chan.find("image/url")
img = im.text.strip() if im is not None and im.text else None
meta = {
"title": t(chan, "title"),
"description": t(chan, "description"),
"site": t(chan, "link"),
"image": img,
}
episodes = []
for it in chan.findall("item"):
enc = it.find("enclosure")
if enc is None or not enc.get("url"):
continue
guid = t(it, "guid") or enc.get("url")
dur = it.find("itunes:duration", _NS)
episodes.append({
"guid": guid,
"title": t(it, "title"),
"description": t(it, "description"),
"pubdate": _ts(t(it, "pubDate")),
"enclosure": enc.get("url"),
"mime": enc.get("type") or "audio/mpeg",
"bytes": int(enc.get("length") or 0),
"duration": dur.text.strip() if dur is not None and dur.text else None,
})
return meta, episodes
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, 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)
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
# ════════════════════════════════════════════════════════════════════
# Download worker
# ════════════════════════════════════════════════════════════════════
def _safe_name(s: str, ext: str) -> str:
base = re.sub(r"[^A-Za-z0-9._-]+", "_", (s or "episode"))[:80].strip("_")
return f"{base or 'episode'}{ext}"
async def _download_one(ep_id: int) -> None:
ep = store.get_episode(ep_id)
if not ep or not ep.get("enclosure"):
return
store.set_episode(ep_id, state="downloading", progress=0, error=None)
ext = os.path.splitext(ep["enclosure"].split("?")[0])[1][:5] or ".mp3"
fdir = MEDIA / str(ep["feed_id"])
fdir.mkdir(parents=True, exist_ok=True)
dest = fdir / _safe_name(f"{ep_id}_{ep.get('title','')}", ext)
tmp = dest.with_suffix(dest.suffix + ".part")
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=None) as cli:
async with cli.stream("GET", ep["enclosure"],
headers={"User-Agent": "SecuBox-Podcaster/1.0"}) as r:
r.raise_for_status()
total = int(r.headers.get("content-length") or ep.get("bytes") or 0)
got = 0
with open(tmp, "wb") as fh:
async for chunk in r.aiter_bytes(65536):
fh.write(chunk)
got += len(chunk)
if total:
store.set_episode(ep_id, progress=min(99, got * 100 // total))
tmp.rename(dest)
store.set_episode(ep_id, state="done", progress=100,
local_path=str(dest), bytes=dest.stat().st_size)
log.info(f"downloaded ep {ep_id} -> {dest}")
except Exception as e:
tmp.unlink(missing_ok=True)
store.set_episode(ep_id, state="error", error=str(e)[:200])
log.error(f"download ep {ep_id} failed: {e}")
async def _worker() -> None:
sem = asyncio.Semaphore(max(1, int(CFG.get("max_parallel", 2))))
async def run(ep_id):
async with sem:
await _download_one(ep_id)
while True:
ep_id = await _queue.get()
asyncio.create_task(run(ep_id))
async def _refresher() -> None:
while True:
await asyncio.sleep(max(5, int(CFG.get("refresh_minutes", 60))) * 60)
for fid, url in store.all_feed_urls():
try:
await fetch_and_store(url)
except Exception as e:
log.error(f"refresh feed {fid} failed: {e}")
def _ensure_worker() -> None:
"""Lazy-start background tasks (sub-app lifespans don't fire under the aggregator)."""
global _worker_started
if _worker_started:
return
_worker_started = True
store.init()
MEDIA.mkdir(parents=True, exist_ok=True)
asyncio.create_task(_worker())
asyncio.create_task(_refresher())
@app.on_event("startup")
async def _startup():
_ensure_worker()
# ════════════════════════════════════════════════════════════════════
# Public endpoints (status / health / share)
# ════════════════════════════════════════════════════════════════════
@app.get("/health")
async def health():
return {"status": "ok", "module": "deb"}
@router.get("/status")
async def status():
_ensure_worker()
return {
"service": "active",
"worker": _worker_started,
"media_path": str(MEDIA),
"public_base": CFG.get("public_base", ""),
**store.counts(),
}
def _xml_esc(s: Optional[str]) -> str:
return html.escape(s or "", quote=True)
@router.get("/share/feed.xml")
async def share_feed(request: Request):
"""A generated RSS of the locally downloaded episodes (the relay/share feed)."""
_ensure_worker()
base = (CFG.get("public_base") or "").rstrip("/")
if not base:
base = str(request.base_url).rstrip("/")
eps = store.downloaded_episodes()
items = []
for e in eps:
url = f"{base}/api/v1/podcaster/media/{e['id']}"
pub = format_datetime(datetime.fromtimestamp(e["pubdate"] or time.time(),
tz=timezone.utc))
items.append(
f"<item><title>{_xml_esc(e.get('title'))}</title>"
f"<description>{_xml_esc(e.get('description'))}</description>"
f"<pubDate>{pub}</pubDate>"
f"<guid isPermaLink=\"false\">sbx-{e['id']}</guid>"
f"<enclosure url=\"{_xml_esc(url)}\" type=\"{_xml_esc(e.get('mime') or 'audio/mpeg')}\" "
f"length=\"{e.get('bytes') or 0}\"/></item>"
)
title = _xml_esc(CFG.get("share_title", "SecuBox Podcaster"))
rss = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<rss version="2.0"><channel>'
f"<title>{title}</title><link>{_xml_esc(base)}</link>"
f"<description>Locally relayed by SecuBox Podcaster</description>"
+ "".join(items) + "</channel></rss>"
)
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)
if not ep or ep.get("state") != "done" or not ep.get("local_path"):
raise HTTPException(404, "not downloaded")
p = Path(ep["local_path"])
if not p.exists():
raise HTTPException(404, "file missing")
return FileResponse(p, media_type=ep.get("mime") or "audio/mpeg",
filename=p.name)
# ════════════════════════════════════════════════════════════════════
# Authenticated endpoints (manage)
# ════════════════════════════════════════════════════════════════════
@router.get("/feeds", dependencies=[Depends(require_jwt)])
async def feeds():
_ensure_worker()
return {"feeds": store.list_feeds()}
@router.post("/feeds", dependencies=[Depends(require_jwt)])
async def add_feed(body: FeedIn):
_ensure_worker()
try:
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)
return {"ok": True}
@router.post("/feeds/import-opml", dependencies=[Depends(require_jwt)])
async def import_opml(body: OPMLIn):
_ensure_worker()
try:
root = ET.fromstring(body.opml)
except Exception as e:
raise HTTPException(400, f"bad OPML: {e}")
urls = [o.get("xmlUrl") for o in root.iter("outline") if o.get("xmlUrl")]
added = 0
for u in urls:
try:
await fetch_and_store(u)
added += 1
except Exception as e:
log.error(f"opml feed {u}: {e}")
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()
return {"episodes": store.list_episodes(feed_id, state)}
@router.post("/episodes/{ep_id}/download", dependencies=[Depends(require_jwt)])
async def download(ep_id: int):
_ensure_worker()
ep = store.get_episode(ep_id)
if not ep:
raise HTTPException(404, "no such episode")
store.set_episode(ep_id, state="queued", progress=0, error=None)
await _queue.put(ep_id)
return {"ok": True, "queued": ep_id}
@router.get("/downloads", dependencies=[Depends(require_jwt)])
async def downloads():
_ensure_worker()
return {"downloads": store.list_episodes(state="downloading")
+ store.list_episodes(state="queued")}
@router.get("/config", dependencies=[Depends(require_jwt)])
async def get_config():
return {"config": load_config()}
@router.post("/config", dependencies=[Depends(require_jwt)])
async def set_config(body: ConfigIn):
cfg = load_config()
for k, v in body.model_dump(exclude_none=True).items():
cfg[k] = v
lines = []
for k, v in cfg.items():
if isinstance(v, str):
lines.append(f'{k} = "{v}"')
elif isinstance(v, bool):
lines.append(f"{k} = {'true' if v else 'false'}")
else:
lines.append(f"{k} = {v}")
try:
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text("\n".join(lines) + "\n")
except Exception as e:
raise HTTPException(500, f"write failed: {e}")
return {"ok": True, "config": cfg, "note": "restart secubox-podcaster to apply"}
app.include_router(router)

View File

@ -0,0 +1,198 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Podcaster store
CyberMind https://cybermind.fr
SQLite persistence for feeds + episodes. Pure stdlib (sqlite3); no ORM.
"""
import sqlite3
import time
from pathlib import Path
from typing import Any, Optional
DB_PATH = Path("/var/lib/secubox/podcaster/podcaster.db")
_SCHEMA = """
CREATE TABLE IF NOT EXISTS feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT UNIQUE NOT NULL,
title TEXT,
description TEXT,
image TEXT,
site TEXT,
added_ts INTEGER NOT NULL,
last_fetch INTEGER DEFAULT 0,
auto_dl INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
guid TEXT NOT NULL,
title TEXT,
description TEXT,
pubdate INTEGER DEFAULT 0,
enclosure TEXT,
mime TEXT,
bytes INTEGER DEFAULT 0,
duration TEXT,
local_path TEXT,
state TEXT DEFAULT 'new', -- new | queued | downloading | done | error
progress INTEGER DEFAULT 0, -- 0..100
error TEXT,
UNIQUE(feed_id, guid)
);
CREATE INDEX IF NOT EXISTS idx_ep_feed ON episodes(feed_id);
CREATE INDEX IF NOT EXISTS idx_ep_state ON episodes(state);
CREATE INDEX IF NOT EXISTS idx_ep_pub ON episodes(pubdate DESC);
"""
def _conn() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
c = sqlite3.connect(DB_PATH, timeout=10)
c.row_factory = sqlite3.Row
c.execute("PRAGMA foreign_keys=ON")
c.execute("PRAGMA journal_mode=WAL")
return c
def init() -> None:
with _conn() as c:
c.executescript(_SCHEMA)
# ── feeds ──────────────────────────────────────────────────────────
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,auto_dl) "
"VALUES(?,?,?,?,?,?,?)",
(url, meta.get("title"), meta.get("description"),
meta.get("image"), meta.get("site"), int(time.time()), int(auto_dl)),
)
if cur.lastrowid:
return cur.lastrowid
row = c.execute("SELECT id FROM feeds WHERE url=?", (url,)).fetchone()
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(
"UPDATE feeds SET title=COALESCE(?,title), description=COALESCE(?,description), "
"image=COALESCE(?,image), site=COALESCE(?,site), last_fetch=? WHERE id=?",
(meta.get("title"), meta.get("description"), meta.get("image"),
meta.get("site"), int(time.time()), feed_id),
)
def list_feeds() -> list[dict]:
with _conn() as c:
rows = c.execute(
"SELECT f.*, "
"(SELECT COUNT(*) FROM episodes e WHERE e.feed_id=f.id) AS episodes, "
"(SELECT COUNT(*) FROM episodes e WHERE e.feed_id=f.id AND e.state='done') AS downloaded "
"FROM feeds f ORDER BY f.title COLLATE NOCASE"
).fetchall()
return [dict(r) for r in rows]
def delete_feed(feed_id: int) -> None:
with _conn() as c:
c.execute("DELETE FROM feeds WHERE id=?", (feed_id,))
def all_feed_urls() -> list[tuple[int, str]]:
with _conn() as c:
return [(r["id"], r["url"]) for r in c.execute("SELECT id,url FROM feeds")]
# ── episodes ───────────────────────────────────────────────────────
def upsert_episode(feed_id: int, ep: dict) -> None:
with _conn() as c:
c.execute(
"INSERT OR IGNORE INTO episodes"
"(feed_id,guid,title,description,pubdate,enclosure,mime,bytes,duration) "
"VALUES(?,?,?,?,?,?,?,?,?)",
(feed_id, ep["guid"], ep.get("title"), ep.get("description"),
ep.get("pubdate", 0), ep.get("enclosure"), ep.get("mime"),
ep.get("bytes", 0), ep.get("duration")),
)
def list_episodes(feed_id: Optional[int] = None, state: Optional[str] = None,
limit: int = 200) -> list[dict]:
q = ("SELECT e.*, f.title AS feed_title, f.image AS feed_image "
"FROM episodes e JOIN feeds f ON f.id=e.feed_id")
where, args = [], []
if feed_id is not None:
where.append("e.feed_id=?"); args.append(feed_id)
if state:
where.append("e.state=?"); args.append(state)
if where:
q += " WHERE " + " AND ".join(where)
q += " ORDER BY e.pubdate DESC LIMIT ?"; args.append(limit)
with _conn() as c:
return [dict(r) for r in c.execute(q, args).fetchall()]
def get_episode(ep_id: int) -> Optional[dict]:
with _conn() as c:
r = c.execute("SELECT * FROM episodes WHERE id=?", (ep_id,)).fetchone()
return dict(r) if r else None
def set_episode(ep_id: int, **fields: Any) -> None:
if not fields:
return
cols = ", ".join(f"{k}=?" for k in fields)
with _conn() as c:
c.execute(f"UPDATE episodes SET {cols} WHERE id=?", (*fields.values(), ep_id))
def downloaded_episodes(limit: int = 500) -> list[dict]:
"""Episodes with a local file — the shareable library."""
with _conn() as c:
rows = c.execute(
"SELECT e.*, f.title AS feed_title FROM episodes e JOIN feeds f ON f.id=e.feed_id "
"WHERE e.state='done' AND e.local_path IS NOT NULL "
"ORDER BY e.pubdate DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
def counts() -> dict:
with _conn() as c:
return {
"feeds": c.execute("SELECT COUNT(*) FROM feeds").fetchone()[0],
"episodes": c.execute("SELECT COUNT(*) FROM episodes").fetchone()[0],
"downloaded": c.execute(
"SELECT COUNT(*) FROM episodes WHERE state='done'").fetchone()[0],
"queued": c.execute(
"SELECT COUNT(*) FROM episodes WHERE state IN('queued','downloading')"
).fetchone()[0],
}

View File

@ -0,0 +1,23 @@
# /etc/secubox/podcaster.toml — SecuBox Podcaster
# Restart secubox-podcaster after editing.
# Where downloaded episodes are stored.
media_path = "/var/lib/secubox/podcaster/media"
# Concurrent downloads.
max_parallel = 2
# Auto-refresh feeds every N minutes (background).
refresh_minutes = 60
# Public base URL for the shareable RSS enclosure links. Leave empty for
# LAN/relative URLs. Set to your exposed vhost to relay publicly, e.g.
# public_base = "https://podcast.gk2.secubox.in"
# (route it through HAProxy TLS -> mitmproxy per WAF policy; see README).
public_base = ""
# Title of the generated share feed.
share_title = "SecuBox Podcaster"
# Keep at most N downloaded episodes per feed (0 = unlimited).
keep_per_feed = 0

View File

@ -0,0 +1,46 @@
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:
- subscribe by RSS URL / OPML import; pure-stdlib feed parsing
- local download queue (asyncio + httpx) with per-episode progress
- generated shareable RSS of the local library (/share/feed.xml) — LAN by
default, public via HAProxy / secubox-exposure (public_base)
- in-UI service status + TOML config; C3BOX WebUI with inline player
- FastAPI on /run/secubox/podcaster.sock; SQLite store; nginx route shipped
to the active secubox-routes.d/ include
- Lyrion integration deferred to a follow-up (standalone first)
-- Gerald KERMA <devel@cybermind.fr> Wed, 24 Jun 2026 12:00:00 +0000

View File

@ -0,0 +1,22 @@
Source: secubox-podcaster
Section: sound
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Package: secubox-podcaster
Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-uvicorn | python3-pip, python3-httpx | python3-pip
Description: Modern podcast manager for SecuBox
Subscribe to podcast feeds, download episodes locally, and re-publish a
shareable RSS feed (LAN by default, public via HAProxy / secubox-exposure).
FastAPI backend on /api/v1/podcaster/ over a Unix socket; SQLite store; an
asyncio download queue. Feed parsing is pure stdlib (no feedparser dep).
.
Features:
- Subscribe by RSS URL or OPML import
- Local download queue with per-episode progress
- Generated shareable RSS of the local library (relay/share to the web)
- In-UI service status + TOML config
- Modern C3BOX cyberpunk WebUI with inline player

View File

@ -0,0 +1,33 @@
#!/bin/bash
set -e
case "$1" in
configure)
# Data dirs (module-owned; never touch the shared /run/secubox parent, #494).
install -d -o secubox -g secubox -m 0750 /var/lib/secubox/podcaster
install -d -o secubox -g secubox -m 0750 /var/lib/secubox/podcaster/media
# Config on first install only (preserve operator edits on upgrade).
if [ ! -f /etc/secubox/podcaster.toml ]; then
if [ -f /usr/share/secubox/podcaster/podcaster.toml ]; then
install -o secubox -g secubox -m 0640 \
/usr/share/secubox/podcaster/podcaster.toml /etc/secubox/podcaster.toml
fi
fi
systemctl daemon-reload 2>/dev/null || true
# Respect operator masking (set -e would abort on a masked enable).
if [ "$(systemctl is-enabled secubox-podcaster.service 2>/dev/null)" != "masked" ]; then
systemctl enable secubox-podcaster.service 2>/dev/null || true
systemctl restart secubox-podcaster.service 2>/dev/null || true
fi
# Reload nginx only if the resulting config is valid.
if command -v nginx >/dev/null 2>&1 && nginx -t 2>/dev/null; then
systemctl reload nginx 2>/dev/null || true
fi
;;
esac
#DEBHELPER#
exit 0

View File

@ -0,0 +1,12 @@
#!/bin/bash
set -e
case "$1" in
remove|deconfigure)
systemctl stop secubox-podcaster.service 2>/dev/null || true
systemctl disable secubox-podcaster.service 2>/dev/null || true
;;
esac
#DEBHELPER#
exit 0

View File

@ -0,0 +1,30 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_install:
# API files (service WorkingDirectory = /usr/share/secubox/podcaster)
install -d debian/secubox-podcaster/usr/share/secubox/podcaster/
cp -r api debian/secubox-podcaster/usr/share/secubox/podcaster/
# Static www files
install -d debian/secubox-podcaster/usr/share/secubox/www
[ -d www ] && cp -r www/. debian/secubox-podcaster/usr/share/secubox/www/ || true
# Menu definitions
install -d debian/secubox-podcaster/usr/share/secubox/menu.d
[ -d menu.d ] && cp -r menu.d/. debian/secubox-podcaster/usr/share/secubox/menu.d/ || true
# Config template (installed by postinst on first install only)
install -d debian/secubox-podcaster/usr/share/secubox/podcaster
[ -f conf/podcaster.toml ] && cp conf/podcaster.toml debian/secubox-podcaster/usr/share/secubox/podcaster/ || true
# nginx route — ship to the ACTIVE include (secubox-routes.d) AND legacy
# secubox.d for back-compat (mirrors grafana/peertube; see #65).
install -d debian/secubox-podcaster/etc/nginx/secubox-routes.d
install -d debian/secubox-podcaster/etc/nginx/secubox.d
[ -f nginx/podcaster.conf ] && cp nginx/podcaster.conf debian/secubox-podcaster/etc/nginx/secubox-routes.d/ || true
[ -f nginx/podcaster.conf ] && cp nginx/podcaster.conf debian/secubox-podcaster/etc/nginx/secubox.d/ || true
# Systemd unit
install -d debian/secubox-podcaster/usr/lib/systemd/system
cp systemd/secubox-podcaster.service debian/secubox-podcaster/usr/lib/systemd/system/
override_dh_installsystemd:
# Handled manually in postinst (respect operator masking).
true

View File

@ -0,0 +1 @@
3.0 (native)

View File

@ -0,0 +1,9 @@
{
"id": "podcaster",
"name": "Podcaster",
"icon": "🎙️",
"path": "/podcaster/",
"category": "mesh",
"order": 612,
"description": "Subscribe, download and relay podcasts"
}

View File

@ -0,0 +1,18 @@
# /etc/nginx/secubox-routes.d/podcaster.conf
# Installed by secubox-podcaster — auto-registered on install, removed on purge.
# Static frontend
location /podcaster/ {
alias /usr/share/secubox/www/podcaster/;
index index.html;
try_files $uri $uri/ /podcaster/index.html;
}
# API backend (Unix socket). Episode media + share feed stream through here too.
location /api/v1/podcaster/ {
proxy_pass http://unix:/run/secubox/podcaster.sock:/;
# secubox-proxy.conf already sets proxy_buffering off + proxy_read_timeout;
# only add what it doesn't (large episode uploads/streams).
include /etc/nginx/snippets/secubox-proxy.conf;
client_max_body_size 0;
}

View File

@ -0,0 +1,27 @@
[Unit]
Description=SecuBox Podcaster API
After=network.target secubox-runtime.service
Requires=secubox-runtime.service
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/usr/share/secubox/podcaster
ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/podcaster.sock --workers 1
# #494: only chmod/chown our OWN socket — never the shared /run/secubox parent.
ExecStartPost=-/bin/sleep 1
ExecStartPost=-/bin/chmod 660 /run/secubox/podcaster.sock
ExecStartPost=-/bin/chown secubox:secubox /run/secubox/podcaster.sock
Restart=on-failure
RestartSec=5
# Security hardening
NoNewPrivileges=no
ProtectSystem=full
ProtectHome=read-only
PrivateTmp=true
ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,191 @@
<!DOCTYPE html>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<!-- 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;
}
body{margin:0;background:var(--cosmos-black);color:var(--text-primary);
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)}
.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.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}
button:hover{border-color:var(--cyber-cyan);color:var(--cyber-cyan)}
button.go{background:var(--void-purple);border-color:var(--void-purple);color:#fff}
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}
.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.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}
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 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}
</style>
</head>
<body>
<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 &amp; status</h2>
<div id="svc" class="muted"></div>
<label>Media path</label><input id="c_media">
<label>Max parallel downloads</label><input id="c_par" type="number">
<label>Refresh minutes</label><input id="c_ref" type="number">
<label>Public base URL (for shareable feed — leave empty for LAN)</label><input id="c_pub">
<label>Share feed title</label><input id="c_title">
<div class="row" style="margin-top:16px">
<button class="go" onclick="saveCfg()">Save</button>
<button onclick="document.getElementById('cfg').close()">Close</button>
</div>
</dialog>
<div class="toast" id="toast"></div>
<script src="/shared/sidebar.js"></script>
<script>
const API="/api/v1/podcaster";
let SEL=null;
function tok(){return localStorage.getItem("sbx_token")||""}
async function api(p,opt={}){
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 r=await fetch(API+p,opt);
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"),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> · 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>`;
}catch(e){}
}
async function loadFeeds(){
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}`:"";
document.getElementById("title").textContent=SEL?"Episodes":"Latest episodes";
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 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>`;
}).join("");
}
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=""}
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||""}
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")}}
function loadAll(){loadStatus();loadFeeds();loadEps()}
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>