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 = '' + '' + - '' + + '' + + 'Hent fil' + + '