333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""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 <sti> (...)"-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"
|