Compare commits

..
2 Commits
Author SHA1 Message Date
agw 86ab8f2a19 feat(podcast): del med ukendte, gæstelinks med navn og udløb der kan trækkes tilbage
CI / check (push) Successful in 1m1s
CD / deploy (push) Successful in 1m31s
Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-11-05 21:31:00 +01:00
root 04958419ae feat(e-pub): jobs overlever genstart (job.json, slettes efter et døgn) og sendes automatisk til CWA/Audiobookshelf
CD / deploy (push) Successful in 21s
CI / check (push) Successful in 33s
Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
2026-10-10 20:19:18 +02:00
11 changed files with 343 additions and 39 deletions
+5 -3
View File
@@ -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".
+4 -1
View File
@@ -89,12 +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(
+81 -14
View File
@@ -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 ----------------
@@ -103,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, "sendt": ""}
_jobs[jid] = job
_gem(job)
return job, None
@@ -132,11 +132,63 @@ 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:
@@ -181,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:
@@ -194,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:
@@ -213,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,
@@ -317,6 +370,7 @@ def send(j: dict) -> str | None:
except OSError as e:
return f"Kunne ikke sende til {navn}: {e.strerror or e}"
j["sendt"] = navn
_gem(j)
return None
@@ -324,9 +378,22 @@ 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()
+47 -12
View File
@@ -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
@@ -459,8 +468,10 @@ _INDBYGGET = re.compile(r"FBAN|FBAV|FB_IAB|FBIOS|Messenger|Instagram|LinkedInApp
async def podcast_faelles_side(request: Request, key: str):
ua = request.headers.get("user-agent", "")
forhaandsvisning = bool(_FORHAANDSVISNING.search(ua))
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "" if forhaandsvisning else "side"):
delt = None if podcast.blokeret(_klient_ip(request)) else podcast.delt_slaa_op(key, "" if forhaandsvisning else "side")
if not delt:
_ikke_fundet(request)
gaest = delt if delt["slags"] == "gaest" else None
rod = f"/podcast/f/{key}"
base = str(request.base_url).rstrip("/")
udsendelser = _podcast_kontekst(request, rod, med_kladder=False)
@@ -469,7 +480,8 @@ async def podcast_faelles_side(request: Request, key: str):
# stedet for et billede i hele beskedens bredde.
lille = bool(s.get("slug")) and podcast.cover(s["slug"], lille=True) is not None
og = {"titel": s["titel"], "url": f"{base}{rod}/",
"beskrivelse": "Familiens podcast. Lyt i browseren, eller abonnér i din podcast-app.",
"beskrivelse": ("Lyt i browseren, eller abonnér i din podcast-app." if gaest else
"Familiens podcast. Lyt i browseren, eller abonnér i din podcast-app."),
"billede": (f"{base}{rod}/{s['slug']}/cover-lille.jpg" if lille else
f"{base}{rod}/{s['slug']}/cover.jpg" if s.get("har_cover") else ""),
"str": 300 if lille else 3000}
@@ -493,12 +505,12 @@ async def podcast_faelles_side(request: Request, key: str):
indbygget = {"platform": "andet"}
return templates.TemplateResponse(request, "podcast.html", {
"request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "og": og,
"indbygget": indbygget, "udsendelser": udsendelser, "saved": ""}, headers=headers)
"indbygget": indbygget, "gaest": gaest, "udsendelser": udsendelser, "saved": ""}, headers=headers)
@app.get("/podcast/f/{key}/{name}.xml")
async def podcast_faelles_feed(request: Request, key: str, name: str):
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "feed"):
if podcast.blokeret(_klient_ip(request)) or not podcast.delt_slaa_op(key, "feed"):
_ikke_fundet(request)
xml = podcast.feed(name, f"/podcast/f/{key}", str(request.base_url).rstrip("/"), med_kladder=False)
if xml is None:
@@ -510,7 +522,7 @@ async def podcast_faelles_feed(request: Request, key: str, name: str):
@app.get("/podcast/f/{key}/{name}/cover-lille.jpg")
async def podcast_faelles_cover_lille(request: Request, key: str, name: str):
p = podcast.cover(name, lille=True)
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key) or not p:
if podcast.blokeret(_klient_ip(request)) or not podcast.delt_slaa_op(key) or not p:
_ikke_fundet(request)
return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"})
@@ -518,7 +530,7 @@ async def podcast_faelles_cover_lille(request: Request, key: str, name: str):
@app.get("/podcast/f/{key}/{name}/cover.jpg")
async def podcast_faelles_cover(request: Request, key: str, name: str):
p = podcast.cover(name)
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key) or not p:
if podcast.blokeret(_klient_ip(request)) or not podcast.delt_slaa_op(key) or not p:
_ikke_fundet(request)
return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"})
@@ -530,7 +542,7 @@ async def podcast_faelles_mp3(request: Request, key: str, name: str, slug: str):
p = podcast.episode_fil(name, slug)
meta = podcast.episode_meta(name, slug) or {}
if (podcast.blokeret(_klient_ip(request)) or not p or meta.get("kladde")
or not podcast.faelles_slaa_op(key, slug if fra_start else "")):
or not podcast.delt_slaa_op(key, slug if fra_start else "")):
_ikke_fundet(request)
return FileResponse(p, media_type="audio/mpeg",
headers={"Cache-Control": "private, max-age=86400", "X-Robots-Tag": "noindex, nofollow"})
@@ -585,7 +597,7 @@ async def admin_podcast(request: Request, saved: str = ""):
user = require_admin(request)
return templates.TemplateResponse(request, "admin_podcast.html", {
"request": request, "user": user, "is_admin": True, "saved": saved,
"noegler": podcast.alle_noegler(), "faelles": podcast.faelles(),
"noegler": podcast.alle_noegler(), "faelles": podcast.faelles(), "gaester": podcast.alle_gaester(),
"base": str(request.base_url).rstrip("/"),
"udsendelser": [{**podcast.show(n), "episoder": podcast.episodes(n, med_kladder=True)} for n in podcast.shows()]})
@@ -606,6 +618,27 @@ async def admin_podcast_faelles(request: Request):
return RedirectResponse(f"/admin/podcast?saved={urllib.parse.quote(besked)}", status_code=303)
@app.post("/admin/podcast/gaest")
async def admin_podcast_gaest(request: Request):
require_admin(request)
f = await request.form()
handling, key = f.get("handling"), f.get("key") or ""
if handling == "ny":
try:
dage = max(0, min(int(f.get("dage") or 30), 365))
except ValueError:
dage = 30
podcast.gaest_ny(f.get("navn") or "", dage)
besked = "Gæstelink lavet. Kopiér det i listen herunder."
elif handling == "tilbagekald":
besked = "Gæstelinket virker ikke længere" if podcast.gaest_tilbagekald(key) else "Ukendt link"
elif handling == "slet":
besked = "Gæstelinket er slettet" if podcast.gaest_slet(key) else "Ukendt link"
else:
besked = "Ukendt handling"
return RedirectResponse(f"/admin/podcast?saved={urllib.parse.quote(besked)}", status_code=303)
@app.post("/admin/podcast/noegle/{key}")
async def admin_podcast_noegle(request: Request, key: str):
require_admin(request)
@@ -1177,7 +1210,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 +1253,7 @@ 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}")
@@ -1256,9 +1290,10 @@ async def admin_epub_save(request: Request):
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("/"),
+72
View File
@@ -12,6 +12,7 @@ Data (ikke i git, persondata):
DATA/podcast/<show>/episodes/<slug>.json titel, beskrivelse, nr, udgivet, kladde
DATA/podcast_keys.json nøgle -> bruger, statistik
DATA/podcast_faelles.json det fælles familielink (én nøgle, kan slås fra)
DATA/podcast_gaester.json gæstelinks til ukendte (navn, udløb, kan trækkes tilbage)
En episode med "kladde": true ses kun af administratorer (i deres eget feed og
på siden), så den kan høres i en rigtig podcast-app før familien får den.
@@ -31,6 +32,7 @@ import config
ROOT = config.DATA / "podcast"
KEYS = config.DATA / "podcast_keys.json"
DELT = config.DATA / "podcast_faelles.json"
GAEST = config.DATA / "podcast_gaester.json"
SHOW = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]{0,120}$")
KEY = re.compile(r"^[A-Za-z0-9_-]{32,64}$")
@@ -303,6 +305,76 @@ def faelles_slaa_op(key: str, hvad: str = "") -> bool:
return True
# ---------------- del med ukendte ----------------
# Gæstelinks til folk uden for familien (fx en jobsamtale): hver har sit eget navn,
# sin egen nøgle og en udløbsdato, og kan trækkes tilbage uden at røre familiens
# link eller de personlige adresser. Samme side som det fælles link, aldrig kladder.
def gaester() -> dict:
try:
return json.loads(GAEST.read_text())
except (OSError, ValueError):
return {}
def alle_gaester() -> list[dict]:
nu = time.time()
ud = [{"key": key, **v, "udloebet": bool(v.get("udloeber") and v["udloeber"] < nu)}
for key, v in gaester().items()]
return sorted(ud, key=lambda v: (bool(v.get("tilbagekaldt") or v["udloebet"]), -v.get("oprettet", 0)))
def gaest_ny(navn: str, dage: int) -> str:
g = gaester()
key = secrets.token_urlsafe(32)
nu = int(time.time())
g[key] = {"navn": navn.strip()[:80] or "Gæst", "oprettet": nu,
"udloeber": nu + dage * 86400 if dage > 0 else None, "hentninger": 0, "hentet": {}}
_skriv(GAEST, g)
return key
def gaest_tilbagekald(key: str) -> bool:
g = gaester()
if key not in g:
return False
g[key]["tilbagekaldt"] = int(time.time())
_skriv(GAEST, g)
return True
def gaest_slet(key: str) -> bool:
g = gaester()
if g.pop(key, None) is None:
return False
_skriv(GAEST, g)
return True
def gaest_slaa_op(key: str, hvad: str = "") -> dict | None:
if not KEY.match(key or ""):
return None
g = gaester()
v = g.get(key)
if not v or v.get("tilbagekaldt") or (v.get("udloeber") and v["udloeber"] < time.time()):
return None
if hvad:
v["hentninger"] = v.get("hentninger", 0) + 1
v["sidst"] = int(time.time())
v.setdefault("hentet", {})
v["hentet"][hvad] = v["hentet"].get(hvad, 0) + 1
_skriv(GAEST, g)
return v
def delt_slaa_op(key: str, hvad: str = "") -> dict | None:
"""Fælles familielink eller gæstelink -> {"slags": "familie"|"gaest", ...}, ellers None."""
if faelles_slaa_op(key, hvad):
return {"slags": "familie"}
v = gaest_slaa_op(key, hvad)
return {"slags": "gaest", **v} if v else None
# ---------------- gæt på nøgler ----------------
# Nøglerne er 256 bit og kan ikke gættes, men en klient der bliver ved med at
# ramme forkerte nøgler, får 404 uden at vi overhovedet slår op (og samme svar
+4 -2
View File
@@ -23,14 +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>
+48
View File
@@ -63,6 +63,54 @@
{% endif %}
</div>
<h2 style="font-size:1.15rem;margin-top:2rem">Del med ukendte</h2>
<p class="wt-muted" style="margin:.3rem 0 .8rem">Gæstelinks til folk uden for familien, fx til en jobsamtale. Hvert link har sin egen nøgle og udløber af sig selv; du kan også trække det tilbage før tid. Det har intet med familiens link eller de personlige adresser at gøre. Kladder vises aldrig.</p>
<form method="post" action="/admin/podcast/gaest" class="wt-card" style="padding:1rem 1.2rem;max-width:48rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:flex-end">
<input type="hidden" name="handling" value="ny">
<label style="flex:1 1 16rem">Hvem er det til<br>
<input class="wt-input" name="navn" required maxlength="80" placeholder="fx Jobsamtale, Firma A/S" style="width:100%"></label>
<label>Udløber efter<br>
<select class="wt-input" name="dage">
<option value="7">7 dage</option><option value="14">14 dage</option>
<option value="30" selected>30 dage</option><option value="90">90 dage</option>
<option value="0">Aldrig (træk selv tilbage)</option>
</select></label>
<button class="wt-btn wt-btn--primary" type="submit">Lav gæstelink</button>
</form>
<table class="wt-table" style="margin-top:.8rem">
<thead><tr><th>Til</th><th>Link</th><th>Udløber</th><th>Sidst brugt</th><th>Hentninger</th><th></th></tr></thead>
<tbody>
{% for g in gaester %}
{% set doed = g.tilbagekaldt or g.udloebet %}
<tr{% if doed %} style="opacity:.55"{% endif %}>
<td>{{ g.navn }}</td>
<td>{% if doed %}<span class="wt-muted">{{ 'Trukket tilbage' if g.tilbagekaldt else 'Udløbet' }}</span>{% else %}
<div style="display:flex;gap:.4rem;min-width:14rem">
<input class="wt-input" readonly id="g-{{ loop.index }}" value="{{ base }}/podcast/f/{{ g.key }}/"
style="flex:1;font-family:ui-monospace,monospace;font-size:.8rem" onclick="this.select()">
<button class="wt-btn" type="button" onclick="var i=document.getElementById('g-{{ loop.index }}');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
</div>{% endif %}</td>
<td>{% if g.udloeber %}<span data-ts="{{ g.udloeber }}"></span>{% else %}Aldrig{% endif %}</td>
<td data-ts="{{ g.sidst or '' }}"></td>
<td title="{% for h, n in (g.hentet or {}).items() %}{{ h }}: {{ n }}&#10;{% endfor %}">{{ g.hentninger or 0 }}</td>
<td>
<form method="post" action="/admin/podcast/gaest" style="margin:0">
<input type="hidden" name="key" value="{{ g.key }}">
{% if doed %}
<button class="wt-btn" type="submit" name="handling" value="slet">Slet</button>
{% else %}
<button class="wt-btn" type="submit" name="handling" value="tilbagekald"
onclick="return confirm('Træk gæstelinket tilbage? Det holder op med at virke med det samme.')">Træk tilbage</button>
{% endif %}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="wt-muted">Ingen gæstelinks.</td></tr>
{% endfor %}
</tbody>
</table>
<h2 style="font-size:1.15rem;margin-top:2rem">Lytternes adresser</h2>
<table class="wt-table">
<thead><tr><th>Bruger</th><th>Oprettet</th><th>Sidst hentet</th><th>Hentninger</th><th>App</th><th></th></tr></thead>
+1 -1
View File
@@ -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"
+4 -1
View File
@@ -52,7 +52,7 @@
{% endif %}
</div>
{% endif %}
<p class="wt-muted" style="margin:.2rem 0 .8rem">{% if delt %}Tryk på din app, så kommer nye episoder af sig selv. Linket er familiens fælles, så del det kun inden for familien.{% else %}Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.{% endif %}</p>
<p class="wt-muted" style="margin:.2rem 0 .8rem">{% if gaest %}Tryk på din app, så kommer nye episoder af sig selv. Linket er delt med dig personligt{% if gaest.udloeber %} og virker til <span data-ts="{{ gaest.udloeber }}"></span>{% endif %}, så del det ikke videre.{% elif delt %}Tryk på din app, så kommer nye episoder af sig selv. Linket er familiens fælles, så del det kun inden for familien.{% else %}Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.{% endif %}</p>
<div style="display:flex;flex-wrap:wrap;gap:.5rem">
<a class="wt-btn wt-btn--primary" href="podcast://{{ u.uden_skema }}">Apple Podcasts</a>
<a class="wt-btn" href="overcast://x-callback-url/add?url={{ u.feed_enc }}">Overcast</a>
@@ -108,6 +108,9 @@ function kopierTekst(t, knap) {
};
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(ok, fallback); else fallback();
}
document.querySelectorAll('[data-ts]').forEach(function (s) {
s.textContent = new Date(parseInt(s.dataset.ts, 10) * 1000).toLocaleDateString('da-DK', { dateStyle: 'long' });
});
document.querySelectorAll('[data-kopier-tekst]').forEach(function (b) {
b.addEventListener('click', function () { kopierTekst(b.dataset.kopierTekst, b); });
});
+26 -5
View File
@@ -28,7 +28,7 @@ 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,
"cwa_ingest_dir": str(ROD / "cwa"), "abs_dir": str(ROD / "abs")}))
"cwa_ingest_dir": str(ROD / "cwa"), "abs_dir": str(ROD / "abs"), "auto_send": False}))
(ROD / "cwa").mkdir()
(ROD / "abs").mkdir()
import main # noqa: E402
@@ -84,7 +84,7 @@ tjek("epub content-type", r.headers["content-type"] == "application/epub+zip" an
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"] == "Ane Forfatter - Lydbog æøå.m4b" and "trin 3" in sb["log"])
@@ -116,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())
+51
View File
@@ -0,0 +1,51 @@
# Røgtest af "Del med ukendte" (gæstelinks) med FastAPI TestClient, uden OIDC.
# Kør i CT151: PORTAL_DATA=/tmp/pg-test/data + env fra /etc/portal.env. Rører ikke prod-data.
import json, time
from fastapi.testclient import TestClient
import main, podcast
d = podcast.ROOT / "test"
(d / "episodes").mkdir(parents=True, exist_ok=True)
(d / "show.json").write_text(json.dumps({"titel": "Testshow", "beskrivelse": "b"}))
for slug, kladde in (("e1", False), ("e2", True)):
(d / "episodes" / f"{slug}.mp3").write_bytes(b"\xff\xfb" + b"\0" * 2000)
(d / "episodes" / f"{slug}.json").write_text(json.dumps({"titel": slug, "nr": 1, "kladde": kladde}))
c = TestClient(main.app, base_url="https://weircon.dk")
admin = {"username": "agw", "name": "Asger", "groups": [main.config.ADMIN_GROUP, "Familie"]}
main.current_user = lambda request: admin
r = c.post("/admin/podcast/gaest", data={"handling": "ny", "navn": "Jobsamtale X", "dage": "14"}, follow_redirects=False)
assert r.status_code == 303, r.text
g = podcast.alle_gaester()[0]; key = g["key"]
assert 13.9 * 86400 < g["udloeber"] - time.time() < 14.1 * 86400
r = c.get("/admin/podcast"); assert r.status_code == 200 and "Jobsamtale X" in r.text and key in r.text
main.current_user = lambda request: None
r = c.get(f"/podcast/f/{key}/"); assert r.status_code == 200 and "delt med dig personligt" in r.text, r.status_code
assert "familiens fælles" not in r.text and "e2.mp3" not in r.text
assert c.get(f"/podcast/f/{key}/test.xml").status_code == 200
assert c.get(f"/podcast/f/{key}/test/e1.mp3").status_code == 200
assert c.get(f"/podcast/f/{key}/test/e2.mp3").status_code == 404, "kladde må ikke kunne hentes"
assert podcast.gaester()[key]["hentninger"] == 3
assert not podcast.faelles().get("key"), "gæstelinket må ikke røre familielinket"
# udløbet
gg = podcast.gaester(); gg[key]["udloeber"] = int(time.time()) - 1; podcast._skriv(podcast.GAEST, gg)
assert c.get(f"/podcast/f/{key}/").status_code == 404
gg[key]["udloeber"] = int(time.time()) + 3600; podcast._skriv(podcast.GAEST, gg)
assert c.get(f"/podcast/f/{key}/").status_code == 200
# trukket tilbage, slettet
main.current_user = lambda request: admin
assert c.post("/admin/podcast/gaest", data={"handling": "tilbagekald", "key": key}, follow_redirects=False).status_code == 303
assert c.get(f"/podcast/f/{key}/test/e1.mp3").status_code == 404
assert c.post("/admin/podcast/gaest", data={"handling": "slet", "key": key}, follow_redirects=False).status_code == 303
assert key not in podcast.gaester()
# "aldrig" + familielinket virker stadig ved siden af
c.post("/admin/podcast/gaest", data={"handling": "ny", "navn": "Uden udløb", "dage": "0"})
assert podcast.alle_gaester()[0]["udloeber"] is None
fk = podcast.faelles_skift()
r = c.get(f"/podcast/f/{fk}/"); assert r.status_code == 200 and "familiens fælles" in r.text
print("OK")