"""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 _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() 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), "cwa_ok": _skrivbar(s.get("cwa_ingest_dir", "")), "abs_ok": _skrivbar(s.get("abs_dir", "")), } # ---------------- 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, "sendt": ""} _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"], "sendt": j["sendt"], "kan_sende": kan_sende(j)} 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 (...)"-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 # ---------------- 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") 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"