feat(e-pub): e-bog/lydbog fra et indsat link, med live-log, kø, timeout og /admin/epub
CI / check (push) Successful in 39s
CD / deploy (push) Successful in 20s

Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
This commit is contained in:
root
2026-10-10 11:17:16 +02:00
parent 6e5bb5eac1
commit 9a57e178b5
8 changed files with 681 additions and 0 deletions
+19
View File
@@ -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
+16
View File
@@ -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": "🎙️",
+6
View File
@@ -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__
+262
View File
@@ -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"
+110
View File
@@ -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"],
+49
View File
@@ -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 &amp; 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 %}
+101
View File
@@ -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 %}
+118
View File
@@ -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)