Merge: podcaster downloads/upload/SSD/socket + webui reskin + cover + apple resolver + add-from-URL (#853 #855 #857)

This commit is contained in:
CyberMind-FR 2026-07-15 16:01:26 +02:00
commit 11b1d71f90
8 changed files with 1335 additions and 195 deletions

View File

@ -0,0 +1,316 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""
SecuBox-Deb :: Podcaster URL importer (yt-dlp podcaster [+ PeerTube + billets])
Productises the one-off playlist importer: given any yt-dlp-supported URL (a
YouTube video or playlist, or a TV/radio page like RTBF), enumerate its entries
and, per entry:
1. download the video (<=720p) + extract audio (stream copy m4a)
2. (optional) upload the video to a PeerTube channel (unlisted) watch URL
3. add a podcaster episode (local audio, state=done)
4. (optional) create a published billet: description + source + stream links
Runs OFF the event loop (the caller uses asyncio.to_thread). Single job at a
time; live progress in the module-level `JOB` dict. PeerTube credentials are
read from a root-only secret, never hard-coded.
"""
import json
import os
import re
import secrets
import sqlite3
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from secubox_core.logger import get_logger
from . import store
log = get_logger("podcaster.importer")
PT_SECRET = Path("/etc/secubox/secrets/peertube-import.json")
# Optional Netscape cookies.txt to defeat YouTube's "confirm you're not a bot"
# throttle; if present it's passed to every yt-dlp call.
YT_COOKIES = Path("/etc/secubox/secrets/yt-cookies.txt")
BILLETS_DB = "/var/lib/secubox/billets/billets.db"
BILLETS_PUBLIC = os.environ.get("BILLETS_PUBLIC_URL", "https://billets.gk2.secubox.in")
PODCASTER_PUBLIC = os.environ.get("PODCASTER_PUBLIC_URL", "https://podcaster.gk2.secubox.in")
_B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
# Single in-flight import job; polled by GET /import/status.
JOB: dict = {"running": False, "url": None, "total": 0, "done": 0,
"current": "", "errors": [], "feed_id": None, "started": 0,
"finished": 0, "log": []}
def _jlog(msg: str) -> None:
log.info(msg)
JOB["log"] = (JOB["log"] + [f"{datetime.now().strftime('%H:%M:%S')} {msg}"])[-40:]
def _ulid() -> str:
t = int(time.time() * 1000)
ts = "".join(_B32[(t >> (10 * (5 - i))) & 0x1F] for i in range(6))
return ts + "".join(secrets.choice(_B32) for _ in range(20))
def _slugify(s: str, extra: str = "") -> str:
base = re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-")[:60] or "item"
return f"{base}-{extra}" if extra else base
def _run(cmd, timeout=None):
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def _ytdlp(args, timeout=None):
"""yt-dlp with the cookies file spliced in when present."""
cmd = ["yt-dlp"]
if YT_COOKIES.exists():
cmd += ["--cookies", str(YT_COOKIES)]
return _run(cmd + args, timeout=timeout)
# ── PeerTube ────────────────────────────────────────────────────────
def _pt_conf() -> dict | None:
try:
return json.loads(PT_SECRET.read_text())
except Exception:
return None
def _pt_token(conf: dict) -> str | None:
h = f"Host: {conf['host']}"
r = _run(["curl", "-s", "--max-time", "20", "-H", h, f"{conf['base']}/api/v1/users/token",
"--data-urlencode", f"client_id={conf['client_id']}",
"--data-urlencode", f"client_secret={conf['client_secret']}",
"--data-urlencode", "grant_type=password",
"--data-urlencode", f"username={conf['username']}",
"--data-urlencode", f"password={conf['password']}"], timeout=25)
try:
return json.loads(r.stdout)["access_token"]
except Exception:
_jlog("peertube auth failed")
return None
def _pt_upload(conf: dict, token: str, vpath: Path, title: str, desc: str):
h = f"Host: {conf['host']}"
r = _run(["curl", "-s", "--max-time", "3600",
"-H", f"Authorization: Bearer {token}", "-H", h,
"-F", f"channelId={conf['channel']}", "-F", f"name={title[:120]}",
"-F", f"privacy={conf.get('privacy', 2)}", "-F", f"description={desc[:9000]}",
"-F", "commentsEnabled=false", "-F", "downloadEnabled=true",
"-F", f"videofile=@{vpath}", f"{conf['base']}/api/v1/videos/upload"], timeout=3700)
try:
return json.loads(r.stdout)["video"]["shortUUID"]
except Exception:
_jlog(f"peertube upload failed: {r.stdout[-160:]}")
return None
# ── billets ─────────────────────────────────────────────────────────
def _add_billet(title, desc, source_url, pt_watch, pod_audio):
now = datetime.now(timezone.utc).isoformat()
bid = _ulid()
slug = _slugify(title, bid[-6:].lower())
parts = [(desc or "").strip()[:4000], "\n\n---\n"]
if source_url:
parts.append(f"\n🎬 **Source :** {source_url}\n")
if pt_watch:
parts.append(f"\n📺 **Vidéo (PeerTube) :** {pt_watch}\n")
if pod_audio:
parts.append(f"\n🎧 **Audio (Podcaster) :** {pod_audio}\n")
body = "".join(parts)
con = sqlite3.connect(BILLETS_DB, timeout=30)
try:
con.execute("PRAGMA journal_mode=WAL")
con.execute(
"INSERT INTO billet(id,created_at,updated_at,published_at,body,ref_url,embed_url,"
"slug,status,style) VALUES(?,?,?,?,?,?,?,?, 'published','default')",
(bid, now, now, now, body, source_url or None, pt_watch or None, slug))
con.commit()
finally:
con.close()
# podcaster runs as root; hand the billets DB back to secubox so billets can write.
for suf in ("", "-wal", "-shm"):
p = BILLETS_DB + suf
if os.path.exists(p):
_run(["chown", "secubox:secubox", p])
return slug
# ── download + cover ────────────────────────────────────────────────
def _extract_cover(thumb: Path, dest: Path) -> bool:
try:
from PIL import Image
im = Image.open(str(thumb)).convert("RGB")
im.thumbnail((600, 600))
dest.parent.mkdir(parents=True, exist_ok=True)
im.save(str(dest), "JPEG", quality=85)
return True
except Exception:
return False
def _download(vid: str, workdir: Path, want_video: bool):
d = workdir / vid
d.mkdir(parents=True, exist_ok=True)
url = f"https://www.youtube.com/watch?v={vid}" if re.fullmatch(r"[\w-]{6,20}", vid) else vid
apath = d / f"{vid}.m4a"
vpath = d / f"{vid}.mp4"
if want_video:
if not vpath.exists():
_ytdlp(["--no-warnings", "-f",
"bv*[height<=720][ext=mp4]+ba[ext=m4a]/b[height<=720]",
"--merge-output-format", "mp4", "--write-info-json",
"--write-thumbnail", "--convert-thumbnails", "jpg",
"-o", str(d / f"{vid}.%(ext)s"), url], timeout=2400)
if vpath.exists() and not apath.exists():
_run(["ffmpeg", "-y", "-i", str(vpath), "-vn", "-c:a", "copy", str(apath)], timeout=900)
if not apath.exists():
_run(["ffmpeg", "-y", "-i", str(vpath), "-vn", "-c:a", "aac", "-b:a", "128k",
str(apath)], timeout=1800)
else:
if not apath.exists():
_ytdlp(["--no-warnings", "-x", "--audio-format", "m4a",
"--write-info-json", "--write-thumbnail", "--convert-thumbnails", "jpg",
"-o", str(d / f"{vid}.%(ext)s"), url], timeout=1800)
info = {}
ij = d / f"{vid}.info.json"
if ij.exists():
try:
info = json.loads(ij.read_text())
except Exception:
info = {}
thumb = next((p for p in (d / f"{vid}.jpg", d / f"{vid}.webp") if p.exists()), None)
return (vpath if vpath.exists() else None), (apath if apath.exists() else None), info, thumb
def _enumerate(url: str):
"""Return [{id,title,playlist}] for a playlist OR a single video/URL.
`--dump-single-json` yields one object: playlists carry an `entries` array,
a single video is the video dict itself."""
r = _ytdlp(["--flat-playlist", "--dump-single-json", "--no-warnings", url], timeout=120)
try:
obj = json.loads(r.stdout)
except Exception:
obj = None
if not isinstance(obj, dict):
err = [l for l in (r.stderr or "").splitlines() if l.strip()]
if err:
_jlog(f"yt-dlp: {err[-1][:180]}")
return []
entries = obj.get("entries")
if entries is None: # single video / URL
if obj.get("id"):
return [{"id": obj["id"], "title": obj.get("title") or obj["id"],
"playlist": obj.get("playlist_title")}]
return []
series = obj.get("title")
out = []
for j in entries:
if j and j.get("id"):
out.append({"id": j["id"], "title": j.get("title") or j["id"], "playlist": series})
return out
# ── job runner (blocking; call via asyncio.to_thread) ───────────────
def run_import(url: str, media_dir: str, mirror_peertube: bool, create_billets: bool):
JOB.update({"running": True, "url": url, "total": 0, "done": 0, "current": "",
"errors": [], "feed_id": None, "started": int(time.time()),
"finished": 0, "log": []})
workdir = Path(media_dir).parent / "youtube-import"
try:
entries = _enumerate(url)
if not entries:
_jlog("nothing found at that URL")
return
JOB["total"] = len(entries)
series = entries[0].get("playlist") or entries[0]["title"]
feed_url = f"youtube:{_slugify(series)}"
_jlog(f"{len(entries)} item(s): {series}")
conf = _pt_conf() if mirror_peertube else None
token = _pt_token(conf) if conf else None
if mirror_peertube and not token:
_jlog("peertube mirror requested but auth unavailable — skipping video upload")
# feed
store.add_feed(feed_url, {"title": series, "description": f"Import: {series}"})
fid = None
for f in store.list_feeds():
if f["url"] == feed_url:
fid = f["id"]
break
JOB["feed_id"] = fid
for i, e in enumerate(entries):
vid, title = e["id"], e["title"]
JOB["current"] = f"{i+1}/{len(entries)}: {title[:60]}"
_jlog(JOB["current"])
try:
vpath, apath, info, thumb = _download(vid, workdir, want_video=mirror_peertube)
if not apath:
JOB["errors"].append(f"{title[:40]}: download failed")
continue
desc = info.get("description") or ""
up_ts = int(time.time())
if info.get("upload_date"):
try:
up_ts = int(datetime.strptime(info["upload_date"], "%Y%m%d")
.replace(tzinfo=timezone.utc).timestamp())
except Exception:
pass
duration = info.get("duration_string") or ""
# feed cover from first thumbnail
if i == 0 and thumb and fid:
cov = Path(media_dir) / str(fid) / "cover.jpg"
if _extract_cover(thumb, cov):
con = sqlite3.connect(store.DB_PATH, timeout=30)
con.execute("UPDATE feeds SET image=? WHERE id=?",
(f"/api/v1/podcaster/feeds/{fid}/cover", fid))
con.commit(); con.close()
pt_watch = None
if token and conf and vpath:
su = _pt_upload(conf, token, vpath, title, desc)
if su:
pt_watch = f"{conf['watch']}/w/{su}"
# podcaster episode
con = sqlite3.connect(store.DB_PATH, timeout=30)
con.execute("PRAGMA journal_mode=WAL")
con.execute(
"INSERT OR IGNORE INTO episodes(feed_id,guid,title,description,pubdate,"
"enclosure,mime,bytes,duration,local_path,state,progress) "
"VALUES(?,?,?,?,?,?,?,?,?,?, 'done',100)",
(fid, f"yt:{vid}", title[:200], desc, up_ts, f"local:{apath}",
"audio/mp4", apath.stat().st_size, duration, str(apath)))
con.execute("UPDATE episodes SET state='done',progress=100,local_path=? "
"WHERE feed_id=? AND guid=?", (str(apath), fid, f"yt:{vid}"))
epid = con.execute("SELECT id FROM episodes WHERE feed_id=? AND guid=?",
(fid, f"yt:{vid}")).fetchone()[0]
con.commit(); con.close()
pod_audio = f"{PODCASTER_PUBLIC}/api/v1/podcaster/media/{epid}"
if create_billets:
src = f"https://www.youtube.com/watch?v={vid}"
slug = _add_billet(title, desc, src, pt_watch, pod_audio)
_jlog(f" billet /b/{slug}")
JOB["done"] += 1
except Exception as ex: # noqa: BLE001
_jlog(f" ERROR {vid}: {ex}")
JOB["errors"].append(f"{title[:40]}: {str(ex)[:120]}")
# ownership so podcaster(root) media stays secubox-readable
_run(["chown", "-R", "secubox:secubox", media_dir])
_run(["chown", "-R", "secubox:secubox", str(workdir)])
_jlog(f"done: {JOB['done']}/{JOB['total']} imported")
finally:
JOB["running"] = False
JOB["finished"] = int(time.time())

View File

@ -29,6 +29,7 @@ from xml.etree import ElementTree as ET
import httpx
from fastapi import FastAPI, APIRouter, BackgroundTasks, Depends, HTTPException, Request
from starlette.requests import ClientDisconnect
from fastapi.responses import Response, FileResponse, JSONResponse
from pydantic import BaseModel
@ -36,6 +37,7 @@ from secubox_core.auth import router as auth_router, require_jwt
from secubox_core.logger import get_logger
from . import store
from . import importer
log = get_logger("podcaster")
@ -70,6 +72,19 @@ router = APIRouter()
# ── download queue (in-process, persisted state in SQLite) ──────────
_queue: "asyncio.Queue[int]" = asyncio.Queue()
_worker_started = False
# Episodes with a live download task, keyed by id. Used to (a) dedup: never run
# two _download_one for the same episode (they share deterministic tmp/dest
# paths → file corruption); (b) let a manual restart cancel a wedged download.
_inflight: "dict[int, asyncio.Task]" = {}
# A stalled server must not hang a slot forever. connect/write are short;
# read is per-chunk (aiter_bytes), so a server that opens the socket then goes
# silent aborts with httpx.ReadTimeout instead of blocking the queue for ever.
_DL_TIMEOUT = httpx.Timeout(connect=30.0, read=120.0, write=30.0, pool=30.0)
# Auto-retry budget: total attempts per episode before it rests in 'error'.
_MAX_ATTEMPTS = 3
# Backoff (seconds) before re-queuing a failed attempt; index by attempt number.
_RETRY_BACKOFF = (10, 30, 60)
# ════════════════════════════════════════════════════════════════════
@ -84,6 +99,12 @@ class OPMLIn(BaseModel):
opml: str
class ImportIn(BaseModel):
url: str
mirror_peertube: bool = True
create_billets: bool = True
class ConfigIn(BaseModel):
media_path: Optional[str] = None
max_parallel: Optional[int] = None
@ -155,8 +176,29 @@ def parse_feed(xml_bytes: bytes) -> tuple[dict, list[dict]]:
return meta, episodes
async def _resolve_apple_podcast(url: str, cli: httpx.AsyncClient) -> str:
"""Apple Podcasts links wrap a real RSS feed. If `url` is an Apple/iTunes
podcast link, look up its numeric id via the iTunes Lookup API and return the
underlying `feedUrl`; otherwise return `url` unchanged (best-effort)."""
m = re.search(r"(?:podcasts|itunes)\.apple\.com/.*?/id(\d+)", url)
if not m:
return url
try:
r = await cli.get(f"https://itunes.apple.com/lookup?id={m.group(1)}&entity=podcast",
headers={"User-Agent": "SecuBox-Podcaster/1.0"})
r.raise_for_status()
for res in r.json().get("results", []):
if res.get("feedUrl"):
log.info(f"resolved Apple Podcasts id {m.group(1)} -> {res['feedUrl']}")
return res["feedUrl"]
except Exception as e: # noqa: BLE001 — fall back to the original url
log.warning(f"apple podcast resolve failed for {url}: {e}")
return url
async def fetch_and_store(url: str, auto_dl: Optional[bool] = None) -> dict:
async with httpx.AsyncClient(follow_redirects=True, timeout=30) as cli:
url = await _resolve_apple_podcast(url, cli)
r = await cli.get(url, headers={"User-Agent": "SecuBox-Podcaster/1.0"})
r.raise_for_status()
meta, eps = parse_feed(r.content)
@ -204,7 +246,7 @@ async def _download_one(ep_id: int) -> None:
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 httpx.AsyncClient(follow_redirects=True, timeout=_DL_TIMEOUT) as cli:
async with cli.stream("GET", ep["enclosure"],
headers={"User-Agent": "SecuBox-Podcaster/1.0"}) as r:
r.raise_for_status()
@ -217,13 +259,35 @@ async def _download_one(ep_id: int) -> None:
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,
store.set_episode(ep_id, state="done", progress=100, error=None, attempts=0,
local_path=str(dest), bytes=dest.stat().st_size)
log.info(f"downloaded ep {ep_id} -> {dest}")
except asyncio.CancelledError:
# Manual restart cancelled us; drop the partial file and let the
# re-queue (done by the caller) start a fresh attempt. Do NOT count
# this as a failed attempt.
tmp.unlink(missing_ok=True)
raise
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}")
attempts = store.bump_attempts(ep_id)
if attempts < _MAX_ATTEMPTS:
delay = _RETRY_BACKOFF[min(attempts, len(_RETRY_BACKOFF)) - 1]
store.set_episode(ep_id, state="queued", progress=0,
error=f"retry {attempts}/{_MAX_ATTEMPTS}: {str(e)[:150]}")
log.warning(f"download ep {ep_id} failed (try {attempts}), "
f"re-queue in {delay}s: {e}")
asyncio.create_task(_requeue_later(ep_id, delay))
else:
store.set_episode(ep_id, state="error",
error=f"gave up after {attempts} tries: {str(e)[:170]}")
log.error(f"download ep {ep_id} failed permanently after {attempts}: {e}")
async def _requeue_later(ep_id: int, delay: float) -> None:
"""Sleep then push the episode back onto the download queue (bounded retry)."""
await asyncio.sleep(delay)
await _queue.put(ep_id)
async def _worker() -> None:
@ -235,7 +299,15 @@ async def _worker() -> None:
while True:
ep_id = await _queue.get()
asyncio.create_task(run(ep_id))
cur = _inflight.get(ep_id)
if cur is not None and not cur.done():
continue # dedup: an identical download is already running
t = asyncio.create_task(run(ep_id))
_inflight[ep_id] = t
# Drop from the registry once finished, but only if we're still the
# registered task (a later restart may have replaced us).
t.add_done_callback(
lambda done, e=ep_id: _inflight.get(e) is done and _inflight.pop(e, None))
async def _refresher() -> None:
@ -256,6 +328,13 @@ def _ensure_worker() -> None:
_worker_started = True
store.init()
MEDIA.mkdir(parents=True, exist_ok=True)
# Recover downloads orphaned by a previous run: episodes left in
# downloading/queued are reset to queued and re-fed to the in-memory queue.
stuck = store.requeue_stuck()
for ep_id in stuck:
_queue.put_nowait(ep_id)
if stuck:
log.info(f"requeued {len(stuck)} orphaned download(s) on start")
asyncio.create_task(_worker())
asyncio.create_task(_refresher())
@ -380,6 +459,17 @@ async def media(ep_id: int):
filename=p.name)
@router.get("/feeds/{fid}/cover")
async def feed_cover(fid: int):
"""Public: the feed's artwork extracted from its media (audiobooks). Podcasts
keep their RSS image URL; this only serves locally-extracted covers."""
p = MEDIA / str(fid) / "cover.jpg"
if not p.exists():
raise HTTPException(404, "no cover")
return FileResponse(p, media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"})
# ════════════════════════════════════════════════════════════════════
# Authenticated endpoints (manage)
# ════════════════════════════════════════════════════════════════════
@ -432,6 +522,29 @@ async def import_opml(body: OPMLIn):
return {"added": added, "total": len(urls)}
@router.post("/import/url", dependencies=[Depends(require_jwt)])
async def import_url(body: ImportIn):
"""Start a background import of a yt-dlp URL (video/playlist/site) into a
podcaster feed, optionally mirroring each video to PeerTube and creating a
billet per item. One import at a time; poll /import/status for progress."""
_ensure_worker()
if importer.JOB.get("running"):
raise HTTPException(409, "an import is already running")
url = body.url.strip()
if not (url.startswith("http://") or url.startswith("https://")):
raise HTTPException(400, "URL must start with http(s)://")
# kick the blocking pipeline onto a worker thread so the loop stays free
asyncio.create_task(asyncio.to_thread(
importer.run_import, url, str(MEDIA),
body.mirror_peertube, body.create_billets))
return {"ok": True, "started": url}
@router.get("/import/status", dependencies=[Depends(require_jwt)])
async def import_status():
return importer.JOB
_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",
@ -445,17 +558,89 @@ async def audiobook_upload(request: Request, title: str = "Audiobook"):
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])
# Stage the upload on the SAME filesystem as the media store (media_path),
# not a hardcoded eMMC path: the temp ZIP + the extracted tracks must live
# where there's room (e.g. the SSD), and same-FS keeps the writes local.
MEDIA.mkdir(parents=True, exist_ok=True)
# Reject an upload we physically cannot store BEFORE streaming it: extraction
# needs room for the temp ZIP *plus* the extracted tracks (~2x the ZIP), so a
# large upload on a near-full store could only fail mid-write (a confusing
# 500/502). Give an instant, clear 413 instead.
clen = request.headers.get("content-length")
if clen and clen.isdigit():
need = int(clen) * 2 + (200 << 20) # zip + extracted + 200MB margin
free = shutil.disk_usage(MEDIA).free
if need > free:
raise HTTPException(
413,
f"not enough disk space: this upload needs ~{int(clen) * 2 >> 20} MB "
f"(ZIP + extracted tracks) but only {free >> 20} MB is free on the "
f"media store. Upload smaller per-book ZIPs, or point media_path "
f"at larger storage.")
tmp = Path(tempfile.mkstemp(suffix=".zip", dir=MEDIA)[1])
try:
try:
with open(tmp, "wb") as fh:
async for chunk in request.stream():
fh.write(chunk)
except ClientDisconnect:
# Upload aborted / a proxy in front cut the body: no partial feed to
# clean up yet (extraction hasn't run), just report it cleanly.
raise HTTPException(400, "upload interrupted before completion")
# Extraction (zip walk + copy of possibly hundreds of MB) is blocking;
# run it OFF the single-worker event loop so a large audiobook cannot
# freeze the service (and delay the response past the proxy read
# timeout, which was surfacing as a 502).
return await asyncio.to_thread(_publish_audiobook_zip, tmp, title)
finally:
tmp.unlink(missing_ok=True)
def _extract_cover(audio_path: Path, dest: Path) -> bool:
"""Pull embedded cover art (ID3 APIC / MP4 'covr' / FLAC picture) from an
audio file, downscale it, and write it to `dest` as JPEG. Returns True on
success, False if there's no art or anything goes wrong (best-effort)."""
try:
import io
from mutagen import File as MutagenFile
mf = MutagenFile(str(audio_path))
if mf is None:
return False
data = None
tags = getattr(mf, "tags", None)
if tags:
for k in list(tags.keys()):
if k.startswith("APIC"): # ID3 (mp3)
data = tags[k].data
break
if data is None and "covr" in tags: # MP4 (m4a/m4b)
covr = tags["covr"]
if covr:
data = bytes(covr[0])
if data is None and getattr(mf, "pictures", None): # FLAC / Ogg
data = mf.pictures[0].data
if not data:
return False
from PIL import Image
img = Image.open(io.BytesIO(data)).convert("RGB")
img.thumbnail((600, 600))
dest.parent.mkdir(parents=True, exist_ok=True)
img.save(dest, "JPEG", quality=85)
return True
except Exception: # noqa: BLE001 — cover art is optional, never break upload
return False
def _publish_audiobook_zip(tmp: Path, title: str) -> dict:
"""Blocking: validate the ZIP, create a synthetic feed and extract its audio
tracks as already-'done' episodes. On ANY failure the partial feed is
removed so a broken upload never leaves an orphaned feed behind."""
if not zipfile.is_zipfile(tmp):
raise HTTPException(400, "not a valid ZIP")
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}"})
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
@ -465,10 +650,13 @@ async def audiobook_upload(request: Request, title: str = "Audiobook"):
and not n.endswith("/")]
names.sort() # chapter order
base = int(time.time())
first_dest: Optional[Path] = None
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
if first_dest is None:
first_dest = dest
with z.open(n) as src, open(dest, "wb") as out:
shutil.copyfileobj(src, out, 1024 * 256)
store.upsert_episode(fid, {
@ -488,10 +676,17 @@ async def audiobook_upload(request: Request, title: str = "Audiobook"):
if not tracks:
store.delete_feed(fid)
raise HTTPException(400, "no audio files found in ZIP")
# Extract the embedded cover (ID3 APIC / MP4 covr / FLAC picture) from the
# first track → the feed's artwork, served at /feeds/<fid>/cover.
if first_dest and _extract_cover(first_dest, fdir / "cover.jpg"):
store.update_feed_meta(fid, {"image": f"/api/v1/podcaster/feeds/{fid}/cover"})
log.info(f"audiobook '{title}' -> feed {fid}, {tracks} tracks")
return {"title": title, "feed_id": fid, "tracks": tracks}
finally:
tmp.unlink(missing_ok=True)
except HTTPException:
raise
except Exception:
store.delete_feed(fid) # never leave a half-extracted feed behind
raise
@router.get("/episodes", dependencies=[Depends(require_jwt)])
@ -506,7 +701,19 @@ async def download(ep_id: int):
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)
# Cancel any live download for this episode first, and wait for it to
# unwind, so the fresh re-queue can never race the old task (which would
# otherwise be deduped away or collide on the temp file).
cur = _inflight.get(ep_id)
if cur is not None and not cur.done():
cur.cancel()
# Wait for the cancelled task to unwind WITHOUT re-raising its
# CancelledError/exception here (asyncio.wait swallows the awaitee's
# outcome but still propagates cancellation of *this* handler).
await asyncio.wait({cur})
# Manual (re)download resets the auto-retry budget so a wedged/failed
# episode gets a fresh set of tries.
store.set_episode(ep_id, state="queued", progress=0, error=None, attempts=0)
await _queue.put(ep_id)
return {"ok": True, "queued": ep_id}

View File

@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS episodes (
state TEXT DEFAULT 'new', -- new | queued | downloading | done | error
progress INTEGER DEFAULT 0, -- 0..100
error TEXT,
attempts INTEGER DEFAULT 0, -- download tries so far (auto-retry budget)
UNIQUE(feed_id, guid)
);
CREATE INDEX IF NOT EXISTS idx_ep_feed ON episodes(feed_id);
@ -63,6 +64,35 @@ def _conn() -> sqlite3.Connection:
def init() -> None:
with _conn() as c:
c.executescript(_SCHEMA)
# Idempotent migration for DBs created before the attempts column.
cols = {r["name"] for r in c.execute("PRAGMA table_info(episodes)")}
if "attempts" not in cols:
c.execute("ALTER TABLE episodes ADD COLUMN attempts INTEGER DEFAULT 0")
def requeue_stuck() -> list[int]:
"""Reset episodes orphaned in downloading/queued (e.g. after a restart) back
to 'queued' with cleared progress, and return their ids so the caller can
feed them to the in-memory download queue."""
with _conn() as c:
rows = c.execute(
"SELECT id FROM episodes WHERE state IN ('downloading','queued')"
).fetchall()
ids = [r["id"] for r in rows]
if ids:
c.execute(
"UPDATE episodes SET state='queued', progress=0 "
"WHERE state IN ('downloading','queued')"
)
return ids
def bump_attempts(ep_id: int) -> int:
"""Increment the episode's attempt counter and return the new value."""
with _conn() as c:
c.execute("UPDATE episodes SET attempts=attempts+1 WHERE id=?", (ep_id,))
r = c.execute("SELECT attempts FROM episodes WHERE id=?", (ep_id,)).fetchone()
return int(r["attempts"]) if r else 0
# ── feeds ──────────────────────────────────────────────────────────
@ -155,7 +185,12 @@ def list_episodes(feed_id: Optional[int] = None, state: Optional[str] = None,
where.append("e.state=?"); args.append(state)
if where:
q += " WHERE " + " AND ".join(where)
q += " ORDER BY e.pubdate DESC LIMIT ?"; args.append(limit)
# Order by container type: an audiobook plays first chapter → last (pubdate
# ASC, since the upload stamps chapter i with base+i); a podcast shows the
# latest first (pubdate DESC). One CASE handles both, even in a mixed list.
q += (" ORDER BY CASE WHEN f.url LIKE 'audiobook:%' "
"THEN e.pubdate ELSE -e.pubdate END ASC LIMIT ?")
args.append(limit)
with _conn() as c:
return [dict(r) for r in c.execute(q, args).fetchall()]
@ -180,7 +215,8 @@ def downloaded_episodes(limit: int = 500) -> list[dict]:
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,)
"ORDER BY CASE WHEN f.url LIKE 'audiobook:%' "
"THEN e.pubdate ELSE -e.pubdate END ASC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]

View File

@ -7,7 +7,8 @@ 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
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-uvicorn | python3-pip, python3-httpx | python3-pip, python3-mutagen, python3-pil
Recommends: yt-dlp, ffmpeg
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).

View File

@ -0,0 +1,74 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""
SecuBox-Deb :: podcaster store retry/recovery tests
CyberMind https://cybermind.fr
Covers the #853 additions: the `attempts` column migration, orphan recovery
(`requeue_stuck`) and the auto-retry counter (`bump_attempts`).
"""
import importlib
import pytest
@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setenv("PODCASTER_DB", str(tmp_path / "p.db"))
import api.store as st
importlib.reload(st)
st.DB_PATH = tmp_path / "p.db"
st.init()
return st
def _add_episode(st, state="new"):
fid = st.add_feed("http://x/f.xml", {"title": "F"})
with st._conn() as c:
cur = c.execute(
"INSERT INTO episodes(feed_id,guid,title,enclosure,state) VALUES(?,?,?,?,?)",
(fid, "g%d" % id(state), "ep", "http://x/a.mp3", state),
)
return cur.lastrowid
def test_attempts_column_present(store):
cols = {r[1] for r in store._conn().execute("PRAGMA table_info(episodes)")}
assert "attempts" in cols
def test_migration_is_idempotent_on_legacy_db(store):
# Drop the column-bearing table and recreate the PRE-attempts schema, then
# prove init() adds the column without error and re-running is a no-op.
with store._conn() as c:
c.execute("DROP TABLE episodes")
c.execute(
"CREATE TABLE episodes(id INTEGER PRIMARY KEY AUTOINCREMENT, "
"feed_id INTEGER, guid TEXT, 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', progress INTEGER DEFAULT 0, error TEXT, "
"UNIQUE(feed_id, guid))"
)
store.init()
store.init() # second call must not raise "duplicate column"
cols = {r[1] for r in store._conn().execute("PRAGMA table_info(episodes)")}
assert "attempts" in cols
def test_requeue_stuck_resets_and_returns_ids(store):
a = _add_episode(store, "downloading")
b = _add_episode(store, "queued")
done = _add_episode(store, "done")
ids = store.requeue_stuck()
assert set(ids) == {a, b}
assert store.get_episode(a)["state"] == "queued"
assert store.get_episode(a)["progress"] == 0
assert store.get_episode(done)["state"] == "done" # untouched
def test_bump_attempts_increments(store):
ep = _add_episode(store, "queued")
assert store.bump_attempts(ep) == 1
assert store.bump_attempts(ep) == 2
assert store.get_episode(ep)["attempts"] == 2

View File

@ -0,0 +1,77 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""
SecuBox-Deb :: podcaster download-worker dedup/cancel tests
CyberMind https://cybermind.fr
Guards the #853 concurrency fix: the worker must never run two _download_one
for the same episode (they share deterministic tmp/dest paths corruption),
and a manual restart must be able to cancel a live download and start a fresh
one.
"""
import asyncio
import logging
import sys
import types
import pytest
@pytest.fixture
def main(monkeypatch):
# Stub secubox_core so api.main imports without the real package installed.
from fastapi import APIRouter
core = types.ModuleType("secubox_core")
auth = types.ModuleType("secubox_core.auth")
logm = types.ModuleType("secubox_core.logger")
auth.router = APIRouter()
auth.require_jwt = lambda: None
logm.get_logger = lambda n: logging.getLogger(n)
monkeypatch.setitem(sys.modules, "secubox_core", core)
monkeypatch.setitem(sys.modules, "secubox_core.auth", auth)
monkeypatch.setitem(sys.modules, "secubox_core.logger", logm)
import api.main as m
return m
def test_worker_dedups_and_supports_cancel_restart(main):
m = main
async def scenario():
calls = []
async def fake_dl(ep_id):
calls.append(("start", ep_id))
try:
await asyncio.sleep(0.3)
except asyncio.CancelledError:
calls.append(("cancel", ep_id))
raise
calls.append(("done", ep_id))
m._download_one = fake_dl
m._inflight.clear()
m._queue = asyncio.Queue()
worker = asyncio.create_task(m._worker())
try:
# Two puts for the same in-flight episode → exactly one download.
await m._queue.put(1)
await m._queue.put(1)
await asyncio.sleep(0.1)
assert calls.count(("start", 1)) == 1, calls
# Manual restart cancels the live task, then a re-put starts fresh.
t = m._inflight.get(1)
t.cancel()
try:
await t
except BaseException:
pass
await m._queue.put(1)
await asyncio.sleep(0.1)
assert ("cancel", 1) in calls, calls
assert calls.count(("start", 1)) == 2, calls
finally:
worker.cancel()
asyncio.run(scenario())

View File

@ -3,118 +3,204 @@
<!-- 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">
<link rel="stylesheet" href="/shared/hybrid-dark.css">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox Podcaster</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared/hybrid-skin.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;
--p31-dim:#6b7b8b;
--bg-dark:#0d1117;
--bg-card:rgba(30,40,55,0.8);
--bg-row:rgba(20,30,45,0.6);
--border:rgba(100,150,200,0.2);
--text:#e8e6d9;
--cyan:#00d4ff;
--red:#ff4466; --orange:#ff9944; --yellow:#ffcc00;
--green:#00dd44; --blue:#4488ff; --purple:#a371f7;
}
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}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Courier Prime',monospace;background:var(--bg-dark);color:var(--text);display:flex;min-height:100vh}
.sidebar{width:220px;position:fixed;height:100vh;overflow-y:auto}
.main{flex:1;margin-left:220px;padding:1.5rem;padding-top:60px;background:linear-gradient(135deg,rgba(10,15,25,0.95),rgba(15,25,40,0.9));min-height:100vh}
.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem}
.header h1{font-size:1.4rem;color:var(--cyan);text-shadow:0 0 10px var(--cyan)}
.btn{padding:0.4rem 0.8rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-card);color:var(--text);cursor:pointer;font-size:0.8rem;font-family:'Courier Prime',monospace;text-transform:uppercase;transition:all 0.2s;text-decoration:none;display:inline-flex;align-items:center;gap:0.3rem}
.btn:hover{border-color:var(--cyan);color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,0.3)}
.btn.primary{background:rgba(0,212,255,0.15);border-color:var(--cyan);color:var(--cyan)}
.btn.danger{background:rgba(255,68,102,0.15);border-color:var(--red);color:var(--red)}
.btn.sm{padding:0.2rem 0.5rem;font-size:0.7rem}
.btn.mini{padding:0.15rem 0.4rem;font-size:0.7rem;line-height:1;text-transform:none}
.btn:disabled{opacity:0.5;cursor:not-allowed}
.btn-group{display:flex;gap:0.5rem;flex-wrap:wrap}
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem;backdrop-filter:blur(10px)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:0.75rem;margin-bottom:1rem}
.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:0.6rem;text-align:center;backdrop-filter:blur(5px)}
.stat-card .value{font-size:1.4rem;font-weight:bold;text-shadow:0 0 10px currentColor}
.stat-card .label{font-size:0.6rem;color:var(--p31-dim);letter-spacing:0.1em;margin-top:0.2rem}
.stat-card.cyan .value{color:var(--cyan)}
.stat-card.green .value{color:var(--green)}
.stat-card.blue .value{color:var(--blue)}
.stat-card.orange .value{color:var(--orange)}
.panes{display:grid;grid-template-columns:340px 1fr;gap:1rem;align-items:start}
.addrow{display:flex;gap:0.5rem;margin-bottom:0.6rem}
.addrow input{flex:1;padding:0.45rem;background:var(--bg-row);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:'Courier Prime',monospace;font-size:0.8rem}
.addrow input:focus{outline:none;border-color:var(--cyan);box-shadow:0 0 5px rgba(0,212,255,0.3)}
label.autodl{display:flex;gap:0.4rem;align-items:center;color:var(--p31-dim);font-size:0.7rem;margin-bottom:0.6rem;cursor:pointer}
.feed{display:flex;gap:0.6rem;align-items:center;padding:0.4rem 0.5rem;background:var(--bg-row);border-radius:4px;border-left:3px solid var(--border);cursor:pointer;transition:all 0.15s;margin-bottom:0.25rem}
.feed:hover{background:rgba(0,212,255,0.1);transform:translateX(2px)}
.feed:hover .x{opacity:1}
.feed.sel{border-left-color:var(--cyan);background:rgba(0,212,255,0.12)}
.feed img{width:36px;height:36px;border-radius:6px;object-fit:cover;background:#222;flex-shrink:0}
.feed .t{flex:1;min-width:0}
.feed .t b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:0.8rem;color:var(--cyan)}
.feed .t span{color:var(--p31-dim);font-size:0.65rem}
.feed .ad{font-size:0.95rem;cursor:pointer;opacity:0.45}
.feed .ad.on{opacity:1;color:var(--green)}
.feed .x{color:var(--p31-dim);opacity:0;font-size:1rem;cursor:pointer}
h2#title{font-size:1rem;color:var(--cyan);border-bottom:1px solid var(--border);padding-bottom:0.5rem;margin-bottom:0.75rem}
.ep{display:flex;gap:0.75rem;align-items:center;padding:0.5rem 0.6rem;background:var(--bg-row);border-radius:4px;border-left:3px solid var(--border);margin-bottom:0.4rem}
.ep .cov{position:relative;overflow:hidden;flex-shrink:0;width:32px;height:32px;border-radius:5px;display:grid;place-items:center;font-size:0.95rem;background:#1a2432;border:1px solid var(--border)}
.ep .cov img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:5px}
.ep .meta{flex:1;min-width:0}
.ep .meta b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:0.8rem}
.ep .meta span{color:var(--p31-dim);font-size:0.65rem}
.ep .act{display:flex;gap:0.4rem;align-items:center}
.badge{font-size:0.6rem;padding:2px 7px;border-radius:20px;border:1px solid var(--border);color:var(--p31-dim);text-transform:uppercase}
.badge.downloading{color:var(--cyan);border-color:var(--cyan)}
.badge.error{color:var(--red);border-color:var(--red)}
.bar{height:4px;border-radius:3px;background:#0e0e16;overflow:hidden;width:80px}
.bar i{display:block;height:100%;background:var(--cyan);width:0}
audio{height:34px;max-width:220px}
.share{font-size:0.7rem;color:var(--p31-dim);word-break:break-all;line-height:1.5}
.share a{color:var(--cyan);text-decoration:none}
.muted{color:var(--p31-dim)}
.empty{color:var(--p31-dim);padding:40px;text-align:center}
dialog{background:rgba(20,30,45,0.97);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:1.5rem;width:min(520px,92vw)}
dialog::backdrop{background:rgba(0,0,0,0.85)}
dialog h3{color:var(--cyan);margin-bottom:1rem}
dialog label{display:block;margin:0.6rem 0 0.2rem;color:var(--p31-dim);font-size:0.65rem;text-transform:uppercase;letter-spacing:0.05em}
dialog input{width:100%;padding:0.45rem;background:var(--bg-row);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:'Courier Prime',monospace;font-size:0.8rem}
dialog input:focus{outline:none;border-color:var(--cyan);box-shadow:0 0 5px rgba(0,212,255,0.3)}
#svc{font-size:0.75rem;margin-bottom:0.5rem}
dialog label.chk{display:flex;gap:0.45rem;align-items:center;text-transform:none;letter-spacing:normal;font-size:0.78rem;color:var(--text);margin:0.5rem 0 0;cursor:pointer}
dialog label.chk input{width:auto;padding:0}
.imphelp{font-size:0.68rem;color:var(--p31-dim);line-height:1.45;margin:0.75rem 0 0.25rem}
#impProg{margin-top:1rem;border-top:1px solid var(--border);padding-top:0.75rem}
#impProg .cur{font-size:0.75rem;color:var(--cyan);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#impProg .bar{width:100%;height:6px;margin:0.5rem 0}
#impProg .errline{font-size:0.68rem;color:var(--red);margin-bottom:0.35rem}
#impLog{background:#0e0e16;border:1px solid var(--border);border-radius:4px;padding:0.5rem;font-family:'Courier Prime',monospace;font-size:0.68rem;line-height:1.35;color:var(--p31-dim);max-height:150px;overflow:auto;white-space:pre-wrap;word-break:break-word}
.toast{position:fixed;bottom:70px;right:20px;padding:0.75rem 1rem;background:var(--cyan);color:#000;border-radius:6px;font-size:0.8rem;z-index:2000;opacity:0;transform:translateY(20px);transition:all 0.3s;box-shadow:0 0 20px rgba(0,212,255,0.4);max-width:360px;display:flex;gap:0.6rem;align-items:center}
.toast.show{opacity:1;transform:translateY(0)}
.toast.error{background:var(--red);color:#fff;box-shadow:0 0 20px rgba(255,68,102,0.4)}
.toast .tclose{cursor:pointer;font-weight:bold;margin-left:auto}
::-webkit-scrollbar{width:5px}
::-webkit-scrollbar-track{background:rgba(20,30,45,0.5)}
::-webkit-scrollbar-thumb{background:var(--p31-dim);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--cyan)}
@media(max-width:768px){.main{margin-left:0;padding-top:60px}.panes{grid-template-columns:1fr}}
</style>
</head>
<body>
<body class="hybrid-dark">
<nav class="sidebar" id="sidebar"></nav>
<script src="/shared/sidebar.js"></script>
<main class="main">
<div class="topbar">
<span class="em">🎙️</span><h1>PODCASTER</h1>
<div class="stats" id="stats"></div>
<a class="portal" href="/podcaster/portal/" target="_blank">🌐 Public portal</a>
<button onclick="openCfg()" title="Config & status">⚙️</button>
<header class="header">
<h1>🎙️ Podcaster</h1>
<div class="btn-group">
<a class="btn" href="/podcaster/portal/" target="_blank">🌐 Portal</a>
<button class="btn" onclick="openCfg()" title="Config &amp; status">⚙️ Config</button>
<button class="btn primary" onclick="loadAll()">↻ Refresh</button>
</div>
</header>
<div class="grid">
<div class="stat-card cyan"><div class="value" id="st_feeds">-</div><div class="label">📻 Containers</div></div>
<div class="stat-card"><div class="value" id="st_eps">-</div><div class="label">🎞️ Episodes</div></div>
<div class="stat-card green"><div class="value" id="st_local">-</div><div class="label">💾 Local</div></div>
<div class="stat-card orange"><div class="value" id="st_queue">-</div><div class="label">⏳ Queue</div></div>
</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 class="panes">
<div>
<div class="card">
<div class="addrow">
<input id="feedUrl" placeholder="RSS feed URL…" onkeydown="if(event.key==='Enter')addFeed()">
<button class="btn primary" onclick="addFeed()"></button>
</div>
<label class="autodl"><input type="checkbox" id="autodl" checked> auto-download new episodes</label>
<div class="btn-group" style="margin-bottom:0.6rem">
<button class="btn sm" onclick="document.getElementById('opml').click()">Import OPML</button>
<button class="btn sm" onclick="document.getElementById('abzip').click()" title="Upload an audiobook ZIP">📚 Audiobook ZIP</button>
<button class="btn sm primary" onclick="openImport()" title="Import from YouTube or any yt-dlp URL"> Add from URL</button>
<button class="btn sm" 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>
<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 class="card"><div class="share" id="share"></div></div>
</div>
<div class="card">
<h2 id="title">Select a container</h2>
<div id="eps"><div class="empty">📻 Select a container to view its episodes</div></div>
</div>
</div>
</main>
<dialog id="cfg">
<h2>Config &amp; status</h2>
<h3>⚙️ Config &amp; status</h3>
<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 class="btn-group" style="margin-top:1rem">
<button class="btn primary" onclick="saveCfg()">Save</button>
<button class="btn" onclick="document.getElementById('cfg').close()">Close</button>
</div>
</dialog>
<dialog id="importDlg">
<h3> Add from URL</h3>
<label>Source URL</label>
<input id="imp_url" placeholder="YouTube video/playlist or any yt-dlp URL…" onkeydown="if(event.key==='Enter')startImport()">
<label class="chk"><input type="checkbox" id="imp_mirror" checked> 📺 Mirror videos to PeerTube</label>
<label class="chk"><input type="checkbox" id="imp_billets" checked> 📝 Create a billet per item</label>
<div class="imphelp">Downloads audio into a podcaster feed; optionally mirrors video to PeerTube and posts a billet per item. Large playlists take a while.</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn primary" id="imp_start" onclick="startImport()">Start import</button>
<button class="btn" onclick="document.getElementById('importDlg').close()">Close</button>
</div>
<div id="impProg" style="display:none">
<div class="cur" id="impCur"></div>
<div class="bar"><i id="impBar" style="width:0"></i></div>
<div class="errline" id="impErr" style="display:none"></div>
<pre id="impLog"></pre>
</div>
</dialog>
<div class="toast" id="toast"></div>
<script src="/shared/sidebar.js"></script>
<script>
const API="/api/v1/podcaster";
let SEL=null;
let FEEDS=[];
function tok(){return localStorage.getItem("sbx_token")||""}
async function api(p,opt={}){
opt.headers=Object.assign({},opt.headers||{});
@ -125,39 +211,56 @@ async function api(p,opt={}){
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 toast(m){const t=document.getElementById("toast");clearTimeout(t._);t.className="toast show";t.textContent=m;t._=setTimeout(()=>t.classList.remove("show"),3000)}
function stickyToast(m){const t=document.getElementById("toast");clearTimeout(t._);t.className="toast show";t.textContent=m}
function errorToast(m){const t=document.getElementById("toast");clearTimeout(t._);t.className="toast show error";t.textContent="";
const s=document.createElement("span");s.textContent=m;
const x=document.createElement("span");x.className="tclose";x.textContent="✕";x.onclick=()=>t.classList.remove("show");
t.appendChild(s);t.appendChild(x)}
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>`;
document.getElementById("st_feeds").textContent=s.feeds;
document.getElementById("st_eps").textContent=s.episodes;
document.getElementById("st_local").textContent=s.downloaded;
document.getElementById("st_queue").textContent=s.queued;
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}
FEEDS=d.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 containers 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()}
function showEmpty(){
document.getElementById("title").textContent="Select a container";
document.getElementById("eps").innerHTML='<div class="empty">📻 Select a container to view its episodes</div>';
}
async function selFeed(id){if(SEL===id){SEL=null;loadFeeds();showEmpty();return}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";
if(SEL==null){showEmpty();return}
const el=document.getElementById("eps");const q=`?feed_id=${SEL}`;
const f=FEEDS.find(x=>x.id===SEL);
document.getElementById("title").textContent=f?(f.title||f.url):"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>`;
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><button class="btn sm mini" title="restart" onclick="dl(${e.id})"></button>`;
else if(e.state==="error") act=`<span class="badge error" title="${esc(e.error)}">error</span><button class="btn sm" onclick="dl(${e.id})">retry</button>`;
else act=`<button class="btn sm" onclick="dl(${e.id})">⬇ download</button>`;
const cover=e.feed_image||"";
const coverImg=cover?`<img src="${esc(cover)}" alt="" loading="lazy" onerror="this.remove()">`:"";
return `<div class="ep"><div class="cov" aria-hidden="true">🎧${coverImg}</div><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;
@ -165,28 +268,105 @@ async function addFeed(){const u=document.getElementById("feedUrl").value.trim()
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))}}
catch(e){errorToast("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")}}
toast(on?`auto-download on${r.queued?' · '+r.queued+' queued':''}`:"auto-download off");loadAll()}catch(e){errorToast("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 dl(id){try{await api(`/episodes/${id}/download`,{method:"POST"});toast("Queued");loadEps()}catch(e){errorToast("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;
try{const r=await api("/feeds/import-opml",{method:"POST",body:JSON.stringify({opml})});toast(`Imported ${r.added}/${r.total}`);loadAll()}catch(e){errorToast("OPML import failed")}inp.value=""}
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=""}
const xhr=new XMLHttpRequest();
xhr.open("POST",API+"/audiobook/upload?title="+encodeURIComponent(title));
const t=tok();if(t)xhr.setRequestHeader("Authorization","Bearer "+t);
xhr.setRequestHeader("Content-Type","application/zip");
xhr.upload.onprogress=ev=>{if(ev.lengthComputable)stickyToast("Uploading "+Math.round(ev.loaded/ev.total*100)+"%")};
xhr.onload=()=>{
if(xhr.status===401){window.location="/login.html";return}
if(xhr.status>=200&&xhr.status<300){
try{const r=JSON.parse(xhr.responseText);toast(`Published "${r.title}" — ${r.tracks} tracks`)}catch(_){toast("Published")}
loadAll();
}else{errorToast("Upload failed: "+String(xhr.responseText||xhr.status).slice(0,120))}
};
xhr.onerror=()=>errorToast("Upload failed: network error");
stickyToast("Uploading "+f.name+" …");
xhr.send(f);
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`;
document.getElementById("svc").innerHTML=`service <b style="color:var(--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);
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){errorToast("Save failed")}}
// ── Add from URL (YouTube / yt-dlp import) ──────────────────────────────
let IMP_POLL=null;
function openImport(){document.getElementById("importDlg").showModal();refreshImportView();}
async function startImport(){
const url=document.getElementById("imp_url").value.trim();
if(!url){errorToast("Enter a URL to import");return}
const mirror_peertube=document.getElementById("imp_mirror").checked;
const create_billets=document.getElementById("imp_billets").checked;
const btn=document.getElementById("imp_start");btn.disabled=true;
try{
await api("/import/url",{method:"POST",body:JSON.stringify({url,mirror_peertube,create_billets})});
toast("Import started");
startImportPoll();
}catch(e){
const msg=(e&&e.message)||"";
if(msg.indexOf("409")>=0||/already running/i.test(msg)) errorToast("An import is already running");
else errorToast("Import failed: "+msg.slice(0,120));
}finally{btn.disabled=false}
}
function startImportPoll(){
if(IMP_POLL) return; // guard against overlapping pollers
pollImport();
IMP_POLL=setInterval(pollImport,2000);
}
function stopImportPoll(){if(IMP_POLL){clearInterval(IMP_POLL);IMP_POLL=null}}
function renderImport(j){
const prog=document.getElementById("impProg");
prog.style.display="block";
const total=+j.total||0, done=+j.done||0;
document.getElementById("impCur").textContent=(j.current?esc(j.current):(j.url?esc(j.url):"…"))+" · "+done+"/"+total;
document.getElementById("impBar").style.width=(total>0?Math.round(done/total*100):(j.running?5:0))+"%";
const errs=Array.isArray(j.errors)?j.errors:[];
const errEl=document.getElementById("impErr");
if(errs.length){errEl.style.display="block";errEl.textContent="⚠ "+errs.length+" error"+(errs.length>1?"s":"")}
else errEl.style.display="none";
const log=Array.isArray(j.log)?j.log:[];
document.getElementById("impLog").innerHTML=log.slice(-12).map(l=>esc(l)).join("\n");
}
async function pollImport(){
let j; try{j=await api("/import/status")}catch(e){return}
renderImport(j);
if(!j.running){
stopImportPoll();
const done=+j.done||0, total=+j.total||0;
if(done===0){
const log=Array.isArray(j.log)?j.log:[];
const reason=(log.length?log[log.length-1]:(j.errors&&j.errors.length?j.errors[j.errors.length-1]:"nothing imported"));
errorToast("Import finished: "+String(reason).slice(0,120));
}else{
toast("Imported "+done+"/"+total);
}
loadAll();
}
}
function refreshImportView(autoOpen){
// If a job is already running (page reload / dialog reopen), attach the poller.
api("/import/status").then(j=>{
if(j&&j.running){
const dlg=document.getElementById("importDlg");
if(autoOpen && !dlg.open) dlg.showModal(); // surface progress on page load
renderImport(j);
startImportPoll();
}
}).catch(()=>{});
}
function loadAll(){loadStatus();loadFeeds();if(SEL!=null)loadEps();else showEmpty()}
loadAll();refreshImportView(true);setInterval(()=>{loadStatus();if(SEL!=null)loadEps()},4000);
</script>
</body>
</html>

View File

@ -5,104 +5,353 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Podcasts · SecuBox</title>
<title>🎙️ Podcasts · SecuBox</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<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;
--bg:#07070d; --panel:#12121d; --panel-2:#171726; --line:#262636;
--ink:#eceafd; --muted:#8a8aa6;
--cyan:#00d4ff; --violet:#8b5cf6; --pink:#ff5cc8; --lime:#7CFF6B; --gold:#f5c451;
--glow:0 0 0 1px rgba(139,92,246,.25), 0 10px 40px -12px rgba(0,212,255,.28);
}
*{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%}}
html,body{overflow-x:hidden}
body{
margin:0; color:var(--ink);
font-family:'JetBrains Mono',ui-monospace,Menlo,monospace;
background:
radial-gradient(900px 480px at 12% -8%, rgba(139,92,246,.22), transparent 60%),
radial-gradient(900px 520px at 92% 4%, rgba(0,212,255,.16), transparent 55%),
radial-gradient(700px 500px at 50% 120%, rgba(255,92,200,.12), transparent 60%),
var(--bg);
min-height:100vh;
}
a{color:inherit}
.wrap{max-width:1040px;margin:0 auto;padding:0 20px}
/* ---- header ---- */
header{padding:46px 20px 20px;text-align:center}
.badge{
font-size:52px;line-height:1;display:inline-block;
filter:drop-shadow(0 6px 18px rgba(0,212,255,.45));
animation:float 5s ease-in-out infinite;
}
@keyframes float{50%{transform:translateY(-8px)}}
header h1{
margin:14px 0 6px;font-family:'Space Grotesk',sans-serif;font-weight:700;
font-size:clamp(28px,6vw,44px);letter-spacing:.5px;
background:linear-gradient(92deg,var(--cyan),var(--violet) 45%,var(--pink));
-webkit-background-clip:text;background-clip:text;color:transparent;
}
header p{color:var(--muted);margin:0;font-size:14px}
.subs{display:inline-flex;gap:10px;margin-top:18px;flex-wrap:wrap;justify-content:center}
.subs a{
text-decoration:none;font-size:13px;font-weight:600;padding:9px 16px;border-radius:999px;
border:1px solid var(--line);background:rgba(255,255,255,.02);color:var(--ink);
transition:transform .15s ease, border-color .15s ease, background .15s ease;
}
.subs a:hover{transform:translateY(-2px);border-color:var(--cyan);background:rgba(0,212,255,.10)}
.subs a:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
/* ---- filter chips ---- */
.filters{display:flex;gap:8px;flex-wrap:wrap;margin:26px auto 0}
.chip{
font-size:12px;font-weight:600;padding:7px 14px;border-radius:999px;cursor:pointer;
border:1px solid var(--line);color:var(--muted);background:rgba(255,255,255,.02);
transition:all .15s ease;user-select:none;
}
.chip:hover{color:var(--ink);border-color:var(--violet)}
.chip:focus-visible{outline:2px solid var(--violet);outline-offset:2px}
.chip.on{
color:#0a0a12;border-color:transparent;
background:linear-gradient(92deg,var(--cyan),var(--violet));
box-shadow:0 6px 20px -8px rgba(0,212,255,.6);
}
/* ---- zip bar ---- */
.zipbar{margin:14px auto 0}
.zipbar a{
display:inline-flex;align-items:center;gap:8px;text-decoration:none;font-size:13px;font-weight:600;
padding:9px 16px;border-radius:999px;color:var(--gold);
border:1px solid rgba(245,196,81,.4);background:rgba(245,196,81,.07);
transition:all .15s ease;
}
.zipbar a:hover{background:rgba(245,196,81,.16);transform:translateY(-2px)}
.zipbar a:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
.zipbar:empty{display:none}
/* ---- list ---- */
main{margin:22px auto 70px}
.ep{
display:grid;
grid-template-columns:auto 1fr auto;
grid-template-areas:"n meta dl" "aud aud aud";
gap:12px 14px;align-items:center;
padding:16px;border:1px solid var(--line);border-radius:16px;margin-bottom:14px;
background:linear-gradient(180deg,var(--panel-2),var(--panel));
transition:border-color .18s ease, transform .18s ease, box-shadow .18s ease;
}
.ep:hover{border-color:rgba(139,92,246,.5);transform:translateY(-2px);box-shadow:var(--glow)}
.ep .n{
grid-area:n;width:44px;height:44px;border-radius:12px;display:grid;place-items:center;
font-size:22px;background:rgba(139,92,246,.14);border:1px solid rgba(139,92,246,.3);
}
.ep .meta{grid-area:meta;min-width:0}
.ep .meta b{
display:block;font-family:'Space Grotesk',sans-serif;font-weight:600;font-size:16px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.ep .meta .sub{color:var(--muted);font-size:12px;margin-top:3px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.ep .meta .kind{
font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px;
border:1px solid var(--line);color:var(--ink);white-space:nowrap;
}
.ep .meta .kind.book{color:var(--lime);border-color:rgba(124,255,107,.35);background:rgba(124,255,107,.08)}
.ep .meta .kind.cast{color:var(--cyan);border-color:rgba(0,212,255,.35);background:rgba(0,212,255,.08)}
.ep audio{grid-area:aud;width:100%;height:40px;border-radius:10px}
.ep a.dl{
grid-area:dl;text-decoration:none;font-size:16px;font-weight:600;
width:44px;height:44px;border-radius:12px;display:grid;place-items:center;
color:var(--lime);border:1px solid rgba(124,255,107,.35);background:rgba(124,255,107,.06);
transition:all .15s ease;
}
.ep a.dl:hover{background:rgba(124,255,107,.16);transform:translateY(-1px)}
.ep a.dl:focus-visible{outline:2px solid var(--lime);outline-offset:2px}
@media(min-width:720px){
.ep{grid-template-columns:auto 1fr minmax(240px,320px) auto;
grid-template-areas:"n meta aud dl";}
.ep audio{width:100%;max-width:320px}
}
.empty{
text-align:center;color:var(--muted);padding:70px 20px;font-size:14px;
border:1px dashed var(--line);border-radius:16px;
}
.empty .big{font-size:40px;display:block;margin-bottom:12px}
footer{text-align:center;color:var(--muted);font-size:11px;padding:26px 20px 40px}
footer a{color:var(--muted)}
footer a:hover{color:var(--cyan)}
/* ---- cover thumb on the kind chip ---- */
.ep .n{position:relative;overflow:hidden}
.ep .n img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:12px}
/* ---- now playing sticky bar ---- */
#np{
position:fixed;left:50%;bottom:14px;transform:translateX(-50%);
z-index:50;width:calc(100% - 28px);max-width:1000px;
display:flex;align-items:center;gap:14px;
padding:10px 14px;border-radius:16px;
border:1px solid rgba(139,92,246,.5);
background:linear-gradient(180deg,rgba(23,23,38,.96),rgba(18,18,29,.96));
box-shadow:0 12px 40px -10px rgba(0,212,255,.4), var(--glow);
backdrop-filter:blur(10px);
transition:opacity .25s ease, transform .25s ease;
}
#np[hidden]{display:none}
#np .npc{
position:relative;overflow:hidden;flex-shrink:0;
width:52px;height:52px;border-radius:12px;display:grid;place-items:center;
font-size:26px;background:rgba(139,92,246,.14);border:1px solid rgba(139,92,246,.3);
}
#np .npc img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:12px}
#np .npt{min-width:0;flex:1}
#np .npt b{
display:block;font-family:'Space Grotesk',sans-serif;font-weight:600;font-size:15px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
#np .npt span{color:var(--muted);font-size:12px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#np .npeq{
flex-shrink:0;font-size:12px;font-weight:600;color:#0a0a12;padding:5px 12px;border-radius:999px;
background:linear-gradient(92deg,var(--cyan),var(--violet));
}
@media(max-width:520px){ #np .npeq{display:none} }
@media (prefers-reduced-motion: reduce){
*{animation:none !important;transition:none !important}
}
</style>
<link rel="stylesheet" href="/shared/hybrid-dark.css">
</head>
<body class="hybrid-dark">
<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 class="wrap">
<div class="badge">🎙️</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" rel="noopener">📡 Subscribe (RSS)</a>
<a id="copy" href="#" onclick="copyRss(event)">🔗 Copy feed URL</a>
</div>
</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>
<div class="wrap">
<div class="filters" id="filters"></div>
<div class="zipbar" id="zipbar"></div>
<main><div id="list" class="empty">🎧 Loading…</div></main>
</div>
<footer>⚡ SecuBox Podcaster · <a href="https://cybermind.fr" target="_blank" rel="noopener">CyberMind</a></footer>
<div id="np" hidden aria-live="polite">
<div class="npc" id="npCover" aria-hidden="true">🎧</div>
<div class="npt"><b id="npTitle"></b><span id="npFeed"></span></div>
<span class="npeq">▶ Now Playing</span>
</div>
<script>
const API="/api/v1/podcaster";
let DATA={episodes:[],feeds:{}}, FILT=null;
let RENDER_SIG=null; // signature of the currently-rendered episode set
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)})}
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);
});
}
/* Best-effort audiobook vs podcast classification from available hints. */
function epKind(e){
const t=((e.feed_title||"")+" "+(e.feed||"")+" "+(e.title||"")).toLowerCase();
if(/audio ?book|livre ?audio|chapt(er|re)?\b|\bbook\b/.test(t))
return {cls:"book",icon:"📚",label:"Audiobook"};
return {cls:"cast",icon:"🎙️",label:"Podcast"};
}
/* Any per-episode <audio> currently playing? If so we must not tear down the DOM. */
function anyPlaying(){
const list=document.getElementById("list");
return Array.prototype.some.call(list.querySelectorAll("audio"),a=>!a.paused && !a.ended);
}
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}
let d;
try{ d=await fetch(API+"/public/library").then(r=>r.json()); }
catch(e){
// Only replace the list if nothing is playing, to avoid killing audio on a transient error.
if(!anyPlaying()){
document.getElementById("list").className="empty";
document.getElementById("list").innerHTML='<span class="big">📡</span>Portal unavailable.';
}
return;
}
DATA=d;
document.getElementById("title").textContent=d.title||"Podcasts";
document.getElementById("rss").href=API+"/share/feed.xml";
// Filter chips (no <audio> here — always safe to rebuild).
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();
fl.innerHTML=(feeds.length>1?[`<span class="chip ${FILT===null?'on':''}" tabindex="0" role="button" onclick="setF(null)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setF(null)}">✨ All</span>`]:[])
.concat(feeds.map(f=>{
const fk=esc(f).replace(/'/g,"");
return `<span class="chip ${FILT===f?'on':''}" tabindex="0" role="button" onclick="setF('${fk}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setF('${fk}')}">${esc(f)} · ${d.feeds[f]}</span>`;
})).join("");
render(); // poll path — guarded (force=false)
}
function setF(f){FILT=f;load._render?render():render()}
function render(){
/* User-initiated filter change may always rebuild. */
function setF(f){ FILT=f; render(true); }
/*
* render(force)
* - Computes a signature of the visible episode set (ordered id:state per active filter).
* - Poll path (force falsy):
* • signature UNCHANGED → return early, never touch list.innerHTML.
* • signature CHANGED but something is PLAYING → defer, return without rebuild.
* The next poll (or a filter change) rebuilds once nothing is playing.
* - force=true (setF) always rebuilds.
* Net effect: a playing episode is NEVER interrupted by the background poll.
*/
function render(force){
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 sig=(FILT===null?"*":FILT)+"|"+eps.map(e=>e.id+":"+(e.state||"")).join(",");
if(!force){
if(sig===RENDER_SIG) return; // nothing changed → leave the DOM (and audio) alone
if(anyPlaying()) return; // changed, but defer so we don't kill playback
}
// 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}
if(!eps.length){
list.className="empty";
list.innerHTML='<span class="big">🎙️</span>No episodes published yet.';
RENDER_SIG=sig;
return;
}
list.className="";
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>
const k=epKind(e);
const meta=[esc(e.feed),date,e.duration?esc(e.duration):""].filter(Boolean).join(" · ");
const cover=e.feed_image||"";
const coverImg=cover?`<img src="${esc(cover)}" alt="" loading="lazy" onerror="this.remove()">`:"";
return `<div class="ep" data-cover="${esc(cover)}" data-title="${esc(e.title)}" data-feed="${esc(e.feed||"")}" data-icon="${k.icon}">
<div class="n" aria-hidden="true">${k.icon}${coverImg}</div>
<div class="meta">
<b>${esc(e.title)}</b>
<div class="sub"><span class="kind ${k.cls}">${k.icon} ${k.label}</span><span>${meta}</span></div>
</div>
<audio controls preload="none" src="${API}/media/${e.id}"></audio>
<a class="dl" href="${API}/media/${e.id}" download title="Download"></a></div>`;
<a class="dl" href="${API}/media/${e.id}" download title="Download episode" aria-label="Download episode"></a>
</div>`;
}).join("");
RENDER_SIG=sig; // remember what we drew
}
load();setInterval(load,15000);
/*
* Now-Playing panel.
* Read-only w.r.t. the episode list: a single capture-phase delegated listener on
* #list (the 'play' event does not bubble, hence capture). It reads the playing
* card's data-* attributes and updates a separate #np panel — it NEVER touches
* list.innerHTML, so the RENDER_SIG / anyPlaying() playback guard is unaffected.
*/
function showNowPlaying(card){
const np=document.getElementById("np");
const cover=card.dataset.cover||"";
const icon=card.dataset.icon||"🎧";
const cc=document.getElementById("npCover");
cc.textContent=icon; // emoji fallback behind the img
if(cover){
const img=document.createElement("img");
img.alt=""; img.loading="lazy";
img.onerror=function(){this.remove()}; // broken cover → reveal emoji
img.src=cover;
cc.appendChild(img);
}
document.getElementById("npTitle").textContent=card.dataset.title||"";
document.getElementById("npFeed").textContent=card.dataset.feed||"";
np.hidden=false;
}
document.getElementById("list").addEventListener("play",function(ev){
const au=ev.target;
if(!au||au.tagName!=="AUDIO") return;
const card=au.closest(".ep");
if(card) showNowPlaying(card);
},true);
load();
setInterval(load,15000); // 15s poll — now guarded, never interrupts playback
</script>
</body>
</html>