Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a57e178b5 | ||
|
|
6e5bb5eac1 | ||
|
|
79f49bda45 | ||
|
|
1c1962e1d8 | ||
|
|
49a33b25a6 | ||
|
|
3dee428a37 | ||
|
|
7f8ddcf7af |
@@ -30,12 +30,35 @@ pr. person under `PORTAL_DATA/erindringer/` (persondata, aldrig i git).
|
||||
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>`). En færdig fil kan hentes én gang og slettes ellers efter
|
||||
`output_ttl_seconds`; jobs lever i hukommelsen, så en genstart/udrulning afbryder igangværende jobs,
|
||||
og scratch-mappen ryddes ved opstart.
|
||||
|
||||
- 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
|
||||
|
||||
@@ -81,6 +81,20 @@ 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
|
||||
"output_ttl_seconds": 1800, # færdig fil slettes efter så lang tid, hvis den ikke er hentet
|
||||
"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
|
||||
}
|
||||
|
||||
GROUP_ORDER = {n: i for i, n in enumerate(
|
||||
["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])}
|
||||
|
||||
@@ -107,6 +121,8 @@ ADMIN_MODULES = [
|
||||
"desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
|
||||
{"key": "yt", "title": "YouTube offline", "icon": "📺",
|
||||
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
|
||||
{"key": "epub", "title": "E-bøger & lydbøger", "icon": "📚",
|
||||
"desc": "/e-pub: hvem der må bruge den, samtidige jobs, timeout og hvor længe filer ligger."},
|
||||
{"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏",
|
||||
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
||||
{"key": "podcast", "title": "Podcast", "icon": "🎙️",
|
||||
|
||||
@@ -17,3 +17,9 @@ ANTHROPIC_WORKSPACE_ID= # kun for bruger-nøgler (sk-ant-usr…), ell
|
||||
ERINDRINGER_MODEL=claude-sonnet-5
|
||||
ELEVEN_API_KEY=__xi-…__
|
||||
ERINDRINGER_VOICE=tJDFCHyviItsYF1qqToS
|
||||
# /e-pub: per-job-mapper (output + TMP). Valgfri, standard PORTAL_DATA/e-pub-scratch.
|
||||
# Bruger RP_API_KEY/RP_BASE_URL (random-proxy) fra samme fil. Kræver bash curl jq openssl ffmpeg
|
||||
# + værktøjerne i tools_dir (sættes i /admin/epub, standard /opt/portal/tools/e-pub).
|
||||
#EPUB_SCRATCH=/opt/portal/data/e-pub-scratch
|
||||
RP_BASE_URL=https://rp.weircon.dk
|
||||
RP_API_KEY=__random-proxy-noegle__
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""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. Jobs lever i hukommelsen; en genstart dræber
|
||||
igangværende jobs, og scratch-roden ryddes ved opstart.
|
||||
"""
|
||||
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
|
||||
|
||||
# Rester fra før en genstart kan ingen længere hente: væk med dem.
|
||||
shutil.rmtree(config.EPUB_SCRATCH, ignore_errors=True)
|
||||
config.EPUB_SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------- indstillinger ----------------
|
||||
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 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),
|
||||
}
|
||||
|
||||
|
||||
# ---------------- 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)
|
||||
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}
|
||||
_jobs[jid] = 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"]}
|
||||
|
||||
|
||||
def _mappe(j: dict) -> Path:
|
||||
return config.EPUB_SCRATCH / j["id"]
|
||||
|
||||
|
||||
def ryd(j: dict, tilstand: str = "udloebet") -> None:
|
||||
shutil.rmtree(_mappe(j), ignore_errors=True)
|
||||
if j["tilstand"] == "faerdig":
|
||||
j["tilstand"] = tilstand
|
||||
j["fil"] = None
|
||||
|
||||
|
||||
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, ryd op efter TTL."""
|
||||
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), ignore_errors=True)
|
||||
await asyncio.sleep(int(s["output_ttl_seconds"]))
|
||||
ryd(j)
|
||||
# Jobbet glemmes efter endnu en TTL, så "udløbet" kan ses et stykke tid.
|
||||
await asyncio.sleep(int(s["output_ttl_seconds"]))
|
||||
_jobs.pop(jid, None)
|
||||
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
|
||||
def content_type(filnavn: str) -> str:
|
||||
return CONTENT_TYPES.get(Path(filnavn).suffix.lower(), "application/octet-stream")
|
||||
|
||||
|
||||
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"
|
||||
+248
-37
@@ -93,13 +93,16 @@ def projekt(slug: str) -> dict | None:
|
||||
return {**p, "slug": slug} if p else None
|
||||
|
||||
|
||||
def gem_projekt(slug: str, titel: str, beskrivelse: str, kontekst: str, start: list[str]) -> bool:
|
||||
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()], "oprettet": gammel.get("oprettet") or _nu()}
|
||||
"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
|
||||
|
||||
@@ -223,8 +226,8 @@ def _klipmappe(pid: str) -> Path:
|
||||
return KLIP / pid.replace(":", "_")
|
||||
|
||||
|
||||
def klip_for(pid: str, projekt_slug: str | None = None, samtale: str | None = None) -> list[dict]:
|
||||
"""Klip for en person, ældste først."""
|
||||
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 []
|
||||
@@ -239,8 +242,10 @@ def klip_for(pid: str, projekt_slug: str | None = None, samtale: str | None = No
|
||||
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["id"])
|
||||
return sorted(ud, key=lambda k: (k.get("tid") or "", k["id"]))
|
||||
|
||||
|
||||
def klip(pid: str, kid: str) -> dict | None:
|
||||
@@ -267,15 +272,22 @@ def _varighed(p: Path) -> int:
|
||||
|
||||
|
||||
def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: bytes | None, filnavn: str,
|
||||
tekst: str | None) -> dict:
|
||||
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.
|
||||
Returnerer klip-metadata. Kaster ValueError med besked til brugeren."""
|
||||
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:
|
||||
@@ -293,8 +305,7 @@ def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: b
|
||||
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.")
|
||||
if tekst is None or not tekst.strip():
|
||||
tekst = transskriber(mp3)
|
||||
# Transskriptionen laves bagefter (transskriber_klip), så svaret gemmes med det samme
|
||||
finally:
|
||||
raa.unlink(missing_ok=True)
|
||||
else:
|
||||
@@ -304,11 +315,59 @@ def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: b
|
||||
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],
|
||||
"tekst": (tekst or "").strip(), "varighed": varighed, "favorit": False, "skrevet": not lyd}
|
||||
"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:
|
||||
@@ -333,11 +392,82 @@ def slet_klip(pid: str, kid: str) -> bool:
|
||||
if not k:
|
||||
return False
|
||||
d = _klipmappe(pid)
|
||||
(d / f"{kid}.json").unlink(missing_ok=True)
|
||||
(d / f"{kid}.mp3").unlink(missing_ok=True)
|
||||
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:
|
||||
@@ -389,17 +519,53 @@ def tts_fil(sha: str) -> Path | None:
|
||||
|
||||
# ---------------- Claude: interviewer ----------------
|
||||
|
||||
SYSTEM = """Du er en varm, nysgerrig interviewer for en dansk familie. Du hjælper et familiemedlem med at
|
||||
fortælle sine minder (eller føre dagbog) ved at stille ÉT kort spørgsmål ad gangen på dansk.
|
||||
# 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 ]+)\]")
|
||||
|
||||
Regler:
|
||||
- Spørg konkret og hverdagsnært: steder, personer, lyde, lugte, hvad man lavede, hvad man følte. Et spørgsmål er på højst to sætninger.
|
||||
- Følg op på det, personen lige har fortalt (navne, steder, ting de kun nævnte kort), før du skifter emne. Brug personens egne ord.
|
||||
- Led ikke vidnet: foreslå ikke svar, og ret ikke personen. Hvis noget i det sagte strider mod baggrunden, så spørg nysgerrigt i stedet for at påpege det.
|
||||
- Hold en rolig, respektfuld tone, ingen smiger, ingen udråbstegn, ingen "fantastisk". Skriv som man taler. Brug aldrig tankestreger.
|
||||
- Hvis personen virker træt, skriver kort eller siger de vil stoppe, så stil et let, afrundende spørgsmål og sæt "afslut" til true.
|
||||
- Gentag ikke et spørgsmål, der allerede er stillet i samtalen. Vend tilbage til startspørgsmålene, hvis samtalen går i stå.
|
||||
- Svar KUN med JSON: {"spoergsmaal": "...", "afslut": false}"""
|
||||
|
||||
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]]:
|
||||
@@ -411,23 +577,46 @@ def _historik(pid: str, projekt_slug: str, samtale: str) -> tuple[list[dict], li
|
||||
|
||||
|
||||
def _kort(k: dict, n: int = 700) -> dict:
|
||||
return {"dato": (k.get("tid") or "")[:10], "spoergsmaal": k.get("spoergsmaal", ""),
|
||||
return {"dato": (k.get("tid") or "")[:10], "spoergsmaal": k.get("spoergsmaal", ""), "kilde": k.get("kilde", ""),
|
||||
"svar": (k.get("tekst") or "")[:n]}
|
||||
|
||||
|
||||
async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str) -> tuple[str, bool]:
|
||||
"""Næste spørgsmål til personen i projektet. Falder tilbage til startspørgsmålene uden nøgle/ved fejl."""
|
||||
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)
|
||||
stillede = {k.get("spoergsmaal") for k in denne + tidligere}
|
||||
start = [s for s in (pr.get("start") or DAGBOG["start"]) if s not in stillede] or list(pr.get("start") or DAGBOG["start"])
|
||||
if not denne:
|
||||
return start[0], False # samtalen åbner altid med et af projektets egne spørgsmål
|
||||
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 start[len(denne) % len(start)], False
|
||||
return fallback()
|
||||
payload = {
|
||||
"person": {"navn": p.get("navn")},
|
||||
"projekt": {"titel": pr.get("titel"), "beskrivelse": pr.get("beskrivelse"), "baggrund": pr.get("kontekst", "")[:6000],
|
||||
"startspoergsmaal": pr.get("start") or []},
|
||||
"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),
|
||||
@@ -435,7 +624,7 @@ async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str) -> tuple[str, bool
|
||||
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": 300, "system": SYSTEM,
|
||||
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:
|
||||
@@ -446,18 +635,33 @@ async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str) -> tuple[str, bool
|
||||
m = re.search(r"\{.*\}", tekst, re.S)
|
||||
if m:
|
||||
svar = json.loads(m.group(0))
|
||||
q = (svar.get("spoergsmaal") or "").strip().replace("—", ",").replace(" - ", ", ")
|
||||
q = untag(svar.get("spoergsmaal") or "").replace("—", ",").replace(" - ", ", ")
|
||||
talt = keep_tags((svar.get("talt") or q).replace("—", ",").replace(" - ", ", "))
|
||||
if q:
|
||||
return q[:400], bool(svar.get("afslut"))
|
||||
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 start[len(denne) % len(start)], False
|
||||
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
|
||||
@@ -470,8 +674,15 @@ def eksport(pid: str) -> bytes:
|
||||
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('spoergsmaal', '')}\n\n{x.get('tekst', '')}\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()
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
Forretningslogik ligger i moduler: config, cache, store, services, authentik."""
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from starlette.background import BackgroundTask
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
@@ -27,6 +29,7 @@ import secretdrop
|
||||
import claudelogin
|
||||
import podcast
|
||||
import erindringer
|
||||
import epub
|
||||
|
||||
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
||||
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
||||
@@ -42,6 +45,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(
|
||||
@@ -241,7 +247,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)
|
||||
@@ -639,7 +646,7 @@ def _erindringer_person(request: Request, noegle: str = "") -> dict:
|
||||
raise HTTPException(403, "Kun for familien")
|
||||
|
||||
|
||||
def _erindringer_side(request: Request, p: dict, rod: str, noegle: str, saved: str, valgt: str):
|
||||
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 []:
|
||||
@@ -647,12 +654,15 @@ def _erindringer_side(request: Request, p: dict, rod: str, noegle: str, saved: s
|
||||
if x:
|
||||
pr.append({**x, "antal": len(erindringer.klip_for(p["pid"], slug))})
|
||||
titler = {x["slug"]: x["titel"] for x in pr}
|
||||
seneste = list(reversed(erindringer.klip_for(p["pid"])))[:20]
|
||||
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 ""})
|
||||
|
||||
|
||||
@@ -662,6 +672,20 @@ async def erindringer_side(request: Request, saved: str = "", valgt: str = ""):
|
||||
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)
|
||||
@@ -680,6 +704,13 @@ async def erindringer_tts(sha: str):
|
||||
|
||||
|
||||
def _klip_svar(pid: str, kid: str):
|
||||
if kid.endswith("-sp"): # spørgsmålets oplæsning hører til svaret: <id>-sp.mp3
|
||||
return _spoergsmaal_svar(pid, kid[:-3])
|
||||
if kid.endswith("-ren"): # svaret med baggrundsstøj fjernet (valgfrit)
|
||||
f = erindringer.renset_fil(pid, kid[:-4])
|
||||
if not f:
|
||||
raise HTTPException(404)
|
||||
return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, no-store"})
|
||||
f = erindringer.klip_fil(pid, kid)
|
||||
if not f:
|
||||
raise HTTPException(404)
|
||||
@@ -698,6 +729,15 @@ async def erindringer_gaest_klip(request: Request, key: str, kid: str):
|
||||
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)
|
||||
|
||||
@@ -714,13 +754,59 @@ async def erindringer_naeste(request: Request):
|
||||
return _fejl(403, "Det emne er ikke åbent for dig.")
|
||||
pr = erindringer.projekt(slug)
|
||||
samtale = str(data.get("samtale") or "")
|
||||
if not re.match(r"^[0-9]{8}-[a-f0-9]{6}$", samtale):
|
||||
if slug == "dagbog":
|
||||
samtale = erindringer.dagbog_samtale(str(data.get("dato") or ""))
|
||||
elif not erindringer.SAMTALE.match(samtale):
|
||||
samtale = erindringer.ny_samtale()
|
||||
q, afslut = await erindringer.naeste_spoergsmaal(p, pr, samtale)
|
||||
lyd = await asyncio.to_thread(erindringer.tts_sti, q)
|
||||
return {"samtale": samtale, "spoergsmaal": q, "afslut": afslut,
|
||||
"lyd_url": f"/erindringer/tts/{lyd.stem}.mp3" if lyd else None,
|
||||
"nr": len(erindringer.klip_for(p["pid"], slug, samtale)) + 1}
|
||||
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")
|
||||
@@ -731,8 +817,8 @@ async def erindringer_svar(request: Request):
|
||||
if not erindringer.maa(p, slug):
|
||||
return _fejl(403, "Det emne er ikke åbent for dig.")
|
||||
samtale = str(form.get("samtale") or "")
|
||||
if not re.match(r"^[0-9]{8}-[a-f0-9]{6}$", samtale):
|
||||
samtale = erindringer.ny_samtale()
|
||||
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"):
|
||||
@@ -741,12 +827,74 @@ async def erindringer_svar(request: Request):
|
||||
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)
|
||||
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))
|
||||
return {"id": meta["id"], "tekst": meta["tekst"], "varighed": meta["varighed"], "samtale": samtale}
|
||||
_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")
|
||||
@@ -782,8 +930,12 @@ async def admin_erindringer_projekt(request: Request):
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -839,6 +991,7 @@ async def admin_erindringer_klip(request: Request, pid: str, kid: str):
|
||||
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)
|
||||
@@ -850,6 +1003,10 @@ async def admin_erindringer_klip_post(request: Request, pid: str, kid: str):
|
||||
erindringer.slet_klip(pid, kid)
|
||||
elif handling == "ret":
|
||||
erindringer.ret_tekst(pid, kid, str(form.get("tekst") or ""))
|
||||
elif handling == "isoler":
|
||||
ok = await asyncio.to_thread(erindringer.isoler_stemme, pid, kid)
|
||||
if not ok:
|
||||
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}?saved=Rensningen+lykkedes+ikke#k-{kid}", status_code=303)
|
||||
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}#k-{kid}", status_code=303)
|
||||
|
||||
|
||||
@@ -993,6 +1150,112 @@ async def admin_access_save(request: Request, slug: str):
|
||||
return RedirectResponse(f"/admin/access?saved=Adgang til '{app_['name']}' gemt — {detail}", status_code=303)
|
||||
|
||||
|
||||
# ---------------- e-pub (ebog/lydbog) ----------------
|
||||
def require_epub(request: Request):
|
||||
"""Logget ind + admin eller medlem af indstillingens allowed_group. Uden login: login og tilbage."""
|
||||
user = current_user(request)
|
||||
if not user and request.method == "GET":
|
||||
nxt = request.url.path + (f"?{request.url.query}" if request.url.query else "")
|
||||
raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
|
||||
if not user:
|
||||
raise HTTPException(401, "Log ind først")
|
||||
if not epub.har_adgang(user, is_admin(user)):
|
||||
raise HTTPException(403, "Du har ikke adgang til denne side")
|
||||
return user
|
||||
|
||||
|
||||
def _epub_job(request: Request, jid: str) -> dict:
|
||||
user = require_epub(request)
|
||||
j = epub.job(jid)
|
||||
if not j or (j["ejer"] != user["username"] and not is_admin(user)):
|
||||
raise HTTPException(404, "Ukendt job")
|
||||
return j
|
||||
|
||||
|
||||
@app.get("/e-pub", response_class=HTMLResponse)
|
||||
async def epub_side(request: Request, saved: str = ""):
|
||||
user = require_epub(request)
|
||||
return templates.TemplateResponse(request, "epub.html", {
|
||||
"request": request, "user": user, "is_admin": is_admin(user), "saved": saved,
|
||||
"jobs": [epub.status(j) for j in epub.brugerens_jobs(user["username"])]})
|
||||
|
||||
|
||||
@app.post("/e-pub/start")
|
||||
async def epub_start(request: Request):
|
||||
user = require_epub(request)
|
||||
vil_json = "application/json" in request.headers.get("accept", "")
|
||||
try:
|
||||
laengde = int(request.headers.get("content-length") or 0)
|
||||
except ValueError:
|
||||
laengde = 0
|
||||
if laengde > 16384:
|
||||
raise HTTPException(413, "For stor forespørgsel")
|
||||
f = await request.form()
|
||||
tekst = str(f.get("link") or "").strip()
|
||||
fejl = epub.valider_input(tekst)
|
||||
j = None
|
||||
if not fejl:
|
||||
j, fejl = epub.opret(user["username"], tekst)
|
||||
if fejl:
|
||||
if vil_json:
|
||||
return JSONResponse({"error": fejl}, status_code=400)
|
||||
return RedirectResponse("/e-pub?saved=" + urllib.parse.quote(fejl), status_code=303)
|
||||
_i_baggrunden(epub.koer(j["id"]))
|
||||
if vil_json:
|
||||
return JSONResponse(epub.status(j))
|
||||
return RedirectResponse("/e-pub?saved=" + urllib.parse.quote("Startet"), status_code=303)
|
||||
|
||||
|
||||
@app.get("/e-pub/status/{jid}")
|
||||
async def epub_status(request: Request, jid: str):
|
||||
return JSONResponse(epub.status(_epub_job(request, jid)), headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.get("/e-pub/hent/{jid}")
|
||||
async def epub_hent(request: Request, jid: str):
|
||||
j = _epub_job(request, jid)
|
||||
if j["tilstand"] != "faerdig" or not j["fil"]:
|
||||
raise HTTPException(410, "Filen er her ikke længere")
|
||||
# Én hentning: filen og jobmappen slettes, når svaret er sendt (ellers ved TTL).
|
||||
return FileResponse(j["fil"], filename=j["filnavn"], media_type=epub.content_type(j["filnavn"]),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
background=BackgroundTask(epub.ryd, j, "hentet"))
|
||||
|
||||
|
||||
@app.get("/admin/epub", response_class=HTMLResponse)
|
||||
async def admin_epub(request: Request, saved: str = ""):
|
||||
user = require_admin(request)
|
||||
s = epub.get_settings()
|
||||
return templates.TemplateResponse(request, "admin_epub.html", {
|
||||
"request": request, "user": user, "is_admin": True, "saved": saved, "settings": s,
|
||||
"tjek": epub.tjek_opsaetning(s), "jobs": [{**epub.status(j), "ejer": j["ejer"]} for j in epub.alle_jobs()],
|
||||
"human": epub.human})
|
||||
|
||||
|
||||
@app.post("/admin/epub")
|
||||
async def admin_epub_save(request: Request):
|
||||
require_admin(request)
|
||||
f = await request.form()
|
||||
|
||||
def num(name, lo, hi):
|
||||
try:
|
||||
return max(lo, min(hi, int(f.get(name))))
|
||||
except (TypeError, ValueError):
|
||||
return config.EPUB_DEFAULTS[name]
|
||||
tools_dir = str(f.get("tools_dir") or "").strip()
|
||||
if not tools_dir.startswith("/"):
|
||||
return RedirectResponse("/admin/epub?saved=" + urllib.parse.quote("Afvist: værktøjsmappen skal være en absolut sti"), status_code=303)
|
||||
epub.save_settings({
|
||||
"max_concurrent_jobs": num("max_concurrent_jobs", 1, 8),
|
||||
"job_timeout_seconds": num("job_timeout_seconds", 60, 3600),
|
||||
"output_ttl_seconds": num("output_ttl_seconds", 60, 86400),
|
||||
"tools_dir": tools_dir.rstrip("/") or "/",
|
||||
"rp_required": f.get("rp_required") == "on",
|
||||
"allowed_group": str(f.get("allowed_group") or "").strip()[:150],
|
||||
})
|
||||
return RedirectResponse("/admin/epub?saved=Indstillinger gemt", status_code=303)
|
||||
|
||||
|
||||
# ---------------- medie-oprydning ----------------
|
||||
@app.get("/admin/media", response_class=HTMLResponse)
|
||||
async def admin_media(request: Request, saved: str = ""):
|
||||
@@ -1145,6 +1408,8 @@ async def search_index(request: Request):
|
||||
{"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"},
|
||||
{"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"},
|
||||
{"type": "page", "title": "MCP-nøgler", "url": "/admin/mcp-keys", "extra": "mcp api nøgler token claude"}]
|
||||
if epub.har_adgang(user, is_admin(user)):
|
||||
items.append({"type": "page", "title": "E-bog / lydbog", "url": "/e-pub", "extra": "epub m4b bog lydbog bibliotek"})
|
||||
try:
|
||||
for a in await ak.fetch_user_apps(user["username"]):
|
||||
items.append({"type": "app", "title": a["name"], "url": a["url"],
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{% 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 & 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>Færdige filer slettes efter (sekunder, hvis ikke hentet)
|
||||
<input class="wt-input" type="number" name="output_ttl_seconds" min="60" max="86400" value="{{ settings.output_ttl_seconds }}"></label>
|
||||
<label>Værktøjsmappe
|
||||
<input class="wt-input" name="tools_dir" value="{{ settings.tools_dir }}" required></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 %}
|
||||
@@ -52,7 +52,7 @@
|
||||
</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). Startspørgsmålene åbner hver samtale; derefter improviserer intervieweren ({{ model }}). "dagbog" er indbygget og kan ikke slettes, men teksten kan rettes.</p>
|
||||
<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>
|
||||
@@ -61,7 +61,8 @@
|
||||
<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">Startspørgsmål, ét pr. linje<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">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 %}
|
||||
@@ -76,7 +77,8 @@
|
||||
<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">Startspø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">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>
|
||||
|
||||
@@ -33,17 +33,22 @@
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem">{{ klip|length }} svar</h2>
|
||||
<p class="wt-muted" style="margin:.2rem 0 .8rem;max-width:48rem">Markér de svar, der skal med i podcasten, som favorit. Kun favoritter kan renses med voice isolation (ElevenLabs Audio Isolation, cirka 1000 credits pr. minut); det laver en ekstra fil ved siden af originalen og bruges ikke til dagbog.</p>
|
||||
{% for k in klip %}
|
||||
<div class="wt-card" id="k-{{ k.id }}" style="padding:.9rem 1.2rem;margin:.6rem 0;max-width:48rem{% if k.favorit %};border-color:var(--color-accent){% endif %}">
|
||||
<div class="wt-muted" style="font-size:.85rem">{{ k.tid[:16]|replace('T',' ') }} · {{ k.projekt_titel }} · {% if k.skrevet %}skrevet{% else %}{{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}{% if k.redigeret %} · tekst rettet{% endif %} · <span style="font-family:ui-monospace,monospace">{{ k.id }}</span></div>
|
||||
<p style="margin:.4rem 0"><strong>{{ k.spoergsmaal }}</strong></p>
|
||||
{% if not k.skrevet %}<audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio>{% endif %}
|
||||
<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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 && 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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{% 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. Filen kan hentes én gang og slettes ellers af sig selv efter et stykke tid.</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>' +
|
||||
'<p class="ep-hent" hidden style="margin:.4rem 0"><a class="wt-btn wt-btn--primary" href="/e-pub/hent/' + id + '">Hent fil</a></p>' +
|
||||
'<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-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;
|
||||
k.querySelector(".ep-besked").textContent = st.besked || "";
|
||||
k.querySelector(".ep-hent").hidden = st.tilstand !== "faerdig";
|
||||
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 %}
|
||||
+270
-15
@@ -90,11 +90,16 @@
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -104,6 +109,7 @@
|
||||
<span class="er-taeller" id="er-taeller" hidden>0:00</span>
|
||||
</button>
|
||||
<p class="er-status" id="er-status" aria-live="polite"></p>
|
||||
<p class="er-tekst" id="er-live" hidden style="width:100%;max-width:32rem;text-align:left;min-height:2.4rem"><span id="er-live-fast"></span><span id="er-live-delvis" class="wt-muted"></span></p>
|
||||
<p class="wt-muted" style="margin:0">Tal bare, som du ville fortælle det til et barnebarn.</p>
|
||||
|
||||
<div id="er-optaget" hidden style="width:100%;max-width:32rem">
|
||||
@@ -139,21 +145,31 @@
|
||||
<p class="wt-muted" id="er-dagens-tom">Ingen endnu. Dit første svar kommer her.</p>
|
||||
</section>
|
||||
|
||||
<!-- Trin 4: tidligere svar -->
|
||||
<!-- Trin 4: tidligere svar, dag for dag; "Afspil begge" spiller spørgsmålet og derefter svaret -->
|
||||
<section id="er-tidligere">
|
||||
<h2>Tidligere svar</h2>
|
||||
<h2>{% if alle %}Alle svar{% else %}Tidligere svar{% endif %}</h2>
|
||||
{% if klip_seneste %}
|
||||
<ul class="er-liste">
|
||||
{% for k in klip_seneste[:20] %}
|
||||
{% 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"><span data-tid="{{ k.tid }}">{{ k.tid[:10] }}</span> · {{ k.projekt_titel or k.projekt }}{% if k.varighed %} · {{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}</p>
|
||||
<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>
|
||||
<audio controls preload="none" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.5rem"></audio>
|
||||
<div class="er-ret"><button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button></div>
|
||||
<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 %}
|
||||
@@ -180,12 +196,15 @@
|
||||
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')
|
||||
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: '', lydUrl: null,
|
||||
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
|
||||
};
|
||||
@@ -291,6 +310,29 @@
|
||||
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); });
|
||||
|
||||
@@ -301,7 +343,10 @@
|
||||
if (!kort) return;
|
||||
st.projekt = slug;
|
||||
st.titel = kort.dataset.titel || slug;
|
||||
st.samtale = hentHusket();
|
||||
// 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 = '';
|
||||
@@ -317,6 +362,13 @@
|
||||
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();
|
||||
@@ -344,12 +396,16 @@
|
||||
nulstilOptagelse();
|
||||
ui.optagelse.hidden = false;
|
||||
status('');
|
||||
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null }).then(function (j) {
|
||||
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) {
|
||||
@@ -375,6 +431,18 @@
|
||||
});
|
||||
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() {
|
||||
@@ -419,11 +487,13 @@
|
||||
}
|
||||
ui.optag.disabled = true;
|
||||
status('Gør mikrofonen klar…');
|
||||
liveForbered(); // AudioContext skal skabes i klikket (iOS), og tokenet hentes imens
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
||||
st.stream = stream;
|
||||
st.chunks = [];
|
||||
st.mime = mime;
|
||||
st.ext = endelse(mime);
|
||||
liveStart(stream);
|
||||
try {
|
||||
st.recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
||||
} catch (e) {
|
||||
@@ -477,12 +547,101 @@
|
||||
stopTimer();
|
||||
ui.optag.disabled = true;
|
||||
status('Gør optagelsen klar…');
|
||||
liveStop();
|
||||
try {
|
||||
if (st.recorder && st.recorder.state !== 'inactive') st.recorder.stop();
|
||||
else optagelseFaerdig();
|
||||
} catch (e) { optagelseFaerdig(); }
|
||||
}
|
||||
|
||||
/* ---------- Live-afskrift (ElevenLabs realtime STT) ----------
|
||||
Mikrofonen sendes som 16 kHz PCM direkte til ElevenLabs over en WebSocket med et
|
||||
engangs-token fra serveren (API-nøglen bliver på serveren). Teksten vokser, mens man
|
||||
taler; ved stop sendes den med optagelsen, så serveren ikke skal skrive af bagefter. */
|
||||
var live = { ctx: null, node: null, src: null, ws: null, token: null, fast: '', delvis: '', aktiv: false, luk: null };
|
||||
|
||||
function liveForbered() {
|
||||
live.fast = ''; live.delvis = ''; live.aktiv = false; live.token = null;
|
||||
try { ui.live.hidden = true; ui.liveFast.textContent = ''; ui.liveDelvis.textContent = ''; } catch (e) {}
|
||||
try {
|
||||
var AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (AC && !live.ctx) {
|
||||
try { live.ctx = new AC({ sampleRate: 16000 }); } catch (e) { live.ctx = new AC(); }
|
||||
}
|
||||
if (live.ctx && live.ctx.state === 'suspended') live.ctx.resume().catch(function () {});
|
||||
} catch (e) { live.ctx = null; }
|
||||
live.token = kald('/stt-token', {}).catch(function () { return null; });
|
||||
}
|
||||
|
||||
function liveStart(stream) {
|
||||
if (!live.ctx || !window.WebSocket) return;
|
||||
live.token.then(function (t) {
|
||||
if (!t || !t.url || !st.stream) return;
|
||||
var ws;
|
||||
try { ws = new WebSocket(t.url); } catch (e) { return; }
|
||||
live.ws = ws;
|
||||
ws.onmessage = function (ev) {
|
||||
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
if (m.message_type === 'partial_transcript') { live.delvis = m.text || ''; }
|
||||
else if (m.message_type === 'committed_transcript') {
|
||||
if (m.text) live.fast = (live.fast + ' ' + m.text).trim();
|
||||
live.delvis = '';
|
||||
}
|
||||
liveVis();
|
||||
};
|
||||
ws.onclose = function () { live.aktiv = false; if (live.luk) { live.luk(); live.luk = null; } };
|
||||
ws.onerror = function () { live.aktiv = false; };
|
||||
ws.onopen = function () {
|
||||
if (!st.stream) { try { ws.close(); } catch (e) {} return; }
|
||||
live.aktiv = true;
|
||||
ui.live.hidden = false;
|
||||
try {
|
||||
live.src = live.ctx.createMediaStreamSource(stream);
|
||||
live.node = live.ctx.createScriptProcessor(4096, 1, 1);
|
||||
var faktor = live.ctx.sampleRate / 16000; // Safari ignorerer af og til ønsket samplerate
|
||||
live.node.onaudioprocess = function (e) {
|
||||
if (!live.aktiv || ws.readyState !== 1) return;
|
||||
var inp = e.inputBuffer.getChannelData(0);
|
||||
var n = Math.floor(inp.length / faktor);
|
||||
var pcm = new Int16Array(n);
|
||||
for (var i = 0; i < n; i++) {
|
||||
var v = inp[Math.floor(i * faktor)];
|
||||
v = Math.max(-1, Math.min(1, v));
|
||||
pcm[i] = v < 0 ? v * 32768 : v * 32767;
|
||||
}
|
||||
var bytes = new Uint8Array(pcm.buffer), bin = '';
|
||||
for (var j = 0; j < bytes.length; j += 8192) bin += String.fromCharCode.apply(null, bytes.subarray(j, j + 8192));
|
||||
ws.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: btoa(bin), commit: false, sample_rate: 16000 }));
|
||||
};
|
||||
live.src.connect(live.node);
|
||||
live.node.connect(live.ctx.destination);
|
||||
} catch (e) { live.aktiv = false; }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function liveVis() {
|
||||
ui.liveFast.textContent = live.fast;
|
||||
ui.liveDelvis.textContent = live.delvis ? (' ' + live.delvis) : '';
|
||||
}
|
||||
|
||||
// Stopper lydstrømmen, beder om den sidste commit og giver teksten max 2,5 s til at lande
|
||||
function liveStop() {
|
||||
live.aktiv = false;
|
||||
try { if (live.node) { live.node.disconnect(); live.node.onaudioprocess = null; } if (live.src) live.src.disconnect(); } catch (e) {}
|
||||
live.node = null; live.src = null;
|
||||
var ws = live.ws; live.ws = null;
|
||||
if (ws && ws.readyState === 1) {
|
||||
try { ws.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: '', commit: true, sample_rate: 16000 })); } catch (e) {}
|
||||
live.aktiv = true; // modtag stadig beskeder, til vi lukker
|
||||
setTimeout(function () { live.aktiv = false; try { ws.close(); } catch (e) {} }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
function liveTekst() {
|
||||
return (live.fast + (live.delvis ? ' ' + live.delvis : '')).trim();
|
||||
}
|
||||
|
||||
function optagelseFaerdig() {
|
||||
stopStream(); // ellers bliver den røde mikrofon-prik stående på iPhone
|
||||
ui.optag.classList.remove('optager');
|
||||
@@ -502,12 +661,62 @@
|
||||
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('');
|
||||
@@ -533,6 +742,9 @@
|
||||
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);
|
||||
@@ -547,6 +759,16 @@
|
||||
|
||||
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);
|
||||
@@ -560,20 +782,42 @@
|
||||
});
|
||||
|
||||
function visSvar(j) {
|
||||
var tekst = j.tekst || '';
|
||||
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, tekst);
|
||||
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
|
||||
@@ -587,10 +831,21 @@
|
||||
var m = document.createElement('p'); m.className = 'er-meta'; m.textContent = 'Varighed ' + mmss(j.varighed);
|
||||
li.appendChild(m);
|
||||
}
|
||||
var ret = document.createElement('div'); ret.className = 'er-ret';
|
||||
var knapper = document.createElement('div'); knapper.className = 'er-knapper'; knapper.style.justifyContent = 'flex-start';
|
||||
if (!j.skrevet) {
|
||||
var begge = document.createElement('button');
|
||||
begge.type = 'button'; begge.className = 'wt-btn'; begge.textContent = '▶ Spørgsmål og svar'; begge.setAttribute('data-begge', j.id);
|
||||
knapper.appendChild(begge);
|
||||
}
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten'; b.setAttribute('data-ret', j.id);
|
||||
ret.appendChild(b); li.appendChild(ret);
|
||||
knapper.appendChild(b); li.appendChild(knapper);
|
||||
if (!j.skrevet) {
|
||||
var a = document.createElement('audio'); a.controls = true; a.preload = 'none'; a.setAttribute('data-svar', j.id);
|
||||
a.src = ROD + '/klip/' + j.id + '.mp3' + (NOEGLE ? ('?noegle=' + encodeURIComponent(NOEGLE)) : '');
|
||||
a.style.marginTop = '.4rem'; li.appendChild(a);
|
||||
}
|
||||
var ret = document.createElement('div'); ret.className = 'er-ret'; li.appendChild(ret);
|
||||
ui.dagens.insertBefore(li, ui.dagens.firstChild);
|
||||
ui.dagensTom.hidden = true;
|
||||
return t;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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*) printf 'PK' > "$PWD/Min Bog.epub"; echo "Wrote $PWD/Min Bog.epub (2 B)" >&2 ;;
|
||||
ok-m4b) head -c 1000 /dev/zero > "$PWD/Lydbog æøå.m4b"; printf 'trin 2\rtrin 3\n' >&2; echo "Wrote $PWD/Lydbog æøå.m4b (1000 B)" >&2 ;;
|
||||
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}))
|
||||
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")
|
||||
r = c.get(f"/e-pub/hent/{a['id']}")
|
||||
tjek("epub content-type", r.headers["content-type"] == "application/epub+zip" and r.content == b"PK")
|
||||
tjek("filnavn i Content-Disposition", "Min%20Bog.epub" in r.headers["content-disposition"] or "Min Bog.epub" in r.headers["content-disposition"])
|
||||
time.sleep(0.3)
|
||||
tjek("slettet efter hentning", c.get(f"/e-pub/status/{a['id']}").json()["tilstand"] == "hentet"
|
||||
and not (config.EPUB_SCRATCH / a["id"]).exists())
|
||||
tjek("anden hentning = 410", c.get(f"/e-pub/hent/{a['id']}").status_code == 410)
|
||||
sb = vent(b["id"])
|
||||
tjek("m4b færdig, \\r-linjer splittet", sb["filnavn"] == "Lydbog æøå.m4b" and "trin 3" in sb["log"])
|
||||
tjek("m4b 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"])
|
||||
u = vent(c.post("/e-pub/start", data={"link": "ok-epub 2"}).json()["id"])
|
||||
time.sleep(5)
|
||||
tjek("ikke-hentet fil udløber efter TTL", c.get(f"/e-pub/status/{u['id']}").json()["tilstand"] == "udloebet"
|
||||
and not (config.EPUB_SCRATCH / u["id"]).exists())
|
||||
tjek("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)
|
||||
@@ -32,13 +32,42 @@ if q1["lyd_url"]:
|
||||
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"], "| afslut:", q2["afslut"])
|
||||
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"], "noegle": noegle}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")})
|
||||
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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user