feat(e-pub): Tilføj til CWA (.epub) og Send til Audiobookshelf (.m4b)
CD / deploy (push) Successful in 26s
CI / check (push) Successful in 40s

Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
This commit is contained in:
root
2026-10-10 11:49:57 +02:00
parent 9a57e178b5
commit 873500f5d4
6 changed files with 127 additions and 10 deletions
+2
View File
@@ -93,6 +93,8 @@ EPUB_DEFAULTS = {
"tools_dir": "/opt/portal/tools/e-pub", # mappen med fetch.sh m.fl. "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 "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 "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( GROUP_ORDER = {n: i for i, n in enumerate(
+72 -2
View File
@@ -61,6 +61,10 @@ def har_adgang(user, admin: bool) -> bool:
return bool(user and gruppe and gruppe in (user.get("groups") or [])) 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: def tjek_opsaetning(s: dict | None = None) -> dict:
"""Til admin-siden: mangler der værktøjer eller programmer hvor portalen kører?""" """Til admin-siden: mangler der værktøjer eller programmer hvor portalen kører?"""
s = s or get_settings() 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_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)], "mangler_programmer": [p for p in KRAEVEDE_PROGRAMMER if not shutil.which(p, path=PATH)],
"rp_noegle": bool(config.RP_API_KEY), "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) jid = secrets.token_urlsafe(12)
job = {"id": jid, "ejer": user, "input": tekst, "tilstand": "koe", "log": deque(maxlen=LOG_LINJER), job = {"id": jid, "ejer": user, "input": tekst, "tilstand": "koe", "log": deque(maxlen=LOG_LINJER),
"besked": "", "fil": None, "filnavn": "", "stoerrelse": 0, "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 _jobs[jid] = job
return job, None return job, None
@@ -119,7 +125,7 @@ def alle_jobs() -> list[dict]:
def status(j: dict) -> dict: def status(j: dict) -> dict:
return {"id": j["id"], "tilstand": j["tilstand"], "besked": j["besked"], "log": list(j["log"])[-80:], return {"id": j["id"], "tilstand": j["tilstand"], "besked": j["besked"], "log": list(j["log"])[-80:],
"filnavn": j["filnavn"], "stoerrelse": j["stoerrelse"], "oprettet": j["oprettet"], "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: def _mappe(j: dict) -> Path:
@@ -250,6 +256,70 @@ def _draeb(proc) -> None:
pass 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: def content_type(filnavn: str) -> str:
return CONTENT_TYPES.get(Path(filnavn).suffix.lower(), "application/octet-stream") return CONTENT_TYPES.get(Path(filnavn).suffix.lower(), "application/octet-stream")
+12 -2
View File
@@ -1222,6 +1222,13 @@ async def epub_hent(request: Request, jid: str):
background=BackgroundTask(epub.ryd, j, "hentet")) 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) @app.get("/admin/epub", response_class=HTMLResponse)
async def admin_epub(request: Request, saved: str = ""): async def admin_epub(request: Request, saved: str = ""):
user = require_admin(request) user = require_admin(request)
@@ -1243,8 +1250,9 @@ async def admin_epub_save(request: Request):
except (TypeError, ValueError): except (TypeError, ValueError):
return config.EPUB_DEFAULTS[name] return config.EPUB_DEFAULTS[name]
tools_dir = str(f.get("tools_dir") or "").strip() tools_dir = str(f.get("tools_dir") or "").strip()
if not tools_dir.startswith("/"): mapper = [str(f.get(k) or "").strip() for k in ("cwa_ingest_dir", "abs_dir")]
return RedirectResponse("/admin/epub?saved=" + urllib.parse.quote("Afvist: værktøjsmappen skal være en absolut sti"), status_code=303) 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({ epub.save_settings({
"max_concurrent_jobs": num("max_concurrent_jobs", 1, 8), "max_concurrent_jobs": num("max_concurrent_jobs", 1, 8),
"job_timeout_seconds": num("job_timeout_seconds", 60, 3600), "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 "/", "tools_dir": tools_dir.rstrip("/") or "/",
"rp_required": f.get("rp_required") == "on", "rp_required": f.get("rp_required") == "on",
"allowed_group": str(f.get("allowed_group") or "").strip()[:150], "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) return RedirectResponse("/admin/epub?saved=Indstillinger gemt", status_code=303)
+4
View File
@@ -27,6 +27,10 @@
<input class="wt-input" type="number" name="output_ttl_seconds" min="60" max="86400" value="{{ settings.output_ttl_seconds }}"></label> <input class="wt-input" type="number" name="output_ttl_seconds" min="60" max="86400" value="{{ settings.output_ttl_seconds }}"></label>
<label>Værktøjsmappe <label>Værktøjsmappe
<input class="wt-input" name="tools_dir" value="{{ settings.tools_dir }}" required></label> <input class="wt-input" name="tools_dir" value="{{ settings.tools_dir }}" required></label>
<label>CWA-indbakke (tom = ingen "Tilføj til CWA"-knap){% if settings.cwa_ingest_dir and not tjek.cwa_ok %} <span class="err">findes ikke / kan ikke skrives</span>{% endif %}
<input class="wt-input" name="cwa_ingest_dir" value="{{ settings.cwa_ingest_dir }}"></label>
<label>Audiobookshelf-mappe (tom = ingen "Send til Audiobookshelf"-knap){% if settings.abs_dir and not tjek.abs_ok %} <span class="err">findes ikke / kan ikke skrives</span>{% endif %}
<input class="wt-input" name="abs_dir" value="{{ settings.abs_dir }}"></label>
<label><input type="checkbox" name="rp_required" {% if settings.rp_required %}checked{% endif %}> <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> 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> <p><button class="wt-btn wt-btn--primary" type="submit">Gem</button></p>
+22 -2
View File
@@ -36,10 +36,25 @@
k.className = "wt-card"; k.id = "ep-" + id; k.style.padding = "1rem 1.2rem"; 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>' + 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-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>' + '<div class="ep-hent" hidden><p style="margin:.4rem 0;display:flex;gap:.6rem;flex-wrap:wrap">' +
'<a class="wt-btn wt-btn--primary" href="/e-pub/hent/' + id + '">Hent fil</a>' +
'<button class="wt-btn ep-send" type="button" style="display:none"></button></p></div>' +
'<details><summary class="wt-muted" style="cursor:pointer">Log</summary>' + '<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>'; '<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); $("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 () { k.querySelector(".ep-hent a").addEventListener("click", function () {
// Filen slettes efter én hentning: knappen forsvinder, og status hentes igen om lidt. // Filen slettes efter én hentning: knappen forsvinder, og status hentes igen om lidt.
setTimeout(function () { hent(id); }, 4000); setTimeout(function () { hent(id); }, 4000);
@@ -52,8 +67,13 @@
var t = TEKST[st.tilstand] || st.tilstand; var t = TEKST[st.tilstand] || st.tilstand;
if (st.tilstand === "faerdig" && st.filnavn) t += " · " + st.filnavn + " (" + str(st.stoerrelse) + ")"; if (st.tilstand === "faerdig" && st.filnavn) t += " · " + st.filnavn + " (" + str(st.stoerrelse) + ")";
k.querySelector(".ep-tilstand").textContent = t; 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"; 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; var log = k.querySelector(".ep-log"), nederst = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
log.textContent = (st.log || []).join("\n"); log.textContent = (st.log || []).join("\n");
if (nederst) log.scrollTop = log.scrollHeight; if (nederst) log.scrollTop = log.scrollHeight;
+15 -4
View File
@@ -13,8 +13,8 @@ for f in ("run.sh", "run-audiobook.sh", "lib.sh"):
(VAERKTOEJ / "fetch.sh").write_text(r'''#!/bin/bash (VAERKTOEJ / "fetch.sh").write_text(r'''#!/bin/bash
echo "trin 1: $1" >&2 echo "trin 1: $1" >&2
case "$1" in case "$1" in
ok-epub*) printf 'PK' > "$PWD/Min Bog.epub"; echo "Wrote $PWD/Min Bog.epub (2 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/Lydbog æøå.m4b"; printf 'trin 2\rtrin 3\n' >&2; echo "Wrote $PWD/Lydbog æøå.m4b (1000 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" ;; env) env | sort >&2; [ -d "$TMP" ] && [ -z "$(ls -A)" ] && touch "$PWD/env.epub" ;;
bad) exit 2 ;; bad) exit 2 ;;
gone) exit 3 ;; 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]) os.chdir(pathlib.Path(__file__).resolve().parents[1])
import config # noqa: E402 import config # noqa: E402
config.EPUB_SETTINGS_PATH.write_text(json.dumps({"tools_dir": str(VAERKTOEJ), "job_timeout_seconds": 3, 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 main # noqa: E402
import uvicorn # noqa: E402 import uvicorn # noqa: E402
import httpx # 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") tjek("semafor: andet job venter i kø", c.get(f"/e-pub/status/{b['id']}").json()["tilstand"] == "koe")
sa = vent(a["id"]) sa = vent(a["id"])
tjek("epub færdig", sa["tilstand"] == "faerdig" and sa["filnavn"] == "Min Bog.epub") 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']}") 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("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"]) 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()) and not (config.EPUB_SCRATCH / a["id"]).exists())
tjek("anden hentning = 410", c.get(f"/e-pub/hent/{a['id']}").status_code == 410) tjek("anden hentning = 410", c.get(f"/e-pub/hent/{a['id']}").status_code == 410)
sb = vent(b["id"]) 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") 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"]) e = vent(c.post("/e-pub/start", data={"link": "env"}).json()["id"])