Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ab8f2a19 | ||
|
|
04958419ae | ||
|
|
873500f5d4 | ||
|
|
9a57e178b5 | ||
|
|
6e5bb5eac1 | ||
|
|
79f49bda45 | ||
|
|
1c1962e1d8 |
@@ -40,6 +40,27 @@ pr. person under `PORTAL_DATA/erindringer/` (persondata, aldrig i git).
|
||||
Røgtest uden OIDC: `PORTAL_DATA=/tmp/x python tests/erindringer_smoke.py` (kalder de rigtige
|
||||
API'er, koster lidt credits).
|
||||
|
||||
## E-bog / lydbog (`/e-pub`)
|
||||
|
||||
Ét felt: indsæt et link, få en `.epub` eller `.m4b` at hente. Selve arbejdet laves af
|
||||
medfølgende kommandolinjeværktøjer (`fetch.sh`, `run.sh`, `run-audiobook.sh`, `lib.sh`), som
|
||||
portalen kører i en frisk mappe pr. job (under `EPUB_SCRATCH`, standard `PORTAL_DATA/e-pub-scratch`)
|
||||
med et minimalt env (`PATH`, `TMP`, `RP_API_KEY`, `RP_BASE_URL`, `RP_REQUIRED`). stderr vises live
|
||||
(siden poller `/e-pub/status/<id>`). Færdige `.epub` lægges automatisk i CWA's indbakke og `.m4b` i
|
||||
Audiobookshelf (`Forfatter/Titel/`), når `auto_send` er slået til; ellers med en knap. Hvert job har en
|
||||
`job.json` i sin mappe, så færdige filer overlever en genstart (jobs der var i gang, markeres som
|
||||
afbrudt). Fil og job slettes `output_ttl_seconds` (standard et døgn) efter jobbet sluttede, eller filen
|
||||
straks efter én download.
|
||||
|
||||
- Adgang: admins, plus medlemmer af den Authentik-gruppe der står i `allowed_group` (tom = kun admins).
|
||||
- `/admin/epub`: adgangsgruppe, samtidige jobs, timeout, TTL, værktøjsmappe, "proxy påkrævet".
|
||||
Siden viser også, om værktøjer, programmer og `RP_API_KEY` mangler.
|
||||
- **Skal være installeret hvor portalen kører:** `bash`, `curl`, `jq`, `openssl`, `ffmpeg`
|
||||
(`apt install jq` m.fl.) og værktøjsfilerne i `tools_dir` (standard `/opt/portal/tools/e-pub`,
|
||||
uden for appmappen/git, læsbare for brugeren portalen kører som).
|
||||
- Plads: et job kan give op til ~300 MB (plus midlertidige filer), gange antal samtidige jobs.
|
||||
- Test uden OIDC og med et stub-værktøj: `python tests/epub_uvicorn.py`.
|
||||
|
||||
## Kør
|
||||
|
||||
```bash
|
||||
|
||||
@@ -81,6 +81,25 @@ YT_DEFAULTS = {
|
||||
"library_name": "YouTube",
|
||||
}
|
||||
|
||||
# ---- e-pub (ebog/lydbog fra en biblioteks-reference) ----
|
||||
EPUB_SETTINGS_PATH = DATA / "epub_settings.json"
|
||||
# Per-job-mapper (output + TMP) oprettes her og slettes efter hentning/udløb.
|
||||
EPUB_SCRATCH = Path(os.environ.get("EPUB_SCRATCH", str(DATA / "e-pub-scratch")))
|
||||
# Standardindstillinger - kan redigeres i /admin/epub (RP_API_KEY/RP_BASE_URL kommer fra env ovenfor):
|
||||
EPUB_DEFAULTS = {
|
||||
"max_concurrent_jobs": 2, # samtidige jobs; resten venter i kø
|
||||
"job_timeout_seconds": 1200, # et job dræbes efter så lang tid
|
||||
# Fil + job slettes så længe efter jobbet sluttede (også hen over genstarter). Et døgn: nok til
|
||||
# at hente filen på en anden enhed senere, og bogen ligger i forvejen i CWA/Audiobookshelf.
|
||||
"output_ttl_seconds": 86400,
|
||||
"tools_dir": "/opt/portal/tools/e-pub", # mappen med fetch.sh m.fl.
|
||||
"rp_required": True, # job fejler (exit 4) frem for at gå direkte ud uden proxy-nøgle
|
||||
"allowed_group": "", # tom = kun admins må bruge /e-pub
|
||||
"cwa_ingest_dir": "/media/cwa-ingest", # Calibre-Web Automateds indbakke (tom = ingen knap)
|
||||
"abs_dir": "/media/audiobooks", # Audiobookshelf-bibliotekets mappe (tom = ingen knap)
|
||||
"auto_send": True, # færdige filer sendes selv til CWA/Audiobookshelf
|
||||
}
|
||||
|
||||
GROUP_ORDER = {n: i for i, n in enumerate(
|
||||
["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])}
|
||||
|
||||
@@ -107,6 +126,8 @@ ADMIN_MODULES = [
|
||||
"desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
|
||||
{"key": "yt", "title": "YouTube offline", "icon": "📺",
|
||||
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
|
||||
{"key": "epub", "title": "E-bøger & lydbøger", "icon": "📚",
|
||||
"desc": "/e-pub: hvem der må bruge den, samtidige jobs, timeout og hvor længe filer ligger."},
|
||||
{"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏",
|
||||
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
||||
{"key": "podcast", "title": "Podcast", "icon": "🎙️",
|
||||
|
||||
@@ -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,399 @@
|
||||
"""e-pub: en indsat biblioteks-reference → .epub eller .m4b til download.
|
||||
|
||||
Selve arbejdet laves af værktøjerne i settings["tools_dir"] (fetch.sh m.fl.); portalen
|
||||
kører dem bare: frisk per-job-mappe som CWD, rent env, stderr som live-log, timeout
|
||||
og en semafor fra indstillingerne. Hvert job har en job.json i sin mappe, så færdige
|
||||
filer overlever en genstart; jobs der var i gang, markeres som afbrudt. Alt (fil og job)
|
||||
slettes output_ttl_seconds efter jobbet sluttede, af fejer() der kører i baggrunden.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
import config
|
||||
import store
|
||||
|
||||
MAX_INPUT = 2048 # tegn i det indsatte link
|
||||
MAX_AKTIVE_PR_BRUGER = 3 # i kø + kørende
|
||||
LOG_LINJER = 300
|
||||
|
||||
TILSTANDE_AKTIVE = ("koe", "koerer")
|
||||
UDFALD = {
|
||||
2: "Kunne ikke læse linket.",
|
||||
3: "Ikke tilgængelig (udløbet?).",
|
||||
4: "Serverfejl: proxy påkrævet, men ikke sat op. Sig det til en administrator.",
|
||||
}
|
||||
CONTENT_TYPES = {".epub": "application/epub+zip", ".m4b": "audio/mp4"}
|
||||
KRAEVEDE_PROGRAMMER = ("bash", "curl", "jq", "openssl", "ffmpeg")
|
||||
KRAEVEDE_FILER = ("fetch.sh", "run.sh", "run-audiobook.sh", "lib.sh")
|
||||
PATH = "/usr/local/bin:/usr/bin:/bin"
|
||||
|
||||
_jobs: dict[str, dict] = {}
|
||||
_sem: tuple[int, asyncio.Semaphore] | None = None
|
||||
|
||||
|
||||
|
||||
# ---------------- indstillinger ----------------
|
||||
def get_settings() -> dict:
|
||||
return {**config.EPUB_DEFAULTS, **(store.load_json(config.EPUB_SETTINGS_PATH, config.EPUB_DEFAULTS) or {})}
|
||||
|
||||
|
||||
def save_settings(patch: dict) -> dict:
|
||||
s = get_settings()
|
||||
s.update(patch)
|
||||
config.EPUB_SETTINGS_PATH.write_text(json.dumps(s, ensure_ascii=False, indent=2), "utf-8")
|
||||
return s
|
||||
|
||||
|
||||
def har_adgang(user, admin: bool) -> bool:
|
||||
if admin:
|
||||
return True
|
||||
gruppe = (get_settings().get("allowed_group") or "").strip()
|
||||
return bool(user and gruppe and gruppe in (user.get("groups") or []))
|
||||
|
||||
|
||||
def _skrivbar(d: str) -> bool:
|
||||
return bool(d) and os.path.isdir(d) and os.access(d, os.W_OK | os.X_OK)
|
||||
|
||||
|
||||
def tjek_opsaetning(s: dict | None = None) -> dict:
|
||||
"""Til admin-siden: mangler der værktøjer eller programmer hvor portalen kører?"""
|
||||
s = s or get_settings()
|
||||
d = Path(s["tools_dir"])
|
||||
return {
|
||||
"mangler_filer": [f for f in KRAEVEDE_FILER if not (d / f).is_file()],
|
||||
"mangler_programmer": [p for p in KRAEVEDE_PROGRAMMER if not shutil.which(p, path=PATH)],
|
||||
"rp_noegle": bool(config.RP_API_KEY),
|
||||
"cwa_ok": _skrivbar(s.get("cwa_ingest_dir", "")),
|
||||
"abs_ok": _skrivbar(s.get("abs_dir", "")),
|
||||
}
|
||||
|
||||
|
||||
# ---------------- jobs ----------------
|
||||
def _semafor(n: int) -> asyncio.Semaphore:
|
||||
# Ny semafor når indstillingen ændres; jobs der allerede venter på den gamle bliver der.
|
||||
global _sem
|
||||
if _sem is None or _sem[0] != n:
|
||||
_sem = (n, asyncio.Semaphore(n))
|
||||
return _sem[1]
|
||||
|
||||
|
||||
def valider_input(tekst: str) -> str | None:
|
||||
"""Returnerer fejltekst eller None. Strengen sendes ellers ordret videre."""
|
||||
if not tekst:
|
||||
return "Indsæt et link."
|
||||
if len(tekst) > MAX_INPUT:
|
||||
return "Linket er for langt."
|
||||
if any(ord(c) < 32 or ord(c) == 127 for c in tekst) or tekst.startswith("-"):
|
||||
return UDFALD[2]
|
||||
return None
|
||||
|
||||
|
||||
def opret(user: str, tekst: str) -> tuple[dict | None, str | None]:
|
||||
aktive = sum(1 for j in _jobs.values() if j["ejer"] == user and j["tilstand"] in TILSTANDE_AKTIVE)
|
||||
if aktive >= MAX_AKTIVE_PR_BRUGER:
|
||||
return None, f"Du har allerede {aktive} i gang. Vent til et af dem er færdigt."
|
||||
jid = secrets.token_urlsafe(12)
|
||||
_mappe({"id": jid}).mkdir(parents=True)
|
||||
job = {"id": jid, "ejer": user, "input": tekst, "tilstand": "koe", "log": deque(maxlen=LOG_LINJER),
|
||||
"besked": "", "fil": None, "filnavn": "", "stoerrelse": 0,
|
||||
"oprettet": time.time(), "startet": None, "slut": None, "hentet": False, "sendt": ""}
|
||||
_jobs[jid] = job
|
||||
_gem(job)
|
||||
return job, None
|
||||
|
||||
|
||||
def job(jid: str) -> dict | None:
|
||||
return _jobs.get(jid)
|
||||
|
||||
|
||||
def brugerens_jobs(user: str) -> list[dict]:
|
||||
return sorted((j for j in _jobs.values() if j["ejer"] == user), key=lambda j: -j["oprettet"])
|
||||
|
||||
|
||||
def alle_jobs() -> list[dict]:
|
||||
return sorted(_jobs.values(), key=lambda j: -j["oprettet"])
|
||||
|
||||
|
||||
def status(j: dict) -> dict:
|
||||
return {"id": j["id"], "tilstand": j["tilstand"], "besked": j["besked"], "log": list(j["log"])[-80:],
|
||||
"filnavn": j["filnavn"], "stoerrelse": j["stoerrelse"], "oprettet": j["oprettet"],
|
||||
"startet": j["startet"], "slut": j["slut"], "sendt": j["sendt"], "kan_sende": kan_sende(j)}
|
||||
|
||||
|
||||
def _mappe(j: dict) -> Path:
|
||||
return config.EPUB_SCRATCH / j["id"]
|
||||
|
||||
|
||||
def _gem(j: dict) -> None:
|
||||
d = _mappe(j)
|
||||
if not d.is_dir():
|
||||
return
|
||||
tmp = d / ".job.json.tmp"
|
||||
tmp.write_text(json.dumps({**j, "log": list(j["log"])}, ensure_ascii=False), "utf-8")
|
||||
os.replace(tmp, d / "job.json")
|
||||
|
||||
|
||||
def _indlaes() -> None:
|
||||
"""Ved opstart: genindlæs jobs fra disken. Afbrudte jobs → fejl, rester uden job.json → væk."""
|
||||
config.EPUB_SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||
for d in config.EPUB_SCRATCH.iterdir():
|
||||
try:
|
||||
j = json.loads((d / "job.json").read_text("utf-8"))
|
||||
assert j["id"] == d.name
|
||||
except Exception:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
continue
|
||||
j["log"] = deque(j.get("log") or [], maxlen=LOG_LINJER)
|
||||
shutil.rmtree(d / "tmp", ignore_errors=True)
|
||||
if j["tilstand"] in TILSTANDE_AKTIVE:
|
||||
j.update(tilstand="fejl", besked="Afbrudt, fordi serveren blev genstartet. Prøv igen.",
|
||||
slut=time.time())
|
||||
shutil.rmtree(d / "ud", ignore_errors=True)
|
||||
elif j["tilstand"] == "faerdig" and not (j.get("fil") and Path(j["fil"]).is_file()):
|
||||
j.update(tilstand="udloebet", fil=None)
|
||||
_jobs[j["id"]] = j
|
||||
_gem(j)
|
||||
|
||||
|
||||
def feje(nu: float | None = None) -> None:
|
||||
"""Slet jobs (mappe + fil) der sluttede for mere end output_ttl_seconds siden."""
|
||||
nu = nu or time.time()
|
||||
ttl = int(get_settings()["output_ttl_seconds"])
|
||||
for j in list(_jobs.values()):
|
||||
if j["tilstand"] not in TILSTANDE_AKTIVE and (j["slut"] or j["oprettet"]) + ttl < nu:
|
||||
shutil.rmtree(_mappe(j), ignore_errors=True)
|
||||
_jobs.pop(j["id"], None)
|
||||
|
||||
|
||||
async def fejer() -> None:
|
||||
while True:
|
||||
try:
|
||||
feje()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(max(1, min(60, int(get_settings()["output_ttl_seconds"]) // 4)))
|
||||
|
||||
|
||||
def ryd(j: dict, tilstand: str = "hentet") -> None:
|
||||
"""Fjern den færdige fil (fx efter download); jobbet bliver stående til TTL."""
|
||||
shutil.rmtree(_mappe(j) / "ud", ignore_errors=True)
|
||||
if j["tilstand"] == "faerdig":
|
||||
j["tilstand"] = tilstand
|
||||
j["fil"] = None
|
||||
_gem(j)
|
||||
|
||||
|
||||
def _log(j: dict, linje: str) -> None:
|
||||
linje = linje.rstrip()
|
||||
if not linje:
|
||||
return
|
||||
if config.RP_API_KEY:
|
||||
linje = linje.replace(config.RP_API_KEY, "***")
|
||||
j["log"].append(linje[:500])
|
||||
|
||||
|
||||
async def _laes_stderr(stream: asyncio.StreamReader, j: dict) -> None:
|
||||
# Delt på både \n og \r, så fremdriftslinjer der overskriver sig selv også kommer med.
|
||||
rest = ""
|
||||
while True:
|
||||
bid = await stream.read(4096)
|
||||
if not bid:
|
||||
break
|
||||
dele = re.split(r"[\r\n]", rest + bid.decode("utf-8", "replace"))
|
||||
rest = dele.pop()
|
||||
for d in dele:
|
||||
_log(j, d)
|
||||
if len(rest) > 4096:
|
||||
_log(j, rest)
|
||||
rest = ""
|
||||
_log(j, rest)
|
||||
|
||||
|
||||
def _find_fil(ud: Path, log: list[str]) -> Path | None:
|
||||
filer = [p for p in ud.iterdir() if p.is_file() and p.suffix.lower() in CONTENT_TYPES]
|
||||
if len(filer) == 1:
|
||||
return filer[0]
|
||||
# Flere/ingen i mappen: fald tilbage på den sidste "Wrote <sti> (...)"-linje.
|
||||
for linje in reversed(log):
|
||||
m = re.match(r"Wrote (/.+?) \(", linje)
|
||||
if m:
|
||||
p = Path(m.group(1))
|
||||
if p.is_file() and p.parent == ud and p.suffix.lower() in CONTENT_TYPES:
|
||||
return p
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
async def koer(jid: str) -> None:
|
||||
"""Køres i baggrunden: vent på plads, kør værktøjet, send evt. videre til biblioteket."""
|
||||
j = _jobs[jid]
|
||||
s = get_settings()
|
||||
try:
|
||||
async with _semafor(int(s["max_concurrent_jobs"])):
|
||||
s = get_settings()
|
||||
await _koer_vaerktoej(j, s)
|
||||
except Exception as e: # sidste skanse: et job må aldrig hænge i "koerer"
|
||||
j["tilstand"], j["besked"] = "fejl", f"Uventet fejl: {e}"
|
||||
finally:
|
||||
j["slut"] = j["slut"] or time.time()
|
||||
shutil.rmtree(_mappe(j) / "tmp", ignore_errors=True)
|
||||
if j["tilstand"] != "faerdig":
|
||||
shutil.rmtree(_mappe(j) / "ud", ignore_errors=True)
|
||||
_gem(j)
|
||||
if s.get("auto_send") and kan_sende(j):
|
||||
fejl = await asyncio.to_thread(send, j)
|
||||
j["besked"] = fejl or f"Sendt automatisk til {j['sendt']}. Du kan også hente filen."
|
||||
_gem(j)
|
||||
|
||||
|
||||
async def _koer_vaerktoej(j: dict, s: dict) -> None:
|
||||
fetch = Path(s["tools_dir"]) / "fetch.sh"
|
||||
if not fetch.is_file():
|
||||
j["tilstand"], j["besked"] = "fejl", "Serverfejl: værktøjet er ikke installeret."
|
||||
return
|
||||
ud, tmp = _mappe(j) / "ud", _mappe(j) / "tmp"
|
||||
ud.mkdir(parents=True)
|
||||
tmp.mkdir()
|
||||
env = {"PATH": PATH, "TMP": str(tmp), "RP_API_KEY": config.RP_API_KEY,
|
||||
"RP_BASE_URL": config.RP_BASE_URL, "RP_REQUIRED": "1" if s["rp_required"] else "0"}
|
||||
j["tilstand"], j["startet"] = "koerer", time.time()
|
||||
_gem(j)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"bash", str(fetch), j["input"], cwd=ud, env=env,
|
||||
stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE, start_new_session=True)
|
||||
laeser = asyncio.create_task(_laes_stderr(proc.stderr, j))
|
||||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=int(s["job_timeout_seconds"]))
|
||||
except asyncio.TimeoutError:
|
||||
_draeb(proc)
|
||||
await proc.wait()
|
||||
rc = None
|
||||
except asyncio.CancelledError:
|
||||
_draeb(proc)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
await asyncio.wait_for(laeser, timeout=5)
|
||||
except Exception:
|
||||
laeser.cancel()
|
||||
j["slut"] = time.time()
|
||||
if rc is None:
|
||||
j["tilstand"], j["besked"] = "fejl", f"Tog for lang tid (over {int(s['job_timeout_seconds']) // 60} min) og blev stoppet."
|
||||
return
|
||||
if rc == 0:
|
||||
fil = _find_fil(ud, list(j["log"]))
|
||||
if fil:
|
||||
j.update(tilstand="faerdig", fil=str(fil), filnavn=fil.name, stoerrelse=fil.stat().st_size,
|
||||
besked="Klar til download.")
|
||||
return
|
||||
rc = -1
|
||||
sidste = " · ".join(list(j["log"])[-3:])
|
||||
j["tilstand"] = "fejl"
|
||||
j["besked"] = UDFALD.get(rc) or ("Noget gik galt." + (f" ({sidste})" if sidste else ""))
|
||||
|
||||
|
||||
def _draeb(proc) -> None:
|
||||
# Hele procesgruppen: værktøjet starter selv underprocesser.
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------- videre til biblioteket ----------------
|
||||
MAAL = {".epub": ("cwa", "cwa_ingest_dir", "CWA"), ".m4b": ("abs", "abs_dir", "Audiobookshelf")}
|
||||
|
||||
|
||||
def kan_sende(j: dict) -> str:
|
||||
""""cwa"/"abs" hvis den færdige fil kan sendes videre (mappen findes og kan skrives), ellers ""."""
|
||||
if j["tilstand"] != "faerdig" or j["sendt"] or not j["filnavn"]:
|
||||
return ""
|
||||
maal = MAAL.get(Path(j["filnavn"]).suffix.lower())
|
||||
return maal[0] if maal and _skrivbar(get_settings().get(maal[1], "")) else ""
|
||||
|
||||
|
||||
def _rent_navn(t: str) -> str:
|
||||
t = re.sub(r'[/\\\x00-\x1f]', " ", t).strip(" .")
|
||||
return re.sub(r"\s+", " ", t)[:150] or "Ukendt"
|
||||
|
||||
|
||||
def _ledig(p: Path) -> Path:
|
||||
n = 2
|
||||
ud = p
|
||||
while ud.exists():
|
||||
ud = p.with_name(f"{p.stem} ({n}){p.suffix}")
|
||||
n += 1
|
||||
return ud
|
||||
|
||||
|
||||
def _kopier(kilde: Path, maal: Path) -> None:
|
||||
# Via et skjult .part-navn og rename, så CWA/Audiobookshelf aldrig ser en halv fil.
|
||||
tmp = maal.with_name("." + maal.name + ".part")
|
||||
try:
|
||||
shutil.copyfile(kilde, tmp)
|
||||
os.chmod(tmp, 0o666)
|
||||
os.replace(tmp, maal)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def send(j: dict) -> str | None:
|
||||
"""Kopiér den færdige fil til CWA's indbakke (.epub) eller Audiobookshelf (.m4b). Fejltekst eller None.
|
||||
Synkron (kan være flere hundrede MB): kaldes via asyncio.to_thread."""
|
||||
hvor = kan_sende(j)
|
||||
if not hvor:
|
||||
return "Filen kan ikke sendes videre (allerede sendt, udløbet eller ikke sat op)."
|
||||
kilde = Path(j["fil"])
|
||||
ext = kilde.suffix.lower()
|
||||
_, noegle, navn = MAAL[ext]
|
||||
rod = Path(get_settings()[noegle])
|
||||
try:
|
||||
if hvor == "cwa":
|
||||
# CWA importerer kun små filendelser.
|
||||
maal = _ledig(rod / (_rent_navn(kilde.stem) + ext))
|
||||
else:
|
||||
# Audiobookshelf: Forfatter/Titel/fil når navnet er "Forfatter - Titel", ellers Titel/fil.
|
||||
forfatter, sep, titel = kilde.stem.partition(" - ")
|
||||
mappe = rod / _rent_navn(forfatter) / _rent_navn(titel) if sep else rod / _rent_navn(kilde.stem)
|
||||
mappe.mkdir(parents=True, exist_ok=True)
|
||||
maal = _ledig(mappe / (_rent_navn(kilde.stem) + ext))
|
||||
_kopier(kilde, maal)
|
||||
except OSError as e:
|
||||
return f"Kunne ikke sende til {navn}: {e.strerror or e}"
|
||||
j["sendt"] = navn
|
||||
_gem(j)
|
||||
return None
|
||||
|
||||
|
||||
def content_type(filnavn: str) -> str:
|
||||
return CONTENT_TYPES.get(Path(filnavn).suffix.lower(), "application/octet-stream")
|
||||
|
||||
|
||||
def varighed(sek: int) -> str:
|
||||
if sek % 86400 == 0:
|
||||
d = sek // 86400
|
||||
return "et døgn" if d == 1 else f"{d} døgn"
|
||||
if sek >= 3600:
|
||||
t = round(sek / 3600)
|
||||
return "en time" if t == 1 else f"{t} timer"
|
||||
return f"{max(1, sek // 60)} min"
|
||||
|
||||
|
||||
def human(n: int) -> str:
|
||||
for enhed in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.0f} {enhed}" if enhed == "B" else f"{n:.1f} {enhed}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
_indlaes()
|
||||
+69
-2
@@ -392,18 +392,82 @@ def slet_klip(pid: str, kid: str) -> bool:
|
||||
if not k:
|
||||
return False
|
||||
d = _klipmappe(pid)
|
||||
for navn in (f"{kid}.json", f"{kid}.mp3", f"{kid}-sp.mp3"):
|
||||
for navn in (f"{kid}.json", f"{kid}.mp3", f"{kid}-sp.mp3", f"{kid}-ren.mp3"):
|
||||
(d / navn).unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
|
||||
def spoergsmaal_fil(pid: str, kid: str) -> Path | None:
|
||||
def spoergsmaal_fil(pid: str, kid: str, lav: bool = True) -> Path | None:
|
||||
"""Spørgsmålets oplæsning til et svar. Mangler den (ældre klip), laves den fra den talte tekst;
|
||||
teksten er den samme, så tts-cachen rammer oftest."""
|
||||
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
|
||||
return None
|
||||
p = _klipmappe(pid) / f"{kid}-sp.mp3"
|
||||
if p.exists():
|
||||
return p
|
||||
if not lav:
|
||||
return None
|
||||
k = klip(pid, kid)
|
||||
if not k:
|
||||
return None
|
||||
sp = tts_sti(k.get("spoergsmaal_talt") or k.get("spoergsmaal") or "")
|
||||
if not sp:
|
||||
return None
|
||||
p.write_bytes(sp.read_bytes())
|
||||
k["spoergsmaal_lyd"] = True
|
||||
_skriv(_klipmappe(pid) / f"{kid}.json", k)
|
||||
return p
|
||||
|
||||
|
||||
def renset_fil(pid: str, kid: str) -> Path | None:
|
||||
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
|
||||
return None
|
||||
p = _klipmappe(pid) / f"{kid}-ren.mp3"
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
def isoler_stemme(pid: str, kid: str) -> bool:
|
||||
"""ElevenLabs Audio Isolation: fjern baggrundsstøj fra svaret -> <id>-ren.mp3 (valgfrit, pr. klip;
|
||||
koster ca. 1000 credits pr. minut, så det er en admin-knap, ikke standard)."""
|
||||
f = klip_fil(pid, kid)
|
||||
if not f or not ELEVEN_KEY:
|
||||
return False
|
||||
try:
|
||||
with open(f, "rb") as fh:
|
||||
r = httpx.post("https://api.elevenlabs.io/v1/audio-isolation", headers={"xi-api-key": ELEVEN_KEY},
|
||||
files={"audio": (f.name, fh, "audio/mpeg")}, timeout=600)
|
||||
if r.status_code != 200 or len(r.content) < 1000:
|
||||
return False
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
ud = _klipmappe(pid) / f"{kid}-ren.mp3"
|
||||
tmp = ud.with_name("." + ud.name + ".tmp")
|
||||
tmp.write_bytes(r.content)
|
||||
os.replace(tmp, ud)
|
||||
k = klip(pid, kid)
|
||||
if k:
|
||||
k["renset"] = True
|
||||
_skriv(_klipmappe(pid) / f"{kid}.json", k)
|
||||
return True
|
||||
|
||||
|
||||
def stt_token() -> dict | None:
|
||||
"""Engangs-token til browserens direkte forbindelse til ElevenLabs' realtime-STT (15 min, kun STT),
|
||||
så API-nøglen aldrig forlader serveren."""
|
||||
if not ELEVEN_KEY:
|
||||
return None
|
||||
try:
|
||||
r = httpx.post("https://api.elevenlabs.io/v1/single-use-token/realtime_scribe",
|
||||
headers={"xi-api-key": ELEVEN_KEY}, timeout=30)
|
||||
if r.status_code == 200 and r.json().get("token"):
|
||||
t = r.json()["token"]
|
||||
return {"token": t, "url": "wss://api.elevenlabs.io/v1/speech-to-text/realtime?model_id=scribe_v2_realtime"
|
||||
f"&token={t}&audio_format=pcm_16000&sample_rate=16000&commit_strategy=vad&language_code=da"}
|
||||
except (httpx.HTTPError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ---------------- ElevenLabs: tale ----------------
|
||||
|
||||
def transskriber(mp3: Path) -> str:
|
||||
@@ -613,6 +677,9 @@ def eksport(pid: str) -> bytes:
|
||||
sp = spoergsmaal_fil(pid, x["id"])
|
||||
if sp:
|
||||
z.write(sp, f"{x['id']}-sp.mp3")
|
||||
ren = renset_fil(pid, x["id"])
|
||||
if ren:
|
||||
z.write(ren, f"{x['id']}-ren.mp3")
|
||||
z.writestr(f"{x['id']}.json", json.dumps(x, ensure_ascii=False, indent=1))
|
||||
linjer.append(f"## {x['id']} · {x.get('tid', '')[:16]} · {x.get('projekt')}{' · favorit' if x.get('favorit') else ''}\n"
|
||||
f"Spørgsmål ({x.get('kilde') or 'ukendt'}{', oplæsning i ' + x['id'] + '-sp.mp3' if sp else ''}): {x.get('spoergsmaal', '')}\n\n"
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
Forretningslogik ligger i moduler: config, cache, store, services, authentik."""
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from starlette.background import BackgroundTask
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@@ -28,11 +30,20 @@ import secretdrop
|
||||
import claudelogin
|
||||
import podcast
|
||||
import erindringer
|
||||
import epub
|
||||
|
||||
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
||||
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
||||
mcp_app = mcp_gateway.gateway.http_app(path="/mcp", json_response=True)
|
||||
app = FastAPI(title="Weircon Portal", lifespan=mcp_app.lifespan)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _lifespan(a):
|
||||
_i_baggrunden(epub.fejer()) # rydder udløbne /e-pub-jobs, også dem fra før en genstart
|
||||
async with mcp_app.lifespan(a):
|
||||
yield
|
||||
|
||||
app = FastAPI(title="Weircon Portal", lifespan=_lifespan)
|
||||
for _mcp_route in mcp_app.routes:
|
||||
app.router.routes.append(_mcp_route)
|
||||
# route-extend copies routes but NOT middleware — carry FastMCP's auth/context
|
||||
@@ -43,6 +54,9 @@ app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=T
|
||||
same_site="lax", max_age=60 * 60 * 12)
|
||||
app.mount("/static", StaticFiles(directory=config.BASE / "static"), name="static")
|
||||
templates = Jinja2Templates(directory=str(config.BASE / "templates"))
|
||||
# Cache-busting af portalens eget CSS: uden Cache-Control cacher Safari heuristisk i timevis,
|
||||
# så en rettelse i portal.css nåede ikke telefonen. Versionen er filens ændringstid.
|
||||
templates.env.globals["static_v"] = str(int((config.BASE / "static" / "portal.css").stat().st_mtime))
|
||||
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
@@ -242,7 +256,8 @@ async def ops_content(request: Request):
|
||||
version, rp, vers = await asyncio.gather(
|
||||
svc.assets_version(), svc.rp_status(), versions.all_versions())
|
||||
return templates.TemplateResponse(request, "admin_ops_body.html", {
|
||||
"request": request, "version": version, "rp": rp, "versions": vers})
|
||||
"request": request, "version": version, "rp": rp, "versions": vers,
|
||||
"host": versions.host_updates()})
|
||||
|
||||
|
||||
@app.get("/admin/stats", response_class=HTMLResponse)
|
||||
@@ -453,8 +468,10 @@ _INDBYGGET = re.compile(r"FBAN|FBAV|FB_IAB|FBIOS|Messenger|Instagram|LinkedInApp
|
||||
async def podcast_faelles_side(request: Request, key: str):
|
||||
ua = request.headers.get("user-agent", "")
|
||||
forhaandsvisning = bool(_FORHAANDSVISNING.search(ua))
|
||||
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "" if forhaandsvisning else "side"):
|
||||
delt = None if podcast.blokeret(_klient_ip(request)) else podcast.delt_slaa_op(key, "" if forhaandsvisning else "side")
|
||||
if not delt:
|
||||
_ikke_fundet(request)
|
||||
gaest = delt if delt["slags"] == "gaest" else None
|
||||
rod = f"/podcast/f/{key}"
|
||||
base = str(request.base_url).rstrip("/")
|
||||
udsendelser = _podcast_kontekst(request, rod, med_kladder=False)
|
||||
@@ -463,7 +480,8 @@ async def podcast_faelles_side(request: Request, key: str):
|
||||
# stedet for et billede i hele beskedens bredde.
|
||||
lille = bool(s.get("slug")) and podcast.cover(s["slug"], lille=True) is not None
|
||||
og = {"titel": s["titel"], "url": f"{base}{rod}/",
|
||||
"beskrivelse": "Familiens podcast. Lyt i browseren, eller abonnér i din podcast-app.",
|
||||
"beskrivelse": ("Lyt i browseren, eller abonnér i din podcast-app." if gaest else
|
||||
"Familiens podcast. Lyt i browseren, eller abonnér i din podcast-app."),
|
||||
"billede": (f"{base}{rod}/{s['slug']}/cover-lille.jpg" if lille else
|
||||
f"{base}{rod}/{s['slug']}/cover.jpg" if s.get("har_cover") else ""),
|
||||
"str": 300 if lille else 3000}
|
||||
@@ -487,12 +505,12 @@ async def podcast_faelles_side(request: Request, key: str):
|
||||
indbygget = {"platform": "andet"}
|
||||
return templates.TemplateResponse(request, "podcast.html", {
|
||||
"request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "og": og,
|
||||
"indbygget": indbygget, "udsendelser": udsendelser, "saved": ""}, headers=headers)
|
||||
"indbygget": indbygget, "gaest": gaest, "udsendelser": udsendelser, "saved": ""}, headers=headers)
|
||||
|
||||
|
||||
@app.get("/podcast/f/{key}/{name}.xml")
|
||||
async def podcast_faelles_feed(request: Request, key: str, name: str):
|
||||
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "feed"):
|
||||
if podcast.blokeret(_klient_ip(request)) or not podcast.delt_slaa_op(key, "feed"):
|
||||
_ikke_fundet(request)
|
||||
xml = podcast.feed(name, f"/podcast/f/{key}", str(request.base_url).rstrip("/"), med_kladder=False)
|
||||
if xml is None:
|
||||
@@ -504,7 +522,7 @@ async def podcast_faelles_feed(request: Request, key: str, name: str):
|
||||
@app.get("/podcast/f/{key}/{name}/cover-lille.jpg")
|
||||
async def podcast_faelles_cover_lille(request: Request, key: str, name: str):
|
||||
p = podcast.cover(name, lille=True)
|
||||
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key) or not p:
|
||||
if podcast.blokeret(_klient_ip(request)) or not podcast.delt_slaa_op(key) or not p:
|
||||
_ikke_fundet(request)
|
||||
return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
@@ -512,7 +530,7 @@ async def podcast_faelles_cover_lille(request: Request, key: str, name: str):
|
||||
@app.get("/podcast/f/{key}/{name}/cover.jpg")
|
||||
async def podcast_faelles_cover(request: Request, key: str, name: str):
|
||||
p = podcast.cover(name)
|
||||
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key) or not p:
|
||||
if podcast.blokeret(_klient_ip(request)) or not podcast.delt_slaa_op(key) or not p:
|
||||
_ikke_fundet(request)
|
||||
return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
@@ -524,7 +542,7 @@ async def podcast_faelles_mp3(request: Request, key: str, name: str, slug: str):
|
||||
p = podcast.episode_fil(name, slug)
|
||||
meta = podcast.episode_meta(name, slug) or {}
|
||||
if (podcast.blokeret(_klient_ip(request)) or not p or meta.get("kladde")
|
||||
or not podcast.faelles_slaa_op(key, slug if fra_start else "")):
|
||||
or not podcast.delt_slaa_op(key, slug if fra_start else "")):
|
||||
_ikke_fundet(request)
|
||||
return FileResponse(p, media_type="audio/mpeg",
|
||||
headers={"Cache-Control": "private, max-age=86400", "X-Robots-Tag": "noindex, nofollow"})
|
||||
@@ -579,7 +597,7 @@ async def admin_podcast(request: Request, saved: str = ""):
|
||||
user = require_admin(request)
|
||||
return templates.TemplateResponse(request, "admin_podcast.html", {
|
||||
"request": request, "user": user, "is_admin": True, "saved": saved,
|
||||
"noegler": podcast.alle_noegler(), "faelles": podcast.faelles(),
|
||||
"noegler": podcast.alle_noegler(), "faelles": podcast.faelles(), "gaester": podcast.alle_gaester(),
|
||||
"base": str(request.base_url).rstrip("/"),
|
||||
"udsendelser": [{**podcast.show(n), "episoder": podcast.episodes(n, med_kladder=True)} for n in podcast.shows()]})
|
||||
|
||||
@@ -600,6 +618,27 @@ async def admin_podcast_faelles(request: Request):
|
||||
return RedirectResponse(f"/admin/podcast?saved={urllib.parse.quote(besked)}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/podcast/gaest")
|
||||
async def admin_podcast_gaest(request: Request):
|
||||
require_admin(request)
|
||||
f = await request.form()
|
||||
handling, key = f.get("handling"), f.get("key") or ""
|
||||
if handling == "ny":
|
||||
try:
|
||||
dage = max(0, min(int(f.get("dage") or 30), 365))
|
||||
except ValueError:
|
||||
dage = 30
|
||||
podcast.gaest_ny(f.get("navn") or "", dage)
|
||||
besked = "Gæstelink lavet. Kopiér det i listen herunder."
|
||||
elif handling == "tilbagekald":
|
||||
besked = "Gæstelinket virker ikke længere" if podcast.gaest_tilbagekald(key) else "Ukendt link"
|
||||
elif handling == "slet":
|
||||
besked = "Gæstelinket er slettet" if podcast.gaest_slet(key) else "Ukendt link"
|
||||
else:
|
||||
besked = "Ukendt handling"
|
||||
return RedirectResponse(f"/admin/podcast?saved={urllib.parse.quote(besked)}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/podcast/noegle/{key}")
|
||||
async def admin_podcast_noegle(request: Request, key: str):
|
||||
require_admin(request)
|
||||
@@ -700,6 +739,11 @@ async def erindringer_tts(sha: str):
|
||||
def _klip_svar(pid: str, kid: str):
|
||||
if kid.endswith("-sp"): # spørgsmålets oplæsning hører til svaret: <id>-sp.mp3
|
||||
return _spoergsmaal_svar(pid, kid[:-3])
|
||||
if kid.endswith("-ren"): # svaret med baggrundsstøj fjernet (valgfrit)
|
||||
f = erindringer.renset_fil(pid, kid[:-4])
|
||||
if not f:
|
||||
raise HTTPException(404)
|
||||
return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, no-store"})
|
||||
f = erindringer.klip_fil(pid, kid)
|
||||
if not f:
|
||||
raise HTTPException(404)
|
||||
@@ -825,7 +869,20 @@ async def erindringer_svar(request: Request):
|
||||
return _fejl(400, str(e))
|
||||
_i_baggrunden(_efter_svar(p, erindringer.projekt(slug), samtale, meta["id"]))
|
||||
return {"id": meta["id"], "tekst": meta["tekst"], "status": meta["status"], "varighed": meta["varighed"],
|
||||
"samtale": samtale, "kladde": kladde}
|
||||
"samtale": samtale, "kladde": kladde, "skrevet": meta.get("skrevet", False)}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/stt-token")
|
||||
async def erindringer_stt_token(request: Request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
data = {}
|
||||
_erindringer_person(request, str(data.get("noegle") or ""))
|
||||
t = await asyncio.to_thread(erindringer.stt_token)
|
||||
if not t:
|
||||
return _fejl(503, "Live-afskrift er ikke tilgængelig lige nu.")
|
||||
return t
|
||||
|
||||
|
||||
@app.post("/erindringer/api/status")
|
||||
@@ -838,7 +895,8 @@ async def erindringer_status(request: Request):
|
||||
k = erindringer.klip(p["pid"], str(data.get("id") or ""))
|
||||
if not k:
|
||||
return _fejl(404, "Svaret findes ikke.")
|
||||
return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0)}
|
||||
return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0),
|
||||
"skrevet": k.get("skrevet", False)}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/bekraeft")
|
||||
@@ -856,7 +914,8 @@ async def erindringer_bekraeft(request: Request):
|
||||
pr = erindringer.projekt(k.get("projekt") or "")
|
||||
if pr:
|
||||
_i_baggrunden(_efter_svar(p, pr, k.get("samtale") or "", k["id"]))
|
||||
return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0)}
|
||||
return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0),
|
||||
"skrevet": k.get("skrevet", False)}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/slet")
|
||||
@@ -977,6 +1036,10 @@ async def admin_erindringer_klip_post(request: Request, pid: str, kid: str):
|
||||
erindringer.slet_klip(pid, kid)
|
||||
elif handling == "ret":
|
||||
erindringer.ret_tekst(pid, kid, str(form.get("tekst") or ""))
|
||||
elif handling == "isoler":
|
||||
ok = await asyncio.to_thread(erindringer.isoler_stemme, pid, kid)
|
||||
if not ok:
|
||||
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}?saved=Rensningen+lykkedes+ikke#k-{kid}", status_code=303)
|
||||
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}#k-{kid}", status_code=303)
|
||||
|
||||
|
||||
@@ -1120,6 +1183,124 @@ async def admin_access_save(request: Request, slug: str):
|
||||
return RedirectResponse(f"/admin/access?saved=Adgang til '{app_['name']}' gemt — {detail}", status_code=303)
|
||||
|
||||
|
||||
# ---------------- e-pub (ebog/lydbog) ----------------
|
||||
def require_epub(request: Request):
|
||||
"""Logget ind + admin eller medlem af indstillingens allowed_group. Uden login: login og tilbage."""
|
||||
user = current_user(request)
|
||||
if not user and request.method == "GET":
|
||||
nxt = request.url.path + (f"?{request.url.query}" if request.url.query else "")
|
||||
raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
|
||||
if not user:
|
||||
raise HTTPException(401, "Log ind først")
|
||||
if not epub.har_adgang(user, is_admin(user)):
|
||||
raise HTTPException(403, "Du har ikke adgang til denne side")
|
||||
return user
|
||||
|
||||
|
||||
def _epub_job(request: Request, jid: str) -> dict:
|
||||
user = require_epub(request)
|
||||
j = epub.job(jid)
|
||||
if not j or (j["ejer"] != user["username"] and not is_admin(user)):
|
||||
raise HTTPException(404, "Ukendt job")
|
||||
return j
|
||||
|
||||
|
||||
@app.get("/e-pub", response_class=HTMLResponse)
|
||||
async def epub_side(request: Request, saved: str = ""):
|
||||
user = require_epub(request)
|
||||
return templates.TemplateResponse(request, "epub.html", {
|
||||
"request": request, "user": user, "is_admin": is_admin(user), "saved": saved,
|
||||
"jobs": [epub.status(j) for j in epub.brugerens_jobs(user["username"])],
|
||||
"ttl": epub.varighed(int(epub.get_settings()["output_ttl_seconds"]))})
|
||||
|
||||
|
||||
@app.post("/e-pub/start")
|
||||
async def epub_start(request: Request):
|
||||
user = require_epub(request)
|
||||
vil_json = "application/json" in request.headers.get("accept", "")
|
||||
try:
|
||||
laengde = int(request.headers.get("content-length") or 0)
|
||||
except ValueError:
|
||||
laengde = 0
|
||||
if laengde > 16384:
|
||||
raise HTTPException(413, "For stor forespørgsel")
|
||||
f = await request.form()
|
||||
tekst = str(f.get("link") or "").strip()
|
||||
fejl = epub.valider_input(tekst)
|
||||
j = None
|
||||
if not fejl:
|
||||
j, fejl = epub.opret(user["username"], tekst)
|
||||
if fejl:
|
||||
if vil_json:
|
||||
return JSONResponse({"error": fejl}, status_code=400)
|
||||
return RedirectResponse("/e-pub?saved=" + urllib.parse.quote(fejl), status_code=303)
|
||||
_i_baggrunden(epub.koer(j["id"]))
|
||||
if vil_json:
|
||||
return JSONResponse(epub.status(j))
|
||||
return RedirectResponse("/e-pub?saved=" + urllib.parse.quote("Startet"), status_code=303)
|
||||
|
||||
|
||||
@app.get("/e-pub/status/{jid}")
|
||||
async def epub_status(request: Request, jid: str):
|
||||
return JSONResponse(epub.status(_epub_job(request, jid)), headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.get("/e-pub/hent/{jid}")
|
||||
async def epub_hent(request: Request, jid: str):
|
||||
j = _epub_job(request, jid)
|
||||
if j["tilstand"] != "faerdig" or not j["fil"]:
|
||||
raise HTTPException(410, "Filen er her ikke længere")
|
||||
# Én hentning: filen og jobmappen slettes, når svaret er sendt (ellers ved TTL).
|
||||
return FileResponse(j["fil"], filename=j["filnavn"], media_type=epub.content_type(j["filnavn"]),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
background=BackgroundTask(epub.ryd, j))
|
||||
|
||||
|
||||
@app.post("/e-pub/send/{jid}")
|
||||
async def epub_send(request: Request, jid: str):
|
||||
j = _epub_job(request, jid)
|
||||
fejl = await asyncio.to_thread(epub.send, j)
|
||||
return JSONResponse({"error": fejl, **epub.status(j)}, status_code=400 if fejl else 200)
|
||||
|
||||
|
||||
@app.get("/admin/epub", response_class=HTMLResponse)
|
||||
async def admin_epub(request: Request, saved: str = ""):
|
||||
user = require_admin(request)
|
||||
s = epub.get_settings()
|
||||
return templates.TemplateResponse(request, "admin_epub.html", {
|
||||
"request": request, "user": user, "is_admin": True, "saved": saved, "settings": s,
|
||||
"tjek": epub.tjek_opsaetning(s), "jobs": [{**epub.status(j), "ejer": j["ejer"]} for j in epub.alle_jobs()],
|
||||
"human": epub.human})
|
||||
|
||||
|
||||
@app.post("/admin/epub")
|
||||
async def admin_epub_save(request: Request):
|
||||
require_admin(request)
|
||||
f = await request.form()
|
||||
|
||||
def num(name, lo, hi):
|
||||
try:
|
||||
return max(lo, min(hi, int(f.get(name))))
|
||||
except (TypeError, ValueError):
|
||||
return config.EPUB_DEFAULTS[name]
|
||||
tools_dir = str(f.get("tools_dir") or "").strip()
|
||||
mapper = [str(f.get(k) or "").strip() for k in ("cwa_ingest_dir", "abs_dir")]
|
||||
if not tools_dir.startswith("/") or any(m and not m.startswith("/") for m in mapper):
|
||||
return RedirectResponse("/admin/epub?saved=" + urllib.parse.quote("Afvist: mapperne skal være absolutte stier"), status_code=303)
|
||||
epub.save_settings({
|
||||
"max_concurrent_jobs": num("max_concurrent_jobs", 1, 8),
|
||||
"job_timeout_seconds": num("job_timeout_seconds", 60, 3600),
|
||||
"output_ttl_seconds": num("output_ttl_seconds", 60, 7 * 86400),
|
||||
"tools_dir": tools_dir.rstrip("/") or "/",
|
||||
"rp_required": f.get("rp_required") == "on",
|
||||
"auto_send": f.get("auto_send") == "on",
|
||||
"allowed_group": str(f.get("allowed_group") or "").strip()[:150],
|
||||
"cwa_ingest_dir": str(f.get("cwa_ingest_dir") or "").strip().rstrip("/"),
|
||||
"abs_dir": str(f.get("abs_dir") or "").strip().rstrip("/"),
|
||||
})
|
||||
return RedirectResponse("/admin/epub?saved=Indstillinger gemt", status_code=303)
|
||||
|
||||
|
||||
# ---------------- medie-oprydning ----------------
|
||||
@app.get("/admin/media", response_class=HTMLResponse)
|
||||
async def admin_media(request: Request, saved: str = ""):
|
||||
@@ -1272,6 +1453,8 @@ async def search_index(request: Request):
|
||||
{"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"},
|
||||
{"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"},
|
||||
{"type": "page", "title": "MCP-nøgler", "url": "/admin/mcp-keys", "extra": "mcp api nøgler token claude"}]
|
||||
if epub.har_adgang(user, is_admin(user)):
|
||||
items.append({"type": "page", "title": "E-bog / lydbog", "url": "/e-pub", "extra": "epub m4b bog lydbog bibliotek"})
|
||||
try:
|
||||
for a in await ak.fetch_user_apps(user["username"]):
|
||||
items.append({"type": "app", "title": a["name"], "url": a["url"],
|
||||
|
||||
+72
@@ -12,6 +12,7 @@ Data (ikke i git, persondata):
|
||||
DATA/podcast/<show>/episodes/<slug>.json titel, beskrivelse, nr, udgivet, kladde
|
||||
DATA/podcast_keys.json nøgle -> bruger, statistik
|
||||
DATA/podcast_faelles.json det fælles familielink (én nøgle, kan slås fra)
|
||||
DATA/podcast_gaester.json gæstelinks til ukendte (navn, udløb, kan trækkes tilbage)
|
||||
|
||||
En episode med "kladde": true ses kun af administratorer (i deres eget feed og
|
||||
på siden), så den kan høres i en rigtig podcast-app før familien får den.
|
||||
@@ -31,6 +32,7 @@ import config
|
||||
ROOT = config.DATA / "podcast"
|
||||
KEYS = config.DATA / "podcast_keys.json"
|
||||
DELT = config.DATA / "podcast_faelles.json"
|
||||
GAEST = config.DATA / "podcast_gaester.json"
|
||||
SHOW = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]{0,120}$")
|
||||
KEY = re.compile(r"^[A-Za-z0-9_-]{32,64}$")
|
||||
@@ -303,6 +305,76 @@ def faelles_slaa_op(key: str, hvad: str = "") -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------- del med ukendte ----------------
|
||||
# Gæstelinks til folk uden for familien (fx en jobsamtale): hver har sit eget navn,
|
||||
# sin egen nøgle og en udløbsdato, og kan trækkes tilbage uden at røre familiens
|
||||
# link eller de personlige adresser. Samme side som det fælles link, aldrig kladder.
|
||||
|
||||
def gaester() -> dict:
|
||||
try:
|
||||
return json.loads(GAEST.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def alle_gaester() -> list[dict]:
|
||||
nu = time.time()
|
||||
ud = [{"key": key, **v, "udloebet": bool(v.get("udloeber") and v["udloeber"] < nu)}
|
||||
for key, v in gaester().items()]
|
||||
return sorted(ud, key=lambda v: (bool(v.get("tilbagekaldt") or v["udloebet"]), -v.get("oprettet", 0)))
|
||||
|
||||
|
||||
def gaest_ny(navn: str, dage: int) -> str:
|
||||
g = gaester()
|
||||
key = secrets.token_urlsafe(32)
|
||||
nu = int(time.time())
|
||||
g[key] = {"navn": navn.strip()[:80] or "Gæst", "oprettet": nu,
|
||||
"udloeber": nu + dage * 86400 if dage > 0 else None, "hentninger": 0, "hentet": {}}
|
||||
_skriv(GAEST, g)
|
||||
return key
|
||||
|
||||
|
||||
def gaest_tilbagekald(key: str) -> bool:
|
||||
g = gaester()
|
||||
if key not in g:
|
||||
return False
|
||||
g[key]["tilbagekaldt"] = int(time.time())
|
||||
_skriv(GAEST, g)
|
||||
return True
|
||||
|
||||
|
||||
def gaest_slet(key: str) -> bool:
|
||||
g = gaester()
|
||||
if g.pop(key, None) is None:
|
||||
return False
|
||||
_skriv(GAEST, g)
|
||||
return True
|
||||
|
||||
|
||||
def gaest_slaa_op(key: str, hvad: str = "") -> dict | None:
|
||||
if not KEY.match(key or ""):
|
||||
return None
|
||||
g = gaester()
|
||||
v = g.get(key)
|
||||
if not v or v.get("tilbagekaldt") or (v.get("udloeber") and v["udloeber"] < time.time()):
|
||||
return None
|
||||
if hvad:
|
||||
v["hentninger"] = v.get("hentninger", 0) + 1
|
||||
v["sidst"] = int(time.time())
|
||||
v.setdefault("hentet", {})
|
||||
v["hentet"][hvad] = v["hentet"].get(hvad, 0) + 1
|
||||
_skriv(GAEST, g)
|
||||
return v
|
||||
|
||||
|
||||
def delt_slaa_op(key: str, hvad: str = "") -> dict | None:
|
||||
"""Fælles familielink eller gæstelink -> {"slags": "familie"|"gaest", ...}, ellers None."""
|
||||
if faelles_slaa_op(key, hvad):
|
||||
return {"slags": "familie"}
|
||||
v = gaest_slaa_op(key, hvad)
|
||||
return {"slags": "gaest", **v} if v else None
|
||||
|
||||
|
||||
# ---------------- gæt på nøgler ----------------
|
||||
# Nøglerne er 256 bit og kan ikke gættes, men en klient der bliver ved med at
|
||||
# ramme forkerte nøgler, får 404 uden at vi overhovedet slår op (og samme svar
|
||||
|
||||
+6
-7
@@ -174,11 +174,10 @@ th { color:var(--muted); font-weight:600; font-size:.8rem; text-transform:upperc
|
||||
.ucard.hidden { display:none; }
|
||||
|
||||
|
||||
/* Tabeller og andet bredt indhold må aldrig give siden vandret scroll på en telefon.
|
||||
Standard for alle sider: på smalle skærme bliver en tabel sit eget rulleområde,
|
||||
og lange ord/nøgler brydes. (Ops-siden har desuden sin egen kort-visning.) */
|
||||
/* Tabeller og andet bredt indhold må aldrig give siden vandret scroll.
|
||||
base.html pakker hver tabel i .wt-table-wrap (standardkomponent, også for injiceret indhold):
|
||||
tabellen ruller i sit eget område. Lange ord/nøgler uden for tabeller brydes. */
|
||||
.wt-main { overflow-wrap: anywhere; min-width: 0; }
|
||||
@media (max-width: 760px) {
|
||||
.wt-main table { display: block; max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.wt-main table td, .wt-main table th { overflow-wrap: normal; }
|
||||
}
|
||||
.wt-table-wrap { overflow-x: auto; max-width: 100%; -webkit-overflow-scrolling: touch; }
|
||||
.wt-table-wrap > table { margin-top: 0; }
|
||||
.wt-table-wrap table td, .wt-table-wrap table th { overflow-wrap: normal; }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — E-bøger & lydbøger{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin" class="wt-muted">← Admin</a></p>
|
||||
<h1>E-bøger & lydbøger</h1>
|
||||
<p class="wt-lead">Indstillinger for <a href="/e-pub">/e-pub</a>. Proxy-nøglen styres i <code>/etc/portal.env</code> og vises ikke her.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
{% if tjek.mangler_filer or tjek.mangler_programmer or (settings.rp_required and not tjek.rp_noegle) %}
|
||||
<div class="err">
|
||||
{% if tjek.mangler_filer %}<p>Mangler i <code>{{ settings.tools_dir }}</code>: {{ tjek.mangler_filer | join(", ") }}</p>{% endif %}
|
||||
{% if tjek.mangler_programmer %}<p>Mangler på serveren: {{ tjek.mangler_programmer | join(", ") }}</p>{% endif %}
|
||||
{% if settings.rp_required and not tjek.rp_noegle %}<p>Proxy er påkrævet, men <code>RP_API_KEY</code> er ikke sat: alle jobs fejler.</p>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="wt-muted">✅ Værktøjer, programmer og proxy-nøgle er på plads.</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/epub" style="margin-top:1rem;max-width:40rem;display:grid;gap:.8rem">
|
||||
<label>Hvem må bruge siden (Authentik-gruppe; tom = kun admins)
|
||||
<input class="wt-input" name="allowed_group" value="{{ settings.allowed_group }}"></label>
|
||||
<label>Samtidige jobs
|
||||
<input class="wt-input" type="number" name="max_concurrent_jobs" min="1" max="8" value="{{ settings.max_concurrent_jobs }}"></label>
|
||||
<label>Timeout pr. job (sekunder)
|
||||
<input class="wt-input" type="number" name="job_timeout_seconds" min="60" max="3600" value="{{ settings.job_timeout_seconds }}"></label>
|
||||
<label>Filer og jobs slettes så længe efter jobbet er færdigt (sekunder, 86400 = et døgn, højst en uge)
|
||||
<input class="wt-input" type="number" name="output_ttl_seconds" min="60" max="604800" value="{{ settings.output_ttl_seconds }}"></label>
|
||||
<label>Værktøjsmappe
|
||||
<input class="wt-input" name="tools_dir" value="{{ settings.tools_dir }}" required></label>
|
||||
<label>CWA-indbakke (tom = ingen "Tilføj til CWA"-knap){% if settings.cwa_ingest_dir and not tjek.cwa_ok %} <span class="err">findes ikke / kan ikke skrives</span>{% endif %}
|
||||
<input class="wt-input" name="cwa_ingest_dir" value="{{ settings.cwa_ingest_dir }}"></label>
|
||||
<label>Audiobookshelf-mappe (tom = ingen "Send til Audiobookshelf"-knap){% if settings.abs_dir and not tjek.abs_ok %} <span class="err">findes ikke / kan ikke skrives</span>{% endif %}
|
||||
<input class="wt-input" name="abs_dir" value="{{ settings.abs_dir }}"></label>
|
||||
<label><input type="checkbox" name="auto_send" {% if settings.auto_send %}checked{% endif %}>
|
||||
Send færdige filer automatisk til CWA (.epub) og Audiobookshelf (.m4b)</label>
|
||||
<label><input type="checkbox" name="rp_required" {% if settings.rp_required %}checked{% endif %}>
|
||||
Proxy påkrævet (job fejler i stedet for at gå direkte ud)</label>
|
||||
<p><button class="wt-btn wt-btn--primary" type="submit">Gem</button></p>
|
||||
</form>
|
||||
|
||||
<h2>Jobs lige nu</h2>
|
||||
{% if jobs %}
|
||||
<table>
|
||||
<thead><tr><th>Bruger</th><th>Tilstand</th><th>Fil</th><th>Besked</th></tr></thead>
|
||||
<tbody>
|
||||
{% for j in jobs %}
|
||||
<tr><td>{{ j.ejer }}</td><td>{{ j.tilstand }}</td>
|
||||
<td>{% if j.filnavn %}{{ j.filnavn }} ({{ human(j.stoerrelse) }}){% endif %}</td><td>{{ j.besked }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -33,19 +33,22 @@
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem">{{ klip|length }} svar</h2>
|
||||
<p class="wt-muted" style="margin:.2rem 0 .8rem;max-width:48rem">Markér de svar, der skal med i podcasten, som favorit. Kun favoritter kan renses med voice isolation (ElevenLabs Audio Isolation, cirka 1000 credits pr. minut); det laver en ekstra fil ved siden af originalen og bruges ikke til dagbog.</p>
|
||||
{% for k in klip %}
|
||||
<div class="wt-card" id="k-{{ k.id }}" style="padding:.9rem 1.2rem;margin:.6rem 0;max-width:48rem{% if k.favorit %};border-color:var(--color-accent){% endif %}">
|
||||
<div class="wt-muted" style="font-size:.85rem">{{ k.tid[:16]|replace('T',' ') }} · {{ k.projekt_titel }} · {% if k.skrevet %}skrevet{% else %}{{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}{% if k.redigeret %} · tekst rettet{% endif %} · <span style="font-family:ui-monospace,monospace">{{ k.id }}</span></div>
|
||||
<p style="margin:.4rem 0"><strong>{{ k.spoergsmaal }}</strong>{% if k.kilde %} <span class="wt-tag muted">{{ k.kilde }}</span>{% endif %}</p>
|
||||
{% if k.spoergsmaal_talt and k.spoergsmaal_talt != k.spoergsmaal %}<p class="wt-muted" style="margin:0 0 .3rem;font-size:.85rem">Oplæst som: {{ k.spoergsmaal_talt }}</p>{% endif %}
|
||||
{% if k.spoergsmaal_lyd %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Spørgsmålet:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}-sp.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}
|
||||
{% if not k.skrevet %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Svaret:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}
|
||||
{% if not k.skrevet %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Svaret:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio></p>
|
||||
{% if k.renset %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Svaret, renset (voice isolation):</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}-ren.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}{% endif %}
|
||||
<form method="post" action="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}" style="margin:.5rem 0 0">
|
||||
<textarea class="wt-input" name="tekst" rows="4" style="width:100%">{{ k.tekst }}</textarea>
|
||||
<p style="margin:.5rem 0 0;display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
<button class="wt-btn" name="handling" value="ret">Gem tekst</button>
|
||||
<button class="wt-btn{% if k.favorit %} wt-btn--primary{% endif %}" name="handling" value="favorit" onclick="this.form.favorit.value='{{ '0' if k.favorit else '1' }}'">{{ '★ Favorit' if k.favorit else '☆ Markér som favorit' }}</button>
|
||||
<input type="hidden" name="favorit" value="1">
|
||||
{% if not k.skrevet and k.favorit %}<button class="wt-btn" name="handling" value="isoler" title="ElevenLabs Audio Isolation fjerner baggrundsstøj; cirka 1000 credits pr. minut" onclick="return confirm('Rens lyden med voice isolation? Det koster cirka 1000 credits pr. minut.')">{{ 'Rens igen' if k.renset else 'Rens lyd (voice isolation)' }}</button>{% endif %}
|
||||
<button class="wt-btn wt-btn--danger" name="handling" value="slet" onclick="return confirm('Slet svaret for altid?')">Slet</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -63,6 +63,54 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Del med ukendte</h2>
|
||||
<p class="wt-muted" style="margin:.3rem 0 .8rem">Gæstelinks til folk uden for familien, fx til en jobsamtale. Hvert link har sin egen nøgle og udløber af sig selv; du kan også trække det tilbage før tid. Det har intet med familiens link eller de personlige adresser at gøre. Kladder vises aldrig.</p>
|
||||
<form method="post" action="/admin/podcast/gaest" class="wt-card" style="padding:1rem 1.2rem;max-width:48rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:flex-end">
|
||||
<input type="hidden" name="handling" value="ny">
|
||||
<label style="flex:1 1 16rem">Hvem er det til<br>
|
||||
<input class="wt-input" name="navn" required maxlength="80" placeholder="fx Jobsamtale, Firma A/S" style="width:100%"></label>
|
||||
<label>Udløber efter<br>
|
||||
<select class="wt-input" name="dage">
|
||||
<option value="7">7 dage</option><option value="14">14 dage</option>
|
||||
<option value="30" selected>30 dage</option><option value="90">90 dage</option>
|
||||
<option value="0">Aldrig (træk selv tilbage)</option>
|
||||
</select></label>
|
||||
<button class="wt-btn wt-btn--primary" type="submit">Lav gæstelink</button>
|
||||
</form>
|
||||
<table class="wt-table" style="margin-top:.8rem">
|
||||
<thead><tr><th>Til</th><th>Link</th><th>Udløber</th><th>Sidst brugt</th><th>Hentninger</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for g in gaester %}
|
||||
{% set doed = g.tilbagekaldt or g.udloebet %}
|
||||
<tr{% if doed %} style="opacity:.55"{% endif %}>
|
||||
<td>{{ g.navn }}</td>
|
||||
<td>{% if doed %}<span class="wt-muted">{{ 'Trukket tilbage' if g.tilbagekaldt else 'Udløbet' }}</span>{% else %}
|
||||
<div style="display:flex;gap:.4rem;min-width:14rem">
|
||||
<input class="wt-input" readonly id="g-{{ loop.index }}" value="{{ base }}/podcast/f/{{ g.key }}/"
|
||||
style="flex:1;font-family:ui-monospace,monospace;font-size:.8rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" onclick="var i=document.getElementById('g-{{ loop.index }}');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
|
||||
</div>{% endif %}</td>
|
||||
<td>{% if g.udloeber %}<span data-ts="{{ g.udloeber }}"></span>{% else %}Aldrig{% endif %}</td>
|
||||
<td data-ts="{{ g.sidst or '' }}"></td>
|
||||
<td title="{% for h, n in (g.hentet or {}).items() %}{{ h }}: {{ n }} {% endfor %}">{{ g.hentninger or 0 }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/podcast/gaest" style="margin:0">
|
||||
<input type="hidden" name="key" value="{{ g.key }}">
|
||||
{% if doed %}
|
||||
<button class="wt-btn" type="submit" name="handling" value="slet">Slet</button>
|
||||
{% else %}
|
||||
<button class="wt-btn" type="submit" name="handling" value="tilbagekald"
|
||||
onclick="return confirm('Træk gæstelinket tilbage? Det holder op med at virke med det samme.')">Træk tilbage</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="wt-muted">Ingen gæstelinks.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Lytternes adresser</h2>
|
||||
<table class="wt-table">
|
||||
<thead><tr><th>Bruger</th><th>Oprettet</th><th>Sidst hentet</th><th>Hentninger</th><th>App</th><th></th></tr></thead>
|
||||
|
||||
+17
-1
@@ -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,121 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}E-bog / lydbog{% endblock %}
|
||||
{% block body %}
|
||||
<h1>E-bog / lydbog</h1>
|
||||
<p class="wt-lead">Indsæt et link, så laver serveren en fil du kan hente: en e-bog (.epub) eller en lydbog (.m4b).
|
||||
Det kan tage fra få sekunder til et kvarter. Færdige bøger lægges selv i CWA (e-bøger) og Audiobookshelf (lydbøger). Filen kan også hentes her én gang, og den slettes af sig selv efter {{ ttl }}.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
<form id="ep-form" method="post" action="/e-pub/start" autocomplete="off"
|
||||
style="margin-top:1.2rem;display:flex;gap:.6rem;flex-wrap:wrap;align-items:center">
|
||||
<input class="wt-input" name="link" id="ep-link" required maxlength="2048" placeholder="Indsæt link"
|
||||
autocapitalize="off" autocorrect="off" spellcheck="false" style="flex:1 1 22rem;min-width:0">
|
||||
<button class="wt-btn wt-btn--primary" type="submit" id="ep-knap">Hent</button>
|
||||
</form>
|
||||
<p id="ep-fejl" class="err" hidden></p>
|
||||
|
||||
<div id="ep-jobs" style="margin-top:1.4rem;display:grid;gap:1rem"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var TEKST = { koe: "⏳ I kø", koerer: "⚙️ Arbejder…", faerdig: "✅ Færdig", fejl: "⚠️ Fejlede",
|
||||
hentet: "Hentet (slettet fra serveren)", udloebet: "Udløbet (slettet fra serveren)" };
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var timere = {};
|
||||
|
||||
function str(n) {
|
||||
var e = ["B", "KB", "MB", "GB"], i = 0;
|
||||
while (n >= 1024 && i < e.length - 1) { n /= 1024; i++; }
|
||||
return (i ? n.toFixed(1) : n) + " " + e[i];
|
||||
}
|
||||
|
||||
function kort(id) {
|
||||
var k = $("ep-" + id);
|
||||
if (k) return k;
|
||||
k = document.createElement("section");
|
||||
k.className = "wt-card"; k.id = "ep-" + id; k.style.padding = "1rem 1.2rem";
|
||||
k.innerHTML = '<p class="ep-tilstand" style="margin:0 0 .4rem;font-weight:600"></p>' +
|
||||
'<p class="ep-besked wt-muted" style="margin:0 0 .4rem"></p>' +
|
||||
'<div class="ep-hent" hidden><p style="margin:.4rem 0;display:flex;gap:.6rem;flex-wrap:wrap">' +
|
||||
'<a class="wt-btn wt-btn--primary" href="/e-pub/hent/' + id + '">Hent fil</a>' +
|
||||
'<button class="wt-btn ep-send" type="button" style="display:none"></button></p></div>' +
|
||||
'<details><summary class="wt-muted" style="cursor:pointer">Log</summary>' +
|
||||
'<pre class="ep-log" style="max-height:16rem;overflow:auto;white-space:pre-wrap;font-size:.85em;margin:.4rem 0 0"></pre></details>';
|
||||
$("ep-jobs").insertBefore(k, $("ep-jobs").firstChild);
|
||||
k.querySelector(".ep-send").addEventListener("click", function () {
|
||||
var b = this;
|
||||
b.disabled = true;
|
||||
b.textContent = "Sender…";
|
||||
fetch("/e-pub/send/" + id, { method: "POST", credentials: "same-origin" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (st) {
|
||||
if (st.error) k.querySelector(".ep-besked").textContent = st.error;
|
||||
vis(st);
|
||||
})
|
||||
.catch(function () { k.querySelector(".ep-besked").textContent = "Kunne ikke nå serveren."; })
|
||||
.finally(function () { b.disabled = false; });
|
||||
});
|
||||
k.querySelector(".ep-hent a").addEventListener("click", function () {
|
||||
// Filen slettes efter én hentning: knappen forsvinder, og status hentes igen om lidt.
|
||||
setTimeout(function () { hent(id); }, 4000);
|
||||
});
|
||||
return k;
|
||||
}
|
||||
|
||||
function vis(st) {
|
||||
var k = kort(st.id);
|
||||
var t = TEKST[st.tilstand] || st.tilstand;
|
||||
if (st.tilstand === "faerdig" && st.filnavn) t += " · " + st.filnavn + " (" + str(st.stoerrelse) + ")";
|
||||
k.querySelector(".ep-tilstand").textContent = t;
|
||||
var besked = st.besked || "";
|
||||
if (st.sendt) besked = "✅ Sendt til " + st.sendt + (st.tilstand === "faerdig" ? ". Du kan stadig hente filen." : ".");
|
||||
if (!st.error) k.querySelector(".ep-besked").textContent = besked;
|
||||
k.querySelector(".ep-hent").hidden = st.tilstand !== "faerdig";
|
||||
var send = k.querySelector(".ep-send");
|
||||
send.style.display = st.kan_sende ? "" : "none";
|
||||
send.textContent = st.kan_sende === "cwa" ? "Tilføj til CWA" : "Send til Audiobookshelf";
|
||||
var log = k.querySelector(".ep-log"), nederst = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
|
||||
log.textContent = (st.log || []).join("\n");
|
||||
if (nederst) log.scrollTop = log.scrollHeight;
|
||||
if (st.tilstand === "koerer" || st.tilstand === "koe") k.querySelector("details").open = true;
|
||||
planlaeg(st.id, st.tilstand === "koe" ? 3000 : st.tilstand === "koerer" ? 1500 : st.tilstand === "faerdig" ? 30000 : 0);
|
||||
}
|
||||
|
||||
function planlaeg(id, ms) {
|
||||
clearTimeout(timere[id]);
|
||||
if (ms) timere[id] = setTimeout(function () { hent(id); }, ms);
|
||||
}
|
||||
|
||||
function hent(id) {
|
||||
fetch("/e-pub/status/" + id, { credentials: "same-origin", cache: "no-store" })
|
||||
.then(function (r) {
|
||||
if (r.status === 404) { var k = $("ep-" + id); if (k) k.remove(); return null; }
|
||||
return r.json();
|
||||
})
|
||||
.then(function (st) { if (st) vis(st); })
|
||||
.catch(function () { planlaeg(id, 5000); });
|
||||
}
|
||||
|
||||
$("ep-form").addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
$("ep-knap").disabled = true;
|
||||
$("ep-fejl").hidden = true;
|
||||
fetch("/e-pub/start", { method: "POST", credentials: "same-origin", headers: { Accept: "application/json" },
|
||||
body: new FormData($("ep-form")) })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (st) {
|
||||
if (st.error) { $("ep-fejl").textContent = st.error; $("ep-fejl").hidden = false; return; }
|
||||
$("ep-link").value = "";
|
||||
vis(st);
|
||||
})
|
||||
.catch(function () { $("ep-fejl").textContent = "Kunne ikke nå serveren."; $("ep-fejl").hidden = false; })
|
||||
.finally(function () { $("ep-knap").disabled = false; });
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!document.hidden) Object.keys(timere).forEach(hent);
|
||||
});
|
||||
({{ jobs | tojson }}).reverse().forEach(vis);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+134
-14
@@ -109,6 +109,7 @@
|
||||
<span class="er-taeller" id="er-taeller" hidden>0:00</span>
|
||||
</button>
|
||||
<p class="er-status" id="er-status" aria-live="polite"></p>
|
||||
<p class="er-tekst" id="er-live" hidden style="width:100%;max-width:32rem;text-align:left;min-height:2.4rem"><span id="er-live-fast"></span><span id="er-live-delvis" class="wt-muted"></span></p>
|
||||
<p class="wt-muted" style="margin:0">Tal bare, som du ville fortælle det til et barnebarn.</p>
|
||||
|
||||
<div id="er-optaget" hidden style="width:100%;max-width:32rem">
|
||||
@@ -158,10 +159,9 @@
|
||||
<p class="er-q">{{ k.spoergsmaal }}</p>
|
||||
<p class="er-tekst" data-tekst>{{ k.tekst }}</p>
|
||||
<div class="er-knapper" style="justify-content:flex-start;margin-top:.4rem">
|
||||
{% if k.spoergsmaal_lyd %}<button type="button" class="wt-btn" data-begge="{{ k.id }}" aria-label="Afspil spørgsmål og svar">▶ Spørgsmål og svar</button>{% endif %}
|
||||
<button type="button" class="wt-btn" data-begge="{{ k.id }}" aria-label="Afspil spørgsmål og svar">▶ Spørgsmål og svar</button>
|
||||
<button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button>
|
||||
</div>
|
||||
{% if k.spoergsmaal_lyd %}<audio preload="none" data-sp="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}-sp.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}"></audio>{% endif %}
|
||||
<audio controls preload="none" data-svar="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.4rem"></audio>
|
||||
<div class="er-ret"></div>
|
||||
</li>
|
||||
@@ -197,7 +197,8 @@
|
||||
svarKort: el('er-svar-kort'), svarTekst: el('er-svar-tekst'), svarRet: el('er-svar-ret'),
|
||||
naeste: el('er-naeste'), pause: el('er-pause'), dagens: el('er-dagens'), dagensTom: el('er-dagens-tom'),
|
||||
flash: el('er-flash'), afrunding: el('er-afrunding'), spring: el('er-spring'),
|
||||
dag: el('er-dag'), dato: el('er-dato')
|
||||
dag: el('er-dag'), dato: el('er-dato'),
|
||||
live: el('er-live'), liveFast: el('er-live-fast'), liveDelvis: el('er-live-delvis')
|
||||
};
|
||||
|
||||
// Tilstand for den igangværende samtale
|
||||
@@ -309,20 +310,27 @@
|
||||
retTekst(b.dataset.ret, els, b.parentNode);
|
||||
});
|
||||
|
||||
// "Spørgsmål og svar": spil spørgsmålets oplæsning, og derefter svaret
|
||||
// "Spørgsmål og svar": ÉT afspiller-element spiller først spørgsmålet og skifter så til svaret.
|
||||
// (To elementer virker ikke på iPhone: kun det element, man selv trykkede på, må spille videre.)
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest && e.target.closest('[data-begge]');
|
||||
if (!b) return;
|
||||
var li = b.closest('[data-id]');
|
||||
var sp = li && li.querySelector('audio[data-sp]');
|
||||
var sv = li && li.querySelector('audio[data-svar]');
|
||||
if (!sv) return;
|
||||
document.querySelectorAll('audio').forEach(function (a) { if (a !== sp && a !== sv) { try { a.pause(); } catch (x) {} } });
|
||||
var spilSvar = function () { try { sv.currentTime = 0; var p = sv.play(); if (p && p.catch) p.catch(function () {}); } catch (x) {} };
|
||||
if (sp) {
|
||||
sp.onended = spilSvar;
|
||||
try { sp.currentTime = 0; var p = sp.play(); if (p && p.catch) p.catch(spilSvar); } catch (x) { spilSvar(); }
|
||||
} else spilSvar();
|
||||
document.querySelectorAll('audio').forEach(function (a) { if (a !== sv) { try { a.pause(); } catch (x) {} } });
|
||||
var svarSrc = sv.dataset.svarSrc || sv.getAttribute('src');
|
||||
sv.dataset.svarSrc = svarSrc;
|
||||
var spSrc = svarSrc.replace(/\.mp3(\?|$)/, '-sp.mp3$1');
|
||||
var tilSvar = function () {
|
||||
sv.onended = null; sv.onerror = null;
|
||||
sv.src = svarSrc; sv.currentTime = 0;
|
||||
var p = sv.play(); if (p && p.catch) p.catch(function () {});
|
||||
};
|
||||
sv.onended = tilSvar;
|
||||
sv.onerror = tilSvar; // intet spørgsmål gemt (eller det kunne ikke laves): spil svaret
|
||||
sv.src = spSrc; sv.currentTime = 0;
|
||||
var p = sv.play(); if (p && p.catch) p.catch(tilSvar);
|
||||
});
|
||||
|
||||
// Datoer i "Tidligere svar" på læsbart dansk
|
||||
@@ -479,11 +487,13 @@
|
||||
}
|
||||
ui.optag.disabled = true;
|
||||
status('Gør mikrofonen klar…');
|
||||
liveForbered(); // AudioContext skal skabes i klikket (iOS), og tokenet hentes imens
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
||||
st.stream = stream;
|
||||
st.chunks = [];
|
||||
st.mime = mime;
|
||||
st.ext = endelse(mime);
|
||||
liveStart(stream);
|
||||
try {
|
||||
st.recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
||||
} catch (e) {
|
||||
@@ -537,12 +547,101 @@
|
||||
stopTimer();
|
||||
ui.optag.disabled = true;
|
||||
status('Gør optagelsen klar…');
|
||||
liveStop();
|
||||
try {
|
||||
if (st.recorder && st.recorder.state !== 'inactive') st.recorder.stop();
|
||||
else optagelseFaerdig();
|
||||
} catch (e) { optagelseFaerdig(); }
|
||||
}
|
||||
|
||||
/* ---------- Live-afskrift (ElevenLabs realtime STT) ----------
|
||||
Mikrofonen sendes som 16 kHz PCM direkte til ElevenLabs over en WebSocket med et
|
||||
engangs-token fra serveren (API-nøglen bliver på serveren). Teksten vokser, mens man
|
||||
taler; ved stop sendes den med optagelsen, så serveren ikke skal skrive af bagefter. */
|
||||
var live = { ctx: null, node: null, src: null, ws: null, token: null, fast: '', delvis: '', aktiv: false, luk: null };
|
||||
|
||||
function liveForbered() {
|
||||
live.fast = ''; live.delvis = ''; live.aktiv = false; live.token = null;
|
||||
try { ui.live.hidden = true; ui.liveFast.textContent = ''; ui.liveDelvis.textContent = ''; } catch (e) {}
|
||||
try {
|
||||
var AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (AC && !live.ctx) {
|
||||
try { live.ctx = new AC({ sampleRate: 16000 }); } catch (e) { live.ctx = new AC(); }
|
||||
}
|
||||
if (live.ctx && live.ctx.state === 'suspended') live.ctx.resume().catch(function () {});
|
||||
} catch (e) { live.ctx = null; }
|
||||
live.token = kald('/stt-token', {}).catch(function () { return null; });
|
||||
}
|
||||
|
||||
function liveStart(stream) {
|
||||
if (!live.ctx || !window.WebSocket) return;
|
||||
live.token.then(function (t) {
|
||||
if (!t || !t.url || !st.stream) return;
|
||||
var ws;
|
||||
try { ws = new WebSocket(t.url); } catch (e) { return; }
|
||||
live.ws = ws;
|
||||
ws.onmessage = function (ev) {
|
||||
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
if (m.message_type === 'partial_transcript') { live.delvis = m.text || ''; }
|
||||
else if (m.message_type === 'committed_transcript') {
|
||||
if (m.text) live.fast = (live.fast + ' ' + m.text).trim();
|
||||
live.delvis = '';
|
||||
}
|
||||
liveVis();
|
||||
};
|
||||
ws.onclose = function () { live.aktiv = false; if (live.luk) { live.luk(); live.luk = null; } };
|
||||
ws.onerror = function () { live.aktiv = false; };
|
||||
ws.onopen = function () {
|
||||
if (!st.stream) { try { ws.close(); } catch (e) {} return; }
|
||||
live.aktiv = true;
|
||||
ui.live.hidden = false;
|
||||
try {
|
||||
live.src = live.ctx.createMediaStreamSource(stream);
|
||||
live.node = live.ctx.createScriptProcessor(4096, 1, 1);
|
||||
var faktor = live.ctx.sampleRate / 16000; // Safari ignorerer af og til ønsket samplerate
|
||||
live.node.onaudioprocess = function (e) {
|
||||
if (!live.aktiv || ws.readyState !== 1) return;
|
||||
var inp = e.inputBuffer.getChannelData(0);
|
||||
var n = Math.floor(inp.length / faktor);
|
||||
var pcm = new Int16Array(n);
|
||||
for (var i = 0; i < n; i++) {
|
||||
var v = inp[Math.floor(i * faktor)];
|
||||
v = Math.max(-1, Math.min(1, v));
|
||||
pcm[i] = v < 0 ? v * 32768 : v * 32767;
|
||||
}
|
||||
var bytes = new Uint8Array(pcm.buffer), bin = '';
|
||||
for (var j = 0; j < bytes.length; j += 8192) bin += String.fromCharCode.apply(null, bytes.subarray(j, j + 8192));
|
||||
ws.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: btoa(bin), commit: false, sample_rate: 16000 }));
|
||||
};
|
||||
live.src.connect(live.node);
|
||||
live.node.connect(live.ctx.destination);
|
||||
} catch (e) { live.aktiv = false; }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function liveVis() {
|
||||
ui.liveFast.textContent = live.fast;
|
||||
ui.liveDelvis.textContent = live.delvis ? (' ' + live.delvis) : '';
|
||||
}
|
||||
|
||||
// Stopper lydstrømmen, beder om den sidste commit og giver teksten max 2,5 s til at lande
|
||||
function liveStop() {
|
||||
live.aktiv = false;
|
||||
try { if (live.node) { live.node.disconnect(); live.node.onaudioprocess = null; } if (live.src) live.src.disconnect(); } catch (e) {}
|
||||
live.node = null; live.src = null;
|
||||
var ws = live.ws; live.ws = null;
|
||||
if (ws && ws.readyState === 1) {
|
||||
try { ws.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: '', commit: true, sample_rate: 16000 })); } catch (e) {}
|
||||
live.aktiv = true; // modtag stadig beskeder, til vi lukker
|
||||
setTimeout(function () { live.aktiv = false; try { ws.close(); } catch (e) {} }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
function liveTekst() {
|
||||
return (live.fast + (live.delvis ? ' ' + live.delvis : '')).trim();
|
||||
}
|
||||
|
||||
function optagelseFaerdig() {
|
||||
stopStream(); // ellers bliver den røde mikrofon-prik stående på iPhone
|
||||
ui.optag.classList.remove('optager');
|
||||
@@ -562,7 +661,13 @@
|
||||
ui.optaget.hidden = false;
|
||||
status('Hør dit svar igennem, og send det, når du er tilfreds.');
|
||||
ui.send.focus();
|
||||
sendKladde();
|
||||
// Giv live-afskriften op til 2 s til at sende den sidste sætning, før kladden går op
|
||||
if (live.ws || live.aktiv) {
|
||||
var blob = st.blob, sendt = false;
|
||||
var go = function () { if (!sendt && st.blob === blob) { sendt = true; sendKladde(); } };
|
||||
live.luk = go;
|
||||
setTimeout(go, 2200);
|
||||
} else sendKladde();
|
||||
}
|
||||
|
||||
// Optagelsen sendes op med det samme som kladde, så afskriften er i gang, mens man lytter.
|
||||
@@ -572,6 +677,10 @@
|
||||
var form = new FormData();
|
||||
form.append('lyd', st.blob, 'svar.' + st.ext);
|
||||
form.append('kladde', '1');
|
||||
// Live-afskriften (hvis den var med) sendes med, så serveren slipper for at skrive af bagefter.
|
||||
// Vent et øjeblik på den sidste committed-besked, så kladden får hele teksten med.
|
||||
var t = liveTekst();
|
||||
if (t) form.append('tekst', t);
|
||||
form.append('projekt', st.projekt);
|
||||
form.append('samtale', st.samtale || '');
|
||||
form.append('spoergsmaal', st.spoergsmaal);
|
||||
@@ -722,10 +831,21 @@
|
||||
var m = document.createElement('p'); m.className = 'er-meta'; m.textContent = 'Varighed ' + mmss(j.varighed);
|
||||
li.appendChild(m);
|
||||
}
|
||||
var ret = document.createElement('div'); ret.className = 'er-ret';
|
||||
var knapper = document.createElement('div'); knapper.className = 'er-knapper'; knapper.style.justifyContent = 'flex-start';
|
||||
if (!j.skrevet) {
|
||||
var begge = document.createElement('button');
|
||||
begge.type = 'button'; begge.className = 'wt-btn'; begge.textContent = '▶ Spørgsmål og svar'; begge.setAttribute('data-begge', j.id);
|
||||
knapper.appendChild(begge);
|
||||
}
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten'; b.setAttribute('data-ret', j.id);
|
||||
ret.appendChild(b); li.appendChild(ret);
|
||||
knapper.appendChild(b); li.appendChild(knapper);
|
||||
if (!j.skrevet) {
|
||||
var a = document.createElement('audio'); a.controls = true; a.preload = 'none'; a.setAttribute('data-svar', j.id);
|
||||
a.src = ROD + '/klip/' + j.id + '.mp3' + (NOEGLE ? ('?noegle=' + encodeURIComponent(NOEGLE)) : '');
|
||||
a.style.marginTop = '.4rem'; li.appendChild(a);
|
||||
}
|
||||
var ret = document.createElement('div'); ret.className = 'er-ret'; li.appendChild(ret);
|
||||
ui.dagens.insertBefore(li, ui.dagens.firstChild);
|
||||
ui.dagensTom.hidden = true;
|
||||
return t;
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="wt-muted" style="margin:.2rem 0 .8rem">{% if delt %}Tryk på din app, så kommer nye episoder af sig selv. Linket er familiens fælles, så del det kun inden for familien.{% else %}Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.{% endif %}</p>
|
||||
<p class="wt-muted" style="margin:.2rem 0 .8rem">{% if gaest %}Tryk på din app, så kommer nye episoder af sig selv. Linket er delt med dig personligt{% if gaest.udloeber %} og virker til <span data-ts="{{ gaest.udloeber }}"></span>{% endif %}, så del det ikke videre.{% elif delt %}Tryk på din app, så kommer nye episoder af sig selv. Linket er familiens fælles, så del det kun inden for familien.{% else %}Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.{% endif %}</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.5rem">
|
||||
<a class="wt-btn wt-btn--primary" href="podcast://{{ u.uden_skema }}">Apple Podcasts</a>
|
||||
<a class="wt-btn" href="overcast://x-callback-url/add?url={{ u.feed_enc }}">Overcast</a>
|
||||
@@ -108,6 +108,9 @@ function kopierTekst(t, knap) {
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(ok, fallback); else fallback();
|
||||
}
|
||||
document.querySelectorAll('[data-ts]').forEach(function (s) {
|
||||
s.textContent = new Date(parseInt(s.dataset.ts, 10) * 1000).toLocaleDateString('da-DK', { dateStyle: 'long' });
|
||||
});
|
||||
document.querySelectorAll('[data-kopier-tekst]').forEach(function (b) {
|
||||
b.addEventListener('click', function () { kopierTekst(b.dataset.kopierTekst, b); });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Integrationstest af /e-pub under en RIGTIG uvicorn (baggrundsjobs + subprocess), med et
|
||||
# stub-værktøj i stedet for det rigtige og current_user patchet (ingen OIDC). Kør fra repo-roden:
|
||||
# python tests/epub_uvicorn.py
|
||||
import json, os, pathlib, sys, tempfile, threading, time
|
||||
|
||||
ROD = pathlib.Path(tempfile.mkdtemp(prefix="epub-test-"))
|
||||
os.environ.update(PORTAL_DATA=str(ROD / "data"), AUTHENTIK_BASE="https://x.invalid", OIDC_CLIENT_ID="x",
|
||||
OIDC_CLIENT_SECRET="x", AUTHENTIK_API_TOKEN="x", SESSION_SECRET="x", RP_API_KEY="hemmelig-noegle")
|
||||
VAERKTOEJ = ROD / "tools"
|
||||
VAERKTOEJ.mkdir()
|
||||
for f in ("run.sh", "run-audiobook.sh", "lib.sh"):
|
||||
(VAERKTOEJ / f).write_text("#!/bin/bash\n")
|
||||
(VAERKTOEJ / "fetch.sh").write_text(r'''#!/bin/bash
|
||||
echo "trin 1: $1" >&2
|
||||
case "$1" in
|
||||
ok-epub*) sleep 1; printf 'PK' > "$PWD/Min Bog.epub"; echo "Wrote $PWD/Min Bog.epub (2 B)" >&2 ;;
|
||||
ok-m4b) head -c 1000 /dev/zero > "$PWD/Ane Forfatter - Lydbog æøå.m4b"; printf 'trin 2\rtrin 3\n' >&2; echo "Wrote $PWD/Ane Forfatter - Lydbog æøå.m4b (1000 B)" >&2 ;;
|
||||
env) env | sort >&2; [ -d "$TMP" ] && [ -z "$(ls -A)" ] && touch "$PWD/env.epub" ;;
|
||||
bad) exit 2 ;;
|
||||
gone) exit 3 ;;
|
||||
proxy) exit 4 ;;
|
||||
slow) sleep 31.7 & wait ;;
|
||||
*) echo "noget andet: $RP_API_KEY" >&2; exit 7 ;;
|
||||
esac
|
||||
''')
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
os.chdir(pathlib.Path(__file__).resolve().parents[1])
|
||||
import config # noqa: E402
|
||||
config.EPUB_SETTINGS_PATH.write_text(json.dumps({"tools_dir": str(VAERKTOEJ), "job_timeout_seconds": 3,
|
||||
"output_ttl_seconds": 4, "max_concurrent_jobs": 1,
|
||||
"cwa_ingest_dir": str(ROD / "cwa"), "abs_dir": str(ROD / "abs"), "auto_send": False}))
|
||||
(ROD / "cwa").mkdir()
|
||||
(ROD / "abs").mkdir()
|
||||
import main # noqa: E402
|
||||
import uvicorn # noqa: E402
|
||||
import httpx # noqa: E402
|
||||
|
||||
BRUGER = {"name": "Test", "username": "test", "groups": []}
|
||||
main.current_user = lambda request: BRUGER
|
||||
srv = uvicorn.Server(uvicorn.Config(main.app, port=8019, log_level="warning"))
|
||||
threading.Thread(target=srv.run, daemon=True).start()
|
||||
while not srv.started:
|
||||
time.sleep(0.1)
|
||||
c = httpx.Client(base_url="http://127.0.0.1:8019", headers={"Accept": "application/json"}, timeout=30)
|
||||
|
||||
|
||||
def tjek(navn, ok):
|
||||
print(("OK " if ok else "FEJL ") + navn)
|
||||
if not ok:
|
||||
global fejl
|
||||
fejl += 1
|
||||
|
||||
|
||||
def vent(jid, slut=("faerdig", "fejl")):
|
||||
for _ in range(100):
|
||||
st = c.get(f"/e-pub/status/{jid}").json()
|
||||
if st["tilstand"] in slut:
|
||||
return st
|
||||
time.sleep(0.2)
|
||||
return st
|
||||
|
||||
|
||||
fejl = 0
|
||||
tjek("ingen adgang uden gruppe", c.get("/e-pub").status_code == 403)
|
||||
BRUGER["groups"] = ["authentik Admins"]
|
||||
tjek("admin har adgang", c.get("/e-pub").status_code == 200)
|
||||
tjek("admin-side", c.get("/admin/epub").status_code == 200)
|
||||
tjek("tom input afvist", c.post("/e-pub/start", data={"link": " "}).status_code == 400)
|
||||
tjek("styretegn afvist", c.post("/e-pub/start", data={"link": "a\nb"}).status_code == 400)
|
||||
tjek("for stor body afvist", c.post("/e-pub/start", data={"link": "x" * 20000}).status_code == 413)
|
||||
|
||||
a = c.post("/e-pub/start", data={"link": "ok-epub 1"}).json()
|
||||
b = c.post("/e-pub/start", data={"link": "ok-m4b"}).json()
|
||||
tjek("semafor: andet job venter i kø", c.get(f"/e-pub/status/{b['id']}").json()["tilstand"] == "koe")
|
||||
sa = vent(a["id"])
|
||||
tjek("epub færdig", sa["tilstand"] == "faerdig" and sa["filnavn"] == "Min Bog.epub")
|
||||
tjek("epub kan sendes til CWA", sa["kan_sende"] == "cwa")
|
||||
r = c.post(f"/e-pub/send/{a['id']}")
|
||||
tjek("sendt til CWA", r.status_code == 200 and r.json()["sendt"] == "CWA"
|
||||
and (ROD / "cwa" / "Min Bog.epub").read_bytes() == b"PK" and not list((ROD / "cwa").glob(".*")))
|
||||
tjek("ikke to gange", c.post(f"/e-pub/send/{a['id']}").status_code == 400)
|
||||
r = c.get(f"/e-pub/hent/{a['id']}")
|
||||
tjek("epub content-type", r.headers["content-type"] == "application/epub+zip" and r.content == b"PK")
|
||||
tjek("filnavn i Content-Disposition", "Min%20Bog.epub" in r.headers["content-disposition"] or "Min Bog.epub" in r.headers["content-disposition"])
|
||||
time.sleep(0.3)
|
||||
tjek("slettet efter hentning", c.get(f"/e-pub/status/{a['id']}").json()["tilstand"] == "hentet"
|
||||
and not (config.EPUB_SCRATCH / a["id"] / "ud").exists())
|
||||
tjek("anden hentning = 410", c.get(f"/e-pub/hent/{a['id']}").status_code == 410)
|
||||
sb = vent(b["id"])
|
||||
tjek("m4b færdig, \\r-linjer splittet", sb["filnavn"] == "Ane Forfatter - Lydbog æøå.m4b" and "trin 3" in sb["log"])
|
||||
r = c.post(f"/e-pub/send/{b['id']}")
|
||||
tjek("m4b til Audiobookshelf som Forfatter/Titel", r.json().get("sendt") == "Audiobookshelf"
|
||||
and (ROD / "abs" / "Ane Forfatter" / "Lydbog æøå" / "Ane Forfatter - Lydbog æøå.m4b").stat().st_size == 1000)
|
||||
tjek("m4b content-type", c.get(f"/e-pub/hent/{b['id']}").headers["content-type"] == "audio/mp4")
|
||||
|
||||
e = vent(c.post("/e-pub/start", data={"link": "env"}).json()["id"])
|
||||
navne = {l.split("=", 1)[0] for l in e["log"] if "=" in l and not l.startswith("trin")}
|
||||
tjek("rent env (kun PATH/TMP/RP_* + bash' egne)", navne <= {"PATH", "TMP", "RP_API_KEY", "RP_BASE_URL", "RP_REQUIRED", "PWD", "SHLVL", "_"})
|
||||
tjek("RP_REQUIRED=1, nøgle maskeret, frisk mappe", "RP_REQUIRED=1" in e["log"] and "RP_API_KEY=***" in e["log"] and e["tilstand"] == "faerdig")
|
||||
|
||||
for inp, forventet in (("bad", "Kunne ikke læse linket."), ("gone", "Ikke tilgængelig (udløbet?)."), ("proxy", "Serverfejl: proxy")):
|
||||
s = vent(c.post("/e-pub/start", data={"link": inp}).json()["id"])
|
||||
tjek(f"exit for {inp}", s["tilstand"] == "fejl" and s["besked"].startswith(forventet))
|
||||
s = vent(c.post("/e-pub/start", data={"link": "andet"}).json()["id"])
|
||||
tjek("generisk fejl m. sidste linjer, ingen nøgle", s["besked"].startswith("Noget gik galt.") and "hemmelig" not in s["besked"])
|
||||
t0 = time.time()
|
||||
s = vent(c.post("/e-pub/start", data={"link": "slow"}).json()["id"])
|
||||
tjek("timeout dræber jobbet", s["tilstand"] == "fejl" and "lang tid" in s["besked"] and time.time() - t0 < 8)
|
||||
tjek("underprocesser dræbt med", os.system("pgrep -f '^sleep 31.7$' >/dev/null") != 0)
|
||||
|
||||
BRUGER.update(username="anden", groups=["Bogklub"])
|
||||
tjek("andre brugeres job = 403 uden gruppe", c.get(f"/e-pub/status/{b['id']}").status_code == 403)
|
||||
config.EPUB_SETTINGS_PATH.write_text(json.dumps({**json.loads(config.EPUB_SETTINGS_PATH.read_text()), "allowed_group": "Bogklub"}))
|
||||
tjek("gruppe giver adgang", c.get("/e-pub").status_code == 200)
|
||||
tjek("men ikke til andres job", c.get(f"/e-pub/status/{b['id']}").status_code == 404)
|
||||
tjek("og ikke admin", c.get("/admin/epub").status_code == 403)
|
||||
|
||||
BRUGER.update(username="test", groups=["authentik Admins"])
|
||||
import epub # noqa: E402
|
||||
indst = json.loads(config.EPUB_SETTINGS_PATH.read_text())
|
||||
config.EPUB_SETTINGS_PATH.write_text(json.dumps({**indst, "auto_send": True, "output_ttl_seconds": 30}))
|
||||
u = vent(c.post("/e-pub/start", data={"link": "ok-epub 3"}).json()["id"])
|
||||
time.sleep(0.5)
|
||||
u = c.get(f"/e-pub/status/{u['id']}").json()
|
||||
tjek("auto-send til CWA", u["sendt"] == "CWA" and (ROD / "cwa" / "Min Bog (2).epub").exists() and u["tilstand"] == "faerdig")
|
||||
|
||||
# Genstart: en færdig fil overlever, et job der var i gang bliver "afbrudt".
|
||||
k = vent(c.post("/e-pub/start", data={"link": "slow"}).json()["id"], slut=("koerer",))
|
||||
epub._jobs.clear()
|
||||
epub._indlaes()
|
||||
tjek("færdig fil overlever genstart", c.get(f"/e-pub/status/{u['id']}").json()["tilstand"] == "faerdig"
|
||||
and c.get(f"/e-pub/hent/{u['id']}").content == b"PK")
|
||||
k2 = c.get(f"/e-pub/status/{k['id']}").json()
|
||||
tjek("igangværende job → afbrudt efter genstart", k2["tilstand"] == "fejl" and "genstartet" in k2["besked"])
|
||||
time.sleep(3.5) # det gamle job når at time ud; må ikke overskrive noget
|
||||
|
||||
config.EPUB_SETTINGS_PATH.write_text(json.dumps({**indst, "auto_send": False, "output_ttl_seconds": 4}))
|
||||
v = vent(c.post("/e-pub/start", data={"link": "ok-epub 4"}).json()["id"])
|
||||
time.sleep(1)
|
||||
tjek("ikke-hentet fil findes stadig inden TTL", c.get(f"/e-pub/status/{v['id']}").json()["tilstand"] == "faerdig")
|
||||
time.sleep(5)
|
||||
tjek("job + fil væk efter TTL", c.get(f"/e-pub/status/{v['id']}").status_code == 404
|
||||
and not (config.EPUB_SCRATCH / v["id"]).exists())
|
||||
tjek("admin gem validerer", c.post("/admin/epub", data={"tools_dir": "relativ"}, follow_redirects=False).headers["location"].startswith("/admin/epub?saved=Afvist"))
|
||||
c.post("/admin/epub", data={"tools_dir": "/x", "max_concurrent_jobs": "99", "job_timeout_seconds": "abc", "rp_required": "on"})
|
||||
s = json.loads(config.EPUB_SETTINGS_PATH.read_text())
|
||||
tjek("ints klemmes, ugyldige → standard", s["max_concurrent_jobs"] == 8 and s["job_timeout_seconds"] == 1200 and s["rp_required"] is True)
|
||||
print("ALT OK" if not fejl else f"{fejl} FEJL")
|
||||
srv.should_exit = True
|
||||
sys.exit(1 if fejl else 0)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,51 @@
|
||||
# Røgtest af "Del med ukendte" (gæstelinks) med FastAPI TestClient, uden OIDC.
|
||||
# Kør i CT151: PORTAL_DATA=/tmp/pg-test/data + env fra /etc/portal.env. Rører ikke prod-data.
|
||||
import json, time
|
||||
from fastapi.testclient import TestClient
|
||||
import main, podcast
|
||||
|
||||
d = podcast.ROOT / "test"
|
||||
(d / "episodes").mkdir(parents=True, exist_ok=True)
|
||||
(d / "show.json").write_text(json.dumps({"titel": "Testshow", "beskrivelse": "b"}))
|
||||
for slug, kladde in (("e1", False), ("e2", True)):
|
||||
(d / "episodes" / f"{slug}.mp3").write_bytes(b"\xff\xfb" + b"\0" * 2000)
|
||||
(d / "episodes" / f"{slug}.json").write_text(json.dumps({"titel": slug, "nr": 1, "kladde": kladde}))
|
||||
|
||||
c = TestClient(main.app, base_url="https://weircon.dk")
|
||||
admin = {"username": "agw", "name": "Asger", "groups": [main.config.ADMIN_GROUP, "Familie"]}
|
||||
main.current_user = lambda request: admin
|
||||
|
||||
r = c.post("/admin/podcast/gaest", data={"handling": "ny", "navn": "Jobsamtale X", "dage": "14"}, follow_redirects=False)
|
||||
assert r.status_code == 303, r.text
|
||||
g = podcast.alle_gaester()[0]; key = g["key"]
|
||||
assert 13.9 * 86400 < g["udloeber"] - time.time() < 14.1 * 86400
|
||||
r = c.get("/admin/podcast"); assert r.status_code == 200 and "Jobsamtale X" in r.text and key in r.text
|
||||
|
||||
main.current_user = lambda request: None
|
||||
r = c.get(f"/podcast/f/{key}/"); assert r.status_code == 200 and "delt med dig personligt" in r.text, r.status_code
|
||||
assert "familiens fælles" not in r.text and "e2.mp3" not in r.text
|
||||
assert c.get(f"/podcast/f/{key}/test.xml").status_code == 200
|
||||
assert c.get(f"/podcast/f/{key}/test/e1.mp3").status_code == 200
|
||||
assert c.get(f"/podcast/f/{key}/test/e2.mp3").status_code == 404, "kladde må ikke kunne hentes"
|
||||
assert podcast.gaester()[key]["hentninger"] == 3
|
||||
assert not podcast.faelles().get("key"), "gæstelinket må ikke røre familielinket"
|
||||
|
||||
# udløbet
|
||||
gg = podcast.gaester(); gg[key]["udloeber"] = int(time.time()) - 1; podcast._skriv(podcast.GAEST, gg)
|
||||
assert c.get(f"/podcast/f/{key}/").status_code == 404
|
||||
gg[key]["udloeber"] = int(time.time()) + 3600; podcast._skriv(podcast.GAEST, gg)
|
||||
assert c.get(f"/podcast/f/{key}/").status_code == 200
|
||||
|
||||
# trukket tilbage, slettet
|
||||
main.current_user = lambda request: admin
|
||||
assert c.post("/admin/podcast/gaest", data={"handling": "tilbagekald", "key": key}, follow_redirects=False).status_code == 303
|
||||
assert c.get(f"/podcast/f/{key}/test/e1.mp3").status_code == 404
|
||||
assert c.post("/admin/podcast/gaest", data={"handling": "slet", "key": key}, follow_redirects=False).status_code == 303
|
||||
assert key not in podcast.gaester()
|
||||
|
||||
# "aldrig" + familielinket virker stadig ved siden af
|
||||
c.post("/admin/podcast/gaest", data={"handling": "ny", "navn": "Uden udløb", "dage": "0"})
|
||||
assert podcast.alle_gaester()[0]["udloeber"] is None
|
||||
fk = podcast.faelles_skift()
|
||||
r = c.get(f"/podcast/f/{fk}/"); assert r.status_code == 200 and "familiens fælles" in r.text
|
||||
print("OK")
|
||||
+13
-3
@@ -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