Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04958419ae | ||
|
|
873500f5d4 |
@@ -46,9 +46,11 @@ API'er, koster lidt credits).
|
||||
medfølgende kommandolinjeværktøjer (`fetch.sh`, `run.sh`, `run-audiobook.sh`, `lib.sh`), som
|
||||
portalen kører i en frisk mappe pr. job (under `EPUB_SCRATCH`, standard `PORTAL_DATA/e-pub-scratch`)
|
||||
med et minimalt env (`PATH`, `TMP`, `RP_API_KEY`, `RP_BASE_URL`, `RP_REQUIRED`). stderr vises live
|
||||
(siden poller `/e-pub/status/<id>`). En færdig fil kan hentes én gang og slettes ellers efter
|
||||
`output_ttl_seconds`; jobs lever i hukommelsen, så en genstart/udrulning afbryder igangværende jobs,
|
||||
og scratch-mappen ryddes ved opstart.
|
||||
(siden poller `/e-pub/status/<id>`). Færdige `.epub` lægges automatisk i CWA's indbakke og `.m4b` i
|
||||
Audiobookshelf (`Forfatter/Titel/`), når `auto_send` er slået til; ellers med en knap. Hvert job har en
|
||||
`job.json` i sin mappe, så færdige filer overlever en genstart (jobs der var i gang, markeres som
|
||||
afbrudt). Fil og job slettes `output_ttl_seconds` (standard et døgn) efter jobbet sluttede, eller filen
|
||||
straks efter én download.
|
||||
|
||||
- Adgang: admins, plus medlemmer af den Authentik-gruppe der står i `allowed_group` (tom = kun admins).
|
||||
- `/admin/epub`: adgangsgruppe, samtidige jobs, timeout, TTL, værktøjsmappe, "proxy påkrævet".
|
||||
|
||||
@@ -89,10 +89,15 @@ EPUB_SCRATCH = Path(os.environ.get("EPUB_SCRATCH", str(DATA / "e-pub-scratch")))
|
||||
EPUB_DEFAULTS = {
|
||||
"max_concurrent_jobs": 2, # samtidige jobs; resten venter i kø
|
||||
"job_timeout_seconds": 1200, # et job dræbes efter så lang tid
|
||||
"output_ttl_seconds": 1800, # færdig fil slettes efter så lang tid, hvis den ikke er hentet
|
||||
# Fil + job slettes så længe efter jobbet sluttede (også hen over genstarter). Et døgn: nok til
|
||||
# at hente filen på en anden enhed senere, og bogen ligger i forvejen i CWA/Audiobookshelf.
|
||||
"output_ttl_seconds": 86400,
|
||||
"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)
|
||||
"auto_send": True, # færdige filer sendes selv til CWA/Audiobookshelf
|
||||
}
|
||||
|
||||
GROUP_ORDER = {n: i for i, n in enumerate(
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
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.
|
||||
og en semafor fra indstillingerne. Hvert job har en job.json i sin mappe, så færdige
|
||||
filer overlever en genstart; jobs der var i gang, markeres som afbrudt. Alt (fil og job)
|
||||
slettes output_ttl_seconds efter jobbet sluttede, af fejer() der kører i baggrunden.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
@@ -37,9 +38,6 @@ 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 ----------------
|
||||
@@ -61,6 +59,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 +71,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", "")),
|
||||
}
|
||||
|
||||
|
||||
@@ -97,10 +101,12 @@ def opret(user: str, tekst: str) -> tuple[dict | None, str | None]:
|
||||
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)
|
||||
_mappe({"id": jid}).mkdir(parents=True)
|
||||
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
|
||||
_gem(job)
|
||||
return job, None
|
||||
|
||||
|
||||
@@ -119,18 +125,70 @@ 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:
|
||||
return config.EPUB_SCRATCH / j["id"]
|
||||
|
||||
|
||||
def ryd(j: dict, tilstand: str = "udloebet") -> None:
|
||||
shutil.rmtree(_mappe(j), ignore_errors=True)
|
||||
def _gem(j: dict) -> None:
|
||||
d = _mappe(j)
|
||||
if not d.is_dir():
|
||||
return
|
||||
tmp = d / ".job.json.tmp"
|
||||
tmp.write_text(json.dumps({**j, "log": list(j["log"])}, ensure_ascii=False), "utf-8")
|
||||
os.replace(tmp, d / "job.json")
|
||||
|
||||
|
||||
def _indlaes() -> None:
|
||||
"""Ved opstart: genindlæs jobs fra disken. Afbrudte jobs → fejl, rester uden job.json → væk."""
|
||||
config.EPUB_SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||
for d in config.EPUB_SCRATCH.iterdir():
|
||||
try:
|
||||
j = json.loads((d / "job.json").read_text("utf-8"))
|
||||
assert j["id"] == d.name
|
||||
except Exception:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
continue
|
||||
j["log"] = deque(j.get("log") or [], maxlen=LOG_LINJER)
|
||||
shutil.rmtree(d / "tmp", ignore_errors=True)
|
||||
if j["tilstand"] in TILSTANDE_AKTIVE:
|
||||
j.update(tilstand="fejl", besked="Afbrudt, fordi serveren blev genstartet. Prøv igen.",
|
||||
slut=time.time())
|
||||
shutil.rmtree(d / "ud", ignore_errors=True)
|
||||
elif j["tilstand"] == "faerdig" and not (j.get("fil") and Path(j["fil"]).is_file()):
|
||||
j.update(tilstand="udloebet", fil=None)
|
||||
_jobs[j["id"]] = j
|
||||
_gem(j)
|
||||
|
||||
|
||||
def feje(nu: float | None = None) -> None:
|
||||
"""Slet jobs (mappe + fil) der sluttede for mere end output_ttl_seconds siden."""
|
||||
nu = nu or time.time()
|
||||
ttl = int(get_settings()["output_ttl_seconds"])
|
||||
for j in list(_jobs.values()):
|
||||
if j["tilstand"] not in TILSTANDE_AKTIVE and (j["slut"] or j["oprettet"]) + ttl < nu:
|
||||
shutil.rmtree(_mappe(j), ignore_errors=True)
|
||||
_jobs.pop(j["id"], None)
|
||||
|
||||
|
||||
async def fejer() -> None:
|
||||
while True:
|
||||
try:
|
||||
feje()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(max(1, min(60, int(get_settings()["output_ttl_seconds"]) // 4)))
|
||||
|
||||
|
||||
def ryd(j: dict, tilstand: str = "hentet") -> None:
|
||||
"""Fjern den færdige fil (fx efter download); jobbet bliver stående til TTL."""
|
||||
shutil.rmtree(_mappe(j) / "ud", ignore_errors=True)
|
||||
if j["tilstand"] == "faerdig":
|
||||
j["tilstand"] = tilstand
|
||||
j["fil"] = None
|
||||
_gem(j)
|
||||
|
||||
|
||||
def _log(j: dict, linje: str) -> None:
|
||||
@@ -175,7 +233,7 @@ def _find_fil(ud: Path, log: list[str]) -> Path | None:
|
||||
|
||||
|
||||
async def koer(jid: str) -> None:
|
||||
"""Køres i baggrunden: vent på plads, kør værktøjet, ryd op efter TTL."""
|
||||
"""Køres i baggrunden: vent på plads, kør værktøjet, send evt. videre til biblioteket."""
|
||||
j = _jobs[jid]
|
||||
s = get_settings()
|
||||
try:
|
||||
@@ -188,12 +246,12 @@ async def koer(jid: str) -> None:
|
||||
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)
|
||||
shutil.rmtree(_mappe(j) / "ud", ignore_errors=True)
|
||||
_gem(j)
|
||||
if s.get("auto_send") and kan_sende(j):
|
||||
fejl = await asyncio.to_thread(send, j)
|
||||
j["besked"] = fejl or f"Sendt automatisk til {j['sendt']}. Du kan også hente filen."
|
||||
_gem(j)
|
||||
|
||||
|
||||
async def _koer_vaerktoej(j: dict, s: dict) -> None:
|
||||
@@ -207,6 +265,7 @@ async def _koer_vaerktoej(j: dict, s: dict) -> None:
|
||||
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()
|
||||
_gem(j)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"bash", str(fetch), j["input"], cwd=ud, env=env,
|
||||
stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
|
||||
@@ -250,13 +309,91 @@ 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
|
||||
_gem(j)
|
||||
return None
|
||||
|
||||
|
||||
def content_type(filnavn: str) -> str:
|
||||
return CONTENT_TYPES.get(Path(filnavn).suffix.lower(), "application/octet-stream")
|
||||
|
||||
|
||||
def varighed(sek: int) -> str:
|
||||
if sek % 86400 == 0:
|
||||
d = sek // 86400
|
||||
return "et døgn" if d == 1 else f"{d} døgn"
|
||||
if sek >= 3600:
|
||||
t = round(sek / 3600)
|
||||
return "en time" if t == 1 else f"{t} timer"
|
||||
return f"{max(1, sek // 60)} min"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
_indlaes()
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@@ -34,7 +35,15 @@ import epub
|
||||
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
||||
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
||||
mcp_app = mcp_gateway.gateway.http_app(path="/mcp", json_response=True)
|
||||
app = FastAPI(title="Weircon Portal", lifespan=mcp_app.lifespan)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _lifespan(a):
|
||||
_i_baggrunden(epub.fejer()) # rydder udløbne /e-pub-jobs, også dem fra før en genstart
|
||||
async with mcp_app.lifespan(a):
|
||||
yield
|
||||
|
||||
app = FastAPI(title="Weircon Portal", lifespan=_lifespan)
|
||||
for _mcp_route in mcp_app.routes:
|
||||
app.router.routes.append(_mcp_route)
|
||||
# route-extend copies routes but NOT middleware — carry FastMCP's auth/context
|
||||
@@ -1177,7 +1186,8 @@ async def epub_side(request: Request, saved: str = ""):
|
||||
user = require_epub(request)
|
||||
return templates.TemplateResponse(request, "epub.html", {
|
||||
"request": request, "user": user, "is_admin": is_admin(user), "saved": saved,
|
||||
"jobs": [epub.status(j) for j in epub.brugerens_jobs(user["username"])]})
|
||||
"jobs": [epub.status(j) for j in epub.brugerens_jobs(user["username"])],
|
||||
"ttl": epub.varighed(int(epub.get_settings()["output_ttl_seconds"]))})
|
||||
|
||||
|
||||
@app.post("/e-pub/start")
|
||||
@@ -1219,7 +1229,14 @@ async def epub_hent(request: Request, jid: str):
|
||||
# Én hentning: filen og jobmappen slettes, når svaret er sendt (ellers ved TTL).
|
||||
return FileResponse(j["fil"], filename=j["filnavn"], media_type=epub.content_type(j["filnavn"]),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
background=BackgroundTask(epub.ryd, j, "hentet"))
|
||||
background=BackgroundTask(epub.ryd, j))
|
||||
|
||||
|
||||
@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)
|
||||
@@ -1243,15 +1260,19 @@ 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),
|
||||
"output_ttl_seconds": num("output_ttl_seconds", 60, 86400),
|
||||
"output_ttl_seconds": num("output_ttl_seconds", 60, 7 * 86400),
|
||||
"tools_dir": tools_dir.rstrip("/") or "/",
|
||||
"rp_required": f.get("rp_required") == "on",
|
||||
"auto_send": f.get("auto_send") == "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)
|
||||
|
||||
|
||||
@@ -23,10 +23,16 @@
|
||||
<input class="wt-input" type="number" name="max_concurrent_jobs" min="1" max="8" value="{{ settings.max_concurrent_jobs }}"></label>
|
||||
<label>Timeout pr. job (sekunder)
|
||||
<input class="wt-input" type="number" name="job_timeout_seconds" min="60" max="3600" value="{{ settings.job_timeout_seconds }}"></label>
|
||||
<label>Færdige filer slettes efter (sekunder, hvis ikke hentet)
|
||||
<input class="wt-input" type="number" name="output_ttl_seconds" min="60" max="86400" value="{{ settings.output_ttl_seconds }}"></label>
|
||||
<label>Filer og jobs slettes så længe efter jobbet er færdigt (sekunder, 86400 = et døgn, højst en uge)
|
||||
<input class="wt-input" type="number" name="output_ttl_seconds" min="60" max="604800" value="{{ settings.output_ttl_seconds }}"></label>
|
||||
<label>Værktøjsmappe
|
||||
<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="auto_send" {% if settings.auto_send %}checked{% endif %}>
|
||||
Send færdige filer automatisk til CWA (.epub) og Audiobookshelf (.m4b)</label>
|
||||
<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>
|
||||
<p><button class="wt-btn wt-btn--primary" type="submit">Gem</button></p>
|
||||
|
||||
+23
-3
@@ -3,7 +3,7 @@
|
||||
{% block body %}
|
||||
<h1>E-bog / lydbog</h1>
|
||||
<p class="wt-lead">Indsæt et link, så laver serveren en fil du kan hente: en e-bog (.epub) eller en lydbog (.m4b).
|
||||
Det kan tage fra få sekunder til et kvarter. Filen kan hentes én gang og slettes ellers af sig selv efter et stykke tid.</p>
|
||||
Det kan tage fra få sekunder til et kvarter. Færdige bøger lægges selv i CWA (e-bøger) og Audiobookshelf (lydbøger). Filen kan også hentes her én gang, og den slettes af sig selv efter {{ ttl }}.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
<form id="ep-form" method="post" action="/e-pub/start" autocomplete="off"
|
||||
@@ -36,10 +36,25 @@
|
||||
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>' +
|
||||
'<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>' +
|
||||
'<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);
|
||||
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;
|
||||
|
||||
+40
-8
@@ -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"), "auto_send": False}))
|
||||
(ROD / "cwa").mkdir()
|
||||
(ROD / "abs").mkdir()
|
||||
import main # noqa: E402
|
||||
import uvicorn # noqa: E402
|
||||
import httpx # noqa: E402
|
||||
@@ -71,15 +74,23 @@ 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"])
|
||||
time.sleep(0.3)
|
||||
tjek("slettet efter hentning", c.get(f"/e-pub/status/{a['id']}").json()["tilstand"] == "hentet"
|
||||
and not (config.EPUB_SCRATCH / a["id"]).exists())
|
||||
and not (config.EPUB_SCRATCH / a["id"] / "ud").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"])
|
||||
@@ -105,10 +116,31 @@ tjek("men ikke til andres job", c.get(f"/e-pub/status/{b['id']}").status_code ==
|
||||
tjek("og ikke admin", c.get("/admin/epub").status_code == 403)
|
||||
|
||||
BRUGER.update(username="test", groups=["authentik Admins"])
|
||||
u = vent(c.post("/e-pub/start", data={"link": "ok-epub 2"}).json()["id"])
|
||||
import epub # noqa: E402
|
||||
indst = json.loads(config.EPUB_SETTINGS_PATH.read_text())
|
||||
config.EPUB_SETTINGS_PATH.write_text(json.dumps({**indst, "auto_send": True, "output_ttl_seconds": 30}))
|
||||
u = vent(c.post("/e-pub/start", data={"link": "ok-epub 3"}).json()["id"])
|
||||
time.sleep(0.5)
|
||||
u = c.get(f"/e-pub/status/{u['id']}").json()
|
||||
tjek("auto-send til CWA", u["sendt"] == "CWA" and (ROD / "cwa" / "Min Bog (2).epub").exists() and u["tilstand"] == "faerdig")
|
||||
|
||||
# Genstart: en færdig fil overlever, et job der var i gang bliver "afbrudt".
|
||||
k = vent(c.post("/e-pub/start", data={"link": "slow"}).json()["id"], slut=("koerer",))
|
||||
epub._jobs.clear()
|
||||
epub._indlaes()
|
||||
tjek("færdig fil overlever genstart", c.get(f"/e-pub/status/{u['id']}").json()["tilstand"] == "faerdig"
|
||||
and c.get(f"/e-pub/hent/{u['id']}").content == b"PK")
|
||||
k2 = c.get(f"/e-pub/status/{k['id']}").json()
|
||||
tjek("igangværende job → afbrudt efter genstart", k2["tilstand"] == "fejl" and "genstartet" in k2["besked"])
|
||||
time.sleep(3.5) # det gamle job når at time ud; må ikke overskrive noget
|
||||
|
||||
config.EPUB_SETTINGS_PATH.write_text(json.dumps({**indst, "auto_send": False, "output_ttl_seconds": 4}))
|
||||
v = vent(c.post("/e-pub/start", data={"link": "ok-epub 4"}).json()["id"])
|
||||
time.sleep(1)
|
||||
tjek("ikke-hentet fil findes stadig inden TTL", c.get(f"/e-pub/status/{v['id']}").json()["tilstand"] == "faerdig")
|
||||
time.sleep(5)
|
||||
tjek("ikke-hentet fil udløber efter TTL", c.get(f"/e-pub/status/{u['id']}").json()["tilstand"] == "udloebet"
|
||||
and not (config.EPUB_SCRATCH / u["id"]).exists())
|
||||
tjek("job + fil væk efter TTL", c.get(f"/e-pub/status/{v['id']}").status_code == 404
|
||||
and not (config.EPUB_SCRATCH / v["id"]).exists())
|
||||
tjek("admin gem validerer", c.post("/admin/epub", data={"tools_dir": "relativ"}, follow_redirects=False).headers["location"].startswith("/admin/epub?saved=Afvist"))
|
||||
c.post("/admin/epub", data={"tools_dir": "/x", "max_concurrent_jobs": "99", "job_timeout_seconds": "abc", "rp_required": "on"})
|
||||
s = json.loads(config.EPUB_SETTINGS_PATH.read_text())
|
||||
|
||||
Reference in New Issue
Block a user