From 873500f5d490d9ae0141bf4263a736aa073d15aa Mon Sep 17 00:00:00 2001 From: root Date: Sat, 10 Oct 2026 11:49:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(e-pub):=20Tilf=C3=B8j=20til=20CWA=20(.epub?= =?UTF-8?q?)=20og=20Send=20til=20Audiobookshelf=20(.m4b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj --- config.py | 2 ++ epub.py | 74 +++++++++++++++++++++++++++++++++++++-- main.py | 14 ++++++-- templates/admin_epub.html | 4 +++ templates/epub.html | 24 +++++++++++-- tests/epub_uvicorn.py | 19 +++++++--- 6 files changed, 127 insertions(+), 10 deletions(-) diff --git a/config.py b/config.py index 67ce1b3..bc248a9 100644 --- a/config.py +++ b/config.py @@ -93,6 +93,8 @@ EPUB_DEFAULTS = { "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) } GROUP_ORDER = {n: i for i, n in enumerate( diff --git a/epub.py b/epub.py index 1eaed18..8925973 100644 --- a/epub.py +++ b/epub.py @@ -61,6 +61,10 @@ def har_adgang(user, admin: bool) -> bool: return bool(user and gruppe and gruppe in (user.get("groups") or [])) +def _skrivbar(d: str) -> bool: + return bool(d) and os.path.isdir(d) and os.access(d, os.W_OK | os.X_OK) + + 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() @@ -69,6 +73,8 @@ def tjek_opsaetning(s: dict | None = None) -> dict: "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), + "cwa_ok": _skrivbar(s.get("cwa_ingest_dir", "")), + "abs_ok": _skrivbar(s.get("abs_dir", "")), } @@ -99,7 +105,7 @@ def opret(user: str, tekst: str) -> tuple[dict | None, str | None]: 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} + "oprettet": time.time(), "startet": None, "slut": None, "hentet": False, "sendt": ""} _jobs[jid] = job return job, None @@ -119,7 +125,7 @@ def alle_jobs() -> list[dict]: 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"]} + "startet": j["startet"], "slut": j["slut"], "sendt": j["sendt"], "kan_sende": kan_sende(j)} def _mappe(j: dict) -> Path: @@ -250,6 +256,70 @@ def _draeb(proc) -> None: pass +# ---------------- videre til biblioteket ---------------- +MAAL = {".epub": ("cwa", "cwa_ingest_dir", "CWA"), ".m4b": ("abs", "abs_dir", "Audiobookshelf")} + + +def kan_sende(j: dict) -> str: + """"cwa"/"abs" hvis den færdige fil kan sendes videre (mappen findes og kan skrives), ellers "".""" + if j["tilstand"] != "faerdig" or j["sendt"] or not j["filnavn"]: + return "" + maal = MAAL.get(Path(j["filnavn"]).suffix.lower()) + return maal[0] if maal and _skrivbar(get_settings().get(maal[1], "")) else "" + + +def _rent_navn(t: str) -> str: + t = re.sub(r'[/\\\x00-\x1f]', " ", t).strip(" .") + return re.sub(r"\s+", " ", t)[:150] or "Ukendt" + + +def _ledig(p: Path) -> Path: + n = 2 + ud = p + while ud.exists(): + ud = p.with_name(f"{p.stem} ({n}){p.suffix}") + n += 1 + return ud + + +def _kopier(kilde: Path, maal: Path) -> None: + # Via et skjult .part-navn og rename, så CWA/Audiobookshelf aldrig ser en halv fil. + tmp = maal.with_name("." + maal.name + ".part") + try: + shutil.copyfile(kilde, tmp) + os.chmod(tmp, 0o666) + os.replace(tmp, maal) + finally: + tmp.unlink(missing_ok=True) + + +def send(j: dict) -> str | None: + """Kopiér den færdige fil til CWA's indbakke (.epub) eller Audiobookshelf (.m4b). Fejltekst eller None. + Synkron (kan være flere hundrede MB): kaldes via asyncio.to_thread.""" + hvor = kan_sende(j) + if not hvor: + return "Filen kan ikke sendes videre (allerede sendt, udløbet eller ikke sat op)." + kilde = Path(j["fil"]) + ext = kilde.suffix.lower() + _, noegle, navn = MAAL[ext] + rod = Path(get_settings()[noegle]) + try: + if hvor == "cwa": + # CWA importerer kun små filendelser. + maal = _ledig(rod / (_rent_navn(kilde.stem) + ext)) + else: + # Audiobookshelf: Forfatter/Titel/fil når navnet er "Forfatter - Titel", ellers Titel/fil. + forfatter, sep, titel = kilde.stem.partition(" - ") + mappe = rod / _rent_navn(forfatter) / _rent_navn(titel) if sep else rod / _rent_navn(kilde.stem) + mappe.mkdir(parents=True, exist_ok=True) + maal = _ledig(mappe / (_rent_navn(kilde.stem) + ext)) + _kopier(kilde, maal) + except OSError as e: + return f"Kunne ikke sende til {navn}: {e.strerror or e}" + j["sendt"] = navn + return None + + def content_type(filnavn: str) -> str: return CONTENT_TYPES.get(Path(filnavn).suffix.lower(), "application/octet-stream") diff --git a/main.py b/main.py index e99ac2d..d8550dd 100644 --- a/main.py +++ b/main.py @@ -1222,6 +1222,13 @@ async def epub_hent(request: Request, jid: str): background=BackgroundTask(epub.ryd, j, "hentet")) +@app.post("/e-pub/send/{jid}") +async def epub_send(request: Request, jid: str): + j = _epub_job(request, jid) + fejl = await asyncio.to_thread(epub.send, j) + return JSONResponse({"error": fejl, **epub.status(j)}, status_code=400 if fejl else 200) + + @app.get("/admin/epub", response_class=HTMLResponse) async def admin_epub(request: Request, saved: str = ""): user = require_admin(request) @@ -1243,8 +1250,9 @@ async def admin_epub_save(request: Request): 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) + mapper = [str(f.get(k) or "").strip() for k in ("cwa_ingest_dir", "abs_dir")] + if not tools_dir.startswith("/") or any(m and not m.startswith("/") for m in mapper): + return RedirectResponse("/admin/epub?saved=" + urllib.parse.quote("Afvist: mapperne skal være absolutte stier"), status_code=303) epub.save_settings({ "max_concurrent_jobs": num("max_concurrent_jobs", 1, 8), "job_timeout_seconds": num("job_timeout_seconds", 60, 3600), @@ -1252,6 +1260,8 @@ async def admin_epub_save(request: Request): "tools_dir": tools_dir.rstrip("/") or "/", "rp_required": f.get("rp_required") == "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("/"), }) return RedirectResponse("/admin/epub?saved=Indstillinger gemt", status_code=303) diff --git a/templates/admin_epub.html b/templates/admin_epub.html index fc692ce..abef101 100644 --- a/templates/admin_epub.html +++ b/templates/admin_epub.html @@ -27,6 +27,10 @@ + +

diff --git a/templates/epub.html b/templates/epub.html index 4715c5d..ae0f782 100644 --- a/templates/epub.html +++ b/templates/epub.html @@ -36,10 +36,25 @@ k.className = "wt-card"; k.id = "ep-" + id; k.style.padding = "1rem 1.2rem"; k.innerHTML = '

' + '

' + - '' + + '' + '
Log' + '
'; $("ep-jobs").insertBefore(k, $("ep-jobs").firstChild); + k.querySelector(".ep-send").addEventListener("click", function () { + var b = this; + b.disabled = true; + b.textContent = "Sender…"; + fetch("/e-pub/send/" + id, { method: "POST", credentials: "same-origin" }) + .then(function (r) { return r.json(); }) + .then(function (st) { + if (st.error) k.querySelector(".ep-besked").textContent = st.error; + vis(st); + }) + .catch(function () { k.querySelector(".ep-besked").textContent = "Kunne ikke nå serveren."; }) + .finally(function () { b.disabled = false; }); + }); 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); @@ -52,8 +67,13 @@ 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 || ""; + var besked = st.besked || ""; + if (st.sendt) besked = "✅ Sendt til " + st.sendt + (st.tilstand === "faerdig" ? ". Du kan stadig hente filen." : "."); + if (!st.error) k.querySelector(".ep-besked").textContent = besked; k.querySelector(".ep-hent").hidden = st.tilstand !== "faerdig"; + var send = k.querySelector(".ep-send"); + send.style.display = st.kan_sende ? "" : "none"; + send.textContent = st.kan_sende === "cwa" ? "Tilføj til CWA" : "Send til Audiobookshelf"; 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; diff --git a/tests/epub_uvicorn.py b/tests/epub_uvicorn.py index c208a52..d14eb46 100644 --- a/tests/epub_uvicorn.py +++ b/tests/epub_uvicorn.py @@ -13,8 +13,8 @@ for f in ("run.sh", "run-audiobook.sh", "lib.sh"): (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 ;; + ok-epub*) sleep 1; printf 'PK' > "$PWD/Min Bog.epub"; echo "Wrote $PWD/Min Bog.epub (2 B)" >&2 ;; + ok-m4b) head -c 1000 /dev/zero > "$PWD/Ane Forfatter - Lydbog æøå.m4b"; printf 'trin 2\rtrin 3\n' >&2; echo "Wrote $PWD/Ane Forfatter - Lydbog æøå.m4b (1000 B)" >&2 ;; env) env | sort >&2; [ -d "$TMP" ] && [ -z "$(ls -A)" ] && touch "$PWD/env.epub" ;; bad) exit 2 ;; gone) exit 3 ;; @@ -27,7 +27,10 @@ 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})) + "output_ttl_seconds": 4, "max_concurrent_jobs": 1, + "cwa_ingest_dir": str(ROD / "cwa"), "abs_dir": str(ROD / "abs")})) +(ROD / "cwa").mkdir() +(ROD / "abs").mkdir() import main # noqa: E402 import uvicorn # noqa: E402 import httpx # noqa: E402 @@ -71,6 +74,11 @@ 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") +tjek("epub kan sendes til CWA", sa["kan_sende"] == "cwa") +r = c.post(f"/e-pub/send/{a['id']}") +tjek("sendt til CWA", r.status_code == 200 and r.json()["sendt"] == "CWA" + and (ROD / "cwa" / "Min Bog.epub").read_bytes() == b"PK" and not list((ROD / "cwa").glob(".*"))) +tjek("ikke to gange", c.post(f"/e-pub/send/{a['id']}").status_code == 400) 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"]) @@ -79,7 +87,10 @@ tjek("slettet efter hentning", c.get(f"/e-pub/status/{a['id']}").json()["tilstan 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 færdig, \\r-linjer splittet", sb["filnavn"] == "Ane Forfatter - Lydbog æøå.m4b" and "trin 3" in sb["log"]) +r = c.post(f"/e-pub/send/{b['id']}") +tjek("m4b til Audiobookshelf som Forfatter/Titel", r.json().get("sendt") == "Audiobookshelf" + and (ROD / "abs" / "Ane Forfatter" / "Lydbog æøå" / "Ane Forfatter - Lydbog æøå.m4b").stat().st_size == 1000) 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"])