mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
Merge: podcaster downloads/upload/SSD/socket + webui reskin + cover + apple resolver + add-from-URL (#853 #855 #857)
This commit is contained in:
commit
11b1d71f90
316
packages/secubox-podcaster/api/importer.py
Normal file
316
packages/secubox-podcaster/api/importer.py
Normal 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())
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
74
packages/secubox-podcaster/tests/test_store_retry.py
Normal file
74
packages/secubox-podcaster/tests/test_store_retry.py
Normal 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
|
||||
77
packages/secubox-podcaster/tests/test_worker_dedup.py
Normal file
77
packages/secubox-podcaster/tests/test_worker_dedup.py
Normal 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())
|
||||
|
|
@ -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 & 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 & status</h2>
|
||||
<h3>⚙️ Config & 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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|
||||
|
|
|
|||
|
|
@ -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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user