mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 13:31:30 +00:00
Compare commits
6 Commits
39d7002b7a
...
ff6fd7632f
| Author | SHA1 | Date | |
|---|---|---|---|
| ff6fd7632f | |||
| 0566672615 | |||
| 9f5bec6a87 | |||
| 560b8d8213 | |||
| 7da61e8fd5 | |||
| f839c9260e |
58
packages/secubox-podcaster/README.md
Normal file
58
packages/secubox-podcaster/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# secubox-podcaster
|
||||
|
||||
Modern podcast manager for SecuBox — subscribe, download locally, and relay a
|
||||
shareable RSS feed.
|
||||
|
||||
## What it does (v1, #726)
|
||||
|
||||
- **Subscribe** by RSS URL or **OPML import** (pure-stdlib feed parsing — no
|
||||
`feedparser` dependency).
|
||||
- **Download locally** into `media_path` via an asyncio + httpx queue with
|
||||
per-episode progress.
|
||||
- **Relay / share**: a generated RSS of the local library at
|
||||
`/api/v1/podcaster/share/feed.xml`. LAN by default; set `public_base` to your
|
||||
exposed vhost to publish externally.
|
||||
- **In-UI service status + TOML config**; modern C3BOX WebUI with inline player.
|
||||
|
||||
Lyrion integration is deferred to a follow-up (standalone first).
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `/usr/share/secubox/podcaster/api` | FastAPI app (uvicorn WorkingDirectory) |
|
||||
| `/run/secubox/podcaster.sock` | Unix socket |
|
||||
| `/var/lib/secubox/podcaster/podcaster.db` | SQLite store |
|
||||
| `/var/lib/secubox/podcaster/media/<feed_id>/` | downloaded episodes |
|
||||
| `/etc/secubox/podcaster.toml` | config |
|
||||
| `/etc/nginx/secubox-routes.d/podcaster.conf` | nginx route (active include) |
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/health`, `/status` | public | service + counts |
|
||||
| GET | `/share/feed.xml` | public | shareable RSS of local library |
|
||||
| GET | `/media/{id}` | public | stream a downloaded episode |
|
||||
| GET/POST/DELETE | `/feeds*` | JWT | manage feeds (+ `/feeds/import-opml`) |
|
||||
| GET | `/episodes` | JWT | list (optional `feed_id`, `state`) |
|
||||
| POST | `/episodes/{id}/download` | JWT | enqueue download |
|
||||
| GET/POST | `/config` | JWT | TOML config |
|
||||
|
||||
## Public exposure (relay to the web)
|
||||
|
||||
Publish the share feed externally via HAProxy TLS → mitmproxy (never bypass the
|
||||
WAF):
|
||||
|
||||
```bash
|
||||
haproxyctl vhost add podcast.gk2.secubox.in # backend = mitmproxy_inspector
|
||||
# add the route to BOTH mitmproxy routes files -> 127.0.0.1:<nginx>
|
||||
systemctl restart mitmproxy
|
||||
```
|
||||
|
||||
Then set `public_base = "https://podcast.gk2.secubox.in"` in
|
||||
`/etc/secubox/podcaster.toml` and restart `secubox-podcaster` so the generated
|
||||
feed's enclosure URLs are absolute.
|
||||
|
||||
---
|
||||
*CyberMind — Gérald Kerma. LicenseRef-CMSD-1.0.*
|
||||
0
packages/secubox-podcaster/api/__init__.py
Normal file
0
packages/secubox-podcaster/api/__init__.py
Normal file
547
packages/secubox-podcaster/api/main.py
Normal file
547
packages/secubox-podcaster/api/main.py
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
"""
|
||||
SecuBox-Deb :: Podcaster
|
||||
CyberMind — https://cybermind.fr
|
||||
|
||||
Modern podcast manager: subscribe to feeds, download episodes locally, and
|
||||
re-publish a shareable RSS. FastAPI on a Unix socket; SQLite store; an asyncio
|
||||
download worker (background task, fire-and-forget queue). Feed parsing is pure
|
||||
stdlib (xml.etree) to keep Debian deps minimal — only httpx is required.
|
||||
"""
|
||||
import asyncio
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import tomllib
|
||||
import zipfile
|
||||
from email.utils import parsedate_to_datetime, format_datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response, FileResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from secubox_core.auth import router as auth_router, require_jwt
|
||||
from secubox_core.logger import get_logger
|
||||
|
||||
from . import store
|
||||
|
||||
log = get_logger("podcaster")
|
||||
|
||||
CONFIG_FILE = Path("/etc/secubox/podcaster.toml")
|
||||
DEFAULT_CONFIG = {
|
||||
"media_path": "/var/lib/secubox/podcaster/media",
|
||||
"max_parallel": 2,
|
||||
"refresh_minutes": 60,
|
||||
"public_base": "", # e.g. https://podcast.gk2.secubox.in (exposure); empty → LAN/relative
|
||||
"share_title": "SecuBox Podcaster",
|
||||
"keep_per_feed": 0, # 0 = unlimited
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
cfg = dict(DEFAULT_CONFIG)
|
||||
try:
|
||||
if CONFIG_FILE.exists():
|
||||
cfg.update(tomllib.loads(CONFIG_FILE.read_text()))
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
log.error(f"config load failed: {e}")
|
||||
return cfg
|
||||
|
||||
|
||||
CFG = load_config()
|
||||
MEDIA = Path(CFG["media_path"])
|
||||
|
||||
app = FastAPI(title="secubox-podcaster", version="1.0.0", root_path="/api/v1/podcaster")
|
||||
app.include_router(auth_router, prefix="/auth")
|
||||
router = APIRouter()
|
||||
|
||||
# ── download queue (in-process, persisted state in SQLite) ──────────
|
||||
_queue: "asyncio.Queue[int]" = asyncio.Queue()
|
||||
_worker_started = False
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Models
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
class FeedIn(BaseModel):
|
||||
url: str
|
||||
auto_dl: bool = True
|
||||
|
||||
|
||||
class OPMLIn(BaseModel):
|
||||
opml: str
|
||||
|
||||
|
||||
class ConfigIn(BaseModel):
|
||||
media_path: Optional[str] = None
|
||||
max_parallel: Optional[int] = None
|
||||
refresh_minutes: Optional[int] = None
|
||||
public_base: Optional[str] = None
|
||||
share_title: Optional[str] = None
|
||||
keep_per_feed: Optional[int] = None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Feed parsing (stdlib)
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
_NS = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd",
|
||||
"atom": "http://www.w3.org/2005/Atom"}
|
||||
|
||||
|
||||
def _ts(s: Optional[str]) -> int:
|
||||
if not s:
|
||||
return 0
|
||||
try:
|
||||
return int(parsedate_to_datetime(s).timestamp())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def parse_feed(xml_bytes: bytes) -> tuple[dict, list[dict]]:
|
||||
"""Return (feed_meta, [episode, ...]) from RSS 2.0 bytes."""
|
||||
root = ET.fromstring(xml_bytes)
|
||||
chan = root.find("channel")
|
||||
if chan is None: # not RSS we understand
|
||||
return {}, []
|
||||
|
||||
def t(el, tag):
|
||||
x = el.find(tag)
|
||||
return x.text.strip() if x is not None and x.text else None
|
||||
|
||||
img = None
|
||||
ic = chan.find("itunes:image", _NS)
|
||||
if ic is not None:
|
||||
img = ic.get("href")
|
||||
if not img:
|
||||
im = chan.find("image/url")
|
||||
img = im.text.strip() if im is not None and im.text else None
|
||||
|
||||
meta = {
|
||||
"title": t(chan, "title"),
|
||||
"description": t(chan, "description"),
|
||||
"site": t(chan, "link"),
|
||||
"image": img,
|
||||
}
|
||||
|
||||
episodes = []
|
||||
for it in chan.findall("item"):
|
||||
enc = it.find("enclosure")
|
||||
if enc is None or not enc.get("url"):
|
||||
continue
|
||||
guid = t(it, "guid") or enc.get("url")
|
||||
dur = it.find("itunes:duration", _NS)
|
||||
episodes.append({
|
||||
"guid": guid,
|
||||
"title": t(it, "title"),
|
||||
"description": t(it, "description"),
|
||||
"pubdate": _ts(t(it, "pubDate")),
|
||||
"enclosure": enc.get("url"),
|
||||
"mime": enc.get("type") or "audio/mpeg",
|
||||
"bytes": int(enc.get("length") or 0),
|
||||
"duration": dur.text.strip() if dur is not None and dur.text else None,
|
||||
})
|
||||
return meta, episodes
|
||||
|
||||
|
||||
async def fetch_and_store(url: str, auto_dl: Optional[bool] = None) -> dict:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=30) as cli:
|
||||
r = await cli.get(url, headers={"User-Agent": "SecuBox-Podcaster/1.0"})
|
||||
r.raise_for_status()
|
||||
meta, eps = parse_feed(r.content)
|
||||
fid = store.add_feed(url, meta, auto_dl=1 if auto_dl else 0)
|
||||
store.update_feed_meta(fid, meta)
|
||||
if auto_dl is not None: # explicit add/toggle; None = refresh (keep current)
|
||||
store.set_feed_autodl(fid, 1 if auto_dl else 0)
|
||||
for ep in eps:
|
||||
store.upsert_episode(fid, ep)
|
||||
queued = await _autoqueue(fid)
|
||||
return {"feed_id": fid, "title": meta.get("title"),
|
||||
"episodes": len(eps), "queued": queued}
|
||||
|
||||
|
||||
async def _autoqueue(fid: int) -> int:
|
||||
"""If a feed has auto_dl, enqueue its not-yet-downloaded episodes (newest
|
||||
first, capped by keep_per_feed to avoid unbounded disk use)."""
|
||||
if not store.feed_autodl(fid):
|
||||
return 0
|
||||
cap = int(CFG.get("keep_per_feed", 0) or 0)
|
||||
n = 0
|
||||
for ep_id in store.pending_episode_ids(fid, cap):
|
||||
store.set_episode(ep_id, state="queued", progress=0, error=None)
|
||||
await _queue.put(ep_id)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Download worker
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
def _safe_name(s: str, ext: str) -> str:
|
||||
base = re.sub(r"[^A-Za-z0-9._-]+", "_", (s or "episode"))[:80].strip("_")
|
||||
return f"{base or 'episode'}{ext}"
|
||||
|
||||
|
||||
async def _download_one(ep_id: int) -> None:
|
||||
ep = store.get_episode(ep_id)
|
||||
if not ep or not ep.get("enclosure"):
|
||||
return
|
||||
store.set_episode(ep_id, state="downloading", progress=0, error=None)
|
||||
ext = os.path.splitext(ep["enclosure"].split("?")[0])[1][:5] or ".mp3"
|
||||
fdir = MEDIA / str(ep["feed_id"])
|
||||
fdir.mkdir(parents=True, exist_ok=True)
|
||||
dest = fdir / _safe_name(f"{ep_id}_{ep.get('title','')}", ext)
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=None) as cli:
|
||||
async with cli.stream("GET", ep["enclosure"],
|
||||
headers={"User-Agent": "SecuBox-Podcaster/1.0"}) as r:
|
||||
r.raise_for_status()
|
||||
total = int(r.headers.get("content-length") or ep.get("bytes") or 0)
|
||||
got = 0
|
||||
with open(tmp, "wb") as fh:
|
||||
async for chunk in r.aiter_bytes(65536):
|
||||
fh.write(chunk)
|
||||
got += len(chunk)
|
||||
if total:
|
||||
store.set_episode(ep_id, progress=min(99, got * 100 // total))
|
||||
tmp.rename(dest)
|
||||
store.set_episode(ep_id, state="done", progress=100,
|
||||
local_path=str(dest), bytes=dest.stat().st_size)
|
||||
log.info(f"downloaded ep {ep_id} -> {dest}")
|
||||
except Exception as e:
|
||||
tmp.unlink(missing_ok=True)
|
||||
store.set_episode(ep_id, state="error", error=str(e)[:200])
|
||||
log.error(f"download ep {ep_id} failed: {e}")
|
||||
|
||||
|
||||
async def _worker() -> None:
|
||||
sem = asyncio.Semaphore(max(1, int(CFG.get("max_parallel", 2))))
|
||||
|
||||
async def run(ep_id):
|
||||
async with sem:
|
||||
await _download_one(ep_id)
|
||||
|
||||
while True:
|
||||
ep_id = await _queue.get()
|
||||
asyncio.create_task(run(ep_id))
|
||||
|
||||
|
||||
async def _refresher() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(max(5, int(CFG.get("refresh_minutes", 60))) * 60)
|
||||
for fid, url in store.all_feed_urls():
|
||||
try:
|
||||
await fetch_and_store(url)
|
||||
except Exception as e:
|
||||
log.error(f"refresh feed {fid} failed: {e}")
|
||||
|
||||
|
||||
def _ensure_worker() -> None:
|
||||
"""Lazy-start background tasks (sub-app lifespans don't fire under the aggregator)."""
|
||||
global _worker_started
|
||||
if _worker_started:
|
||||
return
|
||||
_worker_started = True
|
||||
store.init()
|
||||
MEDIA.mkdir(parents=True, exist_ok=True)
|
||||
asyncio.create_task(_worker())
|
||||
asyncio.create_task(_refresher())
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup():
|
||||
_ensure_worker()
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Public endpoints (status / health / share)
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "module": "deb"}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def status():
|
||||
_ensure_worker()
|
||||
return {
|
||||
"service": "active",
|
||||
"worker": _worker_started,
|
||||
"media_path": str(MEDIA),
|
||||
"public_base": CFG.get("public_base", ""),
|
||||
**store.counts(),
|
||||
}
|
||||
|
||||
|
||||
def _xml_esc(s: Optional[str]) -> str:
|
||||
return html.escape(s or "", quote=True)
|
||||
|
||||
|
||||
@router.get("/share/feed.xml")
|
||||
async def share_feed(request: Request):
|
||||
"""A generated RSS of the locally downloaded episodes (the relay/share feed)."""
|
||||
_ensure_worker()
|
||||
base = (CFG.get("public_base") or "").rstrip("/")
|
||||
if not base:
|
||||
base = str(request.base_url).rstrip("/")
|
||||
eps = store.downloaded_episodes()
|
||||
items = []
|
||||
for e in eps:
|
||||
url = f"{base}/api/v1/podcaster/media/{e['id']}"
|
||||
pub = format_datetime(datetime.fromtimestamp(e["pubdate"] or time.time(),
|
||||
tz=timezone.utc))
|
||||
items.append(
|
||||
f"<item><title>{_xml_esc(e.get('title'))}</title>"
|
||||
f"<description>{_xml_esc(e.get('description'))}</description>"
|
||||
f"<pubDate>{pub}</pubDate>"
|
||||
f"<guid isPermaLink=\"false\">sbx-{e['id']}</guid>"
|
||||
f"<enclosure url=\"{_xml_esc(url)}\" type=\"{_xml_esc(e.get('mime') or 'audio/mpeg')}\" "
|
||||
f"length=\"{e.get('bytes') or 0}\"/></item>"
|
||||
)
|
||||
title = _xml_esc(CFG.get("share_title", "SecuBox Podcaster"))
|
||||
rss = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<rss version="2.0"><channel>'
|
||||
f"<title>{title}</title><link>{_xml_esc(base)}</link>"
|
||||
f"<description>Locally relayed by SecuBox Podcaster</description>"
|
||||
+ "".join(items) + "</channel></rss>"
|
||||
)
|
||||
return Response(content=rss, media_type="application/rss+xml")
|
||||
|
||||
|
||||
@router.get("/public/library")
|
||||
async def public_library():
|
||||
"""Public listener library — downloaded/shared episodes only (no auth).
|
||||
Feeds the external portal frontend; exposes nothing un-shared."""
|
||||
_ensure_worker()
|
||||
eps = store.downloaded_episodes()
|
||||
feeds: dict = {}
|
||||
for e in eps:
|
||||
k = e.get("feed_title") or "Podcast"
|
||||
feeds[k] = feeds.get(k, 0) + 1
|
||||
return {
|
||||
"title": CFG.get("share_title", "SecuBox Podcaster"),
|
||||
"episodes": [{
|
||||
"id": e["id"], "title": e.get("title"), "feed": e.get("feed_title"),
|
||||
"feed_id": e.get("feed_id"),
|
||||
"pubdate": e.get("pubdate"), "duration": e.get("duration"),
|
||||
"mime": e.get("mime"), "bytes": e.get("bytes"),
|
||||
"media": f"/api/v1/podcaster/media/{e['id']}",
|
||||
} for e in eps],
|
||||
"feeds": feeds,
|
||||
"share": "/api/v1/podcaster/share/feed.xml",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/feed/{fid}/zip")
|
||||
async def public_feed_zip(fid: int, background: BackgroundTasks):
|
||||
"""Public: download all downloaded episodes of a feed as one ZIP.
|
||||
mp3/audio are already compressed → STORED (no recompress). Built to a temp
|
||||
file then streamed; cleaned up after send."""
|
||||
_ensure_worker()
|
||||
eps = [e for e in store.downloaded_episodes(limit=2000)
|
||||
if e.get("feed_id") == fid and e.get("local_path")
|
||||
and Path(e["local_path"]).exists()]
|
||||
if not eps:
|
||||
raise HTTPException(404, "no downloaded episodes for this feed")
|
||||
name = re.sub(r"[^A-Za-z0-9._-]+", "_", (eps[0].get("feed_title") or f"feed{fid}"))[:60] or f"feed{fid}"
|
||||
tmp = Path(tempfile.mkstemp(suffix=".zip", dir="/var/lib/secubox/podcaster")[1])
|
||||
eps.sort(key=lambda e: e.get("pubdate") or 0)
|
||||
with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_STORED) as z:
|
||||
for i, e in enumerate(eps):
|
||||
p = Path(e["local_path"])
|
||||
z.write(p, arcname=_safe_name(f"{i+1:03d}_{e.get('title') or p.stem}", p.suffix))
|
||||
background.add_task(lambda: tmp.unlink(missing_ok=True))
|
||||
return FileResponse(tmp, media_type="application/zip", filename=f"{name}.zip",
|
||||
background=background)
|
||||
|
||||
|
||||
@router.get("/media/{ep_id}")
|
||||
async def media(ep_id: int):
|
||||
ep = store.get_episode(ep_id)
|
||||
if not ep or ep.get("state") != "done" or not ep.get("local_path"):
|
||||
raise HTTPException(404, "not downloaded")
|
||||
p = Path(ep["local_path"])
|
||||
if not p.exists():
|
||||
raise HTTPException(404, "file missing")
|
||||
return FileResponse(p, media_type=ep.get("mime") or "audio/mpeg",
|
||||
filename=p.name)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Authenticated endpoints (manage)
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
@router.get("/feeds", dependencies=[Depends(require_jwt)])
|
||||
async def feeds():
|
||||
_ensure_worker()
|
||||
return {"feeds": store.list_feeds()}
|
||||
|
||||
|
||||
@router.post("/feeds", dependencies=[Depends(require_jwt)])
|
||||
async def add_feed(body: FeedIn):
|
||||
_ensure_worker()
|
||||
try:
|
||||
return await fetch_and_store(body.url.strip(), auto_dl=body.auto_dl)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"feed add failed: {e}")
|
||||
|
||||
|
||||
@router.post("/feeds/{fid}/autodl", dependencies=[Depends(require_jwt)])
|
||||
async def toggle_autodl(fid: int, on: bool = True):
|
||||
"""Toggle auto-download for a feed. Turning it on also queues any episodes
|
||||
not yet downloaded (so the portal/share feed sync immediately)."""
|
||||
_ensure_worker()
|
||||
store.set_feed_autodl(fid, 1 if on else 0)
|
||||
queued = await _autoqueue(fid) if on else 0
|
||||
return {"ok": True, "auto_dl": on, "queued": queued}
|
||||
|
||||
|
||||
@router.delete("/feeds/{fid}", dependencies=[Depends(require_jwt)])
|
||||
async def del_feed(fid: int):
|
||||
store.delete_feed(fid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/feeds/import-opml", dependencies=[Depends(require_jwt)])
|
||||
async def import_opml(body: OPMLIn):
|
||||
_ensure_worker()
|
||||
try:
|
||||
root = ET.fromstring(body.opml)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"bad OPML: {e}")
|
||||
urls = [o.get("xmlUrl") for o in root.iter("outline") if o.get("xmlUrl")]
|
||||
added = 0
|
||||
for u in urls:
|
||||
try:
|
||||
await fetch_and_store(u)
|
||||
added += 1
|
||||
except Exception as e:
|
||||
log.error(f"opml feed {u}: {e}")
|
||||
return {"added": added, "total": len(urls)}
|
||||
|
||||
|
||||
_AUDIO_EXT = {".mp3", ".m4a", ".m4b", ".aac", ".ogg", ".opus", ".flac", ".wav", ".mp4"}
|
||||
_AUDIO_MIME = {".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".m4b": "audio/mp4",
|
||||
".aac": "audio/aac", ".ogg": "audio/ogg", ".opus": "audio/opus",
|
||||
".flac": "audio/flac", ".wav": "audio/wav", ".mp4": "audio/mp4"}
|
||||
|
||||
|
||||
@router.post("/audiobook/upload", dependencies=[Depends(require_jwt)])
|
||||
async def audiobook_upload(request: Request, title: str = "Audiobook"):
|
||||
"""Stream an uploaded ZIP to a temp file, extract its audio tracks, and
|
||||
publish them as a self-contained (synthetic) feed — already 'done' so they
|
||||
show in the library + share feed. Raw body (no python-multipart dep)."""
|
||||
_ensure_worker()
|
||||
title = (title or "Audiobook").strip() or "Audiobook"
|
||||
tmp = Path(tempfile.mkstemp(suffix=".zip", dir="/var/lib/secubox/podcaster")[1])
|
||||
try:
|
||||
with open(tmp, "wb") as fh:
|
||||
async for chunk in request.stream():
|
||||
fh.write(chunk)
|
||||
if not zipfile.is_zipfile(tmp):
|
||||
raise HTTPException(400, "not a valid ZIP")
|
||||
# synthetic feed
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "audiobook"
|
||||
fid = store.add_feed(f"audiobook:{slug}:{int(time.time())}",
|
||||
{"title": title, "description": f"Audiobook: {title}"})
|
||||
fdir = MEDIA / str(fid)
|
||||
fdir.mkdir(parents=True, exist_ok=True)
|
||||
tracks = 0
|
||||
with zipfile.ZipFile(tmp) as z:
|
||||
names = [n for n in z.namelist()
|
||||
if os.path.splitext(n)[1].lower() in _AUDIO_EXT
|
||||
and not n.endswith("/")]
|
||||
names.sort() # chapter order
|
||||
base = int(time.time())
|
||||
for i, n in enumerate(names):
|
||||
ext = os.path.splitext(n)[1].lower()
|
||||
tname = _safe_name(f"{i+1:03d}_{os.path.basename(n)}", ext)
|
||||
dest = fdir / tname
|
||||
with z.open(n) as src, open(dest, "wb") as out:
|
||||
shutil.copyfileobj(src, out, 1024 * 256)
|
||||
store.upsert_episode(fid, {
|
||||
"guid": f"ab:{fid}:{i}", "title": f"{i+1:02d} · {os.path.splitext(os.path.basename(n))[0]}",
|
||||
"description": title, "pubdate": base + i,
|
||||
"enclosure": f"local:{dest}", "mime": _AUDIO_MIME.get(ext, "audio/mpeg"),
|
||||
"bytes": dest.stat().st_size,
|
||||
})
|
||||
# mark immediately downloaded (local file already present)
|
||||
ep = store.list_episodes(feed_id=fid)
|
||||
for e in ep:
|
||||
if e["guid"] == f"ab:{fid}:{i}":
|
||||
store.set_episode(e["id"], state="done", progress=100,
|
||||
local_path=str(dest))
|
||||
break
|
||||
tracks += 1
|
||||
if not tracks:
|
||||
store.delete_feed(fid)
|
||||
raise HTTPException(400, "no audio files found in ZIP")
|
||||
log.info(f"audiobook '{title}' -> feed {fid}, {tracks} tracks")
|
||||
return {"title": title, "feed_id": fid, "tracks": tracks}
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.get("/episodes", dependencies=[Depends(require_jwt)])
|
||||
async def episodes(feed_id: Optional[int] = None, state: Optional[str] = None):
|
||||
_ensure_worker()
|
||||
return {"episodes": store.list_episodes(feed_id, state)}
|
||||
|
||||
|
||||
@router.post("/episodes/{ep_id}/download", dependencies=[Depends(require_jwt)])
|
||||
async def download(ep_id: int):
|
||||
_ensure_worker()
|
||||
ep = store.get_episode(ep_id)
|
||||
if not ep:
|
||||
raise HTTPException(404, "no such episode")
|
||||
store.set_episode(ep_id, state="queued", progress=0, error=None)
|
||||
await _queue.put(ep_id)
|
||||
return {"ok": True, "queued": ep_id}
|
||||
|
||||
|
||||
@router.get("/downloads", dependencies=[Depends(require_jwt)])
|
||||
async def downloads():
|
||||
_ensure_worker()
|
||||
return {"downloads": store.list_episodes(state="downloading")
|
||||
+ store.list_episodes(state="queued")}
|
||||
|
||||
|
||||
@router.get("/config", dependencies=[Depends(require_jwt)])
|
||||
async def get_config():
|
||||
return {"config": load_config()}
|
||||
|
||||
|
||||
@router.post("/config", dependencies=[Depends(require_jwt)])
|
||||
async def set_config(body: ConfigIn):
|
||||
cfg = load_config()
|
||||
for k, v in body.model_dump(exclude_none=True).items():
|
||||
cfg[k] = v
|
||||
lines = []
|
||||
for k, v in cfg.items():
|
||||
if isinstance(v, str):
|
||||
lines.append(f'{k} = "{v}"')
|
||||
elif isinstance(v, bool):
|
||||
lines.append(f"{k} = {'true' if v else 'false'}")
|
||||
else:
|
||||
lines.append(f"{k} = {v}")
|
||||
try:
|
||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_FILE.write_text("\n".join(lines) + "\n")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"write failed: {e}")
|
||||
return {"ok": True, "config": cfg, "note": "restart secubox-podcaster to apply"}
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
198
packages/secubox-podcaster/api/store.py
Normal file
198
packages/secubox-podcaster/api/store.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
"""
|
||||
SecuBox-Deb :: Podcaster store
|
||||
CyberMind — https://cybermind.fr
|
||||
|
||||
SQLite persistence for feeds + episodes. Pure stdlib (sqlite3); no ORM.
|
||||
"""
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
DB_PATH = Path("/var/lib/secubox/podcaster/podcaster.db")
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS feeds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
site TEXT,
|
||||
added_ts INTEGER NOT NULL,
|
||||
last_fetch INTEGER DEFAULT 0,
|
||||
auto_dl INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
pubdate INTEGER DEFAULT 0,
|
||||
enclosure TEXT,
|
||||
mime TEXT,
|
||||
bytes INTEGER DEFAULT 0,
|
||||
duration TEXT,
|
||||
local_path TEXT,
|
||||
state TEXT DEFAULT 'new', -- new | queued | downloading | done | error
|
||||
progress INTEGER DEFAULT 0, -- 0..100
|
||||
error TEXT,
|
||||
UNIQUE(feed_id, guid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ep_feed ON episodes(feed_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ep_state ON episodes(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_ep_pub ON episodes(pubdate DESC);
|
||||
"""
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
c = sqlite3.connect(DB_PATH, timeout=10)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("PRAGMA foreign_keys=ON")
|
||||
c.execute("PRAGMA journal_mode=WAL")
|
||||
return c
|
||||
|
||||
|
||||
def init() -> None:
|
||||
with _conn() as c:
|
||||
c.executescript(_SCHEMA)
|
||||
|
||||
|
||||
# ── feeds ──────────────────────────────────────────────────────────
|
||||
def add_feed(url: str, meta: dict, auto_dl: int = 0) -> int:
|
||||
with _conn() as c:
|
||||
cur = c.execute(
|
||||
"INSERT OR IGNORE INTO feeds(url,title,description,image,site,added_ts,auto_dl) "
|
||||
"VALUES(?,?,?,?,?,?,?)",
|
||||
(url, meta.get("title"), meta.get("description"),
|
||||
meta.get("image"), meta.get("site"), int(time.time()), int(auto_dl)),
|
||||
)
|
||||
if cur.lastrowid:
|
||||
return cur.lastrowid
|
||||
row = c.execute("SELECT id FROM feeds WHERE url=?", (url,)).fetchone()
|
||||
return row["id"] if row else 0
|
||||
|
||||
|
||||
def set_feed_autodl(feed_id: int, on: int) -> None:
|
||||
with _conn() as c:
|
||||
c.execute("UPDATE feeds SET auto_dl=? WHERE id=?", (int(on), feed_id))
|
||||
|
||||
|
||||
def feed_autodl(feed_id: int) -> int:
|
||||
with _conn() as c:
|
||||
r = c.execute("SELECT auto_dl FROM feeds WHERE id=?", (feed_id,)).fetchone()
|
||||
return int(r["auto_dl"]) if r else 0
|
||||
|
||||
|
||||
def pending_episode_ids(feed_id: int, limit: int = 0) -> list[int]:
|
||||
"""Episode ids in state 'new' for a feed (newest first); for auto-download."""
|
||||
q = "SELECT id FROM episodes WHERE feed_id=? AND state='new' ORDER BY pubdate DESC"
|
||||
if limit and limit > 0:
|
||||
q += f" LIMIT {int(limit)}"
|
||||
with _conn() as c:
|
||||
return [r["id"] for r in c.execute(q, (feed_id,)).fetchall()]
|
||||
|
||||
|
||||
def update_feed_meta(feed_id: int, meta: dict) -> None:
|
||||
with _conn() as c:
|
||||
c.execute(
|
||||
"UPDATE feeds SET title=COALESCE(?,title), description=COALESCE(?,description), "
|
||||
"image=COALESCE(?,image), site=COALESCE(?,site), last_fetch=? WHERE id=?",
|
||||
(meta.get("title"), meta.get("description"), meta.get("image"),
|
||||
meta.get("site"), int(time.time()), feed_id),
|
||||
)
|
||||
|
||||
|
||||
def list_feeds() -> list[dict]:
|
||||
with _conn() as c:
|
||||
rows = c.execute(
|
||||
"SELECT f.*, "
|
||||
"(SELECT COUNT(*) FROM episodes e WHERE e.feed_id=f.id) AS episodes, "
|
||||
"(SELECT COUNT(*) FROM episodes e WHERE e.feed_id=f.id AND e.state='done') AS downloaded "
|
||||
"FROM feeds f ORDER BY f.title COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_feed(feed_id: int) -> None:
|
||||
with _conn() as c:
|
||||
c.execute("DELETE FROM feeds WHERE id=?", (feed_id,))
|
||||
|
||||
|
||||
def all_feed_urls() -> list[tuple[int, str]]:
|
||||
with _conn() as c:
|
||||
return [(r["id"], r["url"]) for r in c.execute("SELECT id,url FROM feeds")]
|
||||
|
||||
|
||||
# ── episodes ───────────────────────────────────────────────────────
|
||||
def upsert_episode(feed_id: int, ep: dict) -> None:
|
||||
with _conn() as c:
|
||||
c.execute(
|
||||
"INSERT OR IGNORE INTO episodes"
|
||||
"(feed_id,guid,title,description,pubdate,enclosure,mime,bytes,duration) "
|
||||
"VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(feed_id, ep["guid"], ep.get("title"), ep.get("description"),
|
||||
ep.get("pubdate", 0), ep.get("enclosure"), ep.get("mime"),
|
||||
ep.get("bytes", 0), ep.get("duration")),
|
||||
)
|
||||
|
||||
|
||||
def list_episodes(feed_id: Optional[int] = None, state: Optional[str] = None,
|
||||
limit: int = 200) -> list[dict]:
|
||||
q = ("SELECT e.*, f.title AS feed_title, f.image AS feed_image "
|
||||
"FROM episodes e JOIN feeds f ON f.id=e.feed_id")
|
||||
where, args = [], []
|
||||
if feed_id is not None:
|
||||
where.append("e.feed_id=?"); args.append(feed_id)
|
||||
if state:
|
||||
where.append("e.state=?"); args.append(state)
|
||||
if where:
|
||||
q += " WHERE " + " AND ".join(where)
|
||||
q += " ORDER BY e.pubdate DESC LIMIT ?"; args.append(limit)
|
||||
with _conn() as c:
|
||||
return [dict(r) for r in c.execute(q, args).fetchall()]
|
||||
|
||||
|
||||
def get_episode(ep_id: int) -> Optional[dict]:
|
||||
with _conn() as c:
|
||||
r = c.execute("SELECT * FROM episodes WHERE id=?", (ep_id,)).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
def set_episode(ep_id: int, **fields: Any) -> None:
|
||||
if not fields:
|
||||
return
|
||||
cols = ", ".join(f"{k}=?" for k in fields)
|
||||
with _conn() as c:
|
||||
c.execute(f"UPDATE episodes SET {cols} WHERE id=?", (*fields.values(), ep_id))
|
||||
|
||||
|
||||
def downloaded_episodes(limit: int = 500) -> list[dict]:
|
||||
"""Episodes with a local file — the shareable library."""
|
||||
with _conn() as c:
|
||||
rows = c.execute(
|
||||
"SELECT e.*, f.title AS feed_title FROM episodes e JOIN feeds f ON f.id=e.feed_id "
|
||||
"WHERE e.state='done' AND e.local_path IS NOT NULL "
|
||||
"ORDER BY e.pubdate DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def counts() -> dict:
|
||||
with _conn() as c:
|
||||
return {
|
||||
"feeds": c.execute("SELECT COUNT(*) FROM feeds").fetchone()[0],
|
||||
"episodes": c.execute("SELECT COUNT(*) FROM episodes").fetchone()[0],
|
||||
"downloaded": c.execute(
|
||||
"SELECT COUNT(*) FROM episodes WHERE state='done'").fetchone()[0],
|
||||
"queued": c.execute(
|
||||
"SELECT COUNT(*) FROM episodes WHERE state IN('queued','downloading')"
|
||||
).fetchone()[0],
|
||||
}
|
||||
23
packages/secubox-podcaster/conf/podcaster.toml
Normal file
23
packages/secubox-podcaster/conf/podcaster.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# /etc/secubox/podcaster.toml — SecuBox Podcaster
|
||||
# Restart secubox-podcaster after editing.
|
||||
|
||||
# Where downloaded episodes are stored.
|
||||
media_path = "/var/lib/secubox/podcaster/media"
|
||||
|
||||
# Concurrent downloads.
|
||||
max_parallel = 2
|
||||
|
||||
# Auto-refresh feeds every N minutes (background).
|
||||
refresh_minutes = 60
|
||||
|
||||
# Public base URL for the shareable RSS enclosure links. Leave empty for
|
||||
# LAN/relative URLs. Set to your exposed vhost to relay publicly, e.g.
|
||||
# public_base = "https://podcast.gk2.secubox.in"
|
||||
# (route it through HAProxy TLS -> mitmproxy per WAF policy; see README).
|
||||
public_base = ""
|
||||
|
||||
# Title of the generated share feed.
|
||||
share_title = "SecuBox Podcaster"
|
||||
|
||||
# Keep at most N downloaded episodes per feed (0 = unlimited).
|
||||
keep_per_feed = 0
|
||||
46
packages/secubox-podcaster/debian/changelog
Normal file
46
packages/secubox-podcaster/debian/changelog
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
secubox-podcaster (1.0.3-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Auto-download: feeds can auto-queue new episodes so the portal + share feed
|
||||
stay synced without manual downloads. New feeds default auto_dl=on (UI
|
||||
checkbox); per-feed toggle (POST /feeds/{id}/autodl, ⏬ in admin). The
|
||||
periodic refresher auto-queues newly published episodes; bounded by
|
||||
keep_per_feed. (#726)
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Wed, 24 Jun 2026 14:20:00 +0000
|
||||
|
||||
secubox-podcaster (1.0.2-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Public portal: per-episode download (⬇, mp3/audio) + per-feed
|
||||
"Download all (ZIP)" via new public GET /public/feed/{id}/zip (STORED, no
|
||||
recompress; built to temp then streamed + cleaned up). public/library now
|
||||
carries feed_id so the portal can zip by feed.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Wed, 24 Jun 2026 13:40:00 +0000
|
||||
|
||||
secubox-podcaster (1.0.1-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Admin WebUI: integrate the shared Hub sidebar (/shared/sidebar.js) + correct
|
||||
auth token (sbx_token, 401 -> /login.html) — was missing the navbar and
|
||||
falsely prompting to log in.
|
||||
* Public listener PORTAL (no auth) at /podcaster/portal/ — standalone
|
||||
C3BOX page driven by a new public GET /public/library endpoint; subscribe
|
||||
(RSS) + per-feed filter + inline players. Meant to be exposed externally.
|
||||
* Audiobook ZIP import: POST /audiobook/upload (raw body, no python-multipart)
|
||||
streams the ZIP to a temp file, extracts audio tracks, and publishes them as
|
||||
a self-contained synthetic feed (already 'done' -> in library + share feed).
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Wed, 24 Jun 2026 13:00:00 +0000
|
||||
|
||||
secubox-podcaster (1.0.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Initial release (#726). Modern podcast manager:
|
||||
- subscribe by RSS URL / OPML import; pure-stdlib feed parsing
|
||||
- local download queue (asyncio + httpx) with per-episode progress
|
||||
- generated shareable RSS of the local library (/share/feed.xml) — LAN by
|
||||
default, public via HAProxy / secubox-exposure (public_base)
|
||||
- in-UI service status + TOML config; C3BOX WebUI with inline player
|
||||
- FastAPI on /run/secubox/podcaster.sock; SQLite store; nginx route shipped
|
||||
to the active secubox-routes.d/ include
|
||||
- Lyrion integration deferred to a follow-up (standalone first)
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Wed, 24 Jun 2026 12:00:00 +0000
|
||||
22
packages/secubox-podcaster/debian/control
Normal file
22
packages/secubox-podcaster/debian/control
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
Source: secubox-podcaster
|
||||
Section: sound
|
||||
Priority: optional
|
||||
Maintainer: Gerald KERMA <devel@cybermind.fr>
|
||||
Build-Depends: debhelper-compat (= 13)
|
||||
Standards-Version: 4.6.2
|
||||
|
||||
Package: secubox-podcaster
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-uvicorn | python3-pip, python3-httpx | python3-pip
|
||||
Description: Modern podcast manager for SecuBox
|
||||
Subscribe to podcast feeds, download episodes locally, and re-publish a
|
||||
shareable RSS feed (LAN by default, public via HAProxy / secubox-exposure).
|
||||
FastAPI backend on /api/v1/podcaster/ over a Unix socket; SQLite store; an
|
||||
asyncio download queue. Feed parsing is pure stdlib (no feedparser dep).
|
||||
.
|
||||
Features:
|
||||
- Subscribe by RSS URL or OPML import
|
||||
- Local download queue with per-episode progress
|
||||
- Generated shareable RSS of the local library (relay/share to the web)
|
||||
- In-UI service status + TOML config
|
||||
- Modern C3BOX cyberpunk WebUI with inline player
|
||||
33
packages/secubox-podcaster/debian/postinst
Normal file
33
packages/secubox-podcaster/debian/postinst
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
# Data dirs (module-owned; never touch the shared /run/secubox parent, #494).
|
||||
install -d -o secubox -g secubox -m 0750 /var/lib/secubox/podcaster
|
||||
install -d -o secubox -g secubox -m 0750 /var/lib/secubox/podcaster/media
|
||||
|
||||
# Config on first install only (preserve operator edits on upgrade).
|
||||
if [ ! -f /etc/secubox/podcaster.toml ]; then
|
||||
if [ -f /usr/share/secubox/podcaster/podcaster.toml ]; then
|
||||
install -o secubox -g secubox -m 0640 \
|
||||
/usr/share/secubox/podcaster/podcaster.toml /etc/secubox/podcaster.toml
|
||||
fi
|
||||
fi
|
||||
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
# Respect operator masking (set -e would abort on a masked enable).
|
||||
if [ "$(systemctl is-enabled secubox-podcaster.service 2>/dev/null)" != "masked" ]; then
|
||||
systemctl enable secubox-podcaster.service 2>/dev/null || true
|
||||
systemctl restart secubox-podcaster.service 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Reload nginx only if the resulting config is valid.
|
||||
if command -v nginx >/dev/null 2>&1 && nginx -t 2>/dev/null; then
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
12
packages/secubox-podcaster/debian/prerm
Normal file
12
packages/secubox-podcaster/debian/prerm
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
systemctl stop secubox-podcaster.service 2>/dev/null || true
|
||||
systemctl disable secubox-podcaster.service 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
30
packages/secubox-podcaster/debian/rules
Executable file
30
packages/secubox-podcaster/debian/rules
Executable file
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/make -f
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_install:
|
||||
# API files (service WorkingDirectory = /usr/share/secubox/podcaster)
|
||||
install -d debian/secubox-podcaster/usr/share/secubox/podcaster/
|
||||
cp -r api debian/secubox-podcaster/usr/share/secubox/podcaster/
|
||||
# Static www files
|
||||
install -d debian/secubox-podcaster/usr/share/secubox/www
|
||||
[ -d www ] && cp -r www/. debian/secubox-podcaster/usr/share/secubox/www/ || true
|
||||
# Menu definitions
|
||||
install -d debian/secubox-podcaster/usr/share/secubox/menu.d
|
||||
[ -d menu.d ] && cp -r menu.d/. debian/secubox-podcaster/usr/share/secubox/menu.d/ || true
|
||||
# Config template (installed by postinst on first install only)
|
||||
install -d debian/secubox-podcaster/usr/share/secubox/podcaster
|
||||
[ -f conf/podcaster.toml ] && cp conf/podcaster.toml debian/secubox-podcaster/usr/share/secubox/podcaster/ || true
|
||||
# nginx route — ship to the ACTIVE include (secubox-routes.d) AND legacy
|
||||
# secubox.d for back-compat (mirrors grafana/peertube; see #65).
|
||||
install -d debian/secubox-podcaster/etc/nginx/secubox-routes.d
|
||||
install -d debian/secubox-podcaster/etc/nginx/secubox.d
|
||||
[ -f nginx/podcaster.conf ] && cp nginx/podcaster.conf debian/secubox-podcaster/etc/nginx/secubox-routes.d/ || true
|
||||
[ -f nginx/podcaster.conf ] && cp nginx/podcaster.conf debian/secubox-podcaster/etc/nginx/secubox.d/ || true
|
||||
# Systemd unit
|
||||
install -d debian/secubox-podcaster/usr/lib/systemd/system
|
||||
cp systemd/secubox-podcaster.service debian/secubox-podcaster/usr/lib/systemd/system/
|
||||
|
||||
override_dh_installsystemd:
|
||||
# Handled manually in postinst (respect operator masking).
|
||||
true
|
||||
1
packages/secubox-podcaster/debian/source/format
Normal file
1
packages/secubox-podcaster/debian/source/format
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.0 (native)
|
||||
9
packages/secubox-podcaster/menu.d/612-podcaster.json
Normal file
9
packages/secubox-podcaster/menu.d/612-podcaster.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "podcaster",
|
||||
"name": "Podcaster",
|
||||
"icon": "🎙️",
|
||||
"path": "/podcaster/",
|
||||
"category": "mesh",
|
||||
"order": 612,
|
||||
"description": "Subscribe, download and relay podcasts"
|
||||
}
|
||||
18
packages/secubox-podcaster/nginx/podcaster.conf
Normal file
18
packages/secubox-podcaster/nginx/podcaster.conf
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# /etc/nginx/secubox-routes.d/podcaster.conf
|
||||
# Installed by secubox-podcaster — auto-registered on install, removed on purge.
|
||||
|
||||
# Static frontend
|
||||
location /podcaster/ {
|
||||
alias /usr/share/secubox/www/podcaster/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /podcaster/index.html;
|
||||
}
|
||||
|
||||
# API backend (Unix socket). Episode media + share feed stream through here too.
|
||||
location /api/v1/podcaster/ {
|
||||
proxy_pass http://unix:/run/secubox/podcaster.sock:/;
|
||||
# secubox-proxy.conf already sets proxy_buffering off + proxy_read_timeout;
|
||||
# only add what it doesn't (large episode uploads/streams).
|
||||
include /etc/nginx/snippets/secubox-proxy.conf;
|
||||
client_max_body_size 0;
|
||||
}
|
||||
27
packages/secubox-podcaster/systemd/secubox-podcaster.service
Normal file
27
packages/secubox-podcaster/systemd/secubox-podcaster.service
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[Unit]
|
||||
Description=SecuBox Podcaster API
|
||||
After=network.target secubox-runtime.service
|
||||
Requires=secubox-runtime.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/usr/share/secubox/podcaster
|
||||
ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/podcaster.sock --workers 1
|
||||
# #494: only chmod/chown our OWN socket — never the shared /run/secubox parent.
|
||||
ExecStartPost=-/bin/sleep 1
|
||||
ExecStartPost=-/bin/chmod 660 /run/secubox/podcaster.sock
|
||||
ExecStartPost=-/bin/chown secubox:secubox /run/secubox/podcaster.sock
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=no
|
||||
ProtectSystem=full
|
||||
ProtectHome=read-only
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
191
packages/secubox-podcaster/www/podcaster/index.html
Normal file
191
packages/secubox-podcaster/www/podcaster/index.html
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<!DOCTYPE html>
|
||||
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
|
||||
<!-- SecuBox-Deb :: Podcaster admin WebUI — CyberMind https://cybermind.fr -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SecuBox · Podcaster</title>
|
||||
<link rel="stylesheet" href="/shared/crt-light.css">
|
||||
<link rel="stylesheet" href="/shared/sidebar-light.css">
|
||||
<style>
|
||||
:root{
|
||||
--cosmos-black:#0a0a0f; --gold-hermetic:#c9a84c; --cinnabar:#e63946;
|
||||
--matrix-green:#00ff41; --void-purple:#6e40c9; --cyber-cyan:#00d4ff;
|
||||
--text-primary:#e8e6d9; --text-muted:#6b6b7a; --panel:#13131c; --line:#23232f;
|
||||
}
|
||||
body{margin:0;background:var(--cosmos-black);color:var(--text-primary);
|
||||
font-family:'JetBrains Mono',ui-monospace,Menlo,monospace;font-size:14px;display:flex}
|
||||
.main{flex:1;margin-left:220px;min-height:100vh}
|
||||
@media(max-width:900px){.sidebar{display:none}.main{margin-left:0}}
|
||||
.topbar{display:flex;align-items:center;gap:14px;padding:14px 20px;
|
||||
border-bottom:1px solid var(--line);background:linear-gradient(90deg,#0a0a0f,#13131c)}
|
||||
.topbar h1{font-size:18px;margin:0;letter-spacing:1px;color:var(--gold-hermetic)}
|
||||
.topbar .em{font-size:22px}
|
||||
.stats{margin-left:auto;display:flex;gap:16px;color:var(--text-muted);font-size:12px}
|
||||
.stats b{color:var(--cyber-cyan)}
|
||||
.wrap{display:grid;grid-template-columns:300px 1fr;gap:0;min-height:calc(100vh - 58px)}
|
||||
aside.feeds{border-right:1px solid var(--line);background:var(--panel);padding:14px;overflow:auto}
|
||||
section.eps{padding:18px 22px;overflow:auto}
|
||||
@media(max-width:760px){.wrap{grid-template-columns:1fr}aside.feeds{border-right:0;border-bottom:1px solid var(--line)}}
|
||||
.row{display:flex;gap:8px;margin-bottom:12px}
|
||||
input,button,select{font-family:inherit;font-size:13px;border-radius:8px;border:1px solid var(--line)}
|
||||
input{flex:1;background:#0e0e16;color:var(--text-primary);padding:9px 11px}
|
||||
button{background:#1a1a26;color:var(--text-primary);padding:9px 13px;cursor:pointer;transition:.15s}
|
||||
button:hover{border-color:var(--cyber-cyan);color:var(--cyber-cyan)}
|
||||
button.go{background:var(--void-purple);border-color:var(--void-purple);color:#fff}
|
||||
a.portal{color:var(--cyber-cyan);text-decoration:none;font-size:12px;border:1px solid var(--cyber-cyan);
|
||||
padding:7px 11px;border-radius:8px}
|
||||
.feed{display:flex;gap:10px;align-items:center;padding:9px;border-radius:8px;cursor:pointer;border:1px solid transparent}
|
||||
.feed:hover{background:#0e0e16}.feed.sel{background:#0e0e16;border-color:var(--void-purple)}
|
||||
.feed img{width:40px;height:40px;border-radius:6px;object-fit:cover;background:#222}
|
||||
.feed .t{flex:1;min-width:0}.feed .t b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px}
|
||||
.feed .t span{color:var(--text-muted);font-size:11px}
|
||||
.feed .x{color:var(--text-muted);opacity:0;font-size:16px}.feed:hover .x{opacity:1}
|
||||
.feed .ad{font-size:14px;cursor:pointer;opacity:.5}.feed .ad.on{opacity:1;color:var(--matrix-green)}
|
||||
label.autodl{display:flex;gap:7px;align-items:center;color:var(--text-muted);font-size:12px;margin:-4px 0 12px;cursor:pointer}
|
||||
h2{font-size:15px;color:var(--gold-hermetic);border-bottom:1px solid var(--line);padding-bottom:8px;margin:0 0 14px}
|
||||
.ep{display:flex;gap:12px;align-items:center;padding:12px;border:1px solid var(--line);border-radius:10px;margin-bottom:10px;background:var(--panel)}
|
||||
.ep .meta{flex:1;min-width:0}.ep .meta b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ep .meta span{color:var(--text-muted);font-size:11px}
|
||||
.ep .act{display:flex;gap:6px;align-items:center}
|
||||
.badge{font-size:10px;padding:2px 7px;border-radius:20px;border:1px solid var(--line);color:var(--text-muted)}
|
||||
.badge.downloading{color:var(--cyber-cyan);border-color:var(--cyber-cyan)}
|
||||
.badge.error{color:var(--cinnabar);border-color:var(--cinnabar)}
|
||||
.bar{height:4px;border-radius:3px;background:#0e0e16;overflow:hidden;width:90px}.bar i{display:block;height:100%;background:var(--cyber-cyan);width:0}
|
||||
audio{height:34px}
|
||||
.share{margin-top:6px;padding:12px;border:1px dashed var(--void-purple);border-radius:10px;color:var(--text-muted);font-size:12px}
|
||||
.share a{color:var(--cyber-cyan)}
|
||||
.muted{color:var(--text-muted)}.empty{color:var(--text-muted);padding:40px;text-align:center}
|
||||
dialog{background:var(--panel);color:var(--text-primary);border:1px solid var(--void-purple);border-radius:12px;padding:20px;width:min(520px,92vw)}
|
||||
dialog label{display:block;margin:10px 0 4px;color:var(--text-muted);font-size:12px}dialog input{width:100%}
|
||||
.toast{position:fixed;bottom:18px;right:18px;background:#13131c;border:1px solid var(--cyber-cyan);color:var(--cyber-cyan);padding:10px 14px;border-radius:8px;opacity:0;transition:.2s;font-size:12px}.toast.on{opacity:1}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar" id="sidebar"></nav>
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<span class="em">🎙️</span><h1>PODCASTER</h1>
|
||||
<div class="stats" id="stats"></div>
|
||||
<a class="portal" href="/podcaster/portal/" target="_blank">🌐 Public portal</a>
|
||||
<button onclick="openCfg()" title="Config & status">⚙️</button>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<aside class="feeds">
|
||||
<div class="row">
|
||||
<input id="feedUrl" placeholder="RSS feed URL…" onkeydown="if(event.key==='Enter')addFeed()">
|
||||
<button class="go" onclick="addFeed()">+</button>
|
||||
</div>
|
||||
<label class="autodl"><input type="checkbox" id="autodl" checked> auto-download new episodes</label>
|
||||
<div class="row">
|
||||
<button onclick="document.getElementById('opml').click()">Import OPML</button>
|
||||
<button onclick="document.getElementById('abzip').click()" title="Upload an audiobook ZIP">📚 Audiobook ZIP</button>
|
||||
<button onclick="loadAll()">↻</button>
|
||||
</div>
|
||||
<input type="file" id="opml" accept=".opml,.xml" style="display:none" onchange="importOpml(this)">
|
||||
<input type="file" id="abzip" accept=".zip" style="display:none" onchange="uploadZip(this)">
|
||||
<div id="feeds"></div>
|
||||
<div class="share" id="share"></div>
|
||||
</aside>
|
||||
<section class="eps">
|
||||
<h2 id="title">Latest episodes</h2>
|
||||
<div id="eps"><div class="empty">Loading…</div></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<dialog id="cfg">
|
||||
<h2>Config & status</h2>
|
||||
<div id="svc" class="muted"></div>
|
||||
<label>Media path</label><input id="c_media">
|
||||
<label>Max parallel downloads</label><input id="c_par" type="number">
|
||||
<label>Refresh minutes</label><input id="c_ref" type="number">
|
||||
<label>Public base URL (for shareable feed — leave empty for LAN)</label><input id="c_pub">
|
||||
<label>Share feed title</label><input id="c_title">
|
||||
<div class="row" style="margin-top:16px">
|
||||
<button class="go" onclick="saveCfg()">Save</button>
|
||||
<button onclick="document.getElementById('cfg').close()">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/shared/sidebar.js"></script>
|
||||
<script>
|
||||
const API="/api/v1/podcaster";
|
||||
let SEL=null;
|
||||
function tok(){return localStorage.getItem("sbx_token")||""}
|
||||
async function api(p,opt={}){
|
||||
opt.headers=Object.assign({},opt.headers||{});
|
||||
if(!(opt.body instanceof FormData) && !opt.headers["Content-Type"]) opt.headers["Content-Type"]="application/json";
|
||||
const t=tok(); if(t) opt.headers["Authorization"]="Bearer "+t;
|
||||
const r=await fetch(API+p,opt);
|
||||
if(r.status===401){window.location="/login.html";throw new Error("401")}
|
||||
if(!r.ok) throw new Error(await r.text());
|
||||
const ct=r.headers.get("content-type")||""; return ct.includes("json")?r.json():r.text();
|
||||
}
|
||||
function toast(m){const t=document.getElementById("toast");t.textContent=m;t.classList.add("on");clearTimeout(t._);t._=setTimeout(()=>t.classList.remove("on"),3000)}
|
||||
function esc(s){return (s||"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))}
|
||||
async function loadStatus(){
|
||||
try{const s=await fetch(API+"/status").then(r=>r.json());
|
||||
document.getElementById("stats").innerHTML=`feeds <b>${s.feeds}</b> · eps <b>${s.episodes}</b> · local <b>${s.downloaded}</b> · queue <b>${s.queued}</b>`;
|
||||
const base=(s.public_base||location.origin);
|
||||
document.getElementById("share").innerHTML=`🔗 Share feed:<br><a href="${API}/share/feed.xml" target="_blank">${esc(base)}/api/v1/podcaster/share/feed.xml</a>`;
|
||||
}catch(e){}
|
||||
}
|
||||
async function loadFeeds(){
|
||||
let d; try{d=await api("/feeds")}catch(e){return}
|
||||
const el=document.getElementById("feeds");
|
||||
if(!d.feeds.length){el.innerHTML='<div class="muted" style="padding:12px">No feeds yet — add one above.</div>';return}
|
||||
el.innerHTML=d.feeds.map(f=>`<div class="feed ${SEL===f.id?'sel':''}" onclick="selFeed(${f.id})">
|
||||
<img src="${esc(f.image||'')}" onerror="this.style.visibility='hidden'">
|
||||
<div class="t"><b>${esc(f.title||f.url)}</b><span>${f.downloaded}/${f.episodes} local</span></div>
|
||||
<span class="ad ${f.auto_dl?'on':''}" title="auto-download ${f.auto_dl?'on':'off'}" onclick="event.stopPropagation();toggleAd(${f.id},${f.auto_dl?0:1})">⏬</span>
|
||||
<span class="x" onclick="event.stopPropagation();delFeed(${f.id})">✕</span></div>`).join("");
|
||||
}
|
||||
async function selFeed(id){SEL=id;loadFeeds();loadEps()}
|
||||
async function loadEps(){
|
||||
const el=document.getElementById("eps");const q=SEL?`?feed_id=${SEL}`:"";
|
||||
document.getElementById("title").textContent=SEL?"Episodes":"Latest episodes";
|
||||
let d; try{d=await api("/episodes"+q)}catch(e){return}
|
||||
if(!d.episodes.length){el.innerHTML='<div class="empty">No episodes.</div>';return}
|
||||
el.innerHTML=d.episodes.map(e=>{
|
||||
const date=e.pubdate?new Date(e.pubdate*1000).toLocaleDateString():"";
|
||||
let act;
|
||||
if(e.state==="done") act=`<audio controls preload="none" src="${API}/media/${e.id}"></audio>`;
|
||||
else if(e.state==="downloading"||e.state==="queued") act=`<span class="badge downloading">${e.state}</span><div class="bar"><i style="width:${e.progress||0}%"></i></div>`;
|
||||
else if(e.state==="error") act=`<span class="badge error" title="${esc(e.error)}">error</span><button onclick="dl(${e.id})">retry</button>`;
|
||||
else act=`<button onclick="dl(${e.id})">⬇ download</button>`;
|
||||
return `<div class="ep"><div class="meta"><b>${esc(e.title)}</b><span>${esc(e.feed_title)} · ${date} ${e.duration?'· '+esc(e.duration):''}</span></div><div class="act">${act}</div></div>`;
|
||||
}).join("");
|
||||
}
|
||||
async function addFeed(){const u=document.getElementById("feedUrl").value.trim();if(!u)return;
|
||||
const ad=document.getElementById("autodl").checked;
|
||||
try{const r=await api("/feeds",{method:"POST",body:JSON.stringify({url:u,auto_dl:ad})});
|
||||
document.getElementById("feedUrl").value="";
|
||||
toast(`Added ${r.title||u} (${r.episodes} eps${r.queued?', '+r.queued+' queued':''})`);loadAll()}
|
||||
catch(e){toast("Add failed: "+e.message.slice(0,60))}}
|
||||
async function toggleAd(id,on){try{const r=await api(`/feeds/${id}/autodl?on=${on?'true':'false'}`,{method:"POST"});
|
||||
toast(on?`auto-download on${r.queued?' · '+r.queued+' queued':''}`:"auto-download off");loadAll()}catch(e){toast("toggle failed")}}
|
||||
async function delFeed(id){if(!confirm("Remove this feed?"))return;await api("/feeds/"+id,{method:"DELETE"});if(SEL===id)SEL=null;loadAll()}
|
||||
async function dl(id){try{await api(`/episodes/${id}/download`,{method:"POST"});toast("Queued");loadEps()}catch(e){toast("Queue failed")}}
|
||||
async function importOpml(inp){const f=inp.files[0];if(!f)return;const opml=await f.text();
|
||||
try{const r=await api("/feeds/import-opml",{method:"POST",body:JSON.stringify({opml})});toast(`Imported ${r.added}/${r.total}`);loadAll()}catch(e){toast("OPML import failed")}inp.value=""}
|
||||
async function uploadZip(inp){const f=inp.files[0];if(!f)return;
|
||||
const title=prompt("Audiobook title:",f.name.replace(/\.zip$/i,""));if(title===null){inp.value="";return}
|
||||
toast("Uploading "+f.name+" …");
|
||||
try{const r=await api("/audiobook/upload?title="+encodeURIComponent(title),
|
||||
{method:"POST",headers:{"Content-Type":"application/zip"},body:f});
|
||||
toast(`Published "${r.title}" — ${r.tracks} tracks`);loadAll()}
|
||||
catch(e){toast("Upload failed: "+e.message.slice(0,60))}inp.value=""}
|
||||
async function openCfg(){const d=await api("/config").then(r=>r.config).catch(()=>null);
|
||||
const s=await fetch(API+"/status").then(r=>r.json()).catch(()=>({}));
|
||||
document.getElementById("svc").innerHTML=`service <b style="color:var(--matrix-green)">${s.service||'?'}</b> · worker ${s.worker?'on':'off'} · ${s.downloaded||0} local`;
|
||||
if(d){c_media.value=d.media_path||"";c_par.value=d.max_parallel||2;c_ref.value=d.refresh_minutes||60;c_pub.value=d.public_base||"";c_title.value=d.share_title||""}
|
||||
document.getElementById("cfg").showModal()}
|
||||
async function saveCfg(){try{await api("/config",{method:"POST",body:JSON.stringify({media_path:c_media.value,max_parallel:+c_par.value,refresh_minutes:+c_ref.value,public_base:c_pub.value,share_title:c_title.value})});toast("Saved — restart to apply");document.getElementById("cfg").close()}catch(e){toast("Save failed")}}
|
||||
function loadAll(){loadStatus();loadFeeds();loadEps()}
|
||||
loadAll();setInterval(()=>{loadStatus();loadEps()},4000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
107
packages/secubox-podcaster/www/podcaster/portal/index.html
Normal file
107
packages/secubox-podcaster/www/podcaster/portal/index.html
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<!DOCTYPE html>
|
||||
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
|
||||
<!-- SecuBox-Deb :: Podcaster PUBLIC portal (no auth) — CyberMind https://cybermind.fr -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Podcasts · SecuBox</title>
|
||||
<style>
|
||||
:root{
|
||||
--cosmos-black:#0a0a0f; --gold-hermetic:#c9a84c; --void-purple:#6e40c9;
|
||||
--cyber-cyan:#00d4ff; --matrix-green:#00ff41; --text-primary:#e8e6d9;
|
||||
--text-muted:#6b6b7a; --panel:#13131c; --line:#23232f;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:radial-gradient(1200px 600px at 50% -10%,#15101f,#0a0a0f 60%);
|
||||
color:var(--text-primary);font-family:'JetBrains Mono',ui-monospace,Menlo,monospace}
|
||||
header{max-width:1000px;margin:0 auto;padding:38px 20px 18px;text-align:center}
|
||||
header .em{font-size:42px}
|
||||
header h1{margin:8px 0 4px;font-size:30px;letter-spacing:2px;color:var(--gold-hermetic)}
|
||||
header p{color:var(--text-muted);margin:0}
|
||||
.subs{display:inline-flex;gap:10px;margin-top:16px;flex-wrap:wrap;justify-content:center}
|
||||
.subs a{color:var(--cyber-cyan);text-decoration:none;border:1px solid var(--cyber-cyan);
|
||||
padding:8px 14px;border-radius:24px;font-size:13px}
|
||||
.subs a:hover{background:rgba(0,212,255,.1)}
|
||||
.filters{max-width:1000px;margin:18px auto 0;padding:0 20px;display:flex;gap:8px;flex-wrap:wrap}
|
||||
.chip{font-size:12px;padding:6px 12px;border-radius:20px;border:1px solid var(--line);
|
||||
color:var(--text-muted);cursor:pointer}
|
||||
.chip.on{color:var(--void-purple);border-color:var(--void-purple);background:rgba(110,64,201,.12)}
|
||||
main{max-width:1000px;margin:18px auto 60px;padding:0 20px}
|
||||
.ep{display:flex;gap:14px;align-items:center;padding:14px;border:1px solid var(--line);
|
||||
border-radius:12px;margin-bottom:12px;background:var(--panel)}
|
||||
.ep .n{font-size:20px;color:var(--void-purple);width:30px;text-align:center}
|
||||
.ep .meta{flex:1;min-width:0}
|
||||
.ep .meta b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:15px}
|
||||
.ep .meta span{color:var(--text-muted);font-size:12px}
|
||||
audio{height:38px;max-width:300px}
|
||||
.ep a.dl{color:var(--matrix-green);text-decoration:none;border:1px solid var(--matrix-green);
|
||||
border-radius:8px;padding:7px 10px;font-size:13px;white-space:nowrap}
|
||||
.ep a.dl:hover{background:rgba(0,255,65,.1)}
|
||||
.zipbar{max-width:1000px;margin:14px auto 0;padding:0 20px}
|
||||
.zipbar a{color:var(--gold-hermetic);text-decoration:none;border:1px solid var(--gold-hermetic);
|
||||
border-radius:24px;padding:8px 16px;font-size:13px}
|
||||
.zipbar a:hover{background:rgba(201,168,76,.12)}
|
||||
.empty{text-align:center;color:var(--text-muted);padding:60px}
|
||||
footer{text-align:center;color:var(--text-muted);font-size:11px;padding:24px}
|
||||
footer a{color:var(--text-muted)}
|
||||
@media(max-width:680px){.ep{flex-wrap:wrap}audio{max-width:100%;width:100%}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="em">🎙️</div>
|
||||
<h1 id="title">Podcasts</h1>
|
||||
<p id="sub">Relayed locally by SecuBox · listen freely</p>
|
||||
<div class="subs">
|
||||
<a id="rss" href="/api/v1/podcaster/share/feed.xml" target="_blank">📡 Subscribe (RSS)</a>
|
||||
<a id="copy" href="#" onclick="copyRss(event)">🔗 Copy feed URL</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="filters" id="filters"></div>
|
||||
<div class="zipbar" id="zipbar"></div>
|
||||
<main><div id="list" class="empty">Loading…</div></main>
|
||||
<footer>SecuBox Podcaster · <a href="https://cybermind.fr" target="_blank">CyberMind</a></footer>
|
||||
|
||||
<script>
|
||||
const API="/api/v1/podcaster";
|
||||
let DATA={episodes:[],feeds:{}}, FILT=null;
|
||||
function esc(s){return (s||"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))}
|
||||
function rssUrl(){return location.origin+API+"/share/feed.xml"}
|
||||
function copyRss(e){e.preventDefault();navigator.clipboard.writeText(rssUrl()).then(()=>{
|
||||
const a=document.getElementById("copy");a.textContent="✓ Copied";setTimeout(()=>a.textContent="🔗 Copy feed URL",1800)})}
|
||||
async function load(){
|
||||
let d; try{d=await fetch(API+"/public/library").then(r=>r.json())}
|
||||
catch(e){document.getElementById("list").innerHTML='<div class="empty">Portal unavailable.</div>';return}
|
||||
DATA=d;
|
||||
document.getElementById("title").textContent=d.title||"Podcasts";
|
||||
document.getElementById("rss").href=API+"/share/feed.xml";
|
||||
const fl=document.getElementById("filters");
|
||||
const feeds=Object.keys(d.feeds||{});
|
||||
fl.innerHTML=(feeds.length>1?[`<span class="chip ${FILT===null?'on':''}" onclick="setF(null)">All</span>`]:[])
|
||||
.concat(feeds.map(f=>`<span class="chip ${FILT===f?'on':''}" onclick="setF('${esc(f).replace(/'/g,"")}')">${esc(f)} · ${d.feeds[f]}</span>`)).join("");
|
||||
render();
|
||||
}
|
||||
function setF(f){FILT=f;load._render?render():render()}
|
||||
function render(){
|
||||
const list=document.getElementById("list");
|
||||
let eps=DATA.episodes||[];
|
||||
if(FILT) eps=eps.filter(e=>e.feed===FILT);
|
||||
// ZIP-all button when a single feed is in view
|
||||
const zb=document.getElementById("zipbar");
|
||||
const fid=eps.length?eps[0].feed_id:null;
|
||||
zb.innerHTML=(FILT&&fid!=null)
|
||||
? `<a href="${API}/public/feed/${fid}/zip">⬇ Download all (ZIP) · ${esc(FILT)}</a>` : "";
|
||||
if(!eps.length){list.innerHTML='<div class="empty">No episodes published yet.</div>';return}
|
||||
list.innerHTML=eps.map((e,i)=>{
|
||||
const date=e.pubdate?new Date(e.pubdate*1000).toLocaleDateString():"";
|
||||
return `<div class="ep"><div class="n">${i+1}</div>
|
||||
<div class="meta"><b>${esc(e.title)}</b><span>${esc(e.feed)} · ${date} ${e.duration?'· '+esc(e.duration):''}</span></div>
|
||||
<audio controls preload="none" src="${API}/media/${e.id}"></audio>
|
||||
<a class="dl" href="${API}/media/${e.id}" download title="Download">⬇</a></div>`;
|
||||
}).join("");
|
||||
}
|
||||
load();setInterval(load,15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user