Compare commits

..
Author SHA1 Message Date
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
root 49a33b25a6 feat(erindringer): kladde-upload ved stop, afskrift og næste spørgsmål i baggrunden, dagbog pr. dag, afspil spørgsmål + svar; tabeller ruller selv på mobil
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 22s
Optagelsen sendes op som kladde, når man stopper, så STT kører mens man lytter;
'Send svaret' bekræfter. Næste spørgsmål (Claude + TTS) forberedes i baggrunden
efter hvert svar og hentes på 0 s. Dagbogens samtale er datoen (vælg dag).
'Spørgsmål og svar' afspiller oplæsningen og svaret efter hinanden; /erindringer/alle
viser alt dag for dag. Baggrundsjobs holdes med stærke referencer og logger fejl.
portal.css: én standardregel, så tabeller på smalle skærme ruller i sig selv og
lange ord brydes, i stedet for at siden får vandret scroll.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-06 22:34:36 +02:00
root 3dee428a37 feat(erindringer): samtalestyring med forberedte spørgsmål, talt udgave med lydtags, spørgsmålets lyd gemmes med svaret
CD / deploy (push) Successful in 17s
CI / check (push) Successful in 23s
Intervieweren når de forberedte spørgsmål, følger op højst N gange pr. spørgsmål
(indstilles pr. emne), trækker tilbage til emnet og afrunder. 'Spring over' i UI.
Spørgsmålet oplæses med ElevenLabs-tags ([warmly], [chuckles] …) som ikke vises
i teksten; oplæsningen gemmes som <id>-sp.mp3 ved siden af svaret og følger med i
zip-eksporten, så spørgsmål + svar kan klippes ind i en podcast.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-06 22:15:46 +02:00
root 7f8ddcf7af tests: røgtesten under tests/
CI / check (push) Successful in 27s
Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-06 22:04:39 +02:00
root 4005860364 feat(erindringer): familien fortæller minder og dagbog til en AI-interviewer
CD / deploy (push) Successful in 15s
CI / check (push) Canceled after 17s
Nyt modul erindringer.py: emner (projekter) med baggrund og startspørgsmål,
personer (indloggede + gæstelinks), Claude stiller opfølgende spørgsmål,
ElevenLabs læser op (cachet) og transskriberer, ffmpeg laver mp3, admin med
klip, favoritter og zip-eksport. Forsidekort og admin-modul. Røgtest i tests/.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-06 22:04:20 +02:00
20 changed files with 3212 additions and 9 deletions
+47
View File
@@ -14,6 +14,53 @@ FastAPI-app der:
- Matomo-statistik
- App-beskrivelser & grupper (skriver tilbage til Authentik)
## Erindringer (interview + dagbog)
`/erindringer`: familien fortæller minder til en AI-interviewer. Claude (Anthropic
Messages API, `ANTHROPIC_API_KEY`, model `ERINDRINGER_MODEL`) stiller ét spørgsmål ad gangen
og følger op på det sidste svar; spørgsmålet læses op af ElevenLabs (`ELEVEN_API_KEY`,
stemme `ERINDRINGER_VOICE`, cachet pr. tekst), svaret optages i browseren (MediaRecorder,
webm/mp4), konverteres med ffmpeg til mp3 og transskriberes (ElevenLabs STT). Alt gemmes
pr. person under `PORTAL_DATA/erindringer/` (persondata, aldrig i git).
- Indloggede i familiegrupperne har altid emnet "Dagbog"; admin inviterer til andre emner.
- Gæster uden login får et hemmeligt link `/erindringer/g/<nøgle>/`, lavet i `/admin/erindringer`
(kan lukkes og fornys). Nøglen sendes med i API-kald som `noegle`.
- `/admin/erindringer`: emner (titel, beskrivelse, baggrund til intervieweren, startspørgsmål),
personer, klip med afspiller og transskription, favoritter, zip-eksport til podcast-produktion.
- Edge: gæstelink, `/erindringer/api/`, `/erindringer/tts/` og `/erindringer/klip/` skal uden om
bot-udfordringen og have `client_max_body_size 50m` (lange lydsvar).
- Flow: optagelsen sendes op som kladde i samme øjeblik man stopper (afskriften kører i baggrunden,
mens man lytter), "Send svaret" bekræfter kun, og næste spørgsmål (tekst + oplæsning) forberedes
i baggrunden, så det kommer med det samme. Dagbogen føres én dag ad gangen (samtale = datoen).
Spørgsmålets oplæsning gemmes som `<id>-sp.mp3` ved siden af svaret; "Spørgsmål og svar" afspiller begge.
- Uden nøgler virker siden stadig: intervieweren bruger emnets startspørgsmål, og svar gemmes
uden transskription.
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
+23
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,10 +126,14 @@ 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": "🎙️",
"desc": "Slægtens Stemmer: udgiv kladder og se/træk lytternes personlige feed-adresser tilbage."},
{"key": "erindringer", "title": "Erindringer", "icon": "🎤",
"desc": "Familien fortæller minder og dagbog til en AI-interviewer: projekter, gæstelinks, klip og eksport."},
{"key": "claude-login", "title": "Claude-login", "icon": "🤖",
"desc": "Log Claude på serveren ind igen fra telefonen, uden terminal."},
{"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑",
+12
View File
@@ -11,3 +11,15 @@ MATOMO_TOKEN=__matomo-token__
MATOMO_SITE=2
IP_LABELS_REMOTE=user@edge-host:/path/to/ip_labels.json
PORTAL_DATA=/opt/portal/data
# Erindringer (interview + dagbog): Claude stiller spørgsmål, ElevenLabs læser op og transskriberer.
ANTHROPIC_API_KEY=__sk-ant-…__
ANTHROPIC_WORKSPACE_ID= # kun for bruger-nøgler (sk-ant-usr…), ellers tom
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()
+688
View File
@@ -0,0 +1,688 @@
"""Erindringer: familien fortæller sine minder (eller fører dagbog) til en AI-interviewer.
Interviewer = Claude (Anthropic Messages API via httpx; ingen SDK), der stiller ét spørgsmål ad
gangen og følger op på det, personen lige har sagt. Spørgsmålet læses op af ElevenLabs (cachet pr.
tekst), svaret optages i browseren, transskriberes af ElevenLabs STT og gemmes som mp3 + tekst.
Klippene bruges som råstof til podcasten (Slægtens Stemmer) og som personlig dagbog.
Data (ikke i git, persondata):
DATA/erindringer/projekter.json slug -> titel, beskrivelse, kontekst (til Claude), start (spørgsmål)
DATA/erindringer/personer.json pid -> navn, bruger | noegle, projekter, oprettet, sidst
DATA/erindringer/klip/<pid>/<id>.mp3 svaret (mono 64 kbit)
DATA/erindringer/klip/<pid>/<id>.json id, tid, projekt, samtale, spoergsmaal, tekst, varighed, favorit
DATA/erindringer/tts/<sha>.mp3 oplæste spørgsmål
Personer: indloggede brugere har pid "u:<username>" og kan altid føre dagbog; gæster (familie uden
login) får pid "g:<slug>" og en hemmelig nøgle i adressen, som podcasten gør det. Admin bestemmer,
hvilke projekter en person inviteres til; projektet "dagbog" er altid åbent for indloggede.
"""
import hashlib
import json
import os
import re
import secrets
import subprocess
import tempfile
import time
from pathlib import Path
import httpx
import config
ROOT = config.DATA / "erindringer"
PROJEKTER = ROOT / "projekter.json"
PERSONER = ROOT / "personer.json"
KLIP = ROOT / "klip"
TTS = ROOT / "tts"
SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,40}$")
KEY = re.compile(r"^[A-Za-z0-9_-]{32,64}$")
KLIPID = re.compile(r"^[0-9]{8}-[0-9]{6}-[a-f0-9]{6}$")
PID = re.compile(r"^[ug]:[A-Za-z0-9_.@-]{1,64}$")
ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
ANTHROPIC_WORKSPACE = os.environ.get("ANTHROPIC_WORKSPACE_ID", "")
MODEL = os.environ.get("ERINDRINGER_MODEL", "claude-sonnet-5")
ELEVEN_KEY = os.environ.get("ELEVEN_API_KEY", "")
VOICE = os.environ.get("ERINDRINGER_VOICE", "tJDFCHyviItsYF1qqToS") # Leif, podcastens fortæller
MAX_SVAR_SEK = 15 * 60
MAX_UPLOAD = 40 * 1024 * 1024
DAGBOG = {
"titel": "Dagbog",
"beskrivelse": "Hvad der fylder lige nu. Et par minutter, når du har lyst; interviewerens spørgsmål hjælper dig i gang.",
"kontekst": "Dette er personens private dagbog. Spørg til hverdagen, til det der fylder, til små og store ting, "
"og følg op på det, personen selv bringer på bane. Vær nysgerrig uden at være nærgående.",
"start": ["Hvad har fyldt mest for dig i dag?", "Hvad har du tænkt på i denne uge?",
"Er der sket noget siden sidst, du gerne vil huske?"],
}
# ---------------- fil-hjælpere ----------------
def _læs(p: Path, standard):
try:
return json.loads(p.read_text())
except (OSError, ValueError):
return standard
def _skriv(p: Path, data) -> None:
p.parent.mkdir(parents=True, exist_ok=True)
tmp = p.with_name("." + p.name + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1))
os.replace(tmp, p)
def _nu() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%S%z")
# ---------------- projekter ----------------
def projekter() -> dict:
p = _læs(PROJEKTER, {})
p.setdefault("dagbog", dict(DAGBOG))
return p
def projekt(slug: str) -> dict | None:
if not SLUG.match(slug or ""):
return None
p = projekter().get(slug)
return {**p, "slug": slug} if p else None
def gem_projekt(slug: str, titel: str, beskrivelse: str, kontekst: str, start: list[str], opfoelg: int = 2) -> bool:
"""start = de forberedte spørgsmål, som intervieweren skal nå igennem; opfoelg = højst så mange
opfølgende spørgsmål pr. forberedt spørgsmål, før den går videre."""
if not SLUG.match(slug or "") or not titel.strip():
return False
p = projekter()
gammel = p.get(slug, {})
p[slug] = {"titel": titel.strip(), "beskrivelse": beskrivelse.strip(), "kontekst": kontekst.strip(),
"start": [s.strip() for s in start if s.strip()], "opfoelg": max(0, min(6, int(opfoelg or 0))),
"oprettet": gammel.get("oprettet") or _nu()}
_skriv(PROJEKTER, p)
return True
def slet_projekt(slug: str) -> bool:
p = projekter()
if slug == "dagbog" or slug not in p:
return False
del p[slug]
_skriv(PROJEKTER, p)
ps = personer()
for v in ps.values():
if slug in v.get("projekter", []):
v["projekter"].remove(slug)
_skriv(PERSONER, ps)
return True
# ---------------- personer ----------------
def personer() -> dict:
return _læs(PERSONER, {})
def person(pid: str) -> dict | None:
if not PID.match(pid or ""):
return None
v = personer().get(pid)
return {**v, "pid": pid} if v else None
def person_for_bruger(username: str, navn: str) -> dict:
"""Indlogget bruger: oprettes første gang, dagbog er altid med."""
pid = f"u:{username}"
ps = personer()
v = ps.get(pid)
if v is None:
v = {"navn": navn or username, "bruger": username, "projekter": ["dagbog"], "oprettet": _nu()}
ps[pid] = v
else:
if navn and v.get("navn") != navn:
v["navn"] = navn
if "dagbog" not in v.setdefault("projekter", []):
v["projekter"].insert(0, "dagbog")
v["sidst"] = _nu()
_skriv(PERSONER, ps)
return {**v, "pid": pid}
def person_for_noegle(key: str) -> dict | None:
if not KEY.match(key or ""):
return None
for pid, v in personer().items():
if v.get("noegle") == key and not v.get("lukket"):
v["sidst"] = _nu()
ps = personer(); ps[pid]["sidst"] = v["sidst"]; _skriv(PERSONER, ps)
return {**v, "pid": pid}
return None
def ny_gaest(navn: str, projekt_slugs: list[str]) -> dict:
base = re.sub(r"[^a-z0-9]+", "-", navn.lower().replace("æ", "ae").replace("ø", "oe").replace("å", "aa")).strip("-")[:30] or "gaest"
ps = personer()
slug, n = base, 2
while f"g:{slug}" in ps:
slug, n = f"{base}-{n}", n + 1
pid = f"g:{slug}"
ps[pid] = {"navn": navn.strip(), "noegle": secrets.token_urlsafe(32), "projekter": list(projekt_slugs),
"oprettet": _nu()}
_skriv(PERSONER, ps)
return {**ps[pid], "pid": pid}
def saet_projekter(pid: str, projekt_slugs: list[str]) -> bool:
ps = personer()
if pid not in ps:
return False
ps[pid]["projekter"] = [s for s in projekt_slugs if s in projekter()]
if pid.startswith("u:") and "dagbog" not in ps[pid]["projekter"]:
ps[pid]["projekter"].insert(0, "dagbog")
_skriv(PERSONER, ps)
return True
def ny_noegle(pid: str) -> str | None:
ps = personer()
if pid not in ps or not pid.startswith("g:"):
return None
ps[pid]["noegle"] = secrets.token_urlsafe(32)
ps[pid].pop("lukket", None)
_skriv(PERSONER, ps)
return ps[pid]["noegle"]
def luk_gaest(pid: str, lukket: bool) -> bool:
ps = personer()
if pid not in ps or not pid.startswith("g:"):
return False
if lukket:
ps[pid]["lukket"] = _nu()
else:
ps[pid].pop("lukket", None)
_skriv(PERSONER, ps)
return True
def alle_personer() -> list[dict]:
ud = []
for pid, v in personer().items():
ud.append({**v, "pid": pid, "antal": len(klip_for(pid))})
return sorted(ud, key=lambda v: (v.get("sidst") or v.get("oprettet") or ""), reverse=True)
def maa(p: dict, slug: str) -> bool:
return slug in (p.get("projekter") or []) and projekt(slug) is not None
# ---------------- klip ----------------
def _klipmappe(pid: str) -> Path:
return KLIP / pid.replace(":", "_")
def klip_for(pid: str, projekt_slug: str | None = None, samtale: str | None = None, med_kladder: bool = False) -> list[dict]:
"""Klip for en person, ældste først. Kladder (optagelser, der ikke er sendt endnu) udelades."""
d = _klipmappe(pid)
if not PID.match(pid or "") or not d.exists():
return []
ud = []
for meta in d.glob("*.json"):
if not KLIPID.match(meta.stem):
continue
k = _læs(meta, None)
if not k or not (d / f"{meta.stem}.mp3").exists():
continue
if projekt_slug and k.get("projekt") != projekt_slug:
continue
if samtale and k.get("samtale") != samtale:
continue
if k.get("kladde") and not med_kladder:
continue
ud.append(k)
return sorted(ud, key=lambda k: (k.get("tid") or "", k["id"]))
def klip(pid: str, kid: str) -> dict | None:
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
return None
k = _læs(_klipmappe(pid) / f"{kid}.json", None)
return k if k and k.get("id") == kid else None
def klip_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}.mp3"
return p if p.exists() else None
def _varighed(p: Path) -> int:
try:
ud = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(p)],
capture_output=True, text=True, timeout=30).stdout.strip()
return int(float(ud))
except (OSError, ValueError, subprocess.SubprocessError):
return 0
def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: bytes | None, filnavn: str,
tekst: str | None, spoergsmaal_talt: str = "", lyd_sha: str = "", kilde: str = "",
kladde: bool = False) -> dict:
"""Gem et svar: lyd konverteres til mp3 og transskriberes; ren tekst gemmes som det er.
Spørgsmålets oplæsning (lyd_sha fra tts-cachen) kopieres med som <id>-sp.mp3, så spørgsmål og
svar kan klippes sammen i en podcast. Returnerer klip-metadata. Kaster ValueError med besked."""
if not PID.match(pid or "") or not SLUG.match(projekt_slug or ""):
raise ValueError("Ugyldig person eller projekt.")
d = _klipmappe(pid)
d.mkdir(parents=True, exist_ok=True)
kid = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(3)
mp3 = d / f"{kid}.mp3"
sp = tts_fil(lyd_sha) if lyd_sha else None
if sp is None and spoergsmaal_talt:
sp = tts_sti(spoergsmaal_talt)
if sp:
(d / f"{kid}-sp.mp3").write_bytes(sp.read_bytes())
varighed = 0
if lyd:
if len(lyd) > MAX_UPLOAD:
raise ValueError("Optagelsen er for stor. Prøv at dele den op i to svar.")
suffix = "." + (re.sub(r"[^a-z0-9]", "", (filnavn or "").rsplit(".", 1)[-1].lower()) or "webm")
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(lyd)
raa = Path(tmp.name)
try:
r = subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", str(raa), "-vn", "-ac", "1", "-ar", "44100",
"-c:a", "libmp3lame", "-b:a", "64k", str(mp3)], capture_output=True, text=True, timeout=300)
if r.returncode or not mp3.exists():
raise ValueError("Optagelsen kunne ikke læses. Prøv igen, eller skriv svaret i stedet.")
varighed = _varighed(mp3)
if varighed > MAX_SVAR_SEK:
mp3.unlink(missing_ok=True)
raise ValueError("Svaret er længere end et kvarter. Del det op, så bliver det også nemmere at bruge.")
# Transskriptionen laves bagefter (transskriber_klip), så svaret gemmes med det samme
finally:
raa.unlink(missing_ok=True)
else:
if not (tekst or "").strip():
raise ValueError("Der kom hverken lyd eller tekst med.")
# Tekstsvar: en stille mp3 på 1 s, så alle klip har samme form (afspiller + tekst).
subprocess.run(["ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-t", "1",
"-c:a", "libmp3lame", "-b:a", "32k", str(mp3)], capture_output=True, timeout=60)
meta = {"id": kid, "tid": _nu(), "projekt": projekt_slug, "samtale": samtale, "spoergsmaal": spoergsmaal.strip()[:600],
"spoergsmaal_talt": (spoergsmaal_talt or "").strip()[:800], "spoergsmaal_lyd": bool(sp),
"kilde": kilde if kilde in ("forberedt", "opfoelgning", "afrunding") else "",
"tekst": (tekst or "").strip(), "varighed": varighed, "favorit": False, "skrevet": not lyd,
"kladde": bool(kladde), "status": "transskriberes" if (lyd and not (tekst or "").strip()) else "klar"}
_skriv(d / f"{kid}.json", meta)
return meta
def transskriber_klip(pid: str, kid: str) -> dict | None:
"""Transskribér et gemt lydsvar og skriv teksten ind; idempotent."""
k = klip(pid, kid)
if not k:
return None
if k.get("status") != "transskriberes":
return k
f = klip_fil(pid, kid)
tekst = transskriber(f) if f else ""
k = klip(pid, kid) or k # genlæs: brugeren kan have bekræftet imens
k["tekst"] = k["tekst"] or tekst
k["status"] = "klar" if tekst else "fejl"
_skriv(_klipmappe(pid) / f"{kid}.json", k)
return k
def bekraeft(pid: str, kid: str) -> dict | None:
k = klip(pid, kid)
if not k:
return None
k.pop("kladde", None)
_skriv(_klipmappe(pid) / f"{kid}.json", k)
return k
# ---------------- næste spørgsmål, forberedt i baggrunden ----------------
def _naeste_sti(pid: str, samtale: str) -> Path:
return _klipmappe(pid) / f"_naeste_{samtale}.json"
def gem_forberedt(pid: str, samtale: str, basis: str, q: dict, lyd_sha: str) -> None:
_skriv(_naeste_sti(pid, samtale), {"basis": basis, "q": q, "lyd_sha": lyd_sha, "tid": _nu()})
def hent_forberedt(pid: str, samtale: str, basis: str) -> dict | None:
"""Det forudberegnede næste spørgsmål, hvis det bygger på netop det seneste svar."""
p = _naeste_sti(pid, samtale)
v = _læs(p, None)
if v and v.get("basis") == basis:
p.unlink(missing_ok=True)
return v
return None
def ret_tekst(pid: str, kid: str, tekst: str) -> bool:
k = klip(pid, kid)
if not k:
return False
k["tekst"] = tekst.strip()[:20000]
k["redigeret"] = _nu()
_skriv(_klipmappe(pid) / f"{kid}.json", k)
return True
def saet_favorit(pid: str, kid: str, favorit: bool) -> bool:
k = klip(pid, kid)
if not k:
return False
k["favorit"] = favorit
_skriv(_klipmappe(pid) / f"{kid}.json", k)
return True
def slet_klip(pid: str, kid: str) -> bool:
k = klip(pid, kid)
if not k:
return False
d = _klipmappe(pid)
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, 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:
if not ELEVEN_KEY:
return ""
try:
with open(mp3, "rb") as f:
r = httpx.post("https://api.elevenlabs.io/v1/speech-to-text", headers={"xi-api-key": ELEVEN_KEY},
data={"model_id": "scribe_v2", "language_code": "da"},
files={"file": (mp3.name, f, "audio/mpeg")}, timeout=600)
if r.status_code == 200:
return (r.json().get("text") or "").strip()
except httpx.HTTPError:
pass
return ""
def tts_sti(tekst: str) -> Path | None:
"""Oplæst spørgsmål, cachet på tekstens hash; None hvis det ikke kunne laves."""
tekst = (tekst or "").strip()
if not tekst or not ELEVEN_KEY:
return None
sha = hashlib.sha256((VOICE + "|" + tekst).encode()).hexdigest()[:32]
p = TTS / f"{sha}.mp3"
if p.exists():
return p
TTS.mkdir(parents=True, exist_ok=True)
body = {"text": tekst, "model_id": "eleven_v4", "language_code": "da",
"voice_settings": {"stability": 0.5, "similarity_boost": 0.8, "style": 0.2, "use_speaker_boost": True}}
try:
r = httpx.post(f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE}?output_format=mp3_44100_64",
headers={"xi-api-key": ELEVEN_KEY}, json=body, timeout=120)
if r.status_code == 200 and r.content[:3] in (b"ID3", b"\xff\xfb", b"\xff\xf3"):
tmp = p.with_name("." + p.name + ".tmp")
tmp.write_bytes(r.content)
os.replace(tmp, p)
return p
except httpx.HTTPError:
pass
return None
def tts_fil(sha: str) -> Path | None:
if not re.match(r"^[a-f0-9]{32}$", sha or ""):
return None
p = TTS / f"{sha}.mp3"
return p if p.exists() else None
# ---------------- Claude: interviewer ----------------
# Lyd-tags, som ElevenLabs v4 udfører i stedet for at læse dem op. Alt andet i [klammer] fjernes.
AUDIO_TAGS = {"warmly", "softly", "curious", "gently", "chuckles", "laughs", "pause", "thoughtful", "amused",
"sighs", "whispers", "excited", "surprised", "sincere", "calm", "slowly"}
_TAG = re.compile(r"\[([a-zA-Z ]+)\]")
def keep_tags(text: str) -> str:
return _TAG.sub(lambda m: m.group(0) if m.group(1).strip().lower() in AUDIO_TAGS else "", text or "")
def untag(text: str) -> str:
return re.sub(r"\s{2,}", " ", _TAG.sub("", text or "")).strip()
SYSTEM = """Du er en varm, rolig interviewer for en dansk familie. Du hjælper et familiemedlem med at fortælle
sine minder (eller føre dagbog) ved at stille ÉT spørgsmål ad gangen på dansk. Samtalen skal føles som en
stille snak over kaffen, ikke som et forhør.
Sådan styrer du samtalen:
- Emnet har en liste FORBEREDTE spørgsmål. Dem skal du nå igennem i løbet af samtalen, i den rækkefølge der
passer. "tilbage" viser, hvilke der endnu ikke er stillet (eller er sprunget over; dem lader du ligge).
- Når personen lige har svaret, må du stille højst "opfoelg_max" OPFØLGENDE spørgsmål til det svar, og kun hvis
der er noget konkret at spørge ind til: et navn, et sted, en ting, en pudsig detalje, noget personen selv
griner af eller skynder sig forbi. Det pudsige er guld; spørg ind til det med et smil. "opfoelg_brugt" fortæller,
hvor mange du allerede har brugt på det aktuelle forberedte spørgsmål.
- Er der ikke noget konkret at følge op på, eller er opfølgningerne brugt, så gå videre til næste forberedte
spørgsmål. Kobl gerne overgangen til noget, personen har sagt ("Du nævnte …, det minder mig om …").
- Hvis svaret stikker af fra emnet, så tag venligt tråden op igen med næste forberedte spørgsmål, uden at
påpege afvigelsen.
- Når alle forberedte spørgsmål er stillet og opfølgningerne brugt, stiller du ét let, afrundende spørgsmål
("Er der noget om …, vi ikke har fået med?") og sætter "afslut" til true. Det samme hvis personen virker
træt, svarer meget kort flere gange eller siger de vil stoppe.
- Er "denne_samtale" tom, åbner du med det første forberedte spørgsmål i "tilbage", ordret.
Tone og form:
- Konkret og hverdagsnært: steder, personer, lyde, lugte, hvad man lavede. Højst to sætninger pr. spørgsmål, ét spørgsmål ad gangen.
- Brug personens egne ord. Foreslå ikke svar, ret ikke personen; strider noget mod baggrunden, så spørg nysgerrigt.
- Ingen smiger, ingen udråbstegn, ingen "fantastisk", ingen tankestreger. Skriv som man taler. Navnet bruges sjældent.
- Gentag aldrig et spørgsmål, der allerede er stillet.
Du svarer KUN med JSON med disse felter:
- "spoergsmaal": spørgsmålet som ren tekst, til skærmen.
- "talt": samme spørgsmål som det skal læses op, med lydtags i klammer, der gør stemmen levende: [warmly] [softly]
[curious] [gently] [chuckles] [laughs] [pause] [thoughtful] [amused] [sighs] [slowly]. Højst to-tre tags, lagt
naturligt ind, fx "[warmly] Du nævnte motorcyklen. [chuckles] Hvor stod den egentlig henne?". Ingen andre tags.
- "kilde": "forberedt", "opfoelgning" eller "afrunding".
- "afslut": true/false."""
def _historik(pid: str, projekt_slug: str, samtale: str) -> tuple[list[dict], list[dict]]:
"""(denne samtale, tidligere svar i projektet) som korte dicts til prompten."""
alle = klip_for(pid, projekt_slug)
denne = [k for k in alle if k.get("samtale") == samtale]
tidligere = [k for k in alle if k.get("samtale") != samtale][-30:]
return denne, tidligere
def _kort(k: dict, n: int = 700) -> dict:
return {"dato": (k.get("tid") or "")[:10], "spoergsmaal": k.get("spoergsmaal", ""), "kilde": k.get("kilde", ""),
"svar": (k.get("tekst") or "")[:n]}
def _opfoelg_brugt(denne: list[dict], forberedte: list[str]) -> int:
"""Hvor mange opfølgende spørgsmål er stillet siden sidste forberedte spørgsmål i denne samtale."""
n = 0
for k in reversed(denne):
if k.get("spoergsmaal") in forberedte or k.get("kilde") == "forberedt":
break
n += 1
return n
async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str, sprunget: list[str] | None = None) -> dict:
"""Næste spørgsmål: {"spoergsmaal", "talt", "afslut", "kilde"}. Claude styrer samtalen ud fra de forberedte
spørgsmål; uden nøgle (eller ved fejl) tages de forberedte i rækkefølge."""
denne, tidligere = _historik(p["pid"], pr["slug"], samtale)
forberedte = list(pr.get("start") or DAGBOG["start"])
if pr["slug"] == "dagbog" and forberedte:
# Én dags dagbog: ét åbningsspørgsmål (skifter fra dag til dag), opfølgninger, afrunding.
forberedte = [forberedte[int(samtale[:8]) % len(forberedte)]]
stillede = {k.get("spoergsmaal") for k in denne + tidligere} | set(sprunget or [])
tilbage = [s for s in forberedte if s not in stillede]
opfoelg_max = int(pr.get("opfoelg", 2) if pr.get("opfoelg") is not None else 2)
brugt = _opfoelg_brugt(denne, forberedte)
def fallback() -> dict:
if tilbage:
return {"spoergsmaal": tilbage[0], "talt": tilbage[0], "afslut": False, "kilde": "forberedt"}
q = "Er der noget, vi ikke har fået med, som du gerne vil have med?"
return {"spoergsmaal": q, "talt": "[warmly] " + q, "afslut": True, "kilde": "afrunding"}
if not ANTHROPIC_KEY:
return fallback()
payload = {
"person": {"navn": p.get("navn")},
"projekt": {"titel": pr.get("titel"), "beskrivelse": pr.get("beskrivelse"), "baggrund": pr.get("kontekst", "")[:6000]},
"forberedte": forberedte, "tilbage": tilbage, "sprunget_over": sprunget or [],
"opfoelg_max": opfoelg_max, "opfoelg_brugt": brugt,
"tidligere_samtaler": [_kort(k, 300) for k in tidligere],
"denne_samtale": [_kort(k) for k in denne],
"antal_svar_i_dag": len(denne),
}
headers = {"x-api-key": ANTHROPIC_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"}
if ANTHROPIC_WORKSPACE:
headers["anthropic-workspace-id"] = ANTHROPIC_WORKSPACE
body = {"model": MODEL, "max_tokens": 400, "system": SYSTEM,
"messages": [{"role": "user", "content": "Her er samtalen indtil nu som JSON. Stil næste spørgsmål.\n\n"
+ json.dumps(payload, ensure_ascii=False)}]}
try:
async with httpx.AsyncClient(timeout=60) as c:
r = await c.post("https://api.anthropic.com/v1/messages", headers=headers, json=body)
if r.status_code == 200:
tekst = "".join(b.get("text", "") for b in r.json().get("content", []) if b.get("type") == "text")
m = re.search(r"\{.*\}", tekst, re.S)
if m:
svar = json.loads(m.group(0))
q = untag(svar.get("spoergsmaal") or "").replace("—", ",").replace(" - ", ", ")
talt = keep_tags((svar.get("talt") or q).replace("—", ",").replace(" - ", ", "))
if q:
if untag(talt) != q and len(untag(talt)) < 5:
talt = q
kilde = svar.get("kilde") if svar.get("kilde") in ("forberedt", "opfoelgning", "afrunding") else ("forberedt" if q in forberedte else "opfoelgning")
return {"spoergsmaal": q[:400], "talt": talt[:600], "afslut": bool(svar.get("afslut")), "kilde": kilde}
except (httpx.HTTPError, ValueError):
pass
return fallback()
def ny_samtale() -> str:
return time.strftime("%Y%m%d") + "-" + secrets.token_hex(3)
SAMTALE = re.compile(r"^[0-9]{8}(-[a-f0-9]{6})?$")
def dagbog_samtale(dato: str = "") -> str:
"""Dagbogen føres én dag ad gangen: samtalen ER datoen (YYYYMMDD), i dag medmindre andet vælges."""
d = re.sub(r"[^0-9]", "", dato or "")
if len(d) == 8 and "19000101" <= d <= time.strftime("%Y%m%d"):
return d
return time.strftime("%Y%m%d")
def eksport(pid: str) -> bytes:
"""Zip med alle klip (mp3 + json) og en samlet tekstfil, til podcast-produktionen."""
import io
import zipfile
k = klip_for(pid)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
linjer = []
for x in k:
f = klip_fil(pid, x["id"])
if f:
z.write(f, f"{x['id']}.mp3")
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"
f"{x.get('tekst', '')}\n")
z.writestr("alle_svar.md", "\n".join(linjer))
return buf.getvalue()
+538 -3
View File
@@ -2,12 +2,15 @@
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
import urllib.parse
@@ -26,11 +29,21 @@ import mcp_gateway
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
@@ -41,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(
@@ -149,7 +165,8 @@ async def dashboard_content(request: Request):
"fav_apps": [a for a in by_name if a["slug"] in fav_set],
"favorites": list(fav_set), "view": prefs["view"],
"mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL,
"podcasts": _podcast_kort(user)})
"podcasts": _podcast_kort(user),
"erindringer": bool(is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or []))})
def _podcast_kort(user) -> list[dict]:
@@ -239,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)
@@ -615,6 +633,403 @@ async def admin_podcast_udgiv(request: Request, name: str, slug: str):
return RedirectResponse(f"/admin/podcast?saved={besked}", status_code=303)
# ---------------- erindringer (interview + dagbog) ----------------
# Indloggede (familie) bruger sessionen; gæster uden login bruger en hemmelig nøgle i
# adressen og sender den med i API-kald ("noegle"). Begge ender som en "person" i modulet.
def _erindringer_person(request: Request, noegle: str = "") -> dict:
user = current_user(request)
if user and (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])):
return erindringer.person_for_bruger(user["username"], user.get("name") or user["username"])
if noegle:
if podcast.blokeret(_klient_ip(request)):
raise HTTPException(404)
p = erindringer.person_for_noegle(noegle)
if p:
return p
podcast.forkert(_klient_ip(request))
raise HTTPException(404)
if not user and request.method == "GET":
nxt = request.url.path
raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
raise HTTPException(403, "Kun for familien")
def _erindringer_side(request: Request, p: dict, rod: str, noegle: str, saved: str, valgt: str, alle: bool = False):
user = current_user(request)
pr = []
for slug in p.get("projekter") or []:
x = erindringer.projekt(slug)
if x:
pr.append({**x, "antal": len(erindringer.klip_for(p["pid"], slug))})
titler = {x["slug"]: x["titel"] for x in pr}
alle_klip = list(reversed(erindringer.klip_for(p["pid"])))
seneste = alle_klip if alle else alle_klip[:20]
for k in seneste:
k["projekt_titel"] = titler.get(k.get("projekt"), k.get("projekt"))
k["dag"] = (k.get("tid") or "")[:10]
return templates.TemplateResponse(request, "erindringer.html", {
"request": request, "user": user, "is_admin": is_admin(user), "navn": p.get("navn") or "",
"noegle": noegle, "rod": rod, "api": "/erindringer/api", "projekter": pr, "klip_seneste": seneste,
"alle": alle, "antal_alle": len(alle_klip), "idag": time.strftime("%Y-%m-%d"),
"saved": saved, "valgt": valgt if valgt in titler else ""})
@app.get("/erindringer", response_class=HTMLResponse)
async def erindringer_side(request: Request, saved: str = "", valgt: str = ""):
p = _erindringer_person(request)
return _erindringer_side(request, p, "/erindringer", "", saved, valgt)
@app.get("/erindringer/alle", response_class=HTMLResponse)
async def erindringer_alle(request: Request):
p = _erindringer_person(request)
return _erindringer_side(request, p, "/erindringer", "", "", "", alle=True)
@app.get("/erindringer/g/{key}/alle/", response_class=HTMLResponse)
async def erindringer_gaest_alle(request: Request, key: str):
p = _erindringer_person(request, key)
r = _erindringer_side(request, p, f"/erindringer/g/{key}", key, "", "", alle=True)
r.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
return r
@app.get("/erindringer/g/{key}/", response_class=HTMLResponse)
async def erindringer_gaest(request: Request, key: str, saved: str = "", valgt: str = ""):
p = _erindringer_person(request, key)
r = _erindringer_side(request, p, f"/erindringer/g/{key}", key, saved, valgt)
r.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
r.headers["Referrer-Policy"] = "no-referrer"
return r
@app.get("/erindringer/tts/{sha}.mp3")
async def erindringer_tts(sha: str):
p = erindringer.tts_fil(sha)
if not p:
raise HTTPException(404)
return FileResponse(p, media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"})
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)
return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, no-store"})
@app.get("/erindringer/klip/{kid}.mp3")
async def erindringer_klip(request: Request, kid: str):
p = _erindringer_person(request)
return _klip_svar(p["pid"], kid)
@app.get("/erindringer/g/{key}/klip/{kid}.mp3")
async def erindringer_gaest_klip(request: Request, key: str, kid: str):
p = _erindringer_person(request, key)
return _klip_svar(p["pid"], kid)
def _spoergsmaal_svar(pid: str, kid: str):
f = erindringer.spoergsmaal_fil(pid, kid)
if not f:
raise HTTPException(404)
return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"})
def _fejl(status: int, tekst: str):
return JSONResponse({"fejl": tekst}, status_code=status)
@app.post("/erindringer/api/naeste")
async def erindringer_naeste(request: Request):
try:
data = await request.json()
except ValueError:
return _fejl(400, "Ugyldigt kald.")
p = _erindringer_person(request, str(data.get("noegle") or ""))
slug = str(data.get("projekt") or "")
if not erindringer.maa(p, slug):
return _fejl(403, "Det emne er ikke åbent for dig.")
pr = erindringer.projekt(slug)
samtale = str(data.get("samtale") or "")
if slug == "dagbog":
samtale = erindringer.dagbog_samtale(str(data.get("dato") or ""))
elif not erindringer.SAMTALE.match(samtale):
samtale = erindringer.ny_samtale()
sprunget = [str(x)[:400] for x in (data.get("sprunget") or []) if isinstance(x, str)][:50]
q = await _naeste_med_lyd(p, pr, samtale, sprunget)
return {"samtale": samtale, "spoergsmaal": q["spoergsmaal"], "talt": q["talt"], "afslut": q["afslut"],
"kilde": q["kilde"], "lyd_url": f"/erindringer/tts/{q['lyd_sha']}.mp3" if q.get("lyd_sha") else None,
"lyd_sha": q.get("lyd_sha") or "", "nr": len(erindringer.klip_for(p["pid"], slug, samtale)) + 1}
async def _naeste_med_lyd(p: dict, pr: dict, samtale: str, sprunget: list[str]) -> dict:
"""Næste spørgsmål med oplæsning: tag det forudberegnede, hvis det bygger på det seneste svar;
ellers vent (kort) på at det seneste svar er skrevet af, og regn det ud nu."""
seneste = erindringer.klip_for(p["pid"], pr["slug"], samtale)
basis = seneste[-1]["id"] if seneste else ""
if not sprunget:
v = erindringer.hent_forberedt(p["pid"], samtale, basis)
if v and v.get("q"):
return {**v["q"], "lyd_sha": v.get("lyd_sha") or ""}
if seneste and seneste[-1].get("status") == "transskriberes":
for _ in range(25): # op til ca. 25 s: teksten er på vej fra STT
await asyncio.sleep(1)
k = erindringer.klip(p["pid"], basis)
if not k or k.get("status") != "transskriberes":
break
q = await erindringer.naeste_spoergsmaal(p, pr, samtale, sprunget)
lyd = await asyncio.to_thread(erindringer.tts_sti, q["talt"])
return {**q, "lyd_sha": lyd.stem if lyd else ""}
_baggrund: set = set() # stærke referencer: en create_task uden reference kan blive samlet op af GC midtvejs
def _i_baggrunden(coro) -> None:
t = asyncio.create_task(coro)
_baggrund.add(t)
t.add_done_callback(_baggrund.discard)
async def _efter_svar(p: dict, pr: dict, samtale: str, kid: str) -> None:
"""Baggrundsarbejde efter et svar: transskribér, og forbered næste spørgsmål (tekst + lyd),
så det ligger klar, når personen trykker 'Næste spørgsmål'."""
try:
await asyncio.to_thread(erindringer.transskriber_klip, p["pid"], kid)
k = erindringer.klip(p["pid"], kid)
if not k or k.get("kladde"):
return # ikke sendt endnu (eller slettet): intet at forberede
q = await erindringer.naeste_spoergsmaal(p, pr, samtale, [])
lyd = await asyncio.to_thread(erindringer.tts_sti, q["talt"])
erindringer.gem_forberedt(p["pid"], samtale, kid, q, lyd.stem if lyd else "")
except Exception: # baggrund: må aldrig vælte appen; næste kald regner selv
logging.getLogger("portal.erindringer").exception("baggrundsjob efter svar %s fejlede", kid)
@app.post("/erindringer/api/svar")
async def erindringer_svar(request: Request):
form = await request.form()
p = _erindringer_person(request, str(form.get("noegle") or ""))
slug = str(form.get("projekt") or "")
if not erindringer.maa(p, slug):
return _fejl(403, "Det emne er ikke åbent for dig.")
samtale = str(form.get("samtale") or "")
if not erindringer.SAMTALE.match(samtale):
samtale = erindringer.dagbog_samtale() if slug == "dagbog" else erindringer.ny_samtale()
lyd = form.get("lyd")
data, filnavn = None, ""
if lyd is not None and hasattr(lyd, "read"):
data = await lyd.read()
filnavn = getattr(lyd, "filename", "") or ""
if not data:
data = None
tekst = str(form.get("tekst") or "")
kladde = str(form.get("kladde") or "") == "1"
try:
meta = await asyncio.to_thread(erindringer.gem_svar, p["pid"], slug, samtale, str(form.get("spoergsmaal") or ""),
data, filnavn, tekst or None, str(form.get("spoergsmaal_talt") or ""),
str(form.get("lyd_sha") or ""), str(form.get("kilde") or ""), kladde)
except ValueError as e:
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, "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")
async def erindringer_status(request: Request):
try:
data = await request.json()
except ValueError:
return _fejl(400, "Ugyldigt kald.")
p = _erindringer_person(request, str(data.get("noegle") or ""))
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),
"skrevet": k.get("skrevet", False)}
@app.post("/erindringer/api/bekraeft")
async def erindringer_bekraeft(request: Request):
try:
data = await request.json()
except ValueError:
return _fejl(400, "Ugyldigt kald.")
p = _erindringer_person(request, str(data.get("noegle") or ""))
k = erindringer.bekraeft(p["pid"], str(data.get("id") or ""))
if not k:
return _fejl(404, "Svaret findes ikke.")
if k.get("status") != "transskriberes":
# Teksten er allerede klar: forbered næste spørgsmål nu (ellers gør baggrundsjobbet det)
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),
"skrevet": k.get("skrevet", False)}
@app.post("/erindringer/api/slet")
async def erindringer_slet(request: Request):
try:
data = await request.json()
except ValueError:
return _fejl(400, "Ugyldigt kald.")
p = _erindringer_person(request, str(data.get("noegle") or ""))
if not erindringer.slet_klip(p["pid"], str(data.get("id") or "")):
return _fejl(404, "Svaret findes ikke.")
return {"ok": True}
@app.post("/erindringer/api/ret")
async def erindringer_ret(request: Request):
try:
data = await request.json()
except ValueError:
return _fejl(400, "Ugyldigt kald.")
p = _erindringer_person(request, str(data.get("noegle") or ""))
if not erindringer.ret_tekst(p["pid"], str(data.get("id") or ""), str(data.get("tekst") or "")):
return _fejl(404, "Svaret findes ikke.")
return {"ok": True}
@app.get("/admin/erindringer", response_class=HTMLResponse)
async def admin_erindringer(request: Request, saved: str = "", ny: str = ""):
user = require_admin(request)
pr = erindringer.projekter()
return templates.TemplateResponse(request, "admin_erindringer.html", {
"request": request, "user": user, "is_admin": True, "saved": saved, "ny": ny,
"base": str(request.base_url).rstrip("/"),
"projekter": [{**v, "slug": k} for k, v in pr.items()],
"personer": erindringer.alle_personer(),
"har_claude": bool(erindringer.ANTHROPIC_KEY), "har_eleven": bool(erindringer.ELEVEN_KEY), "model": erindringer.MODEL})
@app.post("/admin/erindringer/projekt")
async def admin_erindringer_projekt(request: Request):
require_admin(request)
form = await request.form()
slug = re.sub(r"[^a-z0-9-]", "-", str(form.get("slug") or "").strip().lower()).strip("-")[:40]
if str(form.get("handling") or "") == "slet":
erindringer.slet_projekt(slug)
return RedirectResponse("/admin/erindringer?saved=Projektet+er+slettet", status_code=303)
start = [s for s in str(form.get("start") or "").splitlines()]
try:
opfoelg = int(str(form.get("opfoelg") or "2"))
except ValueError:
opfoelg = 2
if not erindringer.gem_projekt(slug, str(form.get("titel") or ""), str(form.get("beskrivelse") or ""),
str(form.get("kontekst") or ""), start, opfoelg):
return RedirectResponse("/admin/erindringer?saved=Projektet+skal+have+et+navn+(a-z,+0-9,+bindestreg)+og+en+titel", status_code=303)
return RedirectResponse(f"/admin/erindringer?saved=Projektet+{urllib.parse.quote(slug)}+er+gemt", status_code=303)
@app.post("/admin/erindringer/gaest")
async def admin_erindringer_gaest(request: Request):
require_admin(request)
form = await request.form()
navn = str(form.get("navn") or "").strip()[:80]
if not navn:
return RedirectResponse("/admin/erindringer?saved=Skriv+et+navn", status_code=303)
p = erindringer.ny_gaest(navn, [str(x) for x in form.getlist("projekter")])
return RedirectResponse(f"/admin/erindringer?ny={urllib.parse.quote(p['pid'])}", status_code=303)
@app.post("/admin/erindringer/person/{pid}")
async def admin_erindringer_person_save(request: Request, pid: str):
require_admin(request)
form = await request.form()
handling = str(form.get("handling") or "")
if handling == "projekter":
erindringer.saet_projekter(pid, [str(x) for x in form.getlist("projekter")])
besked = "Emnerne er gemt"
elif handling == "ny-noegle":
erindringer.ny_noegle(pid); besked = "Nyt link lavet; det gamle virker ikke mere"
elif handling == "luk":
erindringer.luk_gaest(pid, True); besked = "Linket er lukket"
elif handling == "aabn":
erindringer.luk_gaest(pid, False); besked = "Linket er åbnet igen"
else:
besked = "Ukendt handling"
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}?saved={urllib.parse.quote(besked)}", status_code=303)
@app.get("/admin/erindringer/p/{pid}", response_class=HTMLResponse)
async def admin_erindringer_person(request: Request, pid: str, saved: str = ""):
user = require_admin(request)
p = erindringer.person(pid)
if not p:
raise HTTPException(404)
pr = erindringer.projekter()
k = list(reversed(erindringer.klip_for(pid)))
for x in k:
x["projekt_titel"] = (pr.get(x.get("projekt")) or {}).get("titel", x.get("projekt"))
return templates.TemplateResponse(request, "admin_erindringer_person.html", {
"request": request, "user": user, "is_admin": True, "saved": saved, "p": p, "klip": k,
"base": str(request.base_url).rstrip("/"), "projekter": [{**v, "slug": s} for s, v in pr.items()]})
@app.get("/admin/erindringer/p/{pid}/klip/{kid}.mp3")
async def admin_erindringer_klip(request: Request, pid: str, kid: str):
require_admin(request)
return _klip_svar(pid, kid)
@app.post("/admin/erindringer/p/{pid}/klip/{kid}")
async def admin_erindringer_klip_post(request: Request, pid: str, kid: str):
require_admin(request)
form = await request.form()
handling = str(form.get("handling") or "")
if handling == "favorit":
erindringer.saet_favorit(pid, kid, str(form.get("favorit") or "") == "1")
elif handling == "slet":
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)
@app.get("/admin/erindringer/p/{pid}/eksport.zip")
async def admin_erindringer_eksport(request: Request, pid: str):
require_admin(request)
if not erindringer.person(pid):
raise HTTPException(404)
data = await asyncio.to_thread(erindringer.eksport, pid)
navn = pid.replace(":", "_")
return Response(data, media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="erindringer-{navn}.zip"'})
@app.get("/admin/claude-login", response_class=HTMLResponse)
async def admin_claude_login(request: Request):
user = require_admin(request)
@@ -744,6 +1159,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 = ""):
@@ -896,6 +1429,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"],
+8
View File
@@ -173,3 +173,11 @@ th { color:var(--muted); font-weight:600; font-size:.8rem; text-transform:upperc
.ucard:has(input:checked) .uchk { background:var(--accent); border-color:var(--accent); color:var(--color-accent-ink); }
.ucard.hidden { display:none; }
/* 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; }
.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 %}
+85
View File
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}Admin — erindringer{% endblock %}
{% block body %}
<p><a href="/admin" class="wt-muted">← Admin</a></p>
<h1>Erindringer</h1>
<p class="wt-lead">Familien fortæller minder til en AI-interviewer, der stiller ét spørgsmål ad gangen og følger op. Svarene gemmes som lyd og tekst pr. person og kan hentes som zip til podcasten. Indloggede i familiegruppen har altid "Dagbog" på <a href="/erindringer">/erindringer</a>; gæster uden login får et hemmeligt link, som du laver her.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
{% if not har_claude or not har_eleven %}
<div class="wt-flash">⚠️ {% if not har_claude %}ANTHROPIC_API_KEY mangler i /etc/portal.env: intervieweren falder tilbage til projektets startspørgsmål.{% endif %}
{% if not har_eleven %}ELEVEN_API_KEY mangler: ingen oplæsning og ingen transskription (svar gemmes kun som lyd).{% endif %}</div>
{% endif %}
{% if ny %}
{% for p in personer if p.pid == ny %}
<div class="wt-card" style="padding:1rem 1.2rem;margin:1rem 0;border-color:var(--color-accent)">
<p style="margin:0 0 .5rem"><strong>Link til {{ p.navn }}</strong>, send det privat (Messenger, sms). Linket er personligt og kan lukkes igen.</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<input class="wt-input" readonly id="ny-link" value="{{ base }}/erindringer/g/{{ p.noegle }}/" style="flex:1 1 22rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
<button class="wt-btn" type="button" onclick="var i=document.getElementById('ny-link');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
</div>
</div>
{% endfor %}
{% endif %}
<h2 style="font-size:1.15rem;margin-top:1.6rem">Personer</h2>
<table class="wt-table">
<thead><tr><th>Navn</th><th>Adgang</th><th>Emner</th><th>Svar</th><th>Sidst</th><th></th></tr></thead>
<tbody>
{% for p in personer %}
<tr>
<td><a href="/admin/erindringer/p/{{ p.pid }}">{{ p.navn }}</a></td>
<td>{% if p.bruger %}login ({{ p.bruger }}){% elif p.lukket %}<span class="wt-tag muted">gæstelink lukket</span>{% else %}gæstelink{% endif %}</td>
<td>{{ p.projekter|join(', ') }}</td>
<td>{{ p.antal }}</td>
<td>{{ (p.sidst or p.oprettet or '')[:10] }}</td>
<td><a class="wt-btn" href="/admin/erindringer/p/{{ p.pid }}">Åbn</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="wt-muted">Ingen endnu. Indloggede dukker op, når de åbner /erindringer første gang.</td></tr>
{% endfor %}
</tbody>
</table>
<h2 style="font-size:1.15rem;margin-top:2rem">Nyt gæstelink (uden login)</h2>
<form method="post" action="/admin/erindringer/gaest" class="wt-card" style="padding:1rem 1.2rem;max-width:48rem;margin:0">
<label>Navn (som intervieweren bruger)<br><input class="wt-input" name="navn" required maxlength="80" style="width:100%"></label>
<p style="margin:.8rem 0 .3rem">Emner, personen må fortælle om:</p>
{% for pr in projekter %}
<label style="display:block;margin:.2rem 0"><input type="checkbox" name="projekter" value="{{ pr.slug }}" {% if pr.slug != 'dagbog' %}checked{% endif %}> {{ pr.titel }} <span class="wt-muted">({{ pr.slug }})</span></label>
{% endfor %}
<p style="margin:.8rem 0 0"><button class="wt-btn wt-btn--primary" type="submit">Lav link</button></p>
</form>
<h2 style="font-size:1.15rem;margin-top:2rem">Emner (projekter)</h2>
<p class="wt-muted" style="margin:.3rem 0 .8rem">Baggrunden gives til intervieweren, så spørgsmålene bliver konkrete (navne, steder, årstal, hvad vi allerede ved). De forberedte spørgsmål er samtalens rygrad: intervieweren ({{ model }}) stiller dem alle, følger op på det konkrete og pudsige i svarene (højst det antal, du sætter), trækker venligt tilbage til emnet, hvis det stikker af, og afrunder, når listen er tømt. Personen kan springe et spørgsmål over og holde pause når som helst. Spørgsmålets oplæsning gemmes sammen med svaret, så spørgsmål og svar kan klippes ind i en podcast. "dagbog" er indbygget og kan ikke slettes, men teksten kan rettes.</p>
{% for pr in projekter %}
<details class="wt-card" style="padding:.8rem 1.2rem;max-width:48rem;margin:.5rem 0">
<summary style="cursor:pointer"><strong>{{ pr.titel }}</strong> <span class="wt-muted">({{ pr.slug }}) · {{ pr.start|length }} startspørgsmål</span></summary>
<form method="post" action="/admin/erindringer/projekt" style="margin:.8rem 0 0">
<input type="hidden" name="slug" value="{{ pr.slug }}">
<label>Titel<br><input class="wt-input" name="titel" value="{{ pr.titel }}" required style="width:100%"></label>
<label style="display:block;margin-top:.6rem">Beskrivelse (vises til personen)<br><textarea class="wt-input" name="beskrivelse" rows="2" style="width:100%">{{ pr.beskrivelse }}</textarea></label>
<label style="display:block;margin-top:.6rem">Baggrund til intervieweren (vises ikke til personen)<br><textarea class="wt-input" name="kontekst" rows="8" style="width:100%">{{ pr.kontekst }}</textarea></label>
<label style="display:block;margin-top:.6rem">Forberedte spørgsmål, ét pr. linje (intervieweren når dem alle, i den rækkefølge der passer, og afrunder derefter)<br><textarea class="wt-input" name="start" rows="5" style="width:100%">{{ pr.start|join('\n') }}</textarea></label>
<label style="display:block;margin-top:.6rem">Højst så mange opfølgende spørgsmål pr. forberedt spørgsmål (0-6)<br><input class="wt-input" name="opfoelg" type="number" min="0" max="6" value="{{ pr.opfoelg if pr.opfoelg is not none else 2 }}" style="width:6rem"></label>
<p style="margin:.8rem 0 0;display:flex;gap:.5rem;flex-wrap:wrap">
<button class="wt-btn wt-btn--primary" type="submit">Gem</button>
{% if pr.slug != 'dagbog' %}<button class="wt-btn wt-btn--danger" type="submit" name="handling" value="slet" onclick="return confirm('Slet emnet {{ pr.slug }}? Svarene bliver liggende hos personerne.')">Slet emne</button>{% endif %}
</p>
</form>
</details>
{% endfor %}
<details class="wt-card" style="padding:.8rem 1.2rem;max-width:48rem;margin:.5rem 0">
<summary style="cursor:pointer"><strong>Nyt emne</strong></summary>
<form method="post" action="/admin/erindringer/projekt" style="margin:.8rem 0 0">
<label>Kort navn (a-z, 0-9, bindestreg)<br><input class="wt-input" name="slug" required pattern="[a-z0-9][a-z0-9-]{0,40}" style="width:100%" placeholder="fx ep10-foraeldrene"></label>
<label style="display:block;margin-top:.6rem">Titel<br><input class="wt-input" name="titel" required style="width:100%"></label>
<label style="display:block;margin-top:.6rem">Beskrivelse (vises til personen)<br><textarea class="wt-input" name="beskrivelse" rows="2" style="width:100%"></textarea></label>
<label style="display:block;margin-top:.6rem">Baggrund til intervieweren<br><textarea class="wt-input" name="kontekst" rows="8" style="width:100%"></textarea></label>
<label style="display:block;margin-top:.6rem">Forberedte spørgsmål, ét pr. linje<br><textarea class="wt-input" name="start" rows="5" style="width:100%"></textarea></label>
<label style="display:block;margin-top:.6rem">Højst så mange opfølgende spørgsmål pr. forberedt spørgsmål (0-6)<br><input class="wt-input" name="opfoelg" type="number" min="0" max="6" value="2" style="width:6rem"></label>
<p style="margin:.8rem 0 0"><button class="wt-btn wt-btn--primary" type="submit">Opret</button></p>
</form>
</details>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Admin — erindringer: {{ p.navn }}{% endblock %}
{% block body %}
<p><a href="/admin/erindringer" class="wt-muted">← Erindringer</a></p>
<h1>{{ p.navn }}</h1>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<div class="wt-card" style="padding:1rem 1.2rem;max-width:48rem;margin:0 0 1.2rem">
{% if p.bruger %}
<p style="margin:0 0 .6rem">Logger ind som <strong>{{ p.bruger }}</strong> og bruger <a href="/erindringer">/erindringer</a>.</p>
{% else %}
<p style="margin:0 0 .6rem"><strong>Gæstelink</strong>{% if p.lukket %} <span class="wt-tag muted">lukket</span>{% endif %}</p>
{% if not p.lukket %}
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.8rem">
<input class="wt-input" readonly id="g-link" value="{{ base }}/erindringer/g/{{ p.noegle }}/" style="flex:1 1 22rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
<button class="wt-btn" type="button" onclick="var i=document.getElementById('g-link');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
</div>
{% endif %}
<form method="post" action="/admin/erindringer/person/{{ p.pid }}" style="display:flex;gap:.5rem;flex-wrap:wrap;margin:0 0 .8rem">
<button class="wt-btn" name="handling" value="ny-noegle" onclick="return confirm('Nyt link? Det gamle holder op med at virke.')">Nyt link</button>
{% if p.lukket %}<button class="wt-btn" name="handling" value="aabn">Åbn igen</button>{% else %}<button class="wt-btn wt-btn--danger" name="handling" value="luk">Luk linket</button>{% endif %}
</form>
{% endif %}
<form method="post" action="/admin/erindringer/person/{{ p.pid }}" style="margin:0">
<input type="hidden" name="handling" value="projekter">
<p style="margin:.2rem 0 .3rem">Emner:</p>
{% for pr in projekter %}
<label style="display:inline-block;margin:.2rem .8rem .2rem 0"><input type="checkbox" name="projekter" value="{{ pr.slug }}" {% if pr.slug in p.projekter %}checked{% endif %}{% if p.bruger and pr.slug == 'dagbog' %} disabled checked{% endif %}> {{ pr.titel }}</label>
{% endfor %}
<p style="margin:.6rem 0 0"><button class="wt-btn" type="submit">Gem emner</button>
<a class="wt-btn" href="/admin/erindringer/p/{{ p.pid }}/eksport.zip">Hent alt som zip</a></p>
</form>
</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>
{% 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>
</div>
{% else %}
<p class="wt-muted">Ingen svar endnu.</p>
{% endfor %}
{% endblock %}
+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>
+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;
+9 -1
View File
@@ -12,7 +12,7 @@
</div>
{% endmacro %}
{% if error %}<div class="err">{{ error }}</div>{% endif %}
{% if podcasts %}
{% if podcasts or erindringer %}
<section class="appgroup" id="podsec">
<h2>🎙️ Familiens podcast</h2>
<div class="grid">
@@ -24,6 +24,14 @@
<div class="go">Lyt →</div>
</div>
{% endfor %}
{% if erindringer %}
<div class="card" data-search="erindringer dagbog minder interview">
<a class="card-link" href="/erindringer" aria-label="Erindringer"></a>
<div class="head"><span class="ico">🎤</span><span class="name">Erindringer</span></div>
<div class="sub">Fortæl dine minder eller før dagbog. En interviewer stiller spørgsmålene.</div>
<div class="go">Fortæl →</div>
</div>
{% endif %}
</div>
</section>
{% endif %}
+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 %}
+861
View File
@@ -0,0 +1,861 @@
{% extends "base.html" %}
{% block title %}Erindringer{% endblock %}
{% block body %}
<style>
/* Ingen vandret scroll på iPhone: alt holder sig inden for kortet, og felter
har mindst 16 px skrift, ellers zoomer iOS ind. */
html, body { overflow-x: hidden; }
.er-side { max-width: 100%; overflow-wrap: anywhere; }
.er-side audio, .er-side textarea, .er-side .wt-card { max-width: 100%; box-sizing: border-box; }
.er-side audio { display: block; width: 100%; min-width: 0; }
.er-side textarea { font-size: 16px !important; min-width: 0; min-height: 7rem; line-height: 1.5; }
.er-side .wt-btn { white-space: normal; }
/* Store, rolige knapper til ældre fingre */
.er-stor { font-size: 1.1rem; padding: .85rem 1.4rem; min-height: 3.2rem; }
.er-knapper { display: flex; flex-wrap: wrap; gap: .7rem; justify-content: center; margin-top: 1rem; }
.er-kort { margin: 1rem 0; padding: 1.1rem 1.2rem; }
.er-emne { display: flex; flex-direction: column; gap: .5rem; }
.er-emne h3 { margin: 0; font-size: 1.25rem; }
.er-emne .wt-btn { align-self: flex-start; }
/* Spørgsmålet: stor skrift, god linjeafstand */
.er-spoergsmaal { font-size: 1.3rem; line-height: 1.5; margin: .3rem 0 .8rem; font-weight: 600; }
/* Den runde optage-knap */
.er-midt { display: flex; flex-direction: column; align-items: center; gap: .8rem; margin: 1.4rem 0; text-align: center; }
.er-optag {
width: 7.5rem; height: 7.5rem; min-width: 88px; min-height: 88px; border-radius: 50%;
border: 3px solid var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
color: inherit; font: inherit; font-size: 1.2rem; font-weight: 700; cursor: pointer;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .15rem;
transition: transform .15s, background .2s;
}
.er-optag:hover { transform: scale(1.04); }
.er-optag:disabled { opacity: .5; cursor: default; transform: none; }
.er-optag.optager {
background: var(--color-bad); border-color: var(--color-bad); color: #fff;
animation: er-puls 1.3s ease-in-out infinite;
}
.er-optag .er-taeller { font-size: .95rem; font-weight: 500; font-variant-numeric: tabular-nums; }
@keyframes er-puls {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-bad) 55%, transparent); }
50% { box-shadow: 0 0 0 18px transparent; }
}
@media (prefers-reduced-motion: reduce) { .er-optag.optager { animation: none; } }
.er-status { min-height: 1.5rem; font-size: 1.05rem; color: var(--color-muted); }
.er-status.travl { color: var(--color-accent); font-weight: 600; }
.er-link { background: none; border: 0; padding: .4rem; color: var(--color-accent); text-decoration: underline;
cursor: pointer; font: inherit; font-size: 1rem; }
/* Svar-lister */
.er-liste { list-style: none; padding: 0; margin: .6rem 0 0; }
.er-liste li { border-top: 1px solid var(--color-border); padding: .9rem 0; }
.er-liste li:first-child { border-top: 0; }
.er-liste .er-q { font-weight: 700; margin: 0 0 .3rem; font-size: 1.05rem; }
.er-liste .er-meta { color: var(--color-muted); font-size: .9rem; margin: 0 0 .3rem; }
.er-tekst { margin: 0; white-space: pre-line; line-height: 1.55; font-size: 1.05rem; }
.er-ret { margin-top: .5rem; }
.er-ret .wt-btn { font-size: .95rem; }
.er-dit-svar { background: color-mix(in srgb, var(--color-good) 8%, transparent); border-color: color-mix(in srgb, var(--color-good) 40%, transparent); }
</style>
<div class="er-side" id="er-side"
data-api="{{ api }}" data-rod="{{ rod }}" data-noegle="{{ noegle }}" data-valgt="{{ valgt }}">
<h1>Erindringer</h1>
<p class="wt-lead">Hej {{ navn }}. Fortæl, så gemmer vi det. Du kan altid holde en pause og komme tilbage.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<div class="wt-flash" id="er-flash" hidden role="status" aria-live="polite"></div>
<!-- Trin 2: vælg emne -->
<section id="er-emner">
<h2>Vælg, hvad du vil fortælle om</h2>
{% for p in projekter %}
<div class="wt-card er-kort er-emne" data-slug="{{ p.slug }}" data-titel="{{ p.titel }}">
<h3>{{ p.titel }}</h3>
{% if p.beskrivelse %}<p style="margin:0">{{ p.beskrivelse }}</p>{% endif %}
<p class="wt-muted" style="margin:0"><span class="er-antal">{{ p.antal }}</span> svar indtil nu</p>
<button type="button" class="wt-btn wt-btn--primary er-stor" data-vaelg="{{ p.slug }}"
aria-label="Fortæl om {{ p.titel }}">Fortæl</button>
</div>
{% else %}
<p class="wt-muted">Der er ingen emner endnu.</p>
{% endfor %}
</section>
<!-- Trin 3: samtalen -->
<section id="er-samtale" hidden>
<h2 id="er-samtale-titel">Samtale</h2>
<p id="er-dag" hidden class="wt-muted" style="margin:0 0 .8rem">Dagbog for <input type="date" id="er-dato" max="{{ idag }}" value="{{ idag }}" style="font-size:16px;padding:.2rem .4rem"> <span class="wt-muted">(én dag ad gangen; vælg en anden dag for at skrive bagud)</span></p>
<div class="wt-card er-kort" id="er-spoergsmaal-kort">
<p class="wt-muted" style="margin:0 0 .2rem">Spørgsmål <span id="er-nr"></span></p>
<p class="er-spoergsmaal" id="er-spoergsmaal">Henter spørgsmålet…</p>
<p class="wt-muted" id="er-afrunding" hidden style="margin:0 0 .6rem">Det var det sidste spørgsmål om emnet for nu. Bagefter kan du holde pause, eller fortælle videre, hvis du har lyst.</p>
<div class="er-knapper" style="justify-content:flex-start">
<button type="button" class="wt-btn er-stor" id="er-hoer" hidden aria-label="Hør spørgsmålet læst op">🔊 Hør spørgsmålet</button>
<button type="button" class="wt-btn" id="er-spring" aria-label="Spring dette spørgsmål over">Spring over</button>
</div>
<audio id="er-lyd" preload="auto"></audio>
</div>
<div class="er-midt" id="er-optagelse">
<button type="button" class="er-optag" id="er-optag" aria-label="Optag dit svar" aria-pressed="false">
<span id="er-optag-tekst">Optag</span>
<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">
<audio id="er-afspil" controls></audio>
<div class="er-knapper">
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-send">Send svaret</button>
<button type="button" class="wt-btn er-stor" id="er-om">Tag det om</button>
</div>
</div>
<button type="button" class="er-link" id="er-skriv-link">Skriv i stedet</button>
<div id="er-skriv" hidden style="width:100%;max-width:32rem;text-align:left">
<label for="er-tekstfelt" class="wt-muted">Skriv dit svar her</label>
<textarea id="er-tekstfelt" rows="5"></textarea>
<div class="er-knapper">
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-send-tekst">Send teksten</button>
</div>
</div>
</div>
<div class="wt-card er-kort er-dit-svar" id="er-svar-kort" hidden>
<p class="wt-muted" style="margin:0 0 .3rem">Dit svar</p>
<p class="er-tekst" id="er-svar-tekst"></p>
<div class="er-ret" id="er-svar-ret"></div>
<div class="er-knapper">
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-naeste">Næste spørgsmål</button>
<button type="button" class="wt-btn er-stor" id="er-pause">Hold pause</button>
</div>
</div>
<h2>Dine svar i dag</h2>
<ul class="er-liste" id="er-dagens"></ul>
<p class="wt-muted" id="er-dagens-tom">Ingen endnu. Dit første svar kommer her.</p>
</section>
<!-- Trin 4: tidligere svar, dag for dag; "Afspil begge" spiller spørgsmålet og derefter svaret -->
<section id="er-tidligere">
<h2>{% if alle %}Alle svar{% else %}Tidligere svar{% endif %}</h2>
{% if klip_seneste %}
<ul class="er-liste">
{% for k in klip_seneste %}
{% if loop.first or k.dag != loop.previtem.dag %}
<li class="er-dagsskel"><h3 style="margin:1.2rem 0 .2rem;font-size:1.05rem"><span data-tid="{{ k.tid }}">{{ k.dag }}</span></h3></li>
{% endif %}
<li data-id="{{ k.id }}">
<p class="er-meta">{{ k.projekt_titel or k.projekt }}{% if k.varighed %} · {{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}</p>
<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">
<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>
<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>
{% endfor %}
</ul>
{% if not alle and antal_alle > klip_seneste|length %}
<p><a class="wt-btn" href="{{ rod }}/alle{% if noegle %}/{% endif %}">Se alle {{ antal_alle }} svar</a></p>
{% endif %}
{% else %}
<p class="wt-muted">Ingen svar endnu. Det første gemmes her, når du har fortalt noget.</p>
{% endif %}
</section>
</div>
<script>
(function () {
if (window.__erindringer) return;
window.__erindringer = true;
var side = document.getElementById('er-side');
var API = side.dataset.api, ROD = side.dataset.rod, NOEGLE = side.dataset.noegle || '';
var MAKS_SEK = 600; // 10 minutter pr. svar
var MIMER = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
function el(id) { return document.getElementById(id); }
var ui = {
emner: el('er-emner'), samtale: el('er-samtale'), titel: el('er-samtale-titel'),
nr: el('er-nr'), spoergsmaal: el('er-spoergsmaal'), hoer: el('er-hoer'), lyd: el('er-lyd'),
optagelse: el('er-optagelse'), optag: el('er-optag'), optagTekst: el('er-optag-tekst'), taeller: el('er-taeller'),
status: el('er-status'), optaget: el('er-optaget'), afspil: el('er-afspil'),
send: el('er-send'), om: el('er-om'), skrivLink: el('er-skriv-link'), skriv: el('er-skriv'),
tekstfelt: el('er-tekstfelt'), sendTekst: el('er-send-tekst'),
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'),
live: el('er-live'), liveFast: el('er-live-fast'), liveDelvis: el('er-live-delvis')
};
// Tilstand for den igangværende samtale
var st = {
projekt: null, titel: '', samtale: null, spoergsmaal: '', talt: '', lydSha: '', kilde: '', lydUrl: null,
kladdeId: null, kladdeKlar: false, dato: '', poll: null,
recorder: null, stream: null, chunks: [], blob: null, mime: '', ext: 'webm',
timer: null, sekunder: 0, startet: 0, sender: false, sendtIDag: 0, lydAfspillet: false
};
/* ---------- Hjælpere ---------- */
function visFlash(tekst) {
ui.flash.textContent = tekst;
ui.flash.hidden = !tekst;
}
function status(tekst, travl) {
ui.status.textContent = tekst || '';
ui.status.classList.toggle('travl', !!travl);
}
function mmss(s) {
s = Math.max(0, Math.round(s));
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
}
function datoTekst(iso) {
try {
var d = new Date(iso);
if (isNaN(d)) return iso.slice(0, 10);
return d.toLocaleDateString('da-DK', { day: 'numeric', month: 'long', year: 'numeric' });
} catch (e) { return iso.slice(0, 10); }
}
function lagerNoegle() { return 'erindringer:samtale:' + st.projekt; }
function husk(id) { try { sessionStorage.setItem(lagerNoegle(), id); } catch (e) {} }
function glem() { try { sessionStorage.removeItem(lagerNoegle()); } catch (e) {} }
function hentHusket() { try { return sessionStorage.getItem(lagerNoegle()); } catch (e) { return null; } }
// Fælles fetch: JSON-body eller FormData, altid med nøgle og marker-header.
// Ingen timeout: transskription kan tage lang tid.
function kald(sti, data) {
var erForm = data instanceof FormData;
if (erForm) data.append('noegle', NOEGLE); else data.noegle = NOEGLE;
var opts = { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } };
if (erForm) { opts.body = data; }
else { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(data); }
return fetch(API + sti, opts).then(function (r) {
return r.text().then(function (t) {
var j = null;
try { j = t ? JSON.parse(t) : null; } catch (e) {}
if (!r.ok) throw new Error((j && j.fejl) || 'Noget gik galt (' + r.status + '). Prøv igen om lidt.');
return j || {};
});
}, function () {
throw new Error('Vi kunne ikke få forbindelse. Tjek, at du har net, og prøv igen.');
});
}
/* ---------- Ret teksten (bruges både i dag og i tidligere svar) ---------- */
// tekstEls: ét eller flere elementer, der viser samme tekst og skal følges ad
function retTekst(id, tekstEls, holder) {
if (holder.querySelector('textarea')) return;
if (!Array.isArray(tekstEls)) tekstEls = [tekstEls];
holder.innerHTML = '';
var ta = document.createElement('textarea');
ta.value = tekstEls[0].textContent;
ta.rows = 6;
var gem = document.createElement('button');
gem.type = 'button'; gem.className = 'wt-btn wt-btn--primary'; gem.textContent = 'Gem';
var fortryd = document.createElement('button');
fortryd.type = 'button'; fortryd.className = 'wt-btn'; fortryd.textContent = 'Fortryd';
var knapper = document.createElement('div');
knapper.className = 'er-knapper'; knapper.style.justifyContent = 'flex-start';
knapper.appendChild(gem); knapper.appendChild(fortryd);
holder.appendChild(ta); holder.appendChild(knapper);
ta.focus();
function tilbage() {
holder.innerHTML = '';
var b = document.createElement('button');
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten';
b.setAttribute('data-ret', id);
holder.appendChild(b);
}
fortryd.addEventListener('click', tilbage);
gem.addEventListener('click', function () {
var ny = ta.value.trim();
gem.disabled = true; fortryd.disabled = true; gem.textContent = 'Gemmer…';
kald('/ret', { id: id, tekst: ny }).then(function () {
tekstEls.forEach(function (t) { t.textContent = ny; });
tilbage();
visFlash('Teksten er rettet.');
}).catch(function (e) {
gem.disabled = false; fortryd.disabled = false; gem.textContent = 'Gem';
visFlash(e.message);
});
});
}
// Alle "Ret teksten"-knapper, også dem der oprettes senere
document.addEventListener('click', function (e) {
var b = e.target.closest && e.target.closest('[data-ret]');
if (!b) return;
var li = b.closest('[data-id]');
var tekstEl = li ? li.querySelector('[data-tekst]') : null;
if (!li || !tekstEl) return;
var els = [tekstEl];
// Er det dagens seneste svar, skal "Dit svar"-kortet følge med
if (!ui.svarKort.hidden && ui.svarKort.dataset.id === b.dataset.ret) els.push(ui.svarTekst);
retTekst(b.dataset.ret, els, b.parentNode);
});
// "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 sv = li && li.querySelector('audio[data-svar]');
if (!sv) return;
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
document.querySelectorAll('[data-tid]').forEach(function (s) { s.textContent = datoTekst(s.dataset.tid); });
/* ---------- Emnevalg ---------- */
function vaelg(slug) {
var kort = ui.emner.querySelector('[data-slug="' + slug + '"]');
if (!kort) return;
st.projekt = slug;
st.titel = kort.dataset.titel || slug;
// Dagbogen føres én dag ad gangen: serveren vælger samtalen ud fra datoen
st.dato = (slug === 'dagbog') ? ui.dato.value : '';
ui.dag.hidden = (slug !== 'dagbog');
st.samtale = (slug === 'dagbog') ? null : hentHusket();
st.sendtIDag = 0;
ui.titel.textContent = st.titel;
ui.dagens.innerHTML = '';
ui.dagensTom.hidden = false;
visFlash('');
ui.emner.hidden = true;
ui.samtale.hidden = false;
window.scrollTo({ top: 0, behavior: 'smooth' });
hentSpoergsmaal(true);
}
ui.emner.addEventListener('click', function (e) {
var b = e.target.closest && e.target.closest('[data-vaelg]');
if (b) vaelg(b.dataset.vaelg);
});
ui.dato.addEventListener('change', function () {
if (st.projekt !== 'dagbog' || st.sender) return;
st.dato = ui.dato.value;
st.samtale = null;
ui.dagens.innerHTML = ''; ui.dagensTom.hidden = false;
hentSpoergsmaal(false);
});
function holdPause() {
stopStream();
stopTimer();
nulstilOptagelse();
ui.svarKort.hidden = true;
ui.samtale.hidden = true;
ui.emner.hidden = false;
// Opdatér tællingen på emnekortet
var kort = ui.emner.querySelector('[data-slug="' + st.projekt + '"] .er-antal');
if (kort) kort.textContent = (parseInt(kort.textContent, 10) || 0) + st.sendtIDag;
st.sendtIDag = 0;
visFlash('Tak for i dag. Dine svar er gemt.');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
ui.pause.addEventListener('click', holdPause);
/* ---------- Spørgsmål ---------- */
function hentSpoergsmaal(foersteForsoeg) {
ui.spoergsmaal.textContent = 'Henter spørgsmålet…';
ui.nr.textContent = '';
ui.hoer.hidden = true;
ui.svarKort.hidden = true;
nulstilOptagelse();
ui.optagelse.hidden = false;
status('');
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null, sprunget: hentSprunget(), dato: st.dato || '' }).then(function (j) {
st.samtale = j.samtale;
husk(j.samtale);
st.spoergsmaal = j.spoergsmaal || '';
st.talt = j.talt || st.spoergsmaal;
st.lydSha = j.lyd_sha || '';
st.kilde = j.kilde || '';
st.lydUrl = j.lyd_url || null;
ui.spoergsmaal.textContent = st.spoergsmaal;
ui.afrunding.hidden = !j.afslut;
ui.nr.textContent = j.nr ? String(j.nr) : '';
ui.hoer.hidden = !st.lydUrl;
if (st.lydUrl) {
ui.lyd.src = st.lydUrl;
// Afspil automatisk, hvis browseren tillader det; ellers kun på klik
var p = ui.lyd.play();
if (p && p.catch) p.catch(function () {});
}
}).catch(function (e) {
// Et gammelt samtale-id kan være udløbet: prøv én gang forfra
if (foersteForsoeg && st.samtale) { st.samtale = null; glem(); return hentSpoergsmaal(false); }
ui.spoergsmaal.textContent = 'Vi kunne ikke hente et spørgsmål.';
visFlash(e.message + ' Du kan holde pause og prøve igen senere.');
});
}
ui.hoer.addEventListener('click', function () {
try {
ui.lyd.currentTime = 0;
var p = ui.lyd.play();
if (p && p.catch) p.catch(function () { visFlash('Lyden kunne ikke afspilles. Du kan læse spørgsmålet ovenfor.'); });
} catch (e) {}
});
ui.naeste.addEventListener('click', function () { hentSpoergsmaal(false); });
// Sprungne spørgsmål huskes pr. projekt, så intervieweren ikke stiller dem igen
function sprungetNoegle() { return 'erindringer:sprunget:' + st.projekt; }
function hentSprunget() { try { return JSON.parse(sessionStorage.getItem(sprungetNoegle()) || '[]'); } catch (e) { return []; } }
ui.spring.addEventListener('click', function () {
if (st.sender || !st.spoergsmaal) return;
try { ui.lyd.pause(); } catch (e) {}
var liste = hentSprunget();
if (liste.indexOf(st.spoergsmaal) < 0) liste.push(st.spoergsmaal);
try { sessionStorage.setItem(sprungetNoegle(), JSON.stringify(liste.slice(-50))); } catch (e) {}
hentSpoergsmaal(false);
});
/* ---------- Optagelse ---------- */
function vaelgMime() {
if (!window.MediaRecorder || !MediaRecorder.isTypeSupported) return '';
for (var i = 0; i < MIMER.length; i++) if (MediaRecorder.isTypeSupported(MIMER[i])) return MIMER[i];
return '';
}
function endelse(mime) {
if (mime.indexOf('mp4') > -1) return 'mp4';
if (mime.indexOf('ogg') > -1) return 'ogg';
return 'webm';
}
function stopStream() {
if (st.stream) { st.stream.getTracks().forEach(function (t) { t.stop(); }); st.stream = null; }
}
function stopTimer() {
if (st.timer) { clearInterval(st.timer); st.timer = null; }
}
function nulstilOptagelse() {
st.blob = null; st.chunks = []; st.sekunder = 0;
ui.optag.disabled = false;
ui.optag.classList.remove('optager');
ui.optag.setAttribute('aria-pressed', 'false');
ui.optagTekst.textContent = 'Optag';
ui.taeller.hidden = true; ui.taeller.textContent = '0:00';
ui.optag.hidden = false;
ui.optaget.hidden = true;
if (ui.afspil.src) { try { URL.revokeObjectURL(ui.afspil.src); } catch (e) {} ui.afspil.removeAttribute('src'); }
ui.skriv.hidden = true; ui.skrivLink.hidden = false;
ui.tekstfelt.value = '';
ui.send.disabled = false; ui.om.disabled = false; ui.sendTekst.disabled = false;
st.sender = false;
}
function startOptagelse() {
visFlash('');
var mime = vaelgMime();
if (!window.MediaRecorder || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
visFlash('Din browser kan ikke optage lyd. Brug "Skriv i stedet" herunder.');
visSkriv();
return;
}
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) {
stopStream();
throw new Error('Optageren kunne ikke startes. Brug "Skriv i stedet", eller prøv en anden browser.');
}
if (!st.mime) st.mime = st.recorder.mimeType || '';
st.ext = endelse(st.mime);
st.recorder.addEventListener('dataavailable', function (e) { if (e.data && e.data.size) st.chunks.push(e.data); });
st.recorder.addEventListener('stop', optagelseFaerdig);
st.recorder.addEventListener('error', function () {
visFlash('Optagelsen blev afbrudt. Prøv igen, eller brug "Skriv i stedet".');
stopOptagelse();
});
st.recorder.start(1000);
st.startet = Date.now();
st.sekunder = 0;
ui.optag.disabled = false;
ui.optag.classList.add('optager');
ui.optag.setAttribute('aria-pressed', 'true');
ui.optag.setAttribute('aria-label', 'Stop optagelsen');
ui.optagTekst.textContent = 'Stop';
ui.taeller.hidden = false; ui.taeller.textContent = '0:00';
ui.skrivLink.hidden = true;
status('Vi optager. Tryk på knappen, når du er færdig.');
st.timer = setInterval(function () {
st.sekunder = Math.round((Date.now() - st.startet) / 1000);
ui.taeller.textContent = mmss(st.sekunder);
if (st.sekunder >= MAKS_SEK) {
stopOptagelse();
visFlash('Vi stoppede optagelsen efter 10 minutter. Du kan sende svaret nu og fortsætte på næste spørgsmål.');
}
}, 1000);
}).catch(function (e) {
stopStream();
ui.optag.disabled = false;
status('');
var navn = e && e.name;
if (navn === 'NotAllowedError' || navn === 'SecurityError' || navn === 'PermissionDeniedError') {
visFlash('Vi fik ikke lov til at bruge mikrofonen. Tillad mikrofonen i browseren, eller brug "Skriv i stedet".');
} else if (navn === 'NotFoundError' || navn === 'DevicesNotFoundError') {
visFlash('Vi kunne ikke finde en mikrofon. Brug "Skriv i stedet".');
} else {
visFlash((e && e.message) || 'Mikrofonen kunne ikke startes. Prøv igen, eller brug "Skriv i stedet".');
}
visSkriv();
});
}
function stopOptagelse() {
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');
ui.optag.setAttribute('aria-pressed', 'false');
ui.optag.setAttribute('aria-label', 'Optag dit svar');
ui.optagTekst.textContent = 'Optag';
ui.taeller.hidden = true;
if (!st.chunks.length) {
nulstilOptagelse();
status('');
visFlash('Der kom ingen lyd med. Prøv igen, og tal tæt på telefonen.');
return;
}
st.blob = new Blob(st.chunks, { type: st.mime || 'application/octet-stream' });
ui.afspil.src = URL.createObjectURL(st.blob);
ui.optag.hidden = true;
ui.optaget.hidden = false;
status('Hør dit svar igennem, og send det, når du er tilfreds.');
ui.send.focus();
// 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.
// "Send svaret" bekræfter bare; "Tag det om" sletter kladden igen.
function sendKladde() {
st.kladdeId = null; st.kladdeKlar = false;
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);
form.append('spoergsmaal_talt', st.talt || '');
form.append('lyd_sha', st.lydSha || '');
form.append('kilde', st.kilde || '');
var blob = st.blob;
kald('/svar', form).then(function (j) {
if (st.blob !== blob) { kald('/slet', { id: j.id }).catch(function () {}); return; } // taget om imens
st.kladdeId = j.id;
st.kladdeKlar = true;
if (j.samtale) { st.samtale = j.samtale; if (st.projekt !== 'dagbog') husk(j.samtale); }
if (st.sender) bekraeft(); // brugeren trykkede "Send" før kladden var oppe
}).catch(function () {
st.kladdeKlar = false; // falder tilbage til almindelig sending ved "Send"
});
}
function bekraeft() {
kald('/bekraeft', { id: st.kladdeId }).then(function (j) {
st.sendtIDag++;
visSvar(j);
}).catch(function (e) {
st.sender = false;
ui.send.disabled = false; ui.om.disabled = false; ui.optag.disabled = false;
status('Svaret blev ikke sendt. Det er stadig her, så prøv igen.');
visFlash(e.message);
});
}
ui.optag.addEventListener('click', function () {
if (st.recorder && st.recorder.state === 'recording') stopOptagelse(); else startOptagelse();
});
ui.om.addEventListener('click', function () {
if (st.kladdeId) { kald('/slet', { id: st.kladdeId }).catch(function () {}); }
st.kladdeId = null; st.kladdeKlar = false;
nulstilOptagelse();
status('');
visFlash('');
});
/* ---------- Skriv i stedet ---------- */
function visSkriv() {
ui.skriv.hidden = false;
ui.skrivLink.hidden = true;
ui.tekstfelt.focus();
}
ui.skrivLink.addEventListener('click', visSkriv);
/* ---------- Send svar ---------- */
function sendSvar(form) {
if (st.sender) return;
st.sender = true;
ui.send.disabled = true; ui.om.disabled = true; ui.sendTekst.disabled = true; ui.optag.disabled = true;
status('Gemmer og skriver af… Det kan tage op til et halvt minut.', true);
visFlash('');
form.append('projekt', st.projekt);
form.append('samtale', st.samtale || '');
form.append('spoergsmaal', st.spoergsmaal);
form.append('spoergsmaal_talt', st.talt || '');
form.append('lyd_sha', st.lydSha || '');
form.append('kilde', st.kilde || '');
kald('/svar', form).then(function (j) {
st.sendtIDag++;
visSvar(j);
}).catch(function (e) {
// Behold det optagede, så det kan sendes igen
st.sender = false;
ui.send.disabled = false; ui.om.disabled = false; ui.sendTekst.disabled = false; ui.optag.disabled = false;
status('Svaret blev ikke sendt. Det er stadig her, så prøv igen.');
visFlash(e.message);
});
}
ui.send.addEventListener('click', function () {
if (!st.blob) { visFlash('Der er ikke noget optaget endnu. Tryk på Optag først.'); return; }
if (st.sender) return;
if (st.kladdeId || st.kladdeKlar !== false) {
// Kladden er oppe (eller på vej): bekræft, i stedet for at sende lyden igen
st.sender = true;
ui.send.disabled = true; ui.om.disabled = true; ui.optag.disabled = true;
status('Gemmer…', true);
visFlash('');
if (st.kladdeId) bekraeft();
return;
}
var form = new FormData();
form.append('lyd', st.blob, 'svar.' + st.ext);
sendSvar(form);
});
ui.sendTekst.addEventListener('click', function () {
var t = ui.tekstfelt.value.trim();
if (!t) { visFlash('Skriv lidt i feltet først.'); ui.tekstfelt.focus(); return; }
var form = new FormData();
form.append('tekst', t);
sendSvar(form);
});
function visSvar(j) {
var venter = (j.status === 'transskriberes');
var tekst = j.tekst || (venter ? 'Teksten er på vej. Du kan gå videre til næste spørgsmål imens.' : '');
ui.optagelse.hidden = true;
status('');
ui.svarTekst.textContent = tekst;
ui.svarTekst.classList.toggle('wt-muted', venter);
ui.svarKort.dataset.id = j.id;
ui.svarKort.hidden = false;
var dagensTekst = tilfoejDagens(j, j.tekst || '');
ui.svarRet.innerHTML = '';
var ret = document.createElement('button');
ret.type = 'button'; ret.className = 'wt-btn'; ret.textContent = 'Ret teksten';
ret.addEventListener('click', function () { retTekst(j.id, [ui.svarTekst, dagensTekst], ui.svarRet); });
ret.hidden = venter;
ui.svarRet.appendChild(ret);
ui.naeste.focus();
st.kladdeId = null; st.kladdeKlar = false;
nulstilOptagelse();
if (venter) ventPaaTekst(j.id, [ui.svarTekst, dagensTekst], ret);
}
// Afskriften kommer i baggrunden: spørg hvert par sekunder, og sæt teksten ind, når den er der
function ventPaaTekst(id, els, retKnap) {
if (st.poll) clearInterval(st.poll);
var n = 0;
st.poll = setInterval(function () {
n++;
kald('/status', { id: id }).then(function (j) {
if (j.status !== 'transskriberes' || n > 60) {
clearInterval(st.poll); st.poll = null;
var t = j.tekst || (j.status === 'fejl' ? 'Afskriften lykkedes ikke; lyden er gemt, og du kan skrive teksten selv.' : '');
els.forEach(function (e) { if (e) { e.textContent = t; e.classList.remove('wt-muted'); } });
if (retKnap) retKnap.hidden = false;
}
}).catch(function () {});
}, 2000);
}
// Sætter svaret øverst i "Dine svar i dag" og returnerer tekst-elementet
function tilfoejDagens(j, tekst) {
var li = document.createElement('li');
li.setAttribute('data-id', j.id);
var q = document.createElement('p'); q.className = 'er-q'; q.textContent = st.spoergsmaal;
var t = document.createElement('p'); t.className = 'er-tekst'; t.setAttribute('data-tekst', ''); t.textContent = tekst;
li.appendChild(q); li.appendChild(t);
if (j.varighed) {
var m = document.createElement('p'); m.className = 'er-meta'; m.textContent = 'Varighed ' + mmss(j.varighed);
li.appendChild(m);
}
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);
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;
}
// Slip mikrofonen, hvis siden forlades midt i en optagelse
window.addEventListener('pagehide', function () { stopTimer(); stopStream(); });
// Åbn et projekt med det samme, hvis serveren bad om det
if (side.dataset.valgt) vaelg(side.dataset.valgt);
})();
</script>
{% endblock %}
+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)
+85
View File
@@ -0,0 +1,85 @@
# Røgtest af erindringer-modulet med FastAPI TestClient, uden OIDC: sessionen sættes direkte.
# Kør i CT151: PORTAL_DATA=/tmp/ef-test/data + env fra /etc/portal.env. Rører ikke prod-data.
import io, json, os, sys
from fastapi.testclient import TestClient
import main, erindringer
c = TestClient(main.app, base_url="https://weircon.dk")
adm = {"username": "agw", "name": "Asger", "groups": [main.config.ADMIN_GROUP, "Familie"]}
def login(client, user):
# Starlette-session: skriv via en lille rute er omstændeligt; monkeypatch current_user i stedet.
main.current_user = lambda request: user
login(c, adm)
r = c.get("/admin/erindringer"); assert r.status_code == 200 and "Erindringer" in r.text, r.status_code
r = c.post("/admin/erindringer/projekt", data={"slug": "test-ep", "titel": "Testemne", "beskrivelse": "b", "kontekst": "Personen er Asger. Vi ved at han bor i Sønderlund.", "start": "Hvad husker du fra Rørbæk?\nHvem var din morfar?"}, follow_redirects=False)
assert r.status_code == 303, r.text
assert erindringer.projekt("test-ep")["start"][0].startswith("Hvad husker")
r = c.post("/admin/erindringer/gaest", data={"navn": "Tina Test", "projekter": ["test-ep"]}, follow_redirects=False)
assert r.status_code == 303
pid = [p for p in erindringer.alle_personer() if p["navn"] == "Tina Test"][0]["pid"]; noegle = erindringer.person(pid)["noegle"]
print("gæst", pid)
# API som gæst (uden session)
login(c, None)
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": noegle}); assert r.status_code == 200, r.text
q1 = r.json(); print("Q1", q1["spoergsmaal"], "| lyd:", bool(q1["lyd_url"]))
if q1["lyd_url"]:
assert c.get(q1["lyd_url"]).status_code == 200
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": q1["spoergsmaal"], "noegle": noegle, "tekst": "Jeg husker min morfar Gustav i Villa Monika i Hannerup. Han havde en stor have og en motorcykel, som han aldrig kørte på."})
assert r.status_code == 200, r.text; s1 = r.json(); print("svar1", s1["id"])
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": q1["samtale"], "noegle": noegle}); assert r.status_code == 200, r.text
q2 = r.json(); print("Q2 (Claude)", q2["spoergsmaal"], "| talt:", q2["talt"], "| kilde:", q2["kilde"], "| afslut:", q2["afslut"])
assert "[" not in q2["spoergsmaal"] and q2["lyd_sha"]
# spring over: det sprungne må ikke komme igen
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": q1["samtale"], "noegle": noegle, "sprunget": [q2["spoergsmaal"]]}); assert r.status_code == 200
assert r.json()["spoergsmaal"] != q2["spoergsmaal"]; print("Q3 efter spring", r.json()["spoergsmaal"], "| kilde:", r.json()["kilde"])
# lydsvar: 2 s tone som mp3 lavet med ffmpeg
os.system("ffmpeg -y -v error -f lavfi -i 'sine=frequency=440:duration=2' -c:a libmp3lame /tmp/ef-test/tone.mp3")
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": q2["spoergsmaal"], "spoergsmaal_talt": q2["talt"], "lyd_sha": q2["lyd_sha"], "kilde": q2["kilde"], "noegle": noegle}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")})
assert r.status_code == 200, r.text; s2 = r.json(); print("svar2 lyd", s2["id"], "varighed", s2["varighed"], "tekst:", repr(s2["tekst"])[:60])
r = c.get(f"/erindringer/g/{noegle}/klip/{s2['id']}.mp3"); assert r.status_code == 200 and r.headers["content-type"].startswith("audio")
r = c.get(f"/erindringer/g/{noegle}/klip/{s2['id']}-sp.mp3"); assert r.status_code == 200, "spørgsmålets lyd mangler"
k2 = erindringer.klip(pid, s2["id"]); assert k2["spoergsmaal_lyd"] and k2["kilde"] == q2["kilde"] and k2["spoergsmaal_talt"] == q2["talt"]
r = c.post("/erindringer/api/ret", json={"id": s1["id"], "tekst": "Rettet tekst.", "noegle": noegle}); assert r.status_code == 200
# kladde-flow: optagelsen sendes op med det samme, afskrift i baggrunden, så bekræftes den
import time as _t
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": "Hvad lavede du i haven?", "kladde": "1", "noegle": noegle}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")})
assert r.status_code == 200, r.text; s3 = r.json(); print("kladde", s3["id"], s3["status"], "svartid ok")
assert s3["kladde"] is True and not [k for k in erindringer.klip_for(pid) if k["id"] == s3["id"]], "kladde må ikke tælle med"
r = c.post("/erindringer/api/bekraeft", json={"id": s3["id"], "noegle": noegle}); assert r.status_code == 200
for _ in range(40):
st = c.post("/erindringer/api/status", json={"id": s3["id"], "noegle": noegle}).json()
if st["status"] != "transskriberes": break
_t.sleep(1)
print("status efter bekræft:", st["status"], repr(st["tekst"])[:40])
assert [k for k in erindringer.klip_for(pid) if k["id"] == s3["id"]], "bekræftet klip skal tælle med"
_t.sleep(8) # baggrunden forbereder næste spørgsmål
t0 = _t.time(); r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": q1["samtale"], "noegle": noegle}); dt = _t.time() - t0
assert r.status_code == 200; print("næste efter kladde: %.1fs" % dt, r.json()["spoergsmaal"][:60], "| lyd:", bool(r.json()["lyd_url"]))
# slet kladde
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": "x", "kladde": "1", "noegle": noegle}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")}); s4 = r.json()
r = c.post("/erindringer/api/slet", json={"id": s4["id"], "noegle": noegle}); assert r.status_code == 200 and erindringer.klip(pid, s4["id"]) is None
# dagbog: samtale = dato
login(c, adm)
r = c.post("/erindringer/api/naeste", json={"projekt": "dagbog", "samtale": None, "noegle": "", "dato": "2026-10-05"}); assert r.status_code == 200 and r.json()["samtale"] == "20261005", r.text
r = c.get("/erindringer/alle"); assert r.status_code == 200 and "Alle svar" in r.text
login(c, None)
assert erindringer.klip(pid, s1["id"])["tekst"] == "Rettet tekst."
# forkert nøgle -> 404
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": "x" * 40}); assert r.status_code == 404
# indlogget familie: dagbog findes, admin-person-side, eksport
login(c, adm)
r = c.post("/erindringer/api/naeste", json={"projekt": "dagbog", "samtale": None, "noegle": ""}); assert r.status_code == 200, r.text
print("dagbog Q", r.json()["spoergsmaal"])
r = c.get(f"/admin/erindringer/p/{pid}"); assert r.status_code == 200 and "Rettet tekst." in r.text
r = c.get(f"/admin/erindringer/p/{pid}/eksport.zip"); assert r.status_code == 200 and r.headers["content-type"] == "application/zip" and len(r.content) > 1000
r = c.post(f"/admin/erindringer/p/{pid}/klip/{s1['id']}", data={"handling": "favorit", "favorit": "1"}, follow_redirects=False); assert r.status_code == 303
assert erindringer.klip(pid, s1["id"])["favorit"] is True
r = c.post(f"/admin/erindringer/person/{pid}", data={"handling": "luk"}, follow_redirects=False); assert r.status_code == 303
login(c, None)
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": noegle}); assert r.status_code == 404
print("ALT OK")
+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")
+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: