feat(e-pub): e-bog/lydbog fra et indsat link, med live-log, kø, timeout og /admin/epub
CI / check (push) Successful in 39s
CD / deploy (push) Successful in 20s

Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
This commit is contained in:
root
2026-10-10 11:17:16 +02:00
parent 6e5bb5eac1
commit 9a57e178b5
8 changed files with 681 additions and 0 deletions
+110
View File
@@ -2,6 +2,7 @@
Forretningslogik ligger i moduler: config, cache, store, services, authentik."""
from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI, Form, HTTPException, Request
from starlette.background import BackgroundTask
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -28,6 +29,7 @@ import secretdrop
import claudelogin
import podcast
import erindringer
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.
@@ -1148,6 +1150,112 @@ async def admin_access_save(request: Request, slug: str):
return RedirectResponse(f"/admin/access?saved=Adgang til '{app_['name']}' gemt — {detail}", status_code=303)
# ---------------- e-pub (ebog/lydbog) ----------------
def require_epub(request: Request):
"""Logget ind + admin eller medlem af indstillingens allowed_group. Uden login: login og tilbage."""
user = current_user(request)
if not user and request.method == "GET":
nxt = request.url.path + (f"?{request.url.query}" if request.url.query else "")
raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
if not user:
raise HTTPException(401, "Log ind først")
if not epub.har_adgang(user, is_admin(user)):
raise HTTPException(403, "Du har ikke adgang til denne side")
return user
def _epub_job(request: Request, jid: str) -> dict:
user = require_epub(request)
j = epub.job(jid)
if not j or (j["ejer"] != user["username"] and not is_admin(user)):
raise HTTPException(404, "Ukendt job")
return j
@app.get("/e-pub", response_class=HTMLResponse)
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"])]})
@app.post("/e-pub/start")
async def epub_start(request: Request):
user = require_epub(request)
vil_json = "application/json" in request.headers.get("accept", "")
try:
laengde = int(request.headers.get("content-length") or 0)
except ValueError:
laengde = 0
if laengde > 16384:
raise HTTPException(413, "For stor forespørgsel")
f = await request.form()
tekst = str(f.get("link") or "").strip()
fejl = epub.valider_input(tekst)
j = None
if not fejl:
j, fejl = epub.opret(user["username"], tekst)
if fejl:
if vil_json:
return JSONResponse({"error": fejl}, status_code=400)
return RedirectResponse("/e-pub?saved=" + urllib.parse.quote(fejl), status_code=303)
_i_baggrunden(epub.koer(j["id"]))
if vil_json:
return JSONResponse(epub.status(j))
return RedirectResponse("/e-pub?saved=" + urllib.parse.quote("Startet"), status_code=303)
@app.get("/e-pub/status/{jid}")
async def epub_status(request: Request, jid: str):
return JSONResponse(epub.status(_epub_job(request, jid)), headers={"Cache-Control": "no-store"})
@app.get("/e-pub/hent/{jid}")
async def epub_hent(request: Request, jid: str):
j = _epub_job(request, jid)
if j["tilstand"] != "faerdig" or not j["fil"]:
raise HTTPException(410, "Filen er her ikke længere")
# É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"))
@app.get("/admin/epub", response_class=HTMLResponse)
async def admin_epub(request: Request, saved: str = ""):
user = require_admin(request)
s = epub.get_settings()
return templates.TemplateResponse(request, "admin_epub.html", {
"request": request, "user": user, "is_admin": True, "saved": saved, "settings": s,
"tjek": epub.tjek_opsaetning(s), "jobs": [{**epub.status(j), "ejer": j["ejer"]} for j in epub.alle_jobs()],
"human": epub.human})
@app.post("/admin/epub")
async def admin_epub_save(request: Request):
require_admin(request)
f = await request.form()
def num(name, lo, hi):
try:
return max(lo, min(hi, int(f.get(name))))
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)
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),
"tools_dir": tools_dir.rstrip("/") or "/",
"rp_required": f.get("rp_required") == "on",
"allowed_group": str(f.get("allowed_group") or "").strip()[:150],
})
return RedirectResponse("/admin/epub?saved=Indstillinger gemt", status_code=303)
# ---------------- medie-oprydning ----------------
@app.get("/admin/media", response_class=HTMLResponse)
async def admin_media(request: Request, saved: str = ""):
@@ -1300,6 +1408,8 @@ async def search_index(request: Request):
{"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"},
{"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"},
{"type": "page", "title": "MCP-nøgler", "url": "/admin/mcp-keys", "extra": "mcp api nøgler token claude"}]
if epub.har_adgang(user, is_admin(user)):
items.append({"type": "page", "title": "E-bog / lydbog", "url": "/e-pub", "extra": "epub m4b bog lydbog bibliotek"})
try:
for a in await ak.fetch_user_apps(user["username"]):
items.append({"type": "app", "title": a["name"], "url": a["url"],