Compare commits

...
7 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
root 873500f5d4 feat(e-pub): Tilføj til CWA (.epub) og Send til Audiobookshelf (.m4b)
CD / deploy (push) Successful in 26s
CI / check (push) Successful in 40s
Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
2026-10-10 11:49:57 +02:00
root 9a57e178b5 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
2026-10-10 11:17:16 +02:00
root 6e5bb5eac1 feat(ops): værtens apt-sikkerhedsopdateringer på driftssiden
CD / deploy (push) Successful in 24s
CI / check (push) Successful in 32s
Proxmox VE får 'nyeste' fra værtens apt-kandidat, ny sektion med
sikkerhedspakker/genstartsbehov, ntfy ved nye sikkerhedsopdateringer.
_norm stripper release-præfiks (qBittorrent).

Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
2026-10-09 19:25:57 +02:00
root 79f49bda45 fix(ui): tabeller pakkes i .wt-table-wrap (standardkomponent i base.html), portal.css cache-bustes
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 21s
Rettelsen i v1.5.2 nåede ikke telefonen, fordi /static/portal.css ikke sendes
med Cache-Control og Safari cacher heuristisk; nu ?v=<mtime>. Og i stedet for en
media-query på selve tabellen pakker base.html enhver tabel i hovedindholdet
(også injiceret) i et rulleområde, så bredt indhold aldrig skubber siden ud
over kanten, uanset skærmbredde.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-06 22:51:09 +02:00
root 1c1962e1d8 feat(erindringer): live-afskrift mens man taler, spørgsmål+svar på ét afspillerelement, voice isolation for podcast-favoritter
CD / deploy (push) Successful in 14s
CI / check (push) Successful in 21s
Browseren streamer 16 kHz PCM direkte til ElevenLabs' realtime-STT med et
engangs-token fra serveren (/api/stt-token); teksten vokser under optagelsen og
sendes med kladden, så serveren slipper for at skrive af. 'Spørgsmål og svar'
bruger ét audio-element (iOS blokerer ellers det andet play()) og laver
spørgsmålets lyd on demand for ældre klip. Admin kan rense favorit-klip med
ElevenLabs Audio Isolation (<id>-ren.mp3), aldrig som standard.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-06 22:43:38 +02:00
21 changed files with 1429 additions and 43 deletions
+21
View File
@@ -40,6 +40,27 @@ pr. person under `PORTAL_DATA/erindringer/` (persondata, aldrig i git).
Røgtest uden OIDC: `PORTAL_DATA=/tmp/x python tests/erindringer_smoke.py` (kalder de rigtige
API'er, koster lidt credits).
## E-bog / lydbog (`/e-pub`)
Ét felt: indsæt et link, få en `.epub` eller `.m4b` at hente. Selve arbejdet laves af
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>`). 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".
Siden viser også, om værktøjer, programmer og `RP_API_KEY` mangler.
- **Skal være installeret hvor portalen kører:** `bash`, `curl`, `jq`, `openssl`, `ffmpeg`
(`apt install jq` m.fl.) og værktøjsfilerne i `tools_dir` (standard `/opt/portal/tools/e-pub`,
uden for appmappen/git, læsbare for brugeren portalen kører som).
- Plads: et job kan give op til ~300 MB (plus midlertidige filer), gange antal samtidige jobs.
- Test uden OIDC og med et stub-værktøj: `python tests/epub_uvicorn.py`.
## Kør
```bash
+21
View File
@@ -81,6 +81,25 @@ YT_DEFAULTS = {
"library_name": "YouTube",
}
# ---- e-pub (ebog/lydbog fra en biblioteks-reference) ----
EPUB_SETTINGS_PATH = DATA / "epub_settings.json"
# Per-job-mapper (output + TMP) oprettes her og slettes efter hentning/udløb.
EPUB_SCRATCH = Path(os.environ.get("EPUB_SCRATCH", str(DATA / "e-pub-scratch")))
# Standardindstillinger - kan redigeres i /admin/epub (RP_API_KEY/RP_BASE_URL kommer fra env ovenfor):
EPUB_DEFAULTS = {
"max_concurrent_jobs": 2, # samtidige jobs; resten venter i kø
"job_timeout_seconds": 1200, # et job dræbes efter så lang tid
# 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(
["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])}
@@ -107,6 +126,8 @@ ADMIN_MODULES = [
"desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
{"key": "yt", "title": "YouTube offline", "icon": "📺",
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
{"key": "epub", "title": "E-bøger & lydbøger", "icon": "📚",
"desc": "/e-pub: hvem der må bruge den, samtidige jobs, timeout og hvor længe filer ligger."},
{"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏",
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
{"key": "podcast", "title": "Podcast", "icon": "🎙️",
+6
View File
@@ -17,3 +17,9 @@ ANTHROPIC_WORKSPACE_ID= # kun for bruger-nøgler (sk-ant-usr…), ell
ERINDRINGER_MODEL=claude-sonnet-5
ELEVEN_API_KEY=__xi-…__
ERINDRINGER_VOICE=tJDFCHyviItsYF1qqToS
# /e-pub: per-job-mapper (output + TMP). Valgfri, standard PORTAL_DATA/e-pub-scratch.
# Bruger RP_API_KEY/RP_BASE_URL (random-proxy) fra samme fil. Kræver bash curl jq openssl ffmpeg
# + værktøjerne i tools_dir (sættes i /admin/epub, standard /opt/portal/tools/e-pub).
#EPUB_SCRATCH=/opt/portal/data/e-pub-scratch
RP_BASE_URL=https://rp.weircon.dk
RP_API_KEY=__random-proxy-noegle__
+399
View File
@@ -0,0 +1,399 @@
"""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. 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
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
# ---------------- 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)
_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
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 _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:
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, send evt. videre til biblioteket."""
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) / "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:
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()
_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,
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
_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()
+69 -2
View File
@@ -392,18 +392,82 @@ def slet_klip(pid: str, kid: str) -> bool:
if not k:
return False
d = _klipmappe(pid)
for navn in (f"{kid}.json", f"{kid}.mp3", f"{kid}-sp.mp3"):
for navn in (f"{kid}.json", f"{kid}.mp3", f"{kid}-sp.mp3", f"{kid}-ren.mp3"):
(d / navn).unlink(missing_ok=True)
return True
def spoergsmaal_fil(pid: str, kid: str) -> Path | None:
def spoergsmaal_fil(pid: str, kid: str, lav: bool = True) -> Path | None:
"""Spørgsmålets oplæsning til et svar. Mangler den (ældre klip), laves den fra den talte tekst;
teksten er den samme, så tts-cachen rammer oftest."""
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
return None
p = _klipmappe(pid) / f"{kid}-sp.mp3"
if p.exists():
return p
if not lav:
return None
k = klip(pid, kid)
if not k:
return None
sp = tts_sti(k.get("spoergsmaal_talt") or k.get("spoergsmaal") or "")
if not sp:
return None
p.write_bytes(sp.read_bytes())
k["spoergsmaal_lyd"] = True
_skriv(_klipmappe(pid) / f"{kid}.json", k)
return p
def renset_fil(pid: str, kid: str) -> Path | None:
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
return None
p = _klipmappe(pid) / f"{kid}-ren.mp3"
return p if p.exists() else None
def isoler_stemme(pid: str, kid: str) -> bool:
"""ElevenLabs Audio Isolation: fjern baggrundsstøj fra svaret -> <id>-ren.mp3 (valgfrit, pr. klip;
koster ca. 1000 credits pr. minut, så det er en admin-knap, ikke standard)."""
f = klip_fil(pid, kid)
if not f or not ELEVEN_KEY:
return False
try:
with open(f, "rb") as fh:
r = httpx.post("https://api.elevenlabs.io/v1/audio-isolation", headers={"xi-api-key": ELEVEN_KEY},
files={"audio": (f.name, fh, "audio/mpeg")}, timeout=600)
if r.status_code != 200 or len(r.content) < 1000:
return False
except httpx.HTTPError:
return False
ud = _klipmappe(pid) / f"{kid}-ren.mp3"
tmp = ud.with_name("." + ud.name + ".tmp")
tmp.write_bytes(r.content)
os.replace(tmp, ud)
k = klip(pid, kid)
if k:
k["renset"] = True
_skriv(_klipmappe(pid) / f"{kid}.json", k)
return True
def stt_token() -> dict | None:
"""Engangs-token til browserens direkte forbindelse til ElevenLabs' realtime-STT (15 min, kun STT),
så API-nøglen aldrig forlader serveren."""
if not ELEVEN_KEY:
return None
try:
r = httpx.post("https://api.elevenlabs.io/v1/single-use-token/realtime_scribe",
headers={"xi-api-key": ELEVEN_KEY}, timeout=30)
if r.status_code == 200 and r.json().get("token"):
t = r.json()["token"]
return {"token": t, "url": "wss://api.elevenlabs.io/v1/speech-to-text/realtime?model_id=scribe_v2_realtime"
f"&token={t}&audio_format=pcm_16000&sample_rate=16000&commit_strategy=vad&language_code=da"}
except (httpx.HTTPError, ValueError):
pass
return None
# ---------------- ElevenLabs: tale ----------------
def transskriber(mp3: Path) -> str:
@@ -613,6 +677,9 @@ def eksport(pid: str) -> bytes:
sp = spoergsmaal_fil(pid, x["id"])
if sp:
z.write(sp, f"{x['id']}-sp.mp3")
ren = renset_fil(pid, x["id"])
if ren:
z.write(ren, f"{x['id']}-ren.mp3")
z.writestr(f"{x['id']}.json", json.dumps(x, ensure_ascii=False, indent=1))
linjer.append(f"## {x['id']} · {x.get('tid', '')[:16]} · {x.get('projekt')}{' · favorit' if x.get('favorit') else ''}\n"
f"Spørgsmål ({x.get('kilde') or 'ukendt'}{', oplæsning i ' + x['id'] + '-sp.mp3' if sp else ''}): {x.get('spoergsmaal', '')}\n\n"
+196 -13
View File
@@ -2,12 +2,14 @@
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
from starlette.middleware.sessions import SessionMiddleware
import asyncio
import contextlib
import logging
import re
import time
@@ -28,11 +30,20 @@ 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.
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
@@ -43,6 +54,9 @@ app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=T
same_site="lax", max_age=60 * 60 * 12)
app.mount("/static", StaticFiles(directory=config.BASE / "static"), name="static")
templates = Jinja2Templates(directory=str(config.BASE / "templates"))
# Cache-busting af portalens eget CSS: uden Cache-Control cacher Safari heuristisk i timevis,
# så en rettelse i portal.css nåede ikke telefonen. Versionen er filens ændringstid.
templates.env.globals["static_v"] = str(int((config.BASE / "static" / "portal.css").stat().st_mtime))
oauth = OAuth()
oauth.register(
@@ -242,7 +256,8 @@ async def ops_content(request: Request):
version, rp, vers = await asyncio.gather(
svc.assets_version(), svc.rp_status(), versions.all_versions())
return templates.TemplateResponse(request, "admin_ops_body.html", {
"request": request, "version": version, "rp": rp, "versions": vers})
"request": request, "version": version, "rp": rp, "versions": vers,
"host": versions.host_updates()})
@app.get("/admin/stats", response_class=HTMLResponse)
@@ -453,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)
@@ -463,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}
@@ -487,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:
@@ -504,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"})
@@ -512,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"})
@@ -524,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"})
@@ -579,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()]})
@@ -600,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)
@@ -700,6 +739,11 @@ async def erindringer_tts(sha: str):
def _klip_svar(pid: str, kid: str):
if kid.endswith("-sp"): # spørgsmålets oplæsning hører til svaret: <id>-sp.mp3
return _spoergsmaal_svar(pid, kid[:-3])
if kid.endswith("-ren"): # svaret med baggrundsstøj fjernet (valgfrit)
f = erindringer.renset_fil(pid, kid[:-4])
if not f:
raise HTTPException(404)
return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, no-store"})
f = erindringer.klip_fil(pid, kid)
if not f:
raise HTTPException(404)
@@ -825,7 +869,20 @@ async def erindringer_svar(request: Request):
return _fejl(400, str(e))
_i_baggrunden(_efter_svar(p, erindringer.projekt(slug), samtale, meta["id"]))
return {"id": meta["id"], "tekst": meta["tekst"], "status": meta["status"], "varighed": meta["varighed"],
"samtale": samtale, "kladde": kladde}
"samtale": samtale, "kladde": kladde, "skrevet": meta.get("skrevet", False)}
@app.post("/erindringer/api/stt-token")
async def erindringer_stt_token(request: Request):
try:
data = await request.json()
except ValueError:
data = {}
_erindringer_person(request, str(data.get("noegle") or ""))
t = await asyncio.to_thread(erindringer.stt_token)
if not t:
return _fejl(503, "Live-afskrift er ikke tilgængelig lige nu.")
return t
@app.post("/erindringer/api/status")
@@ -838,7 +895,8 @@ async def erindringer_status(request: Request):
k = erindringer.klip(p["pid"], str(data.get("id") or ""))
if not k:
return _fejl(404, "Svaret findes ikke.")
return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0)}
return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0),
"skrevet": k.get("skrevet", False)}
@app.post("/erindringer/api/bekraeft")
@@ -856,7 +914,8 @@ async def erindringer_bekraeft(request: Request):
pr = erindringer.projekt(k.get("projekt") or "")
if pr:
_i_baggrunden(_efter_svar(p, pr, k.get("samtale") or "", k["id"]))
return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0)}
return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0),
"skrevet": k.get("skrevet", False)}
@app.post("/erindringer/api/slet")
@@ -977,6 +1036,10 @@ async def admin_erindringer_klip_post(request: Request, pid: str, kid: str):
erindringer.slet_klip(pid, kid)
elif handling == "ret":
erindringer.ret_tekst(pid, kid, str(form.get("tekst") or ""))
elif handling == "isoler":
ok = await asyncio.to_thread(erindringer.isoler_stemme, pid, kid)
if not ok:
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}?saved=Rensningen+lykkedes+ikke#k-{kid}", status_code=303)
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}#k-{kid}", status_code=303)
@@ -1120,6 +1183,124 @@ 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"])],
"ttl": epub.varighed(int(epub.get_settings()["output_ttl_seconds"]))})
@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))
@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)
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()
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, 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)
# ---------------- medie-oprydning ----------------
@app.get("/admin/media", response_class=HTMLResponse)
async def admin_media(request: Request, saved: str = ""):
@@ -1272,6 +1453,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"],
+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
+6 -7
View File
@@ -174,11 +174,10 @@ th { color:var(--muted); font-weight:600; font-size:.8rem; text-transform:upperc
.ucard.hidden { display:none; }
/* Tabeller og andet bredt indhold må aldrig give siden vandret scroll på en telefon.
Standard for alle sider: på smalle skærme bliver en tabel sit eget rulleområde,
og lange ord/nøgler brydes. (Ops-siden har desuden sin egen kort-visning.) */
/* Tabeller og andet bredt indhold må aldrig give siden vandret scroll.
base.html pakker hver tabel i .wt-table-wrap (standardkomponent, også for injiceret indhold):
tabellen ruller i sit eget område. Lange ord/nøgler uden for tabeller brydes. */
.wt-main { overflow-wrap: anywhere; min-width: 0; }
@media (max-width: 760px) {
.wt-main table { display: block; max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.wt-main table td, .wt-main table th { overflow-wrap: normal; }
}
.wt-table-wrap { overflow-x: auto; max-width: 100%; -webkit-overflow-scrolling: touch; }
.wt-table-wrap > table { margin-top: 0; }
.wt-table-wrap table td, .wt-table-wrap table th { overflow-wrap: normal; }
+55
View File
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Admin — E-bøger & lydbøger{% endblock %}
{% block body %}
<p><a href="/admin" class="wt-muted">← Admin</a></p>
<h1>E-bøger &amp; lydbøger</h1>
<p class="wt-lead">Indstillinger for <a href="/e-pub">/e-pub</a>. Proxy-nøglen styres i <code>/etc/portal.env</code> og vises ikke her.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
{% if tjek.mangler_filer or tjek.mangler_programmer or (settings.rp_required and not tjek.rp_noegle) %}
<div class="err">
{% if tjek.mangler_filer %}<p>Mangler i <code>{{ settings.tools_dir }}</code>: {{ tjek.mangler_filer | join(", ") }}</p>{% endif %}
{% if tjek.mangler_programmer %}<p>Mangler på serveren: {{ tjek.mangler_programmer | join(", ") }}</p>{% endif %}
{% if settings.rp_required and not tjek.rp_noegle %}<p>Proxy er påkrævet, men <code>RP_API_KEY</code> er ikke sat: alle jobs fejler.</p>{% endif %}
</div>
{% else %}
<p class="wt-muted">✅ Værktøjer, programmer og proxy-nøgle er på plads.</p>
{% endif %}
<form method="post" action="/admin/epub" style="margin-top:1rem;max-width:40rem;display:grid;gap:.8rem">
<label>Hvem må bruge siden (Authentik-gruppe; tom = kun admins)
<input class="wt-input" name="allowed_group" value="{{ settings.allowed_group }}"></label>
<label>Samtidige jobs
<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>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>
</form>
<h2>Jobs lige nu</h2>
{% if jobs %}
<table>
<thead><tr><th>Bruger</th><th>Tilstand</th><th>Fil</th><th>Besked</th></tr></thead>
<tbody>
{% for j in jobs %}
<tr><td>{{ j.ejer }}</td><td>{{ j.tilstand }}</td>
<td>{% if j.filnavn %}{{ j.filnavn }} ({{ human(j.stoerrelse) }}){% endif %}</td><td>{{ j.besked }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="wt-muted">Ingen.</p>
{% endif %}
{% endblock %}
+4 -1
View File
@@ -33,19 +33,22 @@
</div>
<h2 style="font-size:1.15rem">{{ klip|length }} svar</h2>
<p class="wt-muted" style="margin:.2rem 0 .8rem;max-width:48rem">Markér de svar, der skal med i podcasten, som favorit. Kun favoritter kan renses med voice isolation (ElevenLabs Audio Isolation, cirka 1000 credits pr. minut); det laver en ekstra fil ved siden af originalen og bruges ikke til dagbog.</p>
{% for k in klip %}
<div class="wt-card" id="k-{{ k.id }}" style="padding:.9rem 1.2rem;margin:.6rem 0;max-width:48rem{% if k.favorit %};border-color:var(--color-accent){% endif %}">
<div class="wt-muted" style="font-size:.85rem">{{ k.tid[:16]|replace('T',' ') }} · {{ k.projekt_titel }} · {% if k.skrevet %}skrevet{% else %}{{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}{% if k.redigeret %} · tekst rettet{% endif %} · <span style="font-family:ui-monospace,monospace">{{ k.id }}</span></div>
<p style="margin:.4rem 0"><strong>{{ k.spoergsmaal }}</strong>{% if k.kilde %} <span class="wt-tag muted">{{ k.kilde }}</span>{% endif %}</p>
{% if k.spoergsmaal_talt and k.spoergsmaal_talt != k.spoergsmaal %}<p class="wt-muted" style="margin:0 0 .3rem;font-size:.85rem">Oplæst som: {{ k.spoergsmaal_talt }}</p>{% endif %}
{% if k.spoergsmaal_lyd %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Spørgsmålet:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}-sp.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}
{% if not k.skrevet %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Svaret:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}
{% if not k.skrevet %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Svaret:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio></p>
{% if k.renset %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Svaret, renset (voice isolation):</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}-ren.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}{% endif %}
<form method="post" action="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}" style="margin:.5rem 0 0">
<textarea class="wt-input" name="tekst" rows="4" style="width:100%">{{ k.tekst }}</textarea>
<p style="margin:.5rem 0 0;display:flex;gap:.5rem;flex-wrap:wrap">
<button class="wt-btn" name="handling" value="ret">Gem tekst</button>
<button class="wt-btn{% if k.favorit %} wt-btn--primary{% endif %}" name="handling" value="favorit" onclick="this.form.favorit.value='{{ '0' if k.favorit else '1' }}'">{{ '★ Favorit' if k.favorit else '☆ Markér som favorit' }}</button>
<input type="hidden" name="favorit" value="1">
{% if not k.skrevet and k.favorit %}<button class="wt-btn" name="handling" value="isoler" title="ElevenLabs Audio Isolation fjerner baggrundsstøj; cirka 1000 credits pr. minut" onclick="return confirm('Rens lyden med voice isolation? Det koster cirka 1000 credits pr. minut.')">{{ 'Rens igen' if k.renset else 'Rens lyd (voice isolation)' }}</button>{% endif %}
<button class="wt-btn wt-btn--danger" name="handling" value="slet" onclick="return confirm('Slet svaret for altid?')">Slet</button>
</p>
</form>
+1 -1
View File
@@ -97,7 +97,7 @@
var fstate = { q:'', mode:'all', level:'all' };
// Filter ligger i shell (persistent) og genanvendes efter hver poll → fokus/værdi bevares.
function applyFilter(){
var rows = box.querySelectorAll('.vtable tbody tr'), shown = 0;
var rows = box.querySelectorAll('.vtable tbody tr[data-name]'), shown = 0;
rows.forEach(function(tr){
var name = tr.getAttribute('data-name') || '',
st = tr.getAttribute('data-status'), lvl = tr.getAttribute('data-level'),
+21
View File
@@ -41,6 +41,27 @@
</table>
<p id="opsnomatch" class="muted" style="display:none;margin:.4rem 0">Ingen tjenester matcher filteret.</p>
{% if host %}
<h2 style="display:flex;align-items:baseline;gap:.8rem;flex-wrap:wrap">Proxmox-vært (apt)
{% if host.security %}<span class="vbadge eol">🔒 {{ host.security }} sikkerhedsopdatering{{ 'er' if host.security != 1 }}</span>
{% else %}<span class="vbadge ok">ingen sikkerhedsopdateringer</span>{% endif %}
{% if host.upgradable %}<span class="vbadge update">{{ host.upgradable }} pakker i alt</span>{% endif %}
{% if host.reboot_required %}<span class="vbadge soon" title="kører {{ host.kernel_running }}">genstart krævet{% if host.new_kernel %} (ny kerne){% endif %}</span>{% endif %}
</h2>
<p class="muted" style="margin:.2rem 0 .6rem">Kerne {{ host.kernel_running }}{% if host.new_kernel %} → {{ host.new_kernel }}{% endif %}
· apt-lister opdateret {{ (host.apt_checked or '?')|replace('T',' ') }}
· opdatér med <code>apt update &amp;&amp; apt dist-upgrade</code> på værten</p>
{% if host.security_pkgs %}
<details><summary class="muted" style="cursor:pointer">Vis sikkerhedspakker ({{ host.security }})</summary>
<table class="vtable" style="margin-top:.5rem">
<thead><tr><th>Pakke</th><th>Installeret</th><th>Ny</th></tr></thead>
<tbody>{% for p in host.security_pkgs %}
<tr><td data-label="Pakke"><strong>{{ p.name }}</strong></td><td class="font-mono" data-label="Installeret">{{ p['from'] or '—' }}</td><td class="font-mono muted" data-label="Ny">{{ p.to }}</td></tr>
{% endfor %}</tbody>
</table></details>
{% endif %}
{% endif %}
<h2>Static-assets</h2>
<div class="stat" style="max-width:18rem">
<div class="n">{{ version if version else '—' }}</div>
+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>
+17 -1
View File
@@ -11,7 +11,7 @@
<script>(()=>{try{var t=localStorage.getItem('theme')||(matchMedia('(prefers-color-scheme: light)').matches?'light':'dark');document.documentElement.dataset.theme=t}catch(e){document.documentElement.dataset.theme='dark'}})()</script>
<!-- shared design system (SSOT: agw/Weircon-wind) hosted on assets.weircon.dk -->
<link rel="stylesheet" href="https://assets.weircon.dk/css/weirsoe-theme.css" />
<link rel="stylesheet" href="/static/portal.css" />
<link rel="stylesheet" href="/static/portal.css?v={{ static_v }}" />
<script defer src="https://assets.weircon.dk/js/weirsoe-theme.js"></script>
{% block head %}{% endblock %}
</head>
@@ -56,6 +56,22 @@
</div>
</div>
<script>
// Standardkomponent: enhver tabel i hovedindholdet får et rulleområde om sig (.wt-table-wrap),
// så en bred tabel ruller i sig selv i stedet for at skubbe siden ud over kanten. Gælder også
// indhold, der injiceres senere (fx /admin/ops).
(function () {
function pak(rod) {
(rod || document).querySelectorAll('main table').forEach(function (t) {
if (t.parentElement && t.parentElement.classList.contains('wt-table-wrap')) return;
var w = document.createElement('div'); w.className = 'wt-table-wrap';
t.parentNode.insertBefore(w, t); w.appendChild(t);
});
}
window.wtPakTabeller = pak;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { pak(); }); else pak();
var main = document.querySelector('main');
if (main && window.MutationObserver) new MutationObserver(function () { pak(main); }).observe(main, { childList: true, subtree: true });
})();
(function () {
if (window.__palette) return; window.__palette = true;
var index = null, items = [], sel = 0;
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}E-bog / lydbog{% endblock %}
{% 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. 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"
style="margin-top:1.2rem;display:flex;gap:.6rem;flex-wrap:wrap;align-items:center">
<input class="wt-input" name="link" id="ep-link" required maxlength="2048" placeholder="Indsæt link"
autocapitalize="off" autocorrect="off" spellcheck="false" style="flex:1 1 22rem;min-width:0">
<button class="wt-btn wt-btn--primary" type="submit" id="ep-knap">Hent</button>
</form>
<p id="ep-fejl" class="err" hidden></p>
<div id="ep-jobs" style="margin-top:1.4rem;display:grid;gap:1rem"></div>
<script>
(function () {
var TEKST = { koe: "⏳ I kø", koerer: "⚙️ Arbejder…", faerdig: "✅ Færdig", fejl: "⚠️ Fejlede",
hentet: "Hentet (slettet fra serveren)", udloebet: "Udløbet (slettet fra serveren)" };
var $ = function (id) { return document.getElementById(id); };
var timere = {};
function str(n) {
var e = ["B", "KB", "MB", "GB"], i = 0;
while (n >= 1024 && i < e.length - 1) { n /= 1024; i++; }
return (i ? n.toFixed(1) : n) + " " + e[i];
}
function kort(id) {
var k = $("ep-" + id);
if (k) return k;
k = document.createElement("section");
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>' +
'<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);
});
return k;
}
function vis(st) {
var k = kort(st.id);
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;
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;
if (st.tilstand === "koerer" || st.tilstand === "koe") k.querySelector("details").open = true;
planlaeg(st.id, st.tilstand === "koe" ? 3000 : st.tilstand === "koerer" ? 1500 : st.tilstand === "faerdig" ? 30000 : 0);
}
function planlaeg(id, ms) {
clearTimeout(timere[id]);
if (ms) timere[id] = setTimeout(function () { hent(id); }, ms);
}
function hent(id) {
fetch("/e-pub/status/" + id, { credentials: "same-origin", cache: "no-store" })
.then(function (r) {
if (r.status === 404) { var k = $("ep-" + id); if (k) k.remove(); return null; }
return r.json();
})
.then(function (st) { if (st) vis(st); })
.catch(function () { planlaeg(id, 5000); });
}
$("ep-form").addEventListener("submit", function (e) {
e.preventDefault();
$("ep-knap").disabled = true;
$("ep-fejl").hidden = true;
fetch("/e-pub/start", { method: "POST", credentials: "same-origin", headers: { Accept: "application/json" },
body: new FormData($("ep-form")) })
.then(function (r) { return r.json(); })
.then(function (st) {
if (st.error) { $("ep-fejl").textContent = st.error; $("ep-fejl").hidden = false; return; }
$("ep-link").value = "";
vis(st);
})
.catch(function () { $("ep-fejl").textContent = "Kunne ikke nå serveren."; $("ep-fejl").hidden = false; })
.finally(function () { $("ep-knap").disabled = false; });
});
document.addEventListener("visibilitychange", function () {
if (!document.hidden) Object.keys(timere).forEach(hent);
});
({{ jobs | tojson }}).reverse().forEach(vis);
})();
</script>
{% endblock %}
+134 -14
View File
@@ -109,6 +109,7 @@
<span class="er-taeller" id="er-taeller" hidden>0:00</span>
</button>
<p class="er-status" id="er-status" aria-live="polite"></p>
<p class="er-tekst" id="er-live" hidden style="width:100%;max-width:32rem;text-align:left;min-height:2.4rem"><span id="er-live-fast"></span><span id="er-live-delvis" class="wt-muted"></span></p>
<p class="wt-muted" style="margin:0">Tal bare, som du ville fortælle det til et barnebarn.</p>
<div id="er-optaget" hidden style="width:100%;max-width:32rem">
@@ -158,10 +159,9 @@
<p class="er-q">{{ k.spoergsmaal }}</p>
<p class="er-tekst" data-tekst>{{ k.tekst }}</p>
<div class="er-knapper" style="justify-content:flex-start;margin-top:.4rem">
{% if k.spoergsmaal_lyd %}<button type="button" class="wt-btn" data-begge="{{ k.id }}" aria-label="Afspil spørgsmål og svar">▶ Spørgsmål og svar</button>{% endif %}
<button type="button" class="wt-btn" data-begge="{{ k.id }}" aria-label="Afspil spørgsmål og svar">▶ Spørgsmål og svar</button>
<button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button>
</div>
{% if k.spoergsmaal_lyd %}<audio preload="none" data-sp="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}-sp.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}"></audio>{% endif %}
<audio controls preload="none" data-svar="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.4rem"></audio>
<div class="er-ret"></div>
</li>
@@ -197,7 +197,8 @@
svarKort: el('er-svar-kort'), svarTekst: el('er-svar-tekst'), svarRet: el('er-svar-ret'),
naeste: el('er-naeste'), pause: el('er-pause'), dagens: el('er-dagens'), dagensTom: el('er-dagens-tom'),
flash: el('er-flash'), afrunding: el('er-afrunding'), spring: el('er-spring'),
dag: el('er-dag'), dato: el('er-dato')
dag: el('er-dag'), dato: el('er-dato'),
live: el('er-live'), liveFast: el('er-live-fast'), liveDelvis: el('er-live-delvis')
};
// Tilstand for den igangværende samtale
@@ -309,20 +310,27 @@
retTekst(b.dataset.ret, els, b.parentNode);
});
// "Spørgsmål og svar": spil spørgsmålets oplæsning, og derefter svaret
// "Spørgsmål og svar": ÉT afspiller-element spiller først spørgsmålet og skifter så til svaret.
// (To elementer virker ikke på iPhone: kun det element, man selv trykkede på, må spille videre.)
document.addEventListener('click', function (e) {
var b = e.target.closest && e.target.closest('[data-begge]');
if (!b) return;
var li = b.closest('[data-id]');
var sp = li && li.querySelector('audio[data-sp]');
var sv = li && li.querySelector('audio[data-svar]');
if (!sv) return;
document.querySelectorAll('audio').forEach(function (a) { if (a !== sp && a !== sv) { try { a.pause(); } catch (x) {} } });
var spilSvar = function () { try { sv.currentTime = 0; var p = sv.play(); if (p && p.catch) p.catch(function () {}); } catch (x) {} };
if (sp) {
sp.onended = spilSvar;
try { sp.currentTime = 0; var p = sp.play(); if (p && p.catch) p.catch(spilSvar); } catch (x) { spilSvar(); }
} else spilSvar();
document.querySelectorAll('audio').forEach(function (a) { if (a !== sv) { try { a.pause(); } catch (x) {} } });
var svarSrc = sv.dataset.svarSrc || sv.getAttribute('src');
sv.dataset.svarSrc = svarSrc;
var spSrc = svarSrc.replace(/\.mp3(\?|$)/, '-sp.mp3$1');
var tilSvar = function () {
sv.onended = null; sv.onerror = null;
sv.src = svarSrc; sv.currentTime = 0;
var p = sv.play(); if (p && p.catch) p.catch(function () {});
};
sv.onended = tilSvar;
sv.onerror = tilSvar; // intet spørgsmål gemt (eller det kunne ikke laves): spil svaret
sv.src = spSrc; sv.currentTime = 0;
var p = sv.play(); if (p && p.catch) p.catch(tilSvar);
});
// Datoer i "Tidligere svar" på læsbart dansk
@@ -479,11 +487,13 @@
}
ui.optag.disabled = true;
status('Gør mikrofonen klar…');
liveForbered(); // AudioContext skal skabes i klikket (iOS), og tokenet hentes imens
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
st.stream = stream;
st.chunks = [];
st.mime = mime;
st.ext = endelse(mime);
liveStart(stream);
try {
st.recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
} catch (e) {
@@ -537,12 +547,101 @@
stopTimer();
ui.optag.disabled = true;
status('Gør optagelsen klar…');
liveStop();
try {
if (st.recorder && st.recorder.state !== 'inactive') st.recorder.stop();
else optagelseFaerdig();
} catch (e) { optagelseFaerdig(); }
}
/* ---------- Live-afskrift (ElevenLabs realtime STT) ----------
Mikrofonen sendes som 16 kHz PCM direkte til ElevenLabs over en WebSocket med et
engangs-token fra serveren (API-nøglen bliver på serveren). Teksten vokser, mens man
taler; ved stop sendes den med optagelsen, så serveren ikke skal skrive af bagefter. */
var live = { ctx: null, node: null, src: null, ws: null, token: null, fast: '', delvis: '', aktiv: false, luk: null };
function liveForbered() {
live.fast = ''; live.delvis = ''; live.aktiv = false; live.token = null;
try { ui.live.hidden = true; ui.liveFast.textContent = ''; ui.liveDelvis.textContent = ''; } catch (e) {}
try {
var AC = window.AudioContext || window.webkitAudioContext;
if (AC && !live.ctx) {
try { live.ctx = new AC({ sampleRate: 16000 }); } catch (e) { live.ctx = new AC(); }
}
if (live.ctx && live.ctx.state === 'suspended') live.ctx.resume().catch(function () {});
} catch (e) { live.ctx = null; }
live.token = kald('/stt-token', {}).catch(function () { return null; });
}
function liveStart(stream) {
if (!live.ctx || !window.WebSocket) return;
live.token.then(function (t) {
if (!t || !t.url || !st.stream) return;
var ws;
try { ws = new WebSocket(t.url); } catch (e) { return; }
live.ws = ws;
ws.onmessage = function (ev) {
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.message_type === 'partial_transcript') { live.delvis = m.text || ''; }
else if (m.message_type === 'committed_transcript') {
if (m.text) live.fast = (live.fast + ' ' + m.text).trim();
live.delvis = '';
}
liveVis();
};
ws.onclose = function () { live.aktiv = false; if (live.luk) { live.luk(); live.luk = null; } };
ws.onerror = function () { live.aktiv = false; };
ws.onopen = function () {
if (!st.stream) { try { ws.close(); } catch (e) {} return; }
live.aktiv = true;
ui.live.hidden = false;
try {
live.src = live.ctx.createMediaStreamSource(stream);
live.node = live.ctx.createScriptProcessor(4096, 1, 1);
var faktor = live.ctx.sampleRate / 16000; // Safari ignorerer af og til ønsket samplerate
live.node.onaudioprocess = function (e) {
if (!live.aktiv || ws.readyState !== 1) return;
var inp = e.inputBuffer.getChannelData(0);
var n = Math.floor(inp.length / faktor);
var pcm = new Int16Array(n);
for (var i = 0; i < n; i++) {
var v = inp[Math.floor(i * faktor)];
v = Math.max(-1, Math.min(1, v));
pcm[i] = v < 0 ? v * 32768 : v * 32767;
}
var bytes = new Uint8Array(pcm.buffer), bin = '';
for (var j = 0; j < bytes.length; j += 8192) bin += String.fromCharCode.apply(null, bytes.subarray(j, j + 8192));
ws.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: btoa(bin), commit: false, sample_rate: 16000 }));
};
live.src.connect(live.node);
live.node.connect(live.ctx.destination);
} catch (e) { live.aktiv = false; }
};
});
}
function liveVis() {
ui.liveFast.textContent = live.fast;
ui.liveDelvis.textContent = live.delvis ? (' ' + live.delvis) : '';
}
// Stopper lydstrømmen, beder om den sidste commit og giver teksten max 2,5 s til at lande
function liveStop() {
live.aktiv = false;
try { if (live.node) { live.node.disconnect(); live.node.onaudioprocess = null; } if (live.src) live.src.disconnect(); } catch (e) {}
live.node = null; live.src = null;
var ws = live.ws; live.ws = null;
if (ws && ws.readyState === 1) {
try { ws.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: '', commit: true, sample_rate: 16000 })); } catch (e) {}
live.aktiv = true; // modtag stadig beskeder, til vi lukker
setTimeout(function () { live.aktiv = false; try { ws.close(); } catch (e) {} }, 2500);
}
}
function liveTekst() {
return (live.fast + (live.delvis ? ' ' + live.delvis : '')).trim();
}
function optagelseFaerdig() {
stopStream(); // ellers bliver den røde mikrofon-prik stående på iPhone
ui.optag.classList.remove('optager');
@@ -562,7 +661,13 @@
ui.optaget.hidden = false;
status('Hør dit svar igennem, og send det, når du er tilfreds.');
ui.send.focus();
sendKladde();
// Giv live-afskriften op til 2 s til at sende den sidste sætning, før kladden går op
if (live.ws || live.aktiv) {
var blob = st.blob, sendt = false;
var go = function () { if (!sendt && st.blob === blob) { sendt = true; sendKladde(); } };
live.luk = go;
setTimeout(go, 2200);
} else sendKladde();
}
// Optagelsen sendes op med det samme som kladde, så afskriften er i gang, mens man lytter.
@@ -572,6 +677,10 @@
var form = new FormData();
form.append('lyd', st.blob, 'svar.' + st.ext);
form.append('kladde', '1');
// Live-afskriften (hvis den var med) sendes med, så serveren slipper for at skrive af bagefter.
// Vent et øjeblik på den sidste committed-besked, så kladden får hele teksten med.
var t = liveTekst();
if (t) form.append('tekst', t);
form.append('projekt', st.projekt);
form.append('samtale', st.samtale || '');
form.append('spoergsmaal', st.spoergsmaal);
@@ -722,10 +831,21 @@
var m = document.createElement('p'); m.className = 'er-meta'; m.textContent = 'Varighed ' + mmss(j.varighed);
li.appendChild(m);
}
var ret = document.createElement('div'); ret.className = 'er-ret';
var knapper = document.createElement('div'); knapper.className = 'er-knapper'; knapper.style.justifyContent = 'flex-start';
if (!j.skrevet) {
var begge = document.createElement('button');
begge.type = 'button'; begge.className = 'wt-btn'; begge.textContent = '▶ Spørgsmål og svar'; begge.setAttribute('data-begge', j.id);
knapper.appendChild(begge);
}
var b = document.createElement('button');
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten'; b.setAttribute('data-ret', j.id);
ret.appendChild(b); li.appendChild(ret);
knapper.appendChild(b); li.appendChild(knapper);
if (!j.skrevet) {
var a = document.createElement('audio'); a.controls = true; a.preload = 'none'; a.setAttribute('data-svar', j.id);
a.src = ROD + '/klip/' + j.id + '.mp3' + (NOEGLE ? ('?noegle=' + encodeURIComponent(NOEGLE)) : '');
a.style.marginTop = '.4rem'; li.appendChild(a);
}
var ret = document.createElement('div'); ret.className = 'er-ret'; li.appendChild(ret);
ui.dagens.insertBefore(li, ui.dagens.firstChild);
ui.dagensTom.hidden = true;
return t;
+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); });
});
+150
View File
@@ -0,0 +1,150 @@
# Integrationstest af /e-pub under en RIGTIG uvicorn (baggrundsjobs + subprocess), med et
# stub-værktøj i stedet for det rigtige og current_user patchet (ingen OIDC). Kør fra repo-roden:
# python tests/epub_uvicorn.py
import json, os, pathlib, sys, tempfile, threading, time
ROD = pathlib.Path(tempfile.mkdtemp(prefix="epub-test-"))
os.environ.update(PORTAL_DATA=str(ROD / "data"), AUTHENTIK_BASE="https://x.invalid", OIDC_CLIENT_ID="x",
OIDC_CLIENT_SECRET="x", AUTHENTIK_API_TOKEN="x", SESSION_SECRET="x", RP_API_KEY="hemmelig-noegle")
VAERKTOEJ = ROD / "tools"
VAERKTOEJ.mkdir()
for f in ("run.sh", "run-audiobook.sh", "lib.sh"):
(VAERKTOEJ / f).write_text("#!/bin/bash\n")
(VAERKTOEJ / "fetch.sh").write_text(r'''#!/bin/bash
echo "trin 1: $1" >&2
case "$1" in
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 ;;
proxy) exit 4 ;;
slow) sleep 31.7 & wait ;;
*) echo "noget andet: $RP_API_KEY" >&2; exit 7 ;;
esac
''')
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,
"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
BRUGER = {"name": "Test", "username": "test", "groups": []}
main.current_user = lambda request: BRUGER
srv = uvicorn.Server(uvicorn.Config(main.app, port=8019, log_level="warning"))
threading.Thread(target=srv.run, daemon=True).start()
while not srv.started:
time.sleep(0.1)
c = httpx.Client(base_url="http://127.0.0.1:8019", headers={"Accept": "application/json"}, timeout=30)
def tjek(navn, ok):
print(("OK " if ok else "FEJL ") + navn)
if not ok:
global fejl
fejl += 1
def vent(jid, slut=("faerdig", "fejl")):
for _ in range(100):
st = c.get(f"/e-pub/status/{jid}").json()
if st["tilstand"] in slut:
return st
time.sleep(0.2)
return st
fejl = 0
tjek("ingen adgang uden gruppe", c.get("/e-pub").status_code == 403)
BRUGER["groups"] = ["authentik Admins"]
tjek("admin har adgang", c.get("/e-pub").status_code == 200)
tjek("admin-side", c.get("/admin/epub").status_code == 200)
tjek("tom input afvist", c.post("/e-pub/start", data={"link": " "}).status_code == 400)
tjek("styretegn afvist", c.post("/e-pub/start", data={"link": "a\nb"}).status_code == 400)
tjek("for stor body afvist", c.post("/e-pub/start", data={"link": "x" * 20000}).status_code == 413)
a = c.post("/e-pub/start", data={"link": "ok-epub 1"}).json()
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"] / "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"])
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"])
navne = {l.split("=", 1)[0] for l in e["log"] if "=" in l and not l.startswith("trin")}
tjek("rent env (kun PATH/TMP/RP_* + bash' egne)", navne <= {"PATH", "TMP", "RP_API_KEY", "RP_BASE_URL", "RP_REQUIRED", "PWD", "SHLVL", "_"})
tjek("RP_REQUIRED=1, nøgle maskeret, frisk mappe", "RP_REQUIRED=1" in e["log"] and "RP_API_KEY=***" in e["log"] and e["tilstand"] == "faerdig")
for inp, forventet in (("bad", "Kunne ikke læse linket."), ("gone", "Ikke tilgængelig (udløbet?)."), ("proxy", "Serverfejl: proxy")):
s = vent(c.post("/e-pub/start", data={"link": inp}).json()["id"])
tjek(f"exit for {inp}", s["tilstand"] == "fejl" and s["besked"].startswith(forventet))
s = vent(c.post("/e-pub/start", data={"link": "andet"}).json()["id"])
tjek("generisk fejl m. sidste linjer, ingen nøgle", s["besked"].startswith("Noget gik galt.") and "hemmelig" not in s["besked"])
t0 = time.time()
s = vent(c.post("/e-pub/start", data={"link": "slow"}).json()["id"])
tjek("timeout dræber jobbet", s["tilstand"] == "fejl" and "lang tid" in s["besked"] and time.time() - t0 < 8)
tjek("underprocesser dræbt med", os.system("pgrep -f '^sleep 31.7$' >/dev/null") != 0)
BRUGER.update(username="anden", groups=["Bogklub"])
tjek("andre brugeres job = 403 uden gruppe", c.get(f"/e-pub/status/{b['id']}").status_code == 403)
config.EPUB_SETTINGS_PATH.write_text(json.dumps({**json.loads(config.EPUB_SETTINGS_PATH.read_text()), "allowed_group": "Bogklub"}))
tjek("gruppe giver adgang", c.get("/e-pub").status_code == 200)
tjek("men ikke til andres job", c.get(f"/e-pub/status/{b['id']}").status_code == 404)
tjek("og ikke admin", c.get("/admin/epub").status_code == 403)
BRUGER.update(username="test", groups=["authentik Admins"])
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("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())
tjek("ints klemmes, ugyldige → standard", s["max_concurrent_jobs"] == 8 and s["job_timeout_seconds"] == 1200 and s["rp_required"] is True)
print("ALT OK" if not fejl else f"{fejl} FEJL")
srv.should_exit = True
sys.exit(1 if fejl else 0)
+20
View File
@@ -0,0 +1,20 @@
# Integrationstest under en RIGTIG uvicorn (Starlette TestClient afvikler ikke baggrundsjobs færdigt).
# Kør i en kopi af appen: export env fra /etc/portal.env, PORTAL_DATA=/tmp/x, start uvicorn på :8001, kør denne.
import time, httpx, sys, os, pathlib
sys.path.insert(0, "/tmp/ef-test"); import erindringer
os.system('ffmpeg -y -v error -f lavfi -i "sine=frequency=440:duration=2" -c:a libmp3lame /tmp/ef-test/tone.mp3')
c = httpx.Client(base_url="http://127.0.0.1:8001", timeout=120)
erindringer.gem_projekt("test-ep", "Testemne", "b", "Personen er Asger.", ["Hvad husker du fra Rørbæk?", "Hvem var din morfar?"], 2)
p = erindringer.ny_gaest("Tina Test", ["test-ep"]); noegle = p["noegle"]; pid = p["pid"]
t = c.post("/erindringer/api/stt-token", json={"noegle": noegle}).json(); print("stt-token:", "token" in t and t["url"].startswith("wss://"))
sam = erindringer.ny_samtale()
q = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": sam, "noegle": noegle}).json(); print("Q", q["spoergsmaal"][:50])
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": sam, "spoergsmaal": q["spoergsmaal"], "kladde": "1", "noegle": noegle, "lyd_sha": q["lyd_sha"], "tekst": "Jeg husker min morfar Gustav og hans motorcykel i Hannerup."}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")}).json()
print("kladde m. live-tekst:", r["status"], "skrevet:", r["skrevet"])
b = c.post("/erindringer/api/bekraeft", json={"id": r["id"], "noegle": noegle}).json(); print("bekraeft", b["status"])
time.sleep(9)
t0 = time.time(); n = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": sam, "noegle": noegle}).json(); print("naeste %.1fs" % (time.time() - t0), n["spoergsmaal"][:70])
d = pathlib.Path("/tmp/ef-test/data/erindringer/klip/" + pid.replace(":", "_")); sp = d / (r["id"] + "-sp.mp3"); sp.unlink(missing_ok=True)
x = c.get("/erindringer/g/%s/klip/%s-sp.mp3" % (noegle, r["id"])); print("sp on demand:", x.status_code, sp.exists())
print("ren (ikke lavet):", c.get("/erindringer/g/%s/klip/%s-ren.mp3" % (noegle, r["id"])).status_code)
print("ALT OK")
+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")
+13 -3
View File
@@ -74,8 +74,8 @@ SERVICES = [
def _norm(v):
if not v:
return ""
# behold dato-versioner (fx NPMplus 2026-04-21-r2) — strip kun v-præfiks + build-metadata
return re.sub(r"^[vV]", "", str(v).strip()).split("+")[0].strip().lower()
# behold dato-versioner (fx NPMplus 2026-04-21-r2) — strip kun v-/release-præfiks + build-metadata
return re.sub(r"^(release-)?[vV]?", "", str(v).strip()).split("+")[0].strip().lower()
async def _latest(src, client, store):
@@ -229,7 +229,8 @@ async def _one(name, kind, repo, client, local, store):
cur, lat_builtin = await _current(kind, client)
if not cur:
cur = local.get(name) # vært-indsamlet snapshot for LXC-tjenester
latest = lat_builtin or await _latest(repo, client, store)
# _latest i snapshot'et = nyeste fra værten selv (fx apt-kandidat for pve-manager)
latest = lat_builtin or await _latest(repo, client, store) or local.get("_latest", {}).get(name)
eol = await _eol(name, cur, client)
level = None
if cur and latest:
@@ -263,6 +264,10 @@ async def _maybe_alert(res, client):
if e and e.get("state") in ("eol", "soon"):
sig = f"eol:{v['name']}:{e.get('date')}"
cur.add(sig); detail[sig] = f"⏳ {v['name']} EOL {e.get('date')}"
h = host_updates()
if h and h.get("security"):
sig = "sec:host:" + ",".join(sorted(f"{p['name']}={p['to']}" for p in h["security_pkgs"]))
cur.add(sig); detail[sig] = f"🔒 Proxmox-vært: {h['security']} sikkerhedsopdateringer"
try:
stored = set(json.loads(ALERT_STATE.read_text("utf-8")))
except Exception:
@@ -285,6 +290,11 @@ async def _maybe_alert(res, client):
ALERT_STATE.write_text(json.dumps(sorted(cur)), "utf-8")
def host_updates():
"""Værtens apt-status (indsamlet af portal-versions.py) eller None."""
return _load_local().get("_host")
async def all_versions():
hit = cache.get("ver:all")
if hit is not None: