feat(e-pub): e-bog/lydbog fra et indsat link, med live-log, kø, timeout og /admin/epub
Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
This commit is contained in:
@@ -40,6 +40,25 @@ pr. person under `PORTAL_DATA/erindringer/` (persondata, aldrig i git).
|
||||
Røgtest uden OIDC: `PORTAL_DATA=/tmp/x python tests/erindringer_smoke.py` (kalder de rigtige
|
||||
API'er, koster lidt credits).
|
||||
|
||||
## E-bog / lydbog (`/e-pub`)
|
||||
|
||||
Ét felt: indsæt et link, få en `.epub` eller `.m4b` at hente. Selve arbejdet laves af
|
||||
medfølgende kommandolinjeværktøjer (`fetch.sh`, `run.sh`, `run-audiobook.sh`, `lib.sh`), som
|
||||
portalen kører i en frisk mappe pr. job (under `EPUB_SCRATCH`, standard `PORTAL_DATA/e-pub-scratch`)
|
||||
med et minimalt env (`PATH`, `TMP`, `RP_API_KEY`, `RP_BASE_URL`, `RP_REQUIRED`). stderr vises live
|
||||
(siden poller `/e-pub/status/<id>`). En færdig fil kan hentes én gang og slettes ellers efter
|
||||
`output_ttl_seconds`; jobs lever i hukommelsen, så en genstart/udrulning afbryder igangværende jobs,
|
||||
og scratch-mappen ryddes ved opstart.
|
||||
|
||||
- Adgang: admins, plus medlemmer af den Authentik-gruppe der står i `allowed_group` (tom = kun admins).
|
||||
- `/admin/epub`: adgangsgruppe, samtidige jobs, timeout, TTL, værktøjsmappe, "proxy påkrævet".
|
||||
Siden viser også, om værktøjer, programmer og `RP_API_KEY` mangler.
|
||||
- **Skal være installeret hvor portalen kører:** `bash`, `curl`, `jq`, `openssl`, `ffmpeg`
|
||||
(`apt install jq` m.fl.) og værktøjsfilerne i `tools_dir` (standard `/opt/portal/tools/e-pub`,
|
||||
uden for appmappen/git, læsbare for brugeren portalen kører som).
|
||||
- Plads: et job kan give op til ~300 MB (plus midlertidige filer), gange antal samtidige jobs.
|
||||
- Test uden OIDC og med et stub-værktøj: `python tests/epub_uvicorn.py`.
|
||||
|
||||
## Kør
|
||||
|
||||
```bash
|
||||
|
||||
@@ -81,6 +81,20 @@ YT_DEFAULTS = {
|
||||
"library_name": "YouTube",
|
||||
}
|
||||
|
||||
# ---- e-pub (ebog/lydbog fra en biblioteks-reference) ----
|
||||
EPUB_SETTINGS_PATH = DATA / "epub_settings.json"
|
||||
# Per-job-mapper (output + TMP) oprettes her og slettes efter hentning/udløb.
|
||||
EPUB_SCRATCH = Path(os.environ.get("EPUB_SCRATCH", str(DATA / "e-pub-scratch")))
|
||||
# Standardindstillinger - kan redigeres i /admin/epub (RP_API_KEY/RP_BASE_URL kommer fra env ovenfor):
|
||||
EPUB_DEFAULTS = {
|
||||
"max_concurrent_jobs": 2, # samtidige jobs; resten venter i kø
|
||||
"job_timeout_seconds": 1200, # et job dræbes efter så lang tid
|
||||
"output_ttl_seconds": 1800, # færdig fil slettes efter så lang tid, hvis den ikke er hentet
|
||||
"tools_dir": "/opt/portal/tools/e-pub", # mappen med fetch.sh m.fl.
|
||||
"rp_required": True, # job fejler (exit 4) frem for at gå direkte ud uden proxy-nøgle
|
||||
"allowed_group": "", # tom = kun admins må bruge /e-pub
|
||||
}
|
||||
|
||||
GROUP_ORDER = {n: i for i, n in enumerate(
|
||||
["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])}
|
||||
|
||||
@@ -107,6 +121,8 @@ ADMIN_MODULES = [
|
||||
"desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
|
||||
{"key": "yt", "title": "YouTube offline", "icon": "📺",
|
||||
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
|
||||
{"key": "epub", "title": "E-bøger & lydbøger", "icon": "📚",
|
||||
"desc": "/e-pub: hvem der må bruge den, samtidige jobs, timeout og hvor længe filer ligger."},
|
||||
{"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏",
|
||||
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
||||
{"key": "podcast", "title": "Podcast", "icon": "🎙️",
|
||||
|
||||
@@ -17,3 +17,9 @@ ANTHROPIC_WORKSPACE_ID= # kun for bruger-nøgler (sk-ant-usr…), ell
|
||||
ERINDRINGER_MODEL=claude-sonnet-5
|
||||
ELEVEN_API_KEY=__xi-…__
|
||||
ERINDRINGER_VOICE=tJDFCHyviItsYF1qqToS
|
||||
# /e-pub: per-job-mapper (output + TMP). Valgfri, standard PORTAL_DATA/e-pub-scratch.
|
||||
# Bruger RP_API_KEY/RP_BASE_URL (random-proxy) fra samme fil. Kræver bash curl jq openssl ffmpeg
|
||||
# + værktøjerne i tools_dir (sættes i /admin/epub, standard /opt/portal/tools/e-pub).
|
||||
#EPUB_SCRATCH=/opt/portal/data/e-pub-scratch
|
||||
RP_BASE_URL=https://rp.weircon.dk
|
||||
RP_API_KEY=__random-proxy-noegle__
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""e-pub: en indsat biblioteks-reference → .epub eller .m4b til download.
|
||||
|
||||
Selve arbejdet laves af værktøjerne i settings["tools_dir"] (fetch.sh m.fl.); portalen
|
||||
kører dem bare: frisk per-job-mappe som CWD, rent env, stderr som live-log, timeout
|
||||
og en semafor fra indstillingerne. Jobs lever i hukommelsen; en genstart dræber
|
||||
igangværende jobs, og scratch-roden ryddes ved opstart.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
import config
|
||||
import store
|
||||
|
||||
MAX_INPUT = 2048 # tegn i det indsatte link
|
||||
MAX_AKTIVE_PR_BRUGER = 3 # i kø + kørende
|
||||
LOG_LINJER = 300
|
||||
|
||||
TILSTANDE_AKTIVE = ("koe", "koerer")
|
||||
UDFALD = {
|
||||
2: "Kunne ikke læse linket.",
|
||||
3: "Ikke tilgængelig (udløbet?).",
|
||||
4: "Serverfejl: proxy påkrævet, men ikke sat op. Sig det til en administrator.",
|
||||
}
|
||||
CONTENT_TYPES = {".epub": "application/epub+zip", ".m4b": "audio/mp4"}
|
||||
KRAEVEDE_PROGRAMMER = ("bash", "curl", "jq", "openssl", "ffmpeg")
|
||||
KRAEVEDE_FILER = ("fetch.sh", "run.sh", "run-audiobook.sh", "lib.sh")
|
||||
PATH = "/usr/local/bin:/usr/bin:/bin"
|
||||
|
||||
_jobs: dict[str, dict] = {}
|
||||
_sem: tuple[int, asyncio.Semaphore] | None = None
|
||||
|
||||
# Rester fra før en genstart kan ingen længere hente: væk med dem.
|
||||
shutil.rmtree(config.EPUB_SCRATCH, ignore_errors=True)
|
||||
config.EPUB_SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------- indstillinger ----------------
|
||||
def get_settings() -> dict:
|
||||
return {**config.EPUB_DEFAULTS, **(store.load_json(config.EPUB_SETTINGS_PATH, config.EPUB_DEFAULTS) or {})}
|
||||
|
||||
|
||||
def save_settings(patch: dict) -> dict:
|
||||
s = get_settings()
|
||||
s.update(patch)
|
||||
config.EPUB_SETTINGS_PATH.write_text(json.dumps(s, ensure_ascii=False, indent=2), "utf-8")
|
||||
return s
|
||||
|
||||
|
||||
def har_adgang(user, admin: bool) -> bool:
|
||||
if admin:
|
||||
return True
|
||||
gruppe = (get_settings().get("allowed_group") or "").strip()
|
||||
return bool(user and gruppe and gruppe in (user.get("groups") or []))
|
||||
|
||||
|
||||
def tjek_opsaetning(s: dict | None = None) -> dict:
|
||||
"""Til admin-siden: mangler der værktøjer eller programmer hvor portalen kører?"""
|
||||
s = s or get_settings()
|
||||
d = Path(s["tools_dir"])
|
||||
return {
|
||||
"mangler_filer": [f for f in KRAEVEDE_FILER if not (d / f).is_file()],
|
||||
"mangler_programmer": [p for p in KRAEVEDE_PROGRAMMER if not shutil.which(p, path=PATH)],
|
||||
"rp_noegle": bool(config.RP_API_KEY),
|
||||
}
|
||||
|
||||
|
||||
# ---------------- jobs ----------------
|
||||
def _semafor(n: int) -> asyncio.Semaphore:
|
||||
# Ny semafor når indstillingen ændres; jobs der allerede venter på den gamle bliver der.
|
||||
global _sem
|
||||
if _sem is None or _sem[0] != n:
|
||||
_sem = (n, asyncio.Semaphore(n))
|
||||
return _sem[1]
|
||||
|
||||
|
||||
def valider_input(tekst: str) -> str | None:
|
||||
"""Returnerer fejltekst eller None. Strengen sendes ellers ordret videre."""
|
||||
if not tekst:
|
||||
return "Indsæt et link."
|
||||
if len(tekst) > MAX_INPUT:
|
||||
return "Linket er for langt."
|
||||
if any(ord(c) < 32 or ord(c) == 127 for c in tekst) or tekst.startswith("-"):
|
||||
return UDFALD[2]
|
||||
return None
|
||||
|
||||
|
||||
def opret(user: str, tekst: str) -> tuple[dict | None, str | None]:
|
||||
aktive = sum(1 for j in _jobs.values() if j["ejer"] == user and j["tilstand"] in TILSTANDE_AKTIVE)
|
||||
if aktive >= MAX_AKTIVE_PR_BRUGER:
|
||||
return None, f"Du har allerede {aktive} i gang. Vent til et af dem er færdigt."
|
||||
jid = secrets.token_urlsafe(12)
|
||||
job = {"id": jid, "ejer": user, "input": tekst, "tilstand": "koe", "log": deque(maxlen=LOG_LINJER),
|
||||
"besked": "", "fil": None, "filnavn": "", "stoerrelse": 0,
|
||||
"oprettet": time.time(), "startet": None, "slut": None, "hentet": False}
|
||||
_jobs[jid] = job
|
||||
return job, None
|
||||
|
||||
|
||||
def job(jid: str) -> dict | None:
|
||||
return _jobs.get(jid)
|
||||
|
||||
|
||||
def brugerens_jobs(user: str) -> list[dict]:
|
||||
return sorted((j for j in _jobs.values() if j["ejer"] == user), key=lambda j: -j["oprettet"])
|
||||
|
||||
|
||||
def alle_jobs() -> list[dict]:
|
||||
return sorted(_jobs.values(), key=lambda j: -j["oprettet"])
|
||||
|
||||
|
||||
def status(j: dict) -> dict:
|
||||
return {"id": j["id"], "tilstand": j["tilstand"], "besked": j["besked"], "log": list(j["log"])[-80:],
|
||||
"filnavn": j["filnavn"], "stoerrelse": j["stoerrelse"], "oprettet": j["oprettet"],
|
||||
"startet": j["startet"], "slut": j["slut"]}
|
||||
|
||||
|
||||
def _mappe(j: dict) -> Path:
|
||||
return config.EPUB_SCRATCH / j["id"]
|
||||
|
||||
|
||||
def ryd(j: dict, tilstand: str = "udloebet") -> None:
|
||||
shutil.rmtree(_mappe(j), ignore_errors=True)
|
||||
if j["tilstand"] == "faerdig":
|
||||
j["tilstand"] = tilstand
|
||||
j["fil"] = None
|
||||
|
||||
|
||||
def _log(j: dict, linje: str) -> None:
|
||||
linje = linje.rstrip()
|
||||
if not linje:
|
||||
return
|
||||
if config.RP_API_KEY:
|
||||
linje = linje.replace(config.RP_API_KEY, "***")
|
||||
j["log"].append(linje[:500])
|
||||
|
||||
|
||||
async def _laes_stderr(stream: asyncio.StreamReader, j: dict) -> None:
|
||||
# Delt på både \n og \r, så fremdriftslinjer der overskriver sig selv også kommer med.
|
||||
rest = ""
|
||||
while True:
|
||||
bid = await stream.read(4096)
|
||||
if not bid:
|
||||
break
|
||||
dele = re.split(r"[\r\n]", rest + bid.decode("utf-8", "replace"))
|
||||
rest = dele.pop()
|
||||
for d in dele:
|
||||
_log(j, d)
|
||||
if len(rest) > 4096:
|
||||
_log(j, rest)
|
||||
rest = ""
|
||||
_log(j, rest)
|
||||
|
||||
|
||||
def _find_fil(ud: Path, log: list[str]) -> Path | None:
|
||||
filer = [p for p in ud.iterdir() if p.is_file() and p.suffix.lower() in CONTENT_TYPES]
|
||||
if len(filer) == 1:
|
||||
return filer[0]
|
||||
# Flere/ingen i mappen: fald tilbage på den sidste "Wrote <sti> (...)"-linje.
|
||||
for linje in reversed(log):
|
||||
m = re.match(r"Wrote (/.+?) \(", linje)
|
||||
if m:
|
||||
p = Path(m.group(1))
|
||||
if p.is_file() and p.parent == ud and p.suffix.lower() in CONTENT_TYPES:
|
||||
return p
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
async def koer(jid: str) -> None:
|
||||
"""Køres i baggrunden: vent på plads, kør værktøjet, ryd op efter TTL."""
|
||||
j = _jobs[jid]
|
||||
s = get_settings()
|
||||
try:
|
||||
async with _semafor(int(s["max_concurrent_jobs"])):
|
||||
s = get_settings()
|
||||
await _koer_vaerktoej(j, s)
|
||||
except Exception as e: # sidste skanse: et job må aldrig hænge i "koerer"
|
||||
j["tilstand"], j["besked"] = "fejl", f"Uventet fejl: {e}"
|
||||
finally:
|
||||
j["slut"] = j["slut"] or time.time()
|
||||
shutil.rmtree(_mappe(j) / "tmp", ignore_errors=True)
|
||||
if j["tilstand"] != "faerdig":
|
||||
shutil.rmtree(_mappe(j), ignore_errors=True)
|
||||
await asyncio.sleep(int(s["output_ttl_seconds"]))
|
||||
ryd(j)
|
||||
# Jobbet glemmes efter endnu en TTL, så "udløbet" kan ses et stykke tid.
|
||||
await asyncio.sleep(int(s["output_ttl_seconds"]))
|
||||
_jobs.pop(jid, None)
|
||||
|
||||
|
||||
async def _koer_vaerktoej(j: dict, s: dict) -> None:
|
||||
fetch = Path(s["tools_dir"]) / "fetch.sh"
|
||||
if not fetch.is_file():
|
||||
j["tilstand"], j["besked"] = "fejl", "Serverfejl: værktøjet er ikke installeret."
|
||||
return
|
||||
ud, tmp = _mappe(j) / "ud", _mappe(j) / "tmp"
|
||||
ud.mkdir(parents=True)
|
||||
tmp.mkdir()
|
||||
env = {"PATH": PATH, "TMP": str(tmp), "RP_API_KEY": config.RP_API_KEY,
|
||||
"RP_BASE_URL": config.RP_BASE_URL, "RP_REQUIRED": "1" if s["rp_required"] else "0"}
|
||||
j["tilstand"], j["startet"] = "koerer", time.time()
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"bash", str(fetch), j["input"], cwd=ud, env=env,
|
||||
stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE, start_new_session=True)
|
||||
laeser = asyncio.create_task(_laes_stderr(proc.stderr, j))
|
||||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=int(s["job_timeout_seconds"]))
|
||||
except asyncio.TimeoutError:
|
||||
_draeb(proc)
|
||||
await proc.wait()
|
||||
rc = None
|
||||
except asyncio.CancelledError:
|
||||
_draeb(proc)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
await asyncio.wait_for(laeser, timeout=5)
|
||||
except Exception:
|
||||
laeser.cancel()
|
||||
j["slut"] = time.time()
|
||||
if rc is None:
|
||||
j["tilstand"], j["besked"] = "fejl", f"Tog for lang tid (over {int(s['job_timeout_seconds']) // 60} min) og blev stoppet."
|
||||
return
|
||||
if rc == 0:
|
||||
fil = _find_fil(ud, list(j["log"]))
|
||||
if fil:
|
||||
j.update(tilstand="faerdig", fil=str(fil), filnavn=fil.name, stoerrelse=fil.stat().st_size,
|
||||
besked="Klar til download.")
|
||||
return
|
||||
rc = -1
|
||||
sidste = " · ".join(list(j["log"])[-3:])
|
||||
j["tilstand"] = "fejl"
|
||||
j["besked"] = UDFALD.get(rc) or ("Noget gik galt." + (f" ({sidste})" if sidste else ""))
|
||||
|
||||
|
||||
def _draeb(proc) -> None:
|
||||
# Hele procesgruppen: værktøjet starter selv underprocesser.
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def content_type(filnavn: str) -> str:
|
||||
return CONTENT_TYPES.get(Path(filnavn).suffix.lower(), "application/octet-stream")
|
||||
|
||||
|
||||
def human(n: int) -> str:
|
||||
for enhed in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.0f} {enhed}" if enhed == "B" else f"{n:.1f} {enhed}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
@@ -2,6 +2,7 @@
|
||||
Forretningslogik ligger i moduler: config, cache, store, services, authentik."""
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from starlette.background import BackgroundTask
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -28,6 +29,7 @@ import secretdrop
|
||||
import claudelogin
|
||||
import podcast
|
||||
import erindringer
|
||||
import epub
|
||||
|
||||
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
||||
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
||||
@@ -1148,6 +1150,112 @@ async def admin_access_save(request: Request, slug: str):
|
||||
return RedirectResponse(f"/admin/access?saved=Adgang til '{app_['name']}' gemt — {detail}", status_code=303)
|
||||
|
||||
|
||||
# ---------------- e-pub (ebog/lydbog) ----------------
|
||||
def require_epub(request: Request):
|
||||
"""Logget ind + admin eller medlem af indstillingens allowed_group. Uden login: login og tilbage."""
|
||||
user = current_user(request)
|
||||
if not user and request.method == "GET":
|
||||
nxt = request.url.path + (f"?{request.url.query}" if request.url.query else "")
|
||||
raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
|
||||
if not user:
|
||||
raise HTTPException(401, "Log ind først")
|
||||
if not epub.har_adgang(user, is_admin(user)):
|
||||
raise HTTPException(403, "Du har ikke adgang til denne side")
|
||||
return user
|
||||
|
||||
|
||||
def _epub_job(request: Request, jid: str) -> dict:
|
||||
user = require_epub(request)
|
||||
j = epub.job(jid)
|
||||
if not j or (j["ejer"] != user["username"] and not is_admin(user)):
|
||||
raise HTTPException(404, "Ukendt job")
|
||||
return j
|
||||
|
||||
|
||||
@app.get("/e-pub", response_class=HTMLResponse)
|
||||
async def epub_side(request: Request, saved: str = ""):
|
||||
user = require_epub(request)
|
||||
return templates.TemplateResponse(request, "epub.html", {
|
||||
"request": request, "user": user, "is_admin": is_admin(user), "saved": saved,
|
||||
"jobs": [epub.status(j) for j in epub.brugerens_jobs(user["username"])]})
|
||||
|
||||
|
||||
@app.post("/e-pub/start")
|
||||
async def epub_start(request: Request):
|
||||
user = require_epub(request)
|
||||
vil_json = "application/json" in request.headers.get("accept", "")
|
||||
try:
|
||||
laengde = int(request.headers.get("content-length") or 0)
|
||||
except ValueError:
|
||||
laengde = 0
|
||||
if laengde > 16384:
|
||||
raise HTTPException(413, "For stor forespørgsel")
|
||||
f = await request.form()
|
||||
tekst = str(f.get("link") or "").strip()
|
||||
fejl = epub.valider_input(tekst)
|
||||
j = None
|
||||
if not fejl:
|
||||
j, fejl = epub.opret(user["username"], tekst)
|
||||
if fejl:
|
||||
if vil_json:
|
||||
return JSONResponse({"error": fejl}, status_code=400)
|
||||
return RedirectResponse("/e-pub?saved=" + urllib.parse.quote(fejl), status_code=303)
|
||||
_i_baggrunden(epub.koer(j["id"]))
|
||||
if vil_json:
|
||||
return JSONResponse(epub.status(j))
|
||||
return RedirectResponse("/e-pub?saved=" + urllib.parse.quote("Startet"), status_code=303)
|
||||
|
||||
|
||||
@app.get("/e-pub/status/{jid}")
|
||||
async def epub_status(request: Request, jid: str):
|
||||
return JSONResponse(epub.status(_epub_job(request, jid)), headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.get("/e-pub/hent/{jid}")
|
||||
async def epub_hent(request: Request, jid: str):
|
||||
j = _epub_job(request, jid)
|
||||
if j["tilstand"] != "faerdig" or not j["fil"]:
|
||||
raise HTTPException(410, "Filen er her ikke længere")
|
||||
# Én hentning: filen og jobmappen slettes, når svaret er sendt (ellers ved TTL).
|
||||
return FileResponse(j["fil"], filename=j["filnavn"], media_type=epub.content_type(j["filnavn"]),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
background=BackgroundTask(epub.ryd, j, "hentet"))
|
||||
|
||||
|
||||
@app.get("/admin/epub", response_class=HTMLResponse)
|
||||
async def admin_epub(request: Request, saved: str = ""):
|
||||
user = require_admin(request)
|
||||
s = epub.get_settings()
|
||||
return templates.TemplateResponse(request, "admin_epub.html", {
|
||||
"request": request, "user": user, "is_admin": True, "saved": saved, "settings": s,
|
||||
"tjek": epub.tjek_opsaetning(s), "jobs": [{**epub.status(j), "ejer": j["ejer"]} for j in epub.alle_jobs()],
|
||||
"human": epub.human})
|
||||
|
||||
|
||||
@app.post("/admin/epub")
|
||||
async def admin_epub_save(request: Request):
|
||||
require_admin(request)
|
||||
f = await request.form()
|
||||
|
||||
def num(name, lo, hi):
|
||||
try:
|
||||
return max(lo, min(hi, int(f.get(name))))
|
||||
except (TypeError, ValueError):
|
||||
return config.EPUB_DEFAULTS[name]
|
||||
tools_dir = str(f.get("tools_dir") or "").strip()
|
||||
if not tools_dir.startswith("/"):
|
||||
return RedirectResponse("/admin/epub?saved=" + urllib.parse.quote("Afvist: værktøjsmappen skal være en absolut sti"), status_code=303)
|
||||
epub.save_settings({
|
||||
"max_concurrent_jobs": num("max_concurrent_jobs", 1, 8),
|
||||
"job_timeout_seconds": num("job_timeout_seconds", 60, 3600),
|
||||
"output_ttl_seconds": num("output_ttl_seconds", 60, 86400),
|
||||
"tools_dir": tools_dir.rstrip("/") or "/",
|
||||
"rp_required": f.get("rp_required") == "on",
|
||||
"allowed_group": str(f.get("allowed_group") or "").strip()[:150],
|
||||
})
|
||||
return RedirectResponse("/admin/epub?saved=Indstillinger gemt", status_code=303)
|
||||
|
||||
|
||||
# ---------------- medie-oprydning ----------------
|
||||
@app.get("/admin/media", response_class=HTMLResponse)
|
||||
async def admin_media(request: Request, saved: str = ""):
|
||||
@@ -1300,6 +1408,8 @@ async def search_index(request: Request):
|
||||
{"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"},
|
||||
{"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"},
|
||||
{"type": "page", "title": "MCP-nøgler", "url": "/admin/mcp-keys", "extra": "mcp api nøgler token claude"}]
|
||||
if epub.har_adgang(user, is_admin(user)):
|
||||
items.append({"type": "page", "title": "E-bog / lydbog", "url": "/e-pub", "extra": "epub m4b bog lydbog bibliotek"})
|
||||
try:
|
||||
for a in await ak.fetch_user_apps(user["username"]):
|
||||
items.append({"type": "app", "title": a["name"], "url": a["url"],
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — E-bøger & lydbøger{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin" class="wt-muted">← Admin</a></p>
|
||||
<h1>E-bøger & lydbøger</h1>
|
||||
<p class="wt-lead">Indstillinger for <a href="/e-pub">/e-pub</a>. Proxy-nøglen styres i <code>/etc/portal.env</code> og vises ikke her.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
{% if tjek.mangler_filer or tjek.mangler_programmer or (settings.rp_required and not tjek.rp_noegle) %}
|
||||
<div class="err">
|
||||
{% if tjek.mangler_filer %}<p>Mangler i <code>{{ settings.tools_dir }}</code>: {{ tjek.mangler_filer | join(", ") }}</p>{% endif %}
|
||||
{% if tjek.mangler_programmer %}<p>Mangler på serveren: {{ tjek.mangler_programmer | join(", ") }}</p>{% endif %}
|
||||
{% if settings.rp_required and not tjek.rp_noegle %}<p>Proxy er påkrævet, men <code>RP_API_KEY</code> er ikke sat: alle jobs fejler.</p>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="wt-muted">✅ Værktøjer, programmer og proxy-nøgle er på plads.</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/epub" style="margin-top:1rem;max-width:40rem;display:grid;gap:.8rem">
|
||||
<label>Hvem må bruge siden (Authentik-gruppe; tom = kun admins)
|
||||
<input class="wt-input" name="allowed_group" value="{{ settings.allowed_group }}"></label>
|
||||
<label>Samtidige jobs
|
||||
<input class="wt-input" type="number" name="max_concurrent_jobs" min="1" max="8" value="{{ settings.max_concurrent_jobs }}"></label>
|
||||
<label>Timeout pr. job (sekunder)
|
||||
<input class="wt-input" type="number" name="job_timeout_seconds" min="60" max="3600" value="{{ settings.job_timeout_seconds }}"></label>
|
||||
<label>Færdige filer slettes efter (sekunder, hvis ikke hentet)
|
||||
<input class="wt-input" type="number" name="output_ttl_seconds" min="60" max="86400" value="{{ settings.output_ttl_seconds }}"></label>
|
||||
<label>Værktøjsmappe
|
||||
<input class="wt-input" name="tools_dir" value="{{ settings.tools_dir }}" required></label>
|
||||
<label><input type="checkbox" name="rp_required" {% if settings.rp_required %}checked{% endif %}>
|
||||
Proxy påkrævet (job fejler i stedet for at gå direkte ud)</label>
|
||||
<p><button class="wt-btn wt-btn--primary" type="submit">Gem</button></p>
|
||||
</form>
|
||||
|
||||
<h2>Jobs lige nu</h2>
|
||||
{% if jobs %}
|
||||
<table>
|
||||
<thead><tr><th>Bruger</th><th>Tilstand</th><th>Fil</th><th>Besked</th></tr></thead>
|
||||
<tbody>
|
||||
{% for j in jobs %}
|
||||
<tr><td>{{ j.ejer }}</td><td>{{ j.tilstand }}</td>
|
||||
<td>{% if j.filnavn %}{{ j.filnavn }} ({{ human(j.stoerrelse) }}){% endif %}</td><td>{{ j.besked }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}E-bog / lydbog{% endblock %}
|
||||
{% block body %}
|
||||
<h1>E-bog / lydbog</h1>
|
||||
<p class="wt-lead">Indsæt et link, så laver serveren en fil du kan hente: en e-bog (.epub) eller en lydbog (.m4b).
|
||||
Det kan tage fra få sekunder til et kvarter. Filen kan hentes én gang og slettes ellers af sig selv efter et stykke tid.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
<form id="ep-form" method="post" action="/e-pub/start" autocomplete="off"
|
||||
style="margin-top:1.2rem;display:flex;gap:.6rem;flex-wrap:wrap;align-items:center">
|
||||
<input class="wt-input" name="link" id="ep-link" required maxlength="2048" placeholder="Indsæt link"
|
||||
autocapitalize="off" autocorrect="off" spellcheck="false" style="flex:1 1 22rem;min-width:0">
|
||||
<button class="wt-btn wt-btn--primary" type="submit" id="ep-knap">Hent</button>
|
||||
</form>
|
||||
<p id="ep-fejl" class="err" hidden></p>
|
||||
|
||||
<div id="ep-jobs" style="margin-top:1.4rem;display:grid;gap:1rem"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var TEKST = { koe: "⏳ I kø", koerer: "⚙️ Arbejder…", faerdig: "✅ Færdig", fejl: "⚠️ Fejlede",
|
||||
hentet: "Hentet (slettet fra serveren)", udloebet: "Udløbet (slettet fra serveren)" };
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var timere = {};
|
||||
|
||||
function str(n) {
|
||||
var e = ["B", "KB", "MB", "GB"], i = 0;
|
||||
while (n >= 1024 && i < e.length - 1) { n /= 1024; i++; }
|
||||
return (i ? n.toFixed(1) : n) + " " + e[i];
|
||||
}
|
||||
|
||||
function kort(id) {
|
||||
var k = $("ep-" + id);
|
||||
if (k) return k;
|
||||
k = document.createElement("section");
|
||||
k.className = "wt-card"; k.id = "ep-" + id; k.style.padding = "1rem 1.2rem";
|
||||
k.innerHTML = '<p class="ep-tilstand" style="margin:0 0 .4rem;font-weight:600"></p>' +
|
||||
'<p class="ep-besked wt-muted" style="margin:0 0 .4rem"></p>' +
|
||||
'<p class="ep-hent" hidden style="margin:.4rem 0"><a class="wt-btn wt-btn--primary" href="/e-pub/hent/' + id + '">Hent fil</a></p>' +
|
||||
'<details><summary class="wt-muted" style="cursor:pointer">Log</summary>' +
|
||||
'<pre class="ep-log" style="max-height:16rem;overflow:auto;white-space:pre-wrap;font-size:.85em;margin:.4rem 0 0"></pre></details>';
|
||||
$("ep-jobs").insertBefore(k, $("ep-jobs").firstChild);
|
||||
k.querySelector(".ep-hent a").addEventListener("click", function () {
|
||||
// Filen slettes efter én hentning: knappen forsvinder, og status hentes igen om lidt.
|
||||
setTimeout(function () { hent(id); }, 4000);
|
||||
});
|
||||
return k;
|
||||
}
|
||||
|
||||
function vis(st) {
|
||||
var k = kort(st.id);
|
||||
var t = TEKST[st.tilstand] || st.tilstand;
|
||||
if (st.tilstand === "faerdig" && st.filnavn) t += " · " + st.filnavn + " (" + str(st.stoerrelse) + ")";
|
||||
k.querySelector(".ep-tilstand").textContent = t;
|
||||
k.querySelector(".ep-besked").textContent = st.besked || "";
|
||||
k.querySelector(".ep-hent").hidden = st.tilstand !== "faerdig";
|
||||
var log = k.querySelector(".ep-log"), nederst = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
|
||||
log.textContent = (st.log || []).join("\n");
|
||||
if (nederst) log.scrollTop = log.scrollHeight;
|
||||
if (st.tilstand === "koerer" || st.tilstand === "koe") k.querySelector("details").open = true;
|
||||
planlaeg(st.id, st.tilstand === "koe" ? 3000 : st.tilstand === "koerer" ? 1500 : st.tilstand === "faerdig" ? 30000 : 0);
|
||||
}
|
||||
|
||||
function planlaeg(id, ms) {
|
||||
clearTimeout(timere[id]);
|
||||
if (ms) timere[id] = setTimeout(function () { hent(id); }, ms);
|
||||
}
|
||||
|
||||
function hent(id) {
|
||||
fetch("/e-pub/status/" + id, { credentials: "same-origin", cache: "no-store" })
|
||||
.then(function (r) {
|
||||
if (r.status === 404) { var k = $("ep-" + id); if (k) k.remove(); return null; }
|
||||
return r.json();
|
||||
})
|
||||
.then(function (st) { if (st) vis(st); })
|
||||
.catch(function () { planlaeg(id, 5000); });
|
||||
}
|
||||
|
||||
$("ep-form").addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
$("ep-knap").disabled = true;
|
||||
$("ep-fejl").hidden = true;
|
||||
fetch("/e-pub/start", { method: "POST", credentials: "same-origin", headers: { Accept: "application/json" },
|
||||
body: new FormData($("ep-form")) })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (st) {
|
||||
if (st.error) { $("ep-fejl").textContent = st.error; $("ep-fejl").hidden = false; return; }
|
||||
$("ep-link").value = "";
|
||||
vis(st);
|
||||
})
|
||||
.catch(function () { $("ep-fejl").textContent = "Kunne ikke nå serveren."; $("ep-fejl").hidden = false; })
|
||||
.finally(function () { $("ep-knap").disabled = false; });
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!document.hidden) Object.keys(timere).forEach(hent);
|
||||
});
|
||||
({{ jobs | tojson }}).reverse().forEach(vis);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,118 @@
|
||||
# Integrationstest af /e-pub under en RIGTIG uvicorn (baggrundsjobs + subprocess), med et
|
||||
# stub-værktøj i stedet for det rigtige og current_user patchet (ingen OIDC). Kør fra repo-roden:
|
||||
# python tests/epub_uvicorn.py
|
||||
import json, os, pathlib, sys, tempfile, threading, time
|
||||
|
||||
ROD = pathlib.Path(tempfile.mkdtemp(prefix="epub-test-"))
|
||||
os.environ.update(PORTAL_DATA=str(ROD / "data"), AUTHENTIK_BASE="https://x.invalid", OIDC_CLIENT_ID="x",
|
||||
OIDC_CLIENT_SECRET="x", AUTHENTIK_API_TOKEN="x", SESSION_SECRET="x", RP_API_KEY="hemmelig-noegle")
|
||||
VAERKTOEJ = ROD / "tools"
|
||||
VAERKTOEJ.mkdir()
|
||||
for f in ("run.sh", "run-audiobook.sh", "lib.sh"):
|
||||
(VAERKTOEJ / f).write_text("#!/bin/bash\n")
|
||||
(VAERKTOEJ / "fetch.sh").write_text(r'''#!/bin/bash
|
||||
echo "trin 1: $1" >&2
|
||||
case "$1" in
|
||||
ok-epub*) printf 'PK' > "$PWD/Min Bog.epub"; echo "Wrote $PWD/Min Bog.epub (2 B)" >&2 ;;
|
||||
ok-m4b) head -c 1000 /dev/zero > "$PWD/Lydbog æøå.m4b"; printf 'trin 2\rtrin 3\n' >&2; echo "Wrote $PWD/Lydbog æøå.m4b (1000 B)" >&2 ;;
|
||||
env) env | sort >&2; [ -d "$TMP" ] && [ -z "$(ls -A)" ] && touch "$PWD/env.epub" ;;
|
||||
bad) exit 2 ;;
|
||||
gone) exit 3 ;;
|
||||
proxy) exit 4 ;;
|
||||
slow) sleep 31.7 & wait ;;
|
||||
*) echo "noget andet: $RP_API_KEY" >&2; exit 7 ;;
|
||||
esac
|
||||
''')
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
os.chdir(pathlib.Path(__file__).resolve().parents[1])
|
||||
import config # noqa: E402
|
||||
config.EPUB_SETTINGS_PATH.write_text(json.dumps({"tools_dir": str(VAERKTOEJ), "job_timeout_seconds": 3,
|
||||
"output_ttl_seconds": 4, "max_concurrent_jobs": 1}))
|
||||
import main # noqa: E402
|
||||
import uvicorn # noqa: E402
|
||||
import httpx # noqa: E402
|
||||
|
||||
BRUGER = {"name": "Test", "username": "test", "groups": []}
|
||||
main.current_user = lambda request: BRUGER
|
||||
srv = uvicorn.Server(uvicorn.Config(main.app, port=8019, log_level="warning"))
|
||||
threading.Thread(target=srv.run, daemon=True).start()
|
||||
while not srv.started:
|
||||
time.sleep(0.1)
|
||||
c = httpx.Client(base_url="http://127.0.0.1:8019", headers={"Accept": "application/json"}, timeout=30)
|
||||
|
||||
|
||||
def tjek(navn, ok):
|
||||
print(("OK " if ok else "FEJL ") + navn)
|
||||
if not ok:
|
||||
global fejl
|
||||
fejl += 1
|
||||
|
||||
|
||||
def vent(jid, slut=("faerdig", "fejl")):
|
||||
for _ in range(100):
|
||||
st = c.get(f"/e-pub/status/{jid}").json()
|
||||
if st["tilstand"] in slut:
|
||||
return st
|
||||
time.sleep(0.2)
|
||||
return st
|
||||
|
||||
|
||||
fejl = 0
|
||||
tjek("ingen adgang uden gruppe", c.get("/e-pub").status_code == 403)
|
||||
BRUGER["groups"] = ["authentik Admins"]
|
||||
tjek("admin har adgang", c.get("/e-pub").status_code == 200)
|
||||
tjek("admin-side", c.get("/admin/epub").status_code == 200)
|
||||
tjek("tom input afvist", c.post("/e-pub/start", data={"link": " "}).status_code == 400)
|
||||
tjek("styretegn afvist", c.post("/e-pub/start", data={"link": "a\nb"}).status_code == 400)
|
||||
tjek("for stor body afvist", c.post("/e-pub/start", data={"link": "x" * 20000}).status_code == 413)
|
||||
|
||||
a = c.post("/e-pub/start", data={"link": "ok-epub 1"}).json()
|
||||
b = c.post("/e-pub/start", data={"link": "ok-m4b"}).json()
|
||||
tjek("semafor: andet job venter i kø", c.get(f"/e-pub/status/{b['id']}").json()["tilstand"] == "koe")
|
||||
sa = vent(a["id"])
|
||||
tjek("epub færdig", sa["tilstand"] == "faerdig" and sa["filnavn"] == "Min Bog.epub")
|
||||
r = c.get(f"/e-pub/hent/{a['id']}")
|
||||
tjek("epub content-type", r.headers["content-type"] == "application/epub+zip" and r.content == b"PK")
|
||||
tjek("filnavn i Content-Disposition", "Min%20Bog.epub" in r.headers["content-disposition"] or "Min Bog.epub" in r.headers["content-disposition"])
|
||||
time.sleep(0.3)
|
||||
tjek("slettet efter hentning", c.get(f"/e-pub/status/{a['id']}").json()["tilstand"] == "hentet"
|
||||
and not (config.EPUB_SCRATCH / a["id"]).exists())
|
||||
tjek("anden hentning = 410", c.get(f"/e-pub/hent/{a['id']}").status_code == 410)
|
||||
sb = vent(b["id"])
|
||||
tjek("m4b færdig, \\r-linjer splittet", sb["filnavn"] == "Lydbog æøå.m4b" and "trin 3" in sb["log"])
|
||||
tjek("m4b content-type", c.get(f"/e-pub/hent/{b['id']}").headers["content-type"] == "audio/mp4")
|
||||
|
||||
e = vent(c.post("/e-pub/start", data={"link": "env"}).json()["id"])
|
||||
navne = {l.split("=", 1)[0] for l in e["log"] if "=" in l and not l.startswith("trin")}
|
||||
tjek("rent env (kun PATH/TMP/RP_* + bash' egne)", navne <= {"PATH", "TMP", "RP_API_KEY", "RP_BASE_URL", "RP_REQUIRED", "PWD", "SHLVL", "_"})
|
||||
tjek("RP_REQUIRED=1, nøgle maskeret, frisk mappe", "RP_REQUIRED=1" in e["log"] and "RP_API_KEY=***" in e["log"] and e["tilstand"] == "faerdig")
|
||||
|
||||
for inp, forventet in (("bad", "Kunne ikke læse linket."), ("gone", "Ikke tilgængelig (udløbet?)."), ("proxy", "Serverfejl: proxy")):
|
||||
s = vent(c.post("/e-pub/start", data={"link": inp}).json()["id"])
|
||||
tjek(f"exit for {inp}", s["tilstand"] == "fejl" and s["besked"].startswith(forventet))
|
||||
s = vent(c.post("/e-pub/start", data={"link": "andet"}).json()["id"])
|
||||
tjek("generisk fejl m. sidste linjer, ingen nøgle", s["besked"].startswith("Noget gik galt.") and "hemmelig" not in s["besked"])
|
||||
t0 = time.time()
|
||||
s = vent(c.post("/e-pub/start", data={"link": "slow"}).json()["id"])
|
||||
tjek("timeout dræber jobbet", s["tilstand"] == "fejl" and "lang tid" in s["besked"] and time.time() - t0 < 8)
|
||||
tjek("underprocesser dræbt med", os.system("pgrep -f '^sleep 31.7$' >/dev/null") != 0)
|
||||
|
||||
BRUGER.update(username="anden", groups=["Bogklub"])
|
||||
tjek("andre brugeres job = 403 uden gruppe", c.get(f"/e-pub/status/{b['id']}").status_code == 403)
|
||||
config.EPUB_SETTINGS_PATH.write_text(json.dumps({**json.loads(config.EPUB_SETTINGS_PATH.read_text()), "allowed_group": "Bogklub"}))
|
||||
tjek("gruppe giver adgang", c.get("/e-pub").status_code == 200)
|
||||
tjek("men ikke til andres job", c.get(f"/e-pub/status/{b['id']}").status_code == 404)
|
||||
tjek("og ikke admin", c.get("/admin/epub").status_code == 403)
|
||||
|
||||
BRUGER.update(username="test", groups=["authentik Admins"])
|
||||
u = vent(c.post("/e-pub/start", data={"link": "ok-epub 2"}).json()["id"])
|
||||
time.sleep(5)
|
||||
tjek("ikke-hentet fil udløber efter TTL", c.get(f"/e-pub/status/{u['id']}").json()["tilstand"] == "udloebet"
|
||||
and not (config.EPUB_SCRATCH / u["id"]).exists())
|
||||
tjek("admin gem validerer", c.post("/admin/epub", data={"tools_dir": "relativ"}, follow_redirects=False).headers["location"].startswith("/admin/epub?saved=Afvist"))
|
||||
c.post("/admin/epub", data={"tools_dir": "/x", "max_concurrent_jobs": "99", "job_timeout_seconds": "abc", "rp_required": "on"})
|
||||
s = json.loads(config.EPUB_SETTINGS_PATH.read_text())
|
||||
tjek("ints klemmes, ugyldige → standard", s["max_concurrent_jobs"] == 8 and s["job_timeout_seconds"] == 1200 and s["rp_required"] is True)
|
||||
print("ALT OK" if not fejl else f"{fejl} FEJL")
|
||||
srv.should_exit = True
|
||||
sys.exit(1 if fejl else 0)
|
||||
Reference in New Issue
Block a user