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
|
Røgtest uden OIDC: `PORTAL_DATA=/tmp/x python tests/erindringer_smoke.py` (kalder de rigtige
|
||||||
API'er, koster lidt credits).
|
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
|
## Kør
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ YT_DEFAULTS = {
|
|||||||
"library_name": "YouTube",
|
"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(
|
GROUP_ORDER = {n: i for i, n in enumerate(
|
||||||
["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])}
|
["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."},
|
"desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
|
||||||
{"key": "yt", "title": "YouTube offline", "icon": "📺",
|
{"key": "yt", "title": "YouTube offline", "icon": "📺",
|
||||||
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
|
"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": "🔏",
|
{"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏",
|
||||||
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
||||||
{"key": "podcast", "title": "Podcast", "icon": "🎙️",
|
{"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
|
ERINDRINGER_MODEL=claude-sonnet-5
|
||||||
ELEVEN_API_KEY=__xi-…__
|
ELEVEN_API_KEY=__xi-…__
|
||||||
ERINDRINGER_VOICE=tJDFCHyviItsYF1qqToS
|
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."""
|
Forretningslogik ligger i moduler: config, cache, store, services, authentik."""
|
||||||
from authlib.integrations.starlette_client import OAuth
|
from authlib.integrations.starlette_client import OAuth
|
||||||
from fastapi import FastAPI, Form, HTTPException, Request
|
from fastapi import FastAPI, Form, HTTPException, Request
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -28,6 +29,7 @@ import secretdrop
|
|||||||
import claudelogin
|
import claudelogin
|
||||||
import podcast
|
import podcast
|
||||||
import erindringer
|
import erindringer
|
||||||
|
import epub
|
||||||
|
|
||||||
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
||||||
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
# => 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)
|
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 ----------------
|
# ---------------- medie-oprydning ----------------
|
||||||
@app.get("/admin/media", response_class=HTMLResponse)
|
@app.get("/admin/media", response_class=HTMLResponse)
|
||||||
async def admin_media(request: Request, saved: str = ""):
|
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": "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": "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"}]
|
{"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:
|
try:
|
||||||
for a in await ak.fetch_user_apps(user["username"]):
|
for a in await ak.fetch_user_apps(user["username"]):
|
||||||
items.append({"type": "app", "title": a["name"], "url": a["url"],
|
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