From 04958419ae338dbe711f03a7f9135b8516466520 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 10 Oct 2026 20:19:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(e-pub):=20jobs=20overlever=20genstart=20(j?= =?UTF-8?q?ob.json,=20slettes=20efter=20et=20d=C3=B8gn)=20og=20sendes=20au?= =?UTF-8?q?tomatisk=20til=20CWA/Audiobookshelf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj --- README.md | 8 ++-- config.py | 5 ++- epub.py | 95 +++++++++++++++++++++++++++++++++------ main.py | 19 ++++++-- templates/admin_epub.html | 6 ++- templates/epub.html | 2 +- tests/epub_uvicorn.py | 31 ++++++++++--- 7 files changed, 136 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1bb6ba6..149db17 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,11 @@ API'er, koster lidt credits). 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/`). 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. +(siden poller `/e-pub/status/`). Færdige `.epub` lægges automatisk i CWA's indbakke og `.m4b` i +Audiobookshelf (`Forfatter/Titel/`), når `auto_send` er slået til; ellers med en knap. Hvert job har en +`job.json` i sin mappe, så færdige filer overlever en genstart (jobs der var i gang, markeres som +afbrudt). Fil og job slettes `output_ttl_seconds` (standard et døgn) efter jobbet sluttede, eller filen +straks efter én download. - 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". diff --git a/config.py b/config.py index bc248a9..df34f6a 100644 --- a/config.py +++ b/config.py @@ -89,12 +89,15 @@ EPUB_SCRATCH = Path(os.environ.get("EPUB_SCRATCH", str(DATA / "e-pub-scratch"))) 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 + # Fil + job slettes så længe efter jobbet sluttede (også hen over genstarter). Et døgn: nok til + # at hente filen på en anden enhed senere, og bogen ligger i forvejen i CWA/Audiobookshelf. + "output_ttl_seconds": 86400, "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 "cwa_ingest_dir": "/media/cwa-ingest", # Calibre-Web Automateds indbakke (tom = ingen knap) "abs_dir": "/media/audiobooks", # Audiobookshelf-bibliotekets mappe (tom = ingen knap) + "auto_send": True, # færdige filer sendes selv til CWA/Audiobookshelf } GROUP_ORDER = {n: i for i, n in enumerate( diff --git a/epub.py b/epub.py index 8925973..28f064c 100644 --- a/epub.py +++ b/epub.py @@ -2,8 +2,9 @@ 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. +og en semafor fra indstillingerne. Hvert job har en job.json i sin mappe, så færdige +filer overlever en genstart; jobs der var i gang, markeres som afbrudt. Alt (fil og job) +slettes output_ttl_seconds efter jobbet sluttede, af fejer() der kører i baggrunden. """ import asyncio import json @@ -37,9 +38,6 @@ 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 ---------------- @@ -103,10 +101,12 @@ def opret(user: str, tekst: str) -> tuple[dict | None, str | None]: 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) + _mappe({"id": jid}).mkdir(parents=True) 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, "sendt": ""} _jobs[jid] = job + _gem(job) return job, None @@ -132,11 +132,63 @@ 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) +def _gem(j: dict) -> None: + d = _mappe(j) + if not d.is_dir(): + return + tmp = d / ".job.json.tmp" + tmp.write_text(json.dumps({**j, "log": list(j["log"])}, ensure_ascii=False), "utf-8") + os.replace(tmp, d / "job.json") + + +def _indlaes() -> None: + """Ved opstart: genindlæs jobs fra disken. Afbrudte jobs → fejl, rester uden job.json → væk.""" + config.EPUB_SCRATCH.mkdir(parents=True, exist_ok=True) + for d in config.EPUB_SCRATCH.iterdir(): + try: + j = json.loads((d / "job.json").read_text("utf-8")) + assert j["id"] == d.name + except Exception: + shutil.rmtree(d, ignore_errors=True) + continue + j["log"] = deque(j.get("log") or [], maxlen=LOG_LINJER) + shutil.rmtree(d / "tmp", ignore_errors=True) + if j["tilstand"] in TILSTANDE_AKTIVE: + j.update(tilstand="fejl", besked="Afbrudt, fordi serveren blev genstartet. Prøv igen.", + slut=time.time()) + shutil.rmtree(d / "ud", ignore_errors=True) + elif j["tilstand"] == "faerdig" and not (j.get("fil") and Path(j["fil"]).is_file()): + j.update(tilstand="udloebet", fil=None) + _jobs[j["id"]] = j + _gem(j) + + +def feje(nu: float | None = None) -> None: + """Slet jobs (mappe + fil) der sluttede for mere end output_ttl_seconds siden.""" + nu = nu or time.time() + ttl = int(get_settings()["output_ttl_seconds"]) + for j in list(_jobs.values()): + if j["tilstand"] not in TILSTANDE_AKTIVE and (j["slut"] or j["oprettet"]) + ttl < nu: + shutil.rmtree(_mappe(j), ignore_errors=True) + _jobs.pop(j["id"], None) + + +async def fejer() -> None: + while True: + try: + feje() + except Exception: + pass + await asyncio.sleep(max(1, min(60, int(get_settings()["output_ttl_seconds"]) // 4))) + + +def ryd(j: dict, tilstand: str = "hentet") -> None: + """Fjern den færdige fil (fx efter download); jobbet bliver stående til TTL.""" + shutil.rmtree(_mappe(j) / "ud", ignore_errors=True) if j["tilstand"] == "faerdig": j["tilstand"] = tilstand j["fil"] = None + _gem(j) def _log(j: dict, linje: str) -> None: @@ -181,7 +233,7 @@ def _find_fil(ud: Path, log: list[str]) -> Path | None: async def koer(jid: str) -> None: - """Køres i baggrunden: vent på plads, kør værktøjet, ryd op efter TTL.""" + """Køres i baggrunden: vent på plads, kør værktøjet, send evt. videre til biblioteket.""" j = _jobs[jid] s = get_settings() try: @@ -194,12 +246,12 @@ async def koer(jid: str) -> None: 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) + shutil.rmtree(_mappe(j) / "ud", ignore_errors=True) + _gem(j) + if s.get("auto_send") and kan_sende(j): + fejl = await asyncio.to_thread(send, j) + j["besked"] = fejl or f"Sendt automatisk til {j['sendt']}. Du kan også hente filen." + _gem(j) async def _koer_vaerktoej(j: dict, s: dict) -> None: @@ -213,6 +265,7 @@ async def _koer_vaerktoej(j: dict, s: dict) -> None: 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() + _gem(j) proc = await asyncio.create_subprocess_exec( "bash", str(fetch), j["input"], cwd=ud, env=env, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL, @@ -317,6 +370,7 @@ def send(j: dict) -> str | None: except OSError as e: return f"Kunne ikke sende til {navn}: {e.strerror or e}" j["sendt"] = navn + _gem(j) return None @@ -324,9 +378,22 @@ def content_type(filnavn: str) -> str: return CONTENT_TYPES.get(Path(filnavn).suffix.lower(), "application/octet-stream") +def varighed(sek: int) -> str: + if sek % 86400 == 0: + d = sek // 86400 + return "et døgn" if d == 1 else f"{d} døgn" + if sek >= 3600: + t = round(sek / 3600) + return "en time" if t == 1 else f"{t} timer" + return f"{max(1, sek // 60)} min" + + 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" + + +_indlaes() diff --git a/main.py b/main.py index d8550dd..2171f59 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware import asyncio +import contextlib import logging import re import time @@ -34,7 +35,15 @@ 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. mcp_app = mcp_gateway.gateway.http_app(path="/mcp", json_response=True) -app = FastAPI(title="Weircon Portal", lifespan=mcp_app.lifespan) + + +@contextlib.asynccontextmanager +async def _lifespan(a): + _i_baggrunden(epub.fejer()) # rydder udløbne /e-pub-jobs, også dem fra før en genstart + async with mcp_app.lifespan(a): + yield + +app = FastAPI(title="Weircon Portal", lifespan=_lifespan) for _mcp_route in mcp_app.routes: app.router.routes.append(_mcp_route) # route-extend copies routes but NOT middleware — carry FastMCP's auth/context @@ -1177,7 +1186,8 @@ 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"])]}) + "jobs": [epub.status(j) for j in epub.brugerens_jobs(user["username"])], + "ttl": epub.varighed(int(epub.get_settings()["output_ttl_seconds"]))}) @app.post("/e-pub/start") @@ -1219,7 +1229,7 @@ async def epub_hent(request: Request, jid: str): # É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")) + background=BackgroundTask(epub.ryd, j)) @app.post("/e-pub/send/{jid}") @@ -1256,9 +1266,10 @@ async def admin_epub_save(request: Request): 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), + "output_ttl_seconds": num("output_ttl_seconds", 60, 7 * 86400), "tools_dir": tools_dir.rstrip("/") or "/", "rp_required": f.get("rp_required") == "on", + "auto_send": f.get("auto_send") == "on", "allowed_group": str(f.get("allowed_group") or "").strip()[:150], "cwa_ingest_dir": str(f.get("cwa_ingest_dir") or "").strip().rstrip("/"), "abs_dir": str(f.get("abs_dir") or "").strip().rstrip("/"), diff --git a/templates/admin_epub.html b/templates/admin_epub.html index abef101..b7a374c 100644 --- a/templates/admin_epub.html +++ b/templates/admin_epub.html @@ -23,14 +23,16 @@ - + +

diff --git a/templates/epub.html b/templates/epub.html index ae0f782..ded4136 100644 --- a/templates/epub.html +++ b/templates/epub.html @@ -3,7 +3,7 @@ {% block body %}

E-bog / lydbog

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.

+ Det kan tage fra få sekunder til et kvarter. Færdige bøger lægges selv i CWA (e-bøger) og Audiobookshelf (lydbøger). Filen kan også hentes her én gang, og den slettes af sig selv efter {{ ttl }}.

{% if saved %}
{{ saved }}
{% endif %}