Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4005860364 | ||
|
|
9df3c31e43 | ||
|
|
6ee54c1248 | ||
|
|
77f33f7c9e | ||
|
|
776c314b0f |
@@ -14,6 +14,28 @@ FastAPI-app der:
|
||||
- Matomo-statistik
|
||||
- App-beskrivelser & grupper (skriver tilbage til Authentik)
|
||||
|
||||
## Erindringer (interview + dagbog)
|
||||
|
||||
`/erindringer`: familien fortæller minder til en AI-interviewer. Claude (Anthropic
|
||||
Messages API, `ANTHROPIC_API_KEY`, model `ERINDRINGER_MODEL`) stiller ét spørgsmål ad gangen
|
||||
og følger op på det sidste svar; spørgsmålet læses op af ElevenLabs (`ELEVEN_API_KEY`,
|
||||
stemme `ERINDRINGER_VOICE`, cachet pr. tekst), svaret optages i browseren (MediaRecorder,
|
||||
webm/mp4), konverteres med ffmpeg til mp3 og transskriberes (ElevenLabs STT). Alt gemmes
|
||||
pr. person under `PORTAL_DATA/erindringer/` (persondata, aldrig i git).
|
||||
|
||||
- Indloggede i familiegrupperne har altid emnet "Dagbog"; admin inviterer til andre emner.
|
||||
- Gæster uden login får et hemmeligt link `/erindringer/g/<nøgle>/`, lavet i `/admin/erindringer`
|
||||
(kan lukkes og fornys). Nøglen sendes med i API-kald som `noegle`.
|
||||
- `/admin/erindringer`: emner (titel, beskrivelse, baggrund til intervieweren, startspørgsmål),
|
||||
personer, klip med afspiller og transskription, favoritter, zip-eksport til podcast-produktion.
|
||||
- Edge: gæstelink, `/erindringer/api/`, `/erindringer/tts/` og `/erindringer/klip/` skal uden om
|
||||
bot-udfordringen og have `client_max_body_size 50m` (lange lydsvar).
|
||||
- Uden nøgler virker siden stadig: intervieweren bruger emnets startspørgsmål, og svar gemmes
|
||||
uden transskription.
|
||||
|
||||
Røgtest uden OIDC: `PORTAL_DATA=/tmp/x python tests/erindringer_smoke.py` (kalder de rigtige
|
||||
API'er, koster lidt credits).
|
||||
|
||||
## Kør
|
||||
|
||||
```bash
|
||||
|
||||
@@ -111,6 +111,8 @@ ADMIN_MODULES = [
|
||||
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
||||
{"key": "podcast", "title": "Podcast", "icon": "🎙️",
|
||||
"desc": "Slægtens Stemmer: udgiv kladder og se/træk lytternes personlige feed-adresser tilbage."},
|
||||
{"key": "erindringer", "title": "Erindringer", "icon": "🎤",
|
||||
"desc": "Familien fortæller minder og dagbog til en AI-interviewer: projekter, gæstelinks, klip og eksport."},
|
||||
{"key": "claude-login", "title": "Claude-login", "icon": "🤖",
|
||||
"desc": "Log Claude på serveren ind igen fra telefonen, uden terminal."},
|
||||
{"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑",
|
||||
|
||||
@@ -11,3 +11,9 @@ MATOMO_TOKEN=__matomo-token__
|
||||
MATOMO_SITE=2
|
||||
IP_LABELS_REMOTE=user@edge-host:/path/to/ip_labels.json
|
||||
PORTAL_DATA=/opt/portal/data
|
||||
# Erindringer (interview + dagbog): Claude stiller spørgsmål, ElevenLabs læser op og transskriberer.
|
||||
ANTHROPIC_API_KEY=__sk-ant-…__
|
||||
ANTHROPIC_WORKSPACE_ID= # kun for bruger-nøgler (sk-ant-usr…), ellers tom
|
||||
ERINDRINGER_MODEL=claude-sonnet-5
|
||||
ELEVEN_API_KEY=__xi-…__
|
||||
ERINDRINGER_VOICE=tJDFCHyviItsYF1qqToS
|
||||
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
"""Erindringer: familien fortæller sine minder (eller fører dagbog) til en AI-interviewer.
|
||||
|
||||
Interviewer = Claude (Anthropic Messages API via httpx; ingen SDK), der stiller ét spørgsmål ad
|
||||
gangen og følger op på det, personen lige har sagt. Spørgsmålet læses op af ElevenLabs (cachet pr.
|
||||
tekst), svaret optages i browseren, transskriberes af ElevenLabs STT og gemmes som mp3 + tekst.
|
||||
Klippene bruges som råstof til podcasten (Slægtens Stemmer) og som personlig dagbog.
|
||||
|
||||
Data (ikke i git, persondata):
|
||||
DATA/erindringer/projekter.json slug -> titel, beskrivelse, kontekst (til Claude), start (spørgsmål)
|
||||
DATA/erindringer/personer.json pid -> navn, bruger | noegle, projekter, oprettet, sidst
|
||||
DATA/erindringer/klip/<pid>/<id>.mp3 svaret (mono 64 kbit)
|
||||
DATA/erindringer/klip/<pid>/<id>.json id, tid, projekt, samtale, spoergsmaal, tekst, varighed, favorit
|
||||
DATA/erindringer/tts/<sha>.mp3 oplæste spørgsmål
|
||||
|
||||
Personer: indloggede brugere har pid "u:<username>" og kan altid føre dagbog; gæster (familie uden
|
||||
login) får pid "g:<slug>" og en hemmelig nøgle i adressen, som podcasten gør det. Admin bestemmer,
|
||||
hvilke projekter en person inviteres til; projektet "dagbog" er altid åbent for indloggede.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
import config
|
||||
|
||||
ROOT = config.DATA / "erindringer"
|
||||
PROJEKTER = ROOT / "projekter.json"
|
||||
PERSONER = ROOT / "personer.json"
|
||||
KLIP = ROOT / "klip"
|
||||
TTS = ROOT / "tts"
|
||||
SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,40}$")
|
||||
KEY = re.compile(r"^[A-Za-z0-9_-]{32,64}$")
|
||||
KLIPID = re.compile(r"^[0-9]{8}-[0-9]{6}-[a-f0-9]{6}$")
|
||||
PID = re.compile(r"^[ug]:[A-Za-z0-9_.@-]{1,64}$")
|
||||
|
||||
ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
ANTHROPIC_WORKSPACE = os.environ.get("ANTHROPIC_WORKSPACE_ID", "")
|
||||
MODEL = os.environ.get("ERINDRINGER_MODEL", "claude-sonnet-5")
|
||||
ELEVEN_KEY = os.environ.get("ELEVEN_API_KEY", "")
|
||||
VOICE = os.environ.get("ERINDRINGER_VOICE", "tJDFCHyviItsYF1qqToS") # Leif, podcastens fortæller
|
||||
MAX_SVAR_SEK = 15 * 60
|
||||
MAX_UPLOAD = 40 * 1024 * 1024
|
||||
|
||||
DAGBOG = {
|
||||
"titel": "Dagbog",
|
||||
"beskrivelse": "Hvad der fylder lige nu. Et par minutter, når du har lyst; interviewerens spørgsmål hjælper dig i gang.",
|
||||
"kontekst": "Dette er personens private dagbog. Spørg til hverdagen, til det der fylder, til små og store ting, "
|
||||
"og følg op på det, personen selv bringer på bane. Vær nysgerrig uden at være nærgående.",
|
||||
"start": ["Hvad har fyldt mest for dig i dag?", "Hvad har du tænkt på i denne uge?",
|
||||
"Er der sket noget siden sidst, du gerne vil huske?"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------- fil-hjælpere ----------------
|
||||
|
||||
def _læs(p: Path, standard):
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except (OSError, ValueError):
|
||||
return standard
|
||||
|
||||
|
||||
def _skriv(p: Path, data) -> None:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = p.with_name("." + p.name + ".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1))
|
||||
os.replace(tmp, p)
|
||||
|
||||
|
||||
def _nu() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
|
||||
|
||||
# ---------------- projekter ----------------
|
||||
|
||||
def projekter() -> dict:
|
||||
p = _læs(PROJEKTER, {})
|
||||
p.setdefault("dagbog", dict(DAGBOG))
|
||||
return p
|
||||
|
||||
|
||||
def projekt(slug: str) -> dict | None:
|
||||
if not SLUG.match(slug or ""):
|
||||
return None
|
||||
p = projekter().get(slug)
|
||||
return {**p, "slug": slug} if p else None
|
||||
|
||||
|
||||
def gem_projekt(slug: str, titel: str, beskrivelse: str, kontekst: str, start: list[str]) -> bool:
|
||||
if not SLUG.match(slug or "") or not titel.strip():
|
||||
return False
|
||||
p = projekter()
|
||||
gammel = p.get(slug, {})
|
||||
p[slug] = {"titel": titel.strip(), "beskrivelse": beskrivelse.strip(), "kontekst": kontekst.strip(),
|
||||
"start": [s.strip() for s in start if s.strip()], "oprettet": gammel.get("oprettet") or _nu()}
|
||||
_skriv(PROJEKTER, p)
|
||||
return True
|
||||
|
||||
|
||||
def slet_projekt(slug: str) -> bool:
|
||||
p = projekter()
|
||||
if slug == "dagbog" or slug not in p:
|
||||
return False
|
||||
del p[slug]
|
||||
_skriv(PROJEKTER, p)
|
||||
ps = personer()
|
||||
for v in ps.values():
|
||||
if slug in v.get("projekter", []):
|
||||
v["projekter"].remove(slug)
|
||||
_skriv(PERSONER, ps)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------- personer ----------------
|
||||
|
||||
def personer() -> dict:
|
||||
return _læs(PERSONER, {})
|
||||
|
||||
|
||||
def person(pid: str) -> dict | None:
|
||||
if not PID.match(pid or ""):
|
||||
return None
|
||||
v = personer().get(pid)
|
||||
return {**v, "pid": pid} if v else None
|
||||
|
||||
|
||||
def person_for_bruger(username: str, navn: str) -> dict:
|
||||
"""Indlogget bruger: oprettes første gang, dagbog er altid med."""
|
||||
pid = f"u:{username}"
|
||||
ps = personer()
|
||||
v = ps.get(pid)
|
||||
if v is None:
|
||||
v = {"navn": navn or username, "bruger": username, "projekter": ["dagbog"], "oprettet": _nu()}
|
||||
ps[pid] = v
|
||||
else:
|
||||
if navn and v.get("navn") != navn:
|
||||
v["navn"] = navn
|
||||
if "dagbog" not in v.setdefault("projekter", []):
|
||||
v["projekter"].insert(0, "dagbog")
|
||||
v["sidst"] = _nu()
|
||||
_skriv(PERSONER, ps)
|
||||
return {**v, "pid": pid}
|
||||
|
||||
|
||||
def person_for_noegle(key: str) -> dict | None:
|
||||
if not KEY.match(key or ""):
|
||||
return None
|
||||
for pid, v in personer().items():
|
||||
if v.get("noegle") == key and not v.get("lukket"):
|
||||
v["sidst"] = _nu()
|
||||
ps = personer(); ps[pid]["sidst"] = v["sidst"]; _skriv(PERSONER, ps)
|
||||
return {**v, "pid": pid}
|
||||
return None
|
||||
|
||||
|
||||
def ny_gaest(navn: str, projekt_slugs: list[str]) -> dict:
|
||||
base = re.sub(r"[^a-z0-9]+", "-", navn.lower().replace("æ", "ae").replace("ø", "oe").replace("å", "aa")).strip("-")[:30] or "gaest"
|
||||
ps = personer()
|
||||
slug, n = base, 2
|
||||
while f"g:{slug}" in ps:
|
||||
slug, n = f"{base}-{n}", n + 1
|
||||
pid = f"g:{slug}"
|
||||
ps[pid] = {"navn": navn.strip(), "noegle": secrets.token_urlsafe(32), "projekter": list(projekt_slugs),
|
||||
"oprettet": _nu()}
|
||||
_skriv(PERSONER, ps)
|
||||
return {**ps[pid], "pid": pid}
|
||||
|
||||
|
||||
def saet_projekter(pid: str, projekt_slugs: list[str]) -> bool:
|
||||
ps = personer()
|
||||
if pid not in ps:
|
||||
return False
|
||||
ps[pid]["projekter"] = [s for s in projekt_slugs if s in projekter()]
|
||||
if pid.startswith("u:") and "dagbog" not in ps[pid]["projekter"]:
|
||||
ps[pid]["projekter"].insert(0, "dagbog")
|
||||
_skriv(PERSONER, ps)
|
||||
return True
|
||||
|
||||
|
||||
def ny_noegle(pid: str) -> str | None:
|
||||
ps = personer()
|
||||
if pid not in ps or not pid.startswith("g:"):
|
||||
return None
|
||||
ps[pid]["noegle"] = secrets.token_urlsafe(32)
|
||||
ps[pid].pop("lukket", None)
|
||||
_skriv(PERSONER, ps)
|
||||
return ps[pid]["noegle"]
|
||||
|
||||
|
||||
def luk_gaest(pid: str, lukket: bool) -> bool:
|
||||
ps = personer()
|
||||
if pid not in ps or not pid.startswith("g:"):
|
||||
return False
|
||||
if lukket:
|
||||
ps[pid]["lukket"] = _nu()
|
||||
else:
|
||||
ps[pid].pop("lukket", None)
|
||||
_skriv(PERSONER, ps)
|
||||
return True
|
||||
|
||||
|
||||
def alle_personer() -> list[dict]:
|
||||
ud = []
|
||||
for pid, v in personer().items():
|
||||
ud.append({**v, "pid": pid, "antal": len(klip_for(pid))})
|
||||
return sorted(ud, key=lambda v: (v.get("sidst") or v.get("oprettet") or ""), reverse=True)
|
||||
|
||||
|
||||
def maa(p: dict, slug: str) -> bool:
|
||||
return slug in (p.get("projekter") or []) and projekt(slug) is not None
|
||||
|
||||
|
||||
# ---------------- klip ----------------
|
||||
|
||||
def _klipmappe(pid: str) -> Path:
|
||||
return KLIP / pid.replace(":", "_")
|
||||
|
||||
|
||||
def klip_for(pid: str, projekt_slug: str | None = None, samtale: str | None = None) -> list[dict]:
|
||||
"""Klip for en person, ældste først."""
|
||||
d = _klipmappe(pid)
|
||||
if not PID.match(pid or "") or not d.exists():
|
||||
return []
|
||||
ud = []
|
||||
for meta in d.glob("*.json"):
|
||||
if not KLIPID.match(meta.stem):
|
||||
continue
|
||||
k = _læs(meta, None)
|
||||
if not k or not (d / f"{meta.stem}.mp3").exists():
|
||||
continue
|
||||
if projekt_slug and k.get("projekt") != projekt_slug:
|
||||
continue
|
||||
if samtale and k.get("samtale") != samtale:
|
||||
continue
|
||||
ud.append(k)
|
||||
return sorted(ud, key=lambda k: k["id"])
|
||||
|
||||
|
||||
def klip(pid: str, kid: str) -> dict | None:
|
||||
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
|
||||
return None
|
||||
k = _læs(_klipmappe(pid) / f"{kid}.json", None)
|
||||
return k if k and k.get("id") == kid else None
|
||||
|
||||
|
||||
def klip_fil(pid: str, kid: str) -> Path | None:
|
||||
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
|
||||
return None
|
||||
p = _klipmappe(pid) / f"{kid}.mp3"
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
def _varighed(p: Path) -> int:
|
||||
try:
|
||||
ud = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(p)],
|
||||
capture_output=True, text=True, timeout=30).stdout.strip()
|
||||
return int(float(ud))
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return 0
|
||||
|
||||
|
||||
def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: bytes | None, filnavn: str,
|
||||
tekst: str | None) -> dict:
|
||||
"""Gem et svar: lyd konverteres til mp3 og transskriberes; ren tekst gemmes som det er.
|
||||
Returnerer klip-metadata. Kaster ValueError med besked til brugeren."""
|
||||
if not PID.match(pid or "") or not SLUG.match(projekt_slug or ""):
|
||||
raise ValueError("Ugyldig person eller projekt.")
|
||||
d = _klipmappe(pid)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
kid = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(3)
|
||||
mp3 = d / f"{kid}.mp3"
|
||||
varighed = 0
|
||||
if lyd:
|
||||
if len(lyd) > MAX_UPLOAD:
|
||||
raise ValueError("Optagelsen er for stor. Prøv at dele den op i to svar.")
|
||||
suffix = "." + (re.sub(r"[^a-z0-9]", "", (filnavn or "").rsplit(".", 1)[-1].lower()) or "webm")
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(lyd)
|
||||
raa = Path(tmp.name)
|
||||
try:
|
||||
r = subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", str(raa), "-vn", "-ac", "1", "-ar", "44100",
|
||||
"-c:a", "libmp3lame", "-b:a", "64k", str(mp3)], capture_output=True, text=True, timeout=300)
|
||||
if r.returncode or not mp3.exists():
|
||||
raise ValueError("Optagelsen kunne ikke læses. Prøv igen, eller skriv svaret i stedet.")
|
||||
varighed = _varighed(mp3)
|
||||
if varighed > MAX_SVAR_SEK:
|
||||
mp3.unlink(missing_ok=True)
|
||||
raise ValueError("Svaret er længere end et kvarter. Del det op, så bliver det også nemmere at bruge.")
|
||||
if tekst is None or not tekst.strip():
|
||||
tekst = transskriber(mp3)
|
||||
finally:
|
||||
raa.unlink(missing_ok=True)
|
||||
else:
|
||||
if not (tekst or "").strip():
|
||||
raise ValueError("Der kom hverken lyd eller tekst med.")
|
||||
# Tekstsvar: en stille mp3 på 1 s, så alle klip har samme form (afspiller + tekst).
|
||||
subprocess.run(["ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-t", "1",
|
||||
"-c:a", "libmp3lame", "-b:a", "32k", str(mp3)], capture_output=True, timeout=60)
|
||||
meta = {"id": kid, "tid": _nu(), "projekt": projekt_slug, "samtale": samtale, "spoergsmaal": spoergsmaal.strip()[:600],
|
||||
"tekst": (tekst or "").strip(), "varighed": varighed, "favorit": False, "skrevet": not lyd}
|
||||
_skriv(d / f"{kid}.json", meta)
|
||||
return meta
|
||||
|
||||
|
||||
def ret_tekst(pid: str, kid: str, tekst: str) -> bool:
|
||||
k = klip(pid, kid)
|
||||
if not k:
|
||||
return False
|
||||
k["tekst"] = tekst.strip()[:20000]
|
||||
k["redigeret"] = _nu()
|
||||
_skriv(_klipmappe(pid) / f"{kid}.json", k)
|
||||
return True
|
||||
|
||||
|
||||
def saet_favorit(pid: str, kid: str, favorit: bool) -> bool:
|
||||
k = klip(pid, kid)
|
||||
if not k:
|
||||
return False
|
||||
k["favorit"] = favorit
|
||||
_skriv(_klipmappe(pid) / f"{kid}.json", k)
|
||||
return True
|
||||
|
||||
|
||||
def slet_klip(pid: str, kid: str) -> bool:
|
||||
k = klip(pid, kid)
|
||||
if not k:
|
||||
return False
|
||||
d = _klipmappe(pid)
|
||||
(d / f"{kid}.json").unlink(missing_ok=True)
|
||||
(d / f"{kid}.mp3").unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------- ElevenLabs: tale ----------------
|
||||
|
||||
def transskriber(mp3: Path) -> str:
|
||||
if not ELEVEN_KEY:
|
||||
return ""
|
||||
try:
|
||||
with open(mp3, "rb") as f:
|
||||
r = httpx.post("https://api.elevenlabs.io/v1/speech-to-text", headers={"xi-api-key": ELEVEN_KEY},
|
||||
data={"model_id": "scribe_v2", "language_code": "da"},
|
||||
files={"file": (mp3.name, f, "audio/mpeg")}, timeout=600)
|
||||
if r.status_code == 200:
|
||||
return (r.json().get("text") or "").strip()
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def tts_sti(tekst: str) -> Path | None:
|
||||
"""Oplæst spørgsmål, cachet på tekstens hash; None hvis det ikke kunne laves."""
|
||||
tekst = (tekst or "").strip()
|
||||
if not tekst or not ELEVEN_KEY:
|
||||
return None
|
||||
sha = hashlib.sha256((VOICE + "|" + tekst).encode()).hexdigest()[:32]
|
||||
p = TTS / f"{sha}.mp3"
|
||||
if p.exists():
|
||||
return p
|
||||
TTS.mkdir(parents=True, exist_ok=True)
|
||||
body = {"text": tekst, "model_id": "eleven_v4", "language_code": "da",
|
||||
"voice_settings": {"stability": 0.5, "similarity_boost": 0.8, "style": 0.2, "use_speaker_boost": True}}
|
||||
try:
|
||||
r = httpx.post(f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE}?output_format=mp3_44100_64",
|
||||
headers={"xi-api-key": ELEVEN_KEY}, json=body, timeout=120)
|
||||
if r.status_code == 200 and r.content[:3] in (b"ID3", b"\xff\xfb", b"\xff\xf3"):
|
||||
tmp = p.with_name("." + p.name + ".tmp")
|
||||
tmp.write_bytes(r.content)
|
||||
os.replace(tmp, p)
|
||||
return p
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def tts_fil(sha: str) -> Path | None:
|
||||
if not re.match(r"^[a-f0-9]{32}$", sha or ""):
|
||||
return None
|
||||
p = TTS / f"{sha}.mp3"
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
# ---------------- Claude: interviewer ----------------
|
||||
|
||||
SYSTEM = """Du er en varm, nysgerrig interviewer for en dansk familie. Du hjælper et familiemedlem med at
|
||||
fortælle sine minder (eller føre dagbog) ved at stille ÉT kort spørgsmål ad gangen på dansk.
|
||||
|
||||
Regler:
|
||||
- Spørg konkret og hverdagsnært: steder, personer, lyde, lugte, hvad man lavede, hvad man følte. Et spørgsmål er på højst to sætninger.
|
||||
- Følg op på det, personen lige har fortalt (navne, steder, ting de kun nævnte kort), før du skifter emne. Brug personens egne ord.
|
||||
- Led ikke vidnet: foreslå ikke svar, og ret ikke personen. Hvis noget i det sagte strider mod baggrunden, så spørg nysgerrigt i stedet for at påpege det.
|
||||
- Hold en rolig, respektfuld tone, ingen smiger, ingen udråbstegn, ingen "fantastisk". Skriv som man taler. Brug aldrig tankestreger.
|
||||
- Hvis personen virker træt, skriver kort eller siger de vil stoppe, så stil et let, afrundende spørgsmål og sæt "afslut" til true.
|
||||
- Gentag ikke et spørgsmål, der allerede er stillet i samtalen. Vend tilbage til startspørgsmålene, hvis samtalen går i stå.
|
||||
- Svar KUN med JSON: {"spoergsmaal": "...", "afslut": false}"""
|
||||
|
||||
|
||||
def _historik(pid: str, projekt_slug: str, samtale: str) -> tuple[list[dict], list[dict]]:
|
||||
"""(denne samtale, tidligere svar i projektet) som korte dicts til prompten."""
|
||||
alle = klip_for(pid, projekt_slug)
|
||||
denne = [k for k in alle if k.get("samtale") == samtale]
|
||||
tidligere = [k for k in alle if k.get("samtale") != samtale][-30:]
|
||||
return denne, tidligere
|
||||
|
||||
|
||||
def _kort(k: dict, n: int = 700) -> dict:
|
||||
return {"dato": (k.get("tid") or "")[:10], "spoergsmaal": k.get("spoergsmaal", ""),
|
||||
"svar": (k.get("tekst") or "")[:n]}
|
||||
|
||||
|
||||
async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str) -> tuple[str, bool]:
|
||||
"""Næste spørgsmål til personen i projektet. Falder tilbage til startspørgsmålene uden nøgle/ved fejl."""
|
||||
denne, tidligere = _historik(p["pid"], pr["slug"], samtale)
|
||||
stillede = {k.get("spoergsmaal") for k in denne + tidligere}
|
||||
start = [s for s in (pr.get("start") or DAGBOG["start"]) if s not in stillede] or list(pr.get("start") or DAGBOG["start"])
|
||||
if not denne:
|
||||
return start[0], False # samtalen åbner altid med et af projektets egne spørgsmål
|
||||
if not ANTHROPIC_KEY:
|
||||
return start[len(denne) % len(start)], False
|
||||
payload = {
|
||||
"person": {"navn": p.get("navn")},
|
||||
"projekt": {"titel": pr.get("titel"), "beskrivelse": pr.get("beskrivelse"), "baggrund": pr.get("kontekst", "")[:6000],
|
||||
"startspoergsmaal": pr.get("start") or []},
|
||||
"tidligere_samtaler": [_kort(k, 300) for k in tidligere],
|
||||
"denne_samtale": [_kort(k) for k in denne],
|
||||
"antal_svar_i_dag": len(denne),
|
||||
}
|
||||
headers = {"x-api-key": ANTHROPIC_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"}
|
||||
if ANTHROPIC_WORKSPACE:
|
||||
headers["anthropic-workspace-id"] = ANTHROPIC_WORKSPACE
|
||||
body = {"model": MODEL, "max_tokens": 300, "system": SYSTEM,
|
||||
"messages": [{"role": "user", "content": "Her er samtalen indtil nu som JSON. Stil næste spørgsmål.\n\n"
|
||||
+ json.dumps(payload, ensure_ascii=False)}]}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as c:
|
||||
r = await c.post("https://api.anthropic.com/v1/messages", headers=headers, json=body)
|
||||
if r.status_code == 200:
|
||||
tekst = "".join(b.get("text", "") for b in r.json().get("content", []) if b.get("type") == "text")
|
||||
m = re.search(r"\{.*\}", tekst, re.S)
|
||||
if m:
|
||||
svar = json.loads(m.group(0))
|
||||
q = (svar.get("spoergsmaal") or "").strip().replace("—", ",").replace(" - ", ", ")
|
||||
if q:
|
||||
return q[:400], bool(svar.get("afslut"))
|
||||
except (httpx.HTTPError, ValueError):
|
||||
pass
|
||||
return start[len(denne) % len(start)], False
|
||||
|
||||
|
||||
def ny_samtale() -> str:
|
||||
return time.strftime("%Y%m%d") + "-" + secrets.token_hex(3)
|
||||
|
||||
|
||||
def eksport(pid: str) -> bytes:
|
||||
"""Zip med alle klip (mp3 + json) og en samlet tekstfil, til podcast-produktionen."""
|
||||
import io
|
||||
import zipfile
|
||||
k = klip_for(pid)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
linjer = []
|
||||
for x in k:
|
||||
f = klip_fil(pid, x["id"])
|
||||
if f:
|
||||
z.write(f, f"{x['id']}.mp3")
|
||||
z.writestr(f"{x['id']}.json", json.dumps(x, ensure_ascii=False, indent=1))
|
||||
linjer.append(f"## {x['id']} · {x.get('tid', '')[:16]} · {x.get('projekt')}{' · favorit' if x.get('favorit') else ''}\n"
|
||||
f"Spørgsmål: {x.get('spoergsmaal', '')}\n\n{x.get('tekst', '')}\n")
|
||||
z.writestr("alle_svar.md", "\n".join(linjer))
|
||||
return buf.getvalue()
|
||||
@@ -26,6 +26,7 @@ import mcp_gateway
|
||||
import secretdrop
|
||||
import claudelogin
|
||||
import podcast
|
||||
import erindringer
|
||||
|
||||
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
||||
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
||||
@@ -149,7 +150,8 @@ async def dashboard_content(request: Request):
|
||||
"fav_apps": [a for a in by_name if a["slug"] in fav_set],
|
||||
"favorites": list(fav_set), "view": prefs["view"],
|
||||
"mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL,
|
||||
"podcasts": _podcast_kort(user)})
|
||||
"podcasts": _podcast_kort(user),
|
||||
"erindringer": bool(is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or []))})
|
||||
|
||||
|
||||
def _podcast_kort(user) -> list[dict]:
|
||||
@@ -615,6 +617,253 @@ async def admin_podcast_udgiv(request: Request, name: str, slug: str):
|
||||
return RedirectResponse(f"/admin/podcast?saved={besked}", status_code=303)
|
||||
|
||||
|
||||
# ---------------- erindringer (interview + dagbog) ----------------
|
||||
# Indloggede (familie) bruger sessionen; gæster uden login bruger en hemmelig nøgle i
|
||||
# adressen og sender den med i API-kald ("noegle"). Begge ender som en "person" i modulet.
|
||||
|
||||
def _erindringer_person(request: Request, noegle: str = "") -> dict:
|
||||
user = current_user(request)
|
||||
if user and (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])):
|
||||
return erindringer.person_for_bruger(user["username"], user.get("name") or user["username"])
|
||||
if noegle:
|
||||
if podcast.blokeret(_klient_ip(request)):
|
||||
raise HTTPException(404)
|
||||
p = erindringer.person_for_noegle(noegle)
|
||||
if p:
|
||||
return p
|
||||
podcast.forkert(_klient_ip(request))
|
||||
raise HTTPException(404)
|
||||
if not user and request.method == "GET":
|
||||
nxt = request.url.path
|
||||
raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
|
||||
raise HTTPException(403, "Kun for familien")
|
||||
|
||||
|
||||
def _erindringer_side(request: Request, p: dict, rod: str, noegle: str, saved: str, valgt: str):
|
||||
user = current_user(request)
|
||||
pr = []
|
||||
for slug in p.get("projekter") or []:
|
||||
x = erindringer.projekt(slug)
|
||||
if x:
|
||||
pr.append({**x, "antal": len(erindringer.klip_for(p["pid"], slug))})
|
||||
titler = {x["slug"]: x["titel"] for x in pr}
|
||||
seneste = list(reversed(erindringer.klip_for(p["pid"])))[:20]
|
||||
for k in seneste:
|
||||
k["projekt_titel"] = titler.get(k.get("projekt"), k.get("projekt"))
|
||||
return templates.TemplateResponse(request, "erindringer.html", {
|
||||
"request": request, "user": user, "is_admin": is_admin(user), "navn": p.get("navn") or "",
|
||||
"noegle": noegle, "rod": rod, "api": "/erindringer/api", "projekter": pr, "klip_seneste": seneste,
|
||||
"saved": saved, "valgt": valgt if valgt in titler else ""})
|
||||
|
||||
|
||||
@app.get("/erindringer", response_class=HTMLResponse)
|
||||
async def erindringer_side(request: Request, saved: str = "", valgt: str = ""):
|
||||
p = _erindringer_person(request)
|
||||
return _erindringer_side(request, p, "/erindringer", "", saved, valgt)
|
||||
|
||||
|
||||
@app.get("/erindringer/g/{key}/", response_class=HTMLResponse)
|
||||
async def erindringer_gaest(request: Request, key: str, saved: str = "", valgt: str = ""):
|
||||
p = _erindringer_person(request, key)
|
||||
r = _erindringer_side(request, p, f"/erindringer/g/{key}", key, saved, valgt)
|
||||
r.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
|
||||
r.headers["Referrer-Policy"] = "no-referrer"
|
||||
return r
|
||||
|
||||
|
||||
@app.get("/erindringer/tts/{sha}.mp3")
|
||||
async def erindringer_tts(sha: str):
|
||||
p = erindringer.tts_fil(sha)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
return FileResponse(p, media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
|
||||
def _klip_svar(pid: str, kid: str):
|
||||
f = erindringer.klip_fil(pid, kid)
|
||||
if not f:
|
||||
raise HTTPException(404)
|
||||
return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, no-store"})
|
||||
|
||||
|
||||
@app.get("/erindringer/klip/{kid}.mp3")
|
||||
async def erindringer_klip(request: Request, kid: str):
|
||||
p = _erindringer_person(request)
|
||||
return _klip_svar(p["pid"], kid)
|
||||
|
||||
|
||||
@app.get("/erindringer/g/{key}/klip/{kid}.mp3")
|
||||
async def erindringer_gaest_klip(request: Request, key: str, kid: str):
|
||||
p = _erindringer_person(request, key)
|
||||
return _klip_svar(p["pid"], kid)
|
||||
|
||||
|
||||
def _fejl(status: int, tekst: str):
|
||||
return JSONResponse({"fejl": tekst}, status_code=status)
|
||||
|
||||
|
||||
@app.post("/erindringer/api/naeste")
|
||||
async def erindringer_naeste(request: Request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return _fejl(400, "Ugyldigt kald.")
|
||||
p = _erindringer_person(request, str(data.get("noegle") or ""))
|
||||
slug = str(data.get("projekt") or "")
|
||||
if not erindringer.maa(p, slug):
|
||||
return _fejl(403, "Det emne er ikke åbent for dig.")
|
||||
pr = erindringer.projekt(slug)
|
||||
samtale = str(data.get("samtale") or "")
|
||||
if not re.match(r"^[0-9]{8}-[a-f0-9]{6}$", samtale):
|
||||
samtale = erindringer.ny_samtale()
|
||||
q, afslut = await erindringer.naeste_spoergsmaal(p, pr, samtale)
|
||||
lyd = await asyncio.to_thread(erindringer.tts_sti, q)
|
||||
return {"samtale": samtale, "spoergsmaal": q, "afslut": afslut,
|
||||
"lyd_url": f"/erindringer/tts/{lyd.stem}.mp3" if lyd else None,
|
||||
"nr": len(erindringer.klip_for(p["pid"], slug, samtale)) + 1}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/svar")
|
||||
async def erindringer_svar(request: Request):
|
||||
form = await request.form()
|
||||
p = _erindringer_person(request, str(form.get("noegle") or ""))
|
||||
slug = str(form.get("projekt") or "")
|
||||
if not erindringer.maa(p, slug):
|
||||
return _fejl(403, "Det emne er ikke åbent for dig.")
|
||||
samtale = str(form.get("samtale") or "")
|
||||
if not re.match(r"^[0-9]{8}-[a-f0-9]{6}$", samtale):
|
||||
samtale = erindringer.ny_samtale()
|
||||
lyd = form.get("lyd")
|
||||
data, filnavn = None, ""
|
||||
if lyd is not None and hasattr(lyd, "read"):
|
||||
data = await lyd.read()
|
||||
filnavn = getattr(lyd, "filename", "") or ""
|
||||
if not data:
|
||||
data = None
|
||||
tekst = str(form.get("tekst") or "")
|
||||
try:
|
||||
meta = await asyncio.to_thread(erindringer.gem_svar, p["pid"], slug, samtale, str(form.get("spoergsmaal") or ""),
|
||||
data, filnavn, tekst or None)
|
||||
except ValueError as e:
|
||||
return _fejl(400, str(e))
|
||||
return {"id": meta["id"], "tekst": meta["tekst"], "varighed": meta["varighed"], "samtale": samtale}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/ret")
|
||||
async def erindringer_ret(request: Request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return _fejl(400, "Ugyldigt kald.")
|
||||
p = _erindringer_person(request, str(data.get("noegle") or ""))
|
||||
if not erindringer.ret_tekst(p["pid"], str(data.get("id") or ""), str(data.get("tekst") or "")):
|
||||
return _fejl(404, "Svaret findes ikke.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/admin/erindringer", response_class=HTMLResponse)
|
||||
async def admin_erindringer(request: Request, saved: str = "", ny: str = ""):
|
||||
user = require_admin(request)
|
||||
pr = erindringer.projekter()
|
||||
return templates.TemplateResponse(request, "admin_erindringer.html", {
|
||||
"request": request, "user": user, "is_admin": True, "saved": saved, "ny": ny,
|
||||
"base": str(request.base_url).rstrip("/"),
|
||||
"projekter": [{**v, "slug": k} for k, v in pr.items()],
|
||||
"personer": erindringer.alle_personer(),
|
||||
"har_claude": bool(erindringer.ANTHROPIC_KEY), "har_eleven": bool(erindringer.ELEVEN_KEY), "model": erindringer.MODEL})
|
||||
|
||||
|
||||
@app.post("/admin/erindringer/projekt")
|
||||
async def admin_erindringer_projekt(request: Request):
|
||||
require_admin(request)
|
||||
form = await request.form()
|
||||
slug = re.sub(r"[^a-z0-9-]", "-", str(form.get("slug") or "").strip().lower()).strip("-")[:40]
|
||||
if str(form.get("handling") or "") == "slet":
|
||||
erindringer.slet_projekt(slug)
|
||||
return RedirectResponse("/admin/erindringer?saved=Projektet+er+slettet", status_code=303)
|
||||
start = [s for s in str(form.get("start") or "").splitlines()]
|
||||
if not erindringer.gem_projekt(slug, str(form.get("titel") or ""), str(form.get("beskrivelse") or ""),
|
||||
str(form.get("kontekst") or ""), start):
|
||||
return RedirectResponse("/admin/erindringer?saved=Projektet+skal+have+et+navn+(a-z,+0-9,+bindestreg)+og+en+titel", status_code=303)
|
||||
return RedirectResponse(f"/admin/erindringer?saved=Projektet+{urllib.parse.quote(slug)}+er+gemt", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/erindringer/gaest")
|
||||
async def admin_erindringer_gaest(request: Request):
|
||||
require_admin(request)
|
||||
form = await request.form()
|
||||
navn = str(form.get("navn") or "").strip()[:80]
|
||||
if not navn:
|
||||
return RedirectResponse("/admin/erindringer?saved=Skriv+et+navn", status_code=303)
|
||||
p = erindringer.ny_gaest(navn, [str(x) for x in form.getlist("projekter")])
|
||||
return RedirectResponse(f"/admin/erindringer?ny={urllib.parse.quote(p['pid'])}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/erindringer/person/{pid}")
|
||||
async def admin_erindringer_person_save(request: Request, pid: str):
|
||||
require_admin(request)
|
||||
form = await request.form()
|
||||
handling = str(form.get("handling") or "")
|
||||
if handling == "projekter":
|
||||
erindringer.saet_projekter(pid, [str(x) for x in form.getlist("projekter")])
|
||||
besked = "Emnerne er gemt"
|
||||
elif handling == "ny-noegle":
|
||||
erindringer.ny_noegle(pid); besked = "Nyt link lavet; det gamle virker ikke mere"
|
||||
elif handling == "luk":
|
||||
erindringer.luk_gaest(pid, True); besked = "Linket er lukket"
|
||||
elif handling == "aabn":
|
||||
erindringer.luk_gaest(pid, False); besked = "Linket er åbnet igen"
|
||||
else:
|
||||
besked = "Ukendt handling"
|
||||
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}?saved={urllib.parse.quote(besked)}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/erindringer/p/{pid}", response_class=HTMLResponse)
|
||||
async def admin_erindringer_person(request: Request, pid: str, saved: str = ""):
|
||||
user = require_admin(request)
|
||||
p = erindringer.person(pid)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
pr = erindringer.projekter()
|
||||
k = list(reversed(erindringer.klip_for(pid)))
|
||||
for x in k:
|
||||
x["projekt_titel"] = (pr.get(x.get("projekt")) or {}).get("titel", x.get("projekt"))
|
||||
return templates.TemplateResponse(request, "admin_erindringer_person.html", {
|
||||
"request": request, "user": user, "is_admin": True, "saved": saved, "p": p, "klip": k,
|
||||
"base": str(request.base_url).rstrip("/"), "projekter": [{**v, "slug": s} for s, v in pr.items()]})
|
||||
|
||||
|
||||
@app.get("/admin/erindringer/p/{pid}/klip/{kid}.mp3")
|
||||
async def admin_erindringer_klip(request: Request, pid: str, kid: str):
|
||||
require_admin(request)
|
||||
return _klip_svar(pid, kid)
|
||||
|
||||
|
||||
@app.post("/admin/erindringer/p/{pid}/klip/{kid}")
|
||||
async def admin_erindringer_klip_post(request: Request, pid: str, kid: str):
|
||||
require_admin(request)
|
||||
form = await request.form()
|
||||
handling = str(form.get("handling") or "")
|
||||
if handling == "favorit":
|
||||
erindringer.saet_favorit(pid, kid, str(form.get("favorit") or "") == "1")
|
||||
elif handling == "slet":
|
||||
erindringer.slet_klip(pid, kid)
|
||||
elif handling == "ret":
|
||||
erindringer.ret_tekst(pid, kid, str(form.get("tekst") or ""))
|
||||
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}#k-{kid}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/erindringer/p/{pid}/eksport.zip")
|
||||
async def admin_erindringer_eksport(request: Request, pid: str):
|
||||
require_admin(request)
|
||||
if not erindringer.person(pid):
|
||||
raise HTTPException(404)
|
||||
data = await asyncio.to_thread(erindringer.eksport, pid)
|
||||
navn = pid.replace(":", "_")
|
||||
return Response(data, media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="erindringer-{navn}.zip"'})
|
||||
|
||||
|
||||
@app.get("/admin/claude-login", response_class=HTMLResponse)
|
||||
async def admin_claude_login(request: Request):
|
||||
user = require_admin(request)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — erindringer{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin" class="wt-muted">← Admin</a></p>
|
||||
<h1>Erindringer</h1>
|
||||
<p class="wt-lead">Familien fortæller minder til en AI-interviewer, der stiller ét spørgsmål ad gangen og følger op. Svarene gemmes som lyd og tekst pr. person og kan hentes som zip til podcasten. Indloggede i familiegruppen har altid "Dagbog" på <a href="/erindringer">/erindringer</a>; gæster uden login får et hemmeligt link, som du laver her.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
{% if not har_claude or not har_eleven %}
|
||||
<div class="wt-flash">⚠️ {% if not har_claude %}ANTHROPIC_API_KEY mangler i /etc/portal.env: intervieweren falder tilbage til projektets startspørgsmål.{% endif %}
|
||||
{% if not har_eleven %}ELEVEN_API_KEY mangler: ingen oplæsning og ingen transskription (svar gemmes kun som lyd).{% endif %}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ny %}
|
||||
{% for p in personer if p.pid == ny %}
|
||||
<div class="wt-card" style="padding:1rem 1.2rem;margin:1rem 0;border-color:var(--color-accent)">
|
||||
<p style="margin:0 0 .5rem"><strong>Link til {{ p.navn }}</strong>, send det privat (Messenger, sms). Linket er personligt og kan lukkes igen.</p>
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
<input class="wt-input" readonly id="ny-link" value="{{ base }}/erindringer/g/{{ p.noegle }}/" style="flex:1 1 22rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" onclick="var i=document.getElementById('ny-link');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:1.6rem">Personer</h2>
|
||||
<table class="wt-table">
|
||||
<thead><tr><th>Navn</th><th>Adgang</th><th>Emner</th><th>Svar</th><th>Sidst</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in personer %}
|
||||
<tr>
|
||||
<td><a href="/admin/erindringer/p/{{ p.pid }}">{{ p.navn }}</a></td>
|
||||
<td>{% if p.bruger %}login ({{ p.bruger }}){% elif p.lukket %}<span class="wt-tag muted">gæstelink lukket</span>{% else %}gæstelink{% endif %}</td>
|
||||
<td>{{ p.projekter|join(', ') }}</td>
|
||||
<td>{{ p.antal }}</td>
|
||||
<td>{{ (p.sidst or p.oprettet or '')[:10] }}</td>
|
||||
<td><a class="wt-btn" href="/admin/erindringer/p/{{ p.pid }}">Åbn</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="wt-muted">Ingen endnu. Indloggede dukker op, når de åbner /erindringer første gang.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Nyt gæstelink (uden login)</h2>
|
||||
<form method="post" action="/admin/erindringer/gaest" class="wt-card" style="padding:1rem 1.2rem;max-width:48rem;margin:0">
|
||||
<label>Navn (som intervieweren bruger)<br><input class="wt-input" name="navn" required maxlength="80" style="width:100%"></label>
|
||||
<p style="margin:.8rem 0 .3rem">Emner, personen må fortælle om:</p>
|
||||
{% for pr in projekter %}
|
||||
<label style="display:block;margin:.2rem 0"><input type="checkbox" name="projekter" value="{{ pr.slug }}" {% if pr.slug != 'dagbog' %}checked{% endif %}> {{ pr.titel }} <span class="wt-muted">({{ pr.slug }})</span></label>
|
||||
{% endfor %}
|
||||
<p style="margin:.8rem 0 0"><button class="wt-btn wt-btn--primary" type="submit">Lav link</button></p>
|
||||
</form>
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Emner (projekter)</h2>
|
||||
<p class="wt-muted" style="margin:.3rem 0 .8rem">Baggrunden gives til intervieweren, så spørgsmålene bliver konkrete (navne, steder, årstal, hvad vi allerede ved). Startspørgsmålene åbner hver samtale; derefter improviserer intervieweren ({{ model }}). "dagbog" er indbygget og kan ikke slettes, men teksten kan rettes.</p>
|
||||
{% for pr in projekter %}
|
||||
<details class="wt-card" style="padding:.8rem 1.2rem;max-width:48rem;margin:.5rem 0">
|
||||
<summary style="cursor:pointer"><strong>{{ pr.titel }}</strong> <span class="wt-muted">({{ pr.slug }}) · {{ pr.start|length }} startspørgsmål</span></summary>
|
||||
<form method="post" action="/admin/erindringer/projekt" style="margin:.8rem 0 0">
|
||||
<input type="hidden" name="slug" value="{{ pr.slug }}">
|
||||
<label>Titel<br><input class="wt-input" name="titel" value="{{ pr.titel }}" required style="width:100%"></label>
|
||||
<label style="display:block;margin-top:.6rem">Beskrivelse (vises til personen)<br><textarea class="wt-input" name="beskrivelse" rows="2" style="width:100%">{{ pr.beskrivelse }}</textarea></label>
|
||||
<label style="display:block;margin-top:.6rem">Baggrund til intervieweren (vises ikke til personen)<br><textarea class="wt-input" name="kontekst" rows="8" style="width:100%">{{ pr.kontekst }}</textarea></label>
|
||||
<label style="display:block;margin-top:.6rem">Startspørgsmål, ét pr. linje<br><textarea class="wt-input" name="start" rows="5" style="width:100%">{{ pr.start|join('\n') }}</textarea></label>
|
||||
<p style="margin:.8rem 0 0;display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
<button class="wt-btn wt-btn--primary" type="submit">Gem</button>
|
||||
{% if pr.slug != 'dagbog' %}<button class="wt-btn wt-btn--danger" type="submit" name="handling" value="slet" onclick="return confirm('Slet emnet {{ pr.slug }}? Svarene bliver liggende hos personerne.')">Slet emne</button>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
</details>
|
||||
{% endfor %}
|
||||
<details class="wt-card" style="padding:.8rem 1.2rem;max-width:48rem;margin:.5rem 0">
|
||||
<summary style="cursor:pointer"><strong>Nyt emne</strong></summary>
|
||||
<form method="post" action="/admin/erindringer/projekt" style="margin:.8rem 0 0">
|
||||
<label>Kort navn (a-z, 0-9, bindestreg)<br><input class="wt-input" name="slug" required pattern="[a-z0-9][a-z0-9-]{0,40}" style="width:100%" placeholder="fx ep10-foraeldrene"></label>
|
||||
<label style="display:block;margin-top:.6rem">Titel<br><input class="wt-input" name="titel" required style="width:100%"></label>
|
||||
<label style="display:block;margin-top:.6rem">Beskrivelse (vises til personen)<br><textarea class="wt-input" name="beskrivelse" rows="2" style="width:100%"></textarea></label>
|
||||
<label style="display:block;margin-top:.6rem">Baggrund til intervieweren<br><textarea class="wt-input" name="kontekst" rows="8" style="width:100%"></textarea></label>
|
||||
<label style="display:block;margin-top:.6rem">Startspørgsmål, ét pr. linje<br><textarea class="wt-input" name="start" rows="5" style="width:100%"></textarea></label>
|
||||
<p style="margin:.8rem 0 0"><button class="wt-btn wt-btn--primary" type="submit">Opret</button></p>
|
||||
</form>
|
||||
</details>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — erindringer: {{ p.navn }}{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin/erindringer" class="wt-muted">← Erindringer</a></p>
|
||||
<h1>{{ p.navn }}</h1>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
<div class="wt-card" style="padding:1rem 1.2rem;max-width:48rem;margin:0 0 1.2rem">
|
||||
{% if p.bruger %}
|
||||
<p style="margin:0 0 .6rem">Logger ind som <strong>{{ p.bruger }}</strong> og bruger <a href="/erindringer">/erindringer</a>.</p>
|
||||
{% else %}
|
||||
<p style="margin:0 0 .6rem"><strong>Gæstelink</strong>{% if p.lukket %} <span class="wt-tag muted">lukket</span>{% endif %}</p>
|
||||
{% if not p.lukket %}
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.8rem">
|
||||
<input class="wt-input" readonly id="g-link" value="{{ base }}/erindringer/g/{{ p.noegle }}/" style="flex:1 1 22rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" onclick="var i=document.getElementById('g-link');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/erindringer/person/{{ p.pid }}" style="display:flex;gap:.5rem;flex-wrap:wrap;margin:0 0 .8rem">
|
||||
<button class="wt-btn" name="handling" value="ny-noegle" onclick="return confirm('Nyt link? Det gamle holder op med at virke.')">Nyt link</button>
|
||||
{% if p.lukket %}<button class="wt-btn" name="handling" value="aabn">Åbn igen</button>{% else %}<button class="wt-btn wt-btn--danger" name="handling" value="luk">Luk linket</button>{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/erindringer/person/{{ p.pid }}" style="margin:0">
|
||||
<input type="hidden" name="handling" value="projekter">
|
||||
<p style="margin:.2rem 0 .3rem">Emner:</p>
|
||||
{% for pr in projekter %}
|
||||
<label style="display:inline-block;margin:.2rem .8rem .2rem 0"><input type="checkbox" name="projekter" value="{{ pr.slug }}" {% if pr.slug in p.projekter %}checked{% endif %}{% if p.bruger and pr.slug == 'dagbog' %} disabled checked{% endif %}> {{ pr.titel }}</label>
|
||||
{% endfor %}
|
||||
<p style="margin:.6rem 0 0"><button class="wt-btn" type="submit">Gem emner</button>
|
||||
<a class="wt-btn" href="/admin/erindringer/p/{{ p.pid }}/eksport.zip">Hent alt som zip</a></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem">{{ klip|length }} svar</h2>
|
||||
{% for k in klip %}
|
||||
<div class="wt-card" id="k-{{ k.id }}" style="padding:.9rem 1.2rem;margin:.6rem 0;max-width:48rem{% if k.favorit %};border-color:var(--color-accent){% endif %}">
|
||||
<div class="wt-muted" style="font-size:.85rem">{{ k.tid[:16]|replace('T',' ') }} · {{ k.projekt_titel }} · {% if k.skrevet %}skrevet{% else %}{{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}{% if k.redigeret %} · tekst rettet{% endif %} · <span style="font-family:ui-monospace,monospace">{{ k.id }}</span></div>
|
||||
<p style="margin:.4rem 0"><strong>{{ k.spoergsmaal }}</strong></p>
|
||||
{% if not k.skrevet %}<audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio>{% endif %}
|
||||
<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">
|
||||
<button class="wt-btn wt-btn--danger" name="handling" value="slet" onclick="return confirm('Slet svaret for altid?')">Slet</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen svar endnu.</p>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="setgrid" style="margin-top:.8rem">
|
||||
<label class="fld"><span>Karens efter set (dage)</span><input type="number" name="grace_days" min="0" max="365" value="{{ settings.grace_days }}"></label>
|
||||
<label class="fld"><span>Undertekst-sprog</span><input name="sub_langs" value="{{ settings.sub_langs }}"></label>
|
||||
<label class="fld"><span>Emby-bibliotek</span><input name="library_name" value="{{ settings.library_name }}"></label>
|
||||
<label class="fld"><span>Emby-kanal/bibliotek</span><input name="library_name" value="{{ settings.library_name }}"></label>
|
||||
<label class="fld"><span>Kvalitet (yt-dlp -f)</span><input name="quality" value="{{ settings.quality }}"></label>
|
||||
</div>
|
||||
<p style="margin-top:1rem;display:flex;gap:.6rem;flex-wrap:wrap">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||
{% if podcasts %}
|
||||
{% if podcasts or erindringer %}
|
||||
<section class="appgroup" id="podsec">
|
||||
<h2>🎙️ Familiens podcast</h2>
|
||||
<div class="grid">
|
||||
@@ -24,6 +24,14 @@
|
||||
<div class="go">Lyt →</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if erindringer %}
|
||||
<div class="card" data-search="erindringer dagbog minder interview">
|
||||
<a class="card-link" href="/erindringer" aria-label="Erindringer"></a>
|
||||
<div class="head"><span class="ico">🎤</span><span class="name">Erindringer</span></div>
|
||||
<div class="sub">Fortæl dine minder eller før dagbog. En interviewer stiller spørgsmålene.</div>
|
||||
<div class="go">Fortæl →</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Erindringer{% endblock %}
|
||||
{% block body %}
|
||||
<style>
|
||||
/* Ingen vandret scroll på iPhone: alt holder sig inden for kortet, og felter
|
||||
har mindst 16 px skrift, ellers zoomer iOS ind. */
|
||||
html, body { overflow-x: hidden; }
|
||||
.er-side { max-width: 100%; overflow-wrap: anywhere; }
|
||||
.er-side audio, .er-side textarea, .er-side .wt-card { max-width: 100%; box-sizing: border-box; }
|
||||
.er-side audio { display: block; width: 100%; min-width: 0; }
|
||||
.er-side textarea { font-size: 16px !important; min-width: 0; min-height: 7rem; line-height: 1.5; }
|
||||
.er-side .wt-btn { white-space: normal; }
|
||||
|
||||
/* Store, rolige knapper til ældre fingre */
|
||||
.er-stor { font-size: 1.1rem; padding: .85rem 1.4rem; min-height: 3.2rem; }
|
||||
.er-knapper { display: flex; flex-wrap: wrap; gap: .7rem; justify-content: center; margin-top: 1rem; }
|
||||
.er-kort { margin: 1rem 0; padding: 1.1rem 1.2rem; }
|
||||
.er-emne { display: flex; flex-direction: column; gap: .5rem; }
|
||||
.er-emne h3 { margin: 0; font-size: 1.25rem; }
|
||||
.er-emne .wt-btn { align-self: flex-start; }
|
||||
|
||||
/* Spørgsmålet: stor skrift, god linjeafstand */
|
||||
.er-spoergsmaal { font-size: 1.3rem; line-height: 1.5; margin: .3rem 0 .8rem; font-weight: 600; }
|
||||
|
||||
/* Den runde optage-knap */
|
||||
.er-midt { display: flex; flex-direction: column; align-items: center; gap: .8rem; margin: 1.4rem 0; text-align: center; }
|
||||
.er-optag {
|
||||
width: 7.5rem; height: 7.5rem; min-width: 88px; min-height: 88px; border-radius: 50%;
|
||||
border: 3px solid var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||
color: inherit; font: inherit; font-size: 1.2rem; font-weight: 700; cursor: pointer;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .15rem;
|
||||
transition: transform .15s, background .2s;
|
||||
}
|
||||
.er-optag:hover { transform: scale(1.04); }
|
||||
.er-optag:disabled { opacity: .5; cursor: default; transform: none; }
|
||||
.er-optag.optager {
|
||||
background: var(--color-bad); border-color: var(--color-bad); color: #fff;
|
||||
animation: er-puls 1.3s ease-in-out infinite;
|
||||
}
|
||||
.er-optag .er-taeller { font-size: .95rem; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
@keyframes er-puls {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-bad) 55%, transparent); }
|
||||
50% { box-shadow: 0 0 0 18px transparent; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .er-optag.optager { animation: none; } }
|
||||
|
||||
.er-status { min-height: 1.5rem; font-size: 1.05rem; color: var(--color-muted); }
|
||||
.er-status.travl { color: var(--color-accent); font-weight: 600; }
|
||||
.er-link { background: none; border: 0; padding: .4rem; color: var(--color-accent); text-decoration: underline;
|
||||
cursor: pointer; font: inherit; font-size: 1rem; }
|
||||
|
||||
/* Svar-lister */
|
||||
.er-liste { list-style: none; padding: 0; margin: .6rem 0 0; }
|
||||
.er-liste li { border-top: 1px solid var(--color-border); padding: .9rem 0; }
|
||||
.er-liste li:first-child { border-top: 0; }
|
||||
.er-liste .er-q { font-weight: 700; margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||
.er-liste .er-meta { color: var(--color-muted); font-size: .9rem; margin: 0 0 .3rem; }
|
||||
.er-tekst { margin: 0; white-space: pre-line; line-height: 1.55; font-size: 1.05rem; }
|
||||
.er-ret { margin-top: .5rem; }
|
||||
.er-ret .wt-btn { font-size: .95rem; }
|
||||
.er-dit-svar { background: color-mix(in srgb, var(--color-good) 8%, transparent); border-color: color-mix(in srgb, var(--color-good) 40%, transparent); }
|
||||
</style>
|
||||
|
||||
<div class="er-side" id="er-side"
|
||||
data-api="{{ api }}" data-rod="{{ rod }}" data-noegle="{{ noegle }}" data-valgt="{{ valgt }}">
|
||||
|
||||
<h1>Erindringer</h1>
|
||||
<p class="wt-lead">Hej {{ navn }}. Fortæl, så gemmer vi det. Du kan altid holde en pause og komme tilbage.</p>
|
||||
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
<div class="wt-flash" id="er-flash" hidden role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- Trin 2: vælg emne -->
|
||||
<section id="er-emner">
|
||||
<h2>Vælg, hvad du vil fortælle om</h2>
|
||||
{% for p in projekter %}
|
||||
<div class="wt-card er-kort er-emne" data-slug="{{ p.slug }}" data-titel="{{ p.titel }}">
|
||||
<h3>{{ p.titel }}</h3>
|
||||
{% if p.beskrivelse %}<p style="margin:0">{{ p.beskrivelse }}</p>{% endif %}
|
||||
<p class="wt-muted" style="margin:0"><span class="er-antal">{{ p.antal }}</span> svar indtil nu</p>
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" data-vaelg="{{ p.slug }}"
|
||||
aria-label="Fortæl om {{ p.titel }}">Fortæl</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="wt-muted">Der er ingen emner endnu.</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<!-- Trin 3: samtalen -->
|
||||
<section id="er-samtale" hidden>
|
||||
<h2 id="er-samtale-titel">Samtale</h2>
|
||||
|
||||
<div class="wt-card er-kort" id="er-spoergsmaal-kort">
|
||||
<p class="wt-muted" style="margin:0 0 .2rem">Spørgsmål <span id="er-nr"></span></p>
|
||||
<p class="er-spoergsmaal" id="er-spoergsmaal">Henter spørgsmålet…</p>
|
||||
<button type="button" class="wt-btn er-stor" id="er-hoer" hidden aria-label="Hør spørgsmålet læst op">🔊 Hør spørgsmålet</button>
|
||||
<audio id="er-lyd" preload="auto"></audio>
|
||||
</div>
|
||||
|
||||
<div class="er-midt" id="er-optagelse">
|
||||
<button type="button" class="er-optag" id="er-optag" aria-label="Optag dit svar" aria-pressed="false">
|
||||
<span id="er-optag-tekst">Optag</span>
|
||||
<span class="er-taeller" id="er-taeller" hidden>0:00</span>
|
||||
</button>
|
||||
<p class="er-status" id="er-status" aria-live="polite"></p>
|
||||
<p class="wt-muted" style="margin:0">Tal bare, som du ville fortælle det til et barnebarn.</p>
|
||||
|
||||
<div id="er-optaget" hidden style="width:100%;max-width:32rem">
|
||||
<audio id="er-afspil" controls></audio>
|
||||
<div class="er-knapper">
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-send">Send svaret</button>
|
||||
<button type="button" class="wt-btn er-stor" id="er-om">Tag det om</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="er-link" id="er-skriv-link">Skriv i stedet</button>
|
||||
<div id="er-skriv" hidden style="width:100%;max-width:32rem;text-align:left">
|
||||
<label for="er-tekstfelt" class="wt-muted">Skriv dit svar her</label>
|
||||
<textarea id="er-tekstfelt" rows="5"></textarea>
|
||||
<div class="er-knapper">
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-send-tekst">Send teksten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wt-card er-kort er-dit-svar" id="er-svar-kort" hidden>
|
||||
<p class="wt-muted" style="margin:0 0 .3rem">Dit svar</p>
|
||||
<p class="er-tekst" id="er-svar-tekst"></p>
|
||||
<div class="er-ret" id="er-svar-ret"></div>
|
||||
<div class="er-knapper">
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-naeste">Næste spørgsmål</button>
|
||||
<button type="button" class="wt-btn er-stor" id="er-pause">Hold pause</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Dine svar i dag</h2>
|
||||
<ul class="er-liste" id="er-dagens"></ul>
|
||||
<p class="wt-muted" id="er-dagens-tom">Ingen endnu. Dit første svar kommer her.</p>
|
||||
</section>
|
||||
|
||||
<!-- Trin 4: tidligere svar -->
|
||||
<section id="er-tidligere">
|
||||
<h2>Tidligere svar</h2>
|
||||
{% if klip_seneste %}
|
||||
<ul class="er-liste">
|
||||
{% for k in klip_seneste[:20] %}
|
||||
<li data-id="{{ k.id }}">
|
||||
<p class="er-meta"><span data-tid="{{ k.tid }}">{{ k.tid[:10] }}</span> · {{ k.projekt_titel or k.projekt }}{% if k.varighed %} · {{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}</p>
|
||||
<p class="er-q">{{ k.spoergsmaal }}</p>
|
||||
<p class="er-tekst" data-tekst>{{ k.tekst }}</p>
|
||||
<audio controls preload="none" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.5rem"></audio>
|
||||
<div class="er-ret"><button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button></div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen svar endnu. Det første gemmes her, når du har fortalt noget.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (window.__erindringer) return;
|
||||
window.__erindringer = true;
|
||||
|
||||
var side = document.getElementById('er-side');
|
||||
var API = side.dataset.api, ROD = side.dataset.rod, NOEGLE = side.dataset.noegle || '';
|
||||
var MAKS_SEK = 600; // 10 minutter pr. svar
|
||||
var MIMER = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
var ui = {
|
||||
emner: el('er-emner'), samtale: el('er-samtale'), titel: el('er-samtale-titel'),
|
||||
nr: el('er-nr'), spoergsmaal: el('er-spoergsmaal'), hoer: el('er-hoer'), lyd: el('er-lyd'),
|
||||
optagelse: el('er-optagelse'), optag: el('er-optag'), optagTekst: el('er-optag-tekst'), taeller: el('er-taeller'),
|
||||
status: el('er-status'), optaget: el('er-optaget'), afspil: el('er-afspil'),
|
||||
send: el('er-send'), om: el('er-om'), skrivLink: el('er-skriv-link'), skriv: el('er-skriv'),
|
||||
tekstfelt: el('er-tekstfelt'), sendTekst: el('er-send-tekst'),
|
||||
svarKort: el('er-svar-kort'), svarTekst: el('er-svar-tekst'), svarRet: el('er-svar-ret'),
|
||||
naeste: el('er-naeste'), pause: el('er-pause'), dagens: el('er-dagens'), dagensTom: el('er-dagens-tom'),
|
||||
flash: el('er-flash')
|
||||
};
|
||||
|
||||
// Tilstand for den igangværende samtale
|
||||
var st = {
|
||||
projekt: null, titel: '', samtale: null, spoergsmaal: '', lydUrl: null,
|
||||
recorder: null, stream: null, chunks: [], blob: null, mime: '', ext: 'webm',
|
||||
timer: null, sekunder: 0, startet: 0, sender: false, sendtIDag: 0, lydAfspillet: false
|
||||
};
|
||||
|
||||
/* ---------- Hjælpere ---------- */
|
||||
|
||||
function visFlash(tekst) {
|
||||
ui.flash.textContent = tekst;
|
||||
ui.flash.hidden = !tekst;
|
||||
}
|
||||
function status(tekst, travl) {
|
||||
ui.status.textContent = tekst || '';
|
||||
ui.status.classList.toggle('travl', !!travl);
|
||||
}
|
||||
function mmss(s) {
|
||||
s = Math.max(0, Math.round(s));
|
||||
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
|
||||
}
|
||||
function datoTekst(iso) {
|
||||
try {
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d)) return iso.slice(0, 10);
|
||||
return d.toLocaleDateString('da-DK', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
} catch (e) { return iso.slice(0, 10); }
|
||||
}
|
||||
function lagerNoegle() { return 'erindringer:samtale:' + st.projekt; }
|
||||
function husk(id) { try { sessionStorage.setItem(lagerNoegle(), id); } catch (e) {} }
|
||||
function glem() { try { sessionStorage.removeItem(lagerNoegle()); } catch (e) {} }
|
||||
function hentHusket() { try { return sessionStorage.getItem(lagerNoegle()); } catch (e) { return null; } }
|
||||
|
||||
// Fælles fetch: JSON-body eller FormData, altid med nøgle og marker-header.
|
||||
// Ingen timeout: transskription kan tage lang tid.
|
||||
function kald(sti, data) {
|
||||
var erForm = data instanceof FormData;
|
||||
if (erForm) data.append('noegle', NOEGLE); else data.noegle = NOEGLE;
|
||||
var opts = { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } };
|
||||
if (erForm) { opts.body = data; }
|
||||
else { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(data); }
|
||||
return fetch(API + sti, opts).then(function (r) {
|
||||
return r.text().then(function (t) {
|
||||
var j = null;
|
||||
try { j = t ? JSON.parse(t) : null; } catch (e) {}
|
||||
if (!r.ok) throw new Error((j && j.fejl) || 'Noget gik galt (' + r.status + '). Prøv igen om lidt.');
|
||||
return j || {};
|
||||
});
|
||||
}, function () {
|
||||
throw new Error('Vi kunne ikke få forbindelse. Tjek, at du har net, og prøv igen.');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Ret teksten (bruges både i dag og i tidligere svar) ---------- */
|
||||
|
||||
// tekstEls: ét eller flere elementer, der viser samme tekst og skal følges ad
|
||||
function retTekst(id, tekstEls, holder) {
|
||||
if (holder.querySelector('textarea')) return;
|
||||
if (!Array.isArray(tekstEls)) tekstEls = [tekstEls];
|
||||
holder.innerHTML = '';
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = tekstEls[0].textContent;
|
||||
ta.rows = 6;
|
||||
var gem = document.createElement('button');
|
||||
gem.type = 'button'; gem.className = 'wt-btn wt-btn--primary'; gem.textContent = 'Gem';
|
||||
var fortryd = document.createElement('button');
|
||||
fortryd.type = 'button'; fortryd.className = 'wt-btn'; fortryd.textContent = 'Fortryd';
|
||||
var knapper = document.createElement('div');
|
||||
knapper.className = 'er-knapper'; knapper.style.justifyContent = 'flex-start';
|
||||
knapper.appendChild(gem); knapper.appendChild(fortryd);
|
||||
holder.appendChild(ta); holder.appendChild(knapper);
|
||||
ta.focus();
|
||||
|
||||
function tilbage() {
|
||||
holder.innerHTML = '';
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten';
|
||||
b.setAttribute('data-ret', id);
|
||||
holder.appendChild(b);
|
||||
}
|
||||
fortryd.addEventListener('click', tilbage);
|
||||
gem.addEventListener('click', function () {
|
||||
var ny = ta.value.trim();
|
||||
gem.disabled = true; fortryd.disabled = true; gem.textContent = 'Gemmer…';
|
||||
kald('/ret', { id: id, tekst: ny }).then(function () {
|
||||
tekstEls.forEach(function (t) { t.textContent = ny; });
|
||||
tilbage();
|
||||
visFlash('Teksten er rettet.');
|
||||
}).catch(function (e) {
|
||||
gem.disabled = false; fortryd.disabled = false; gem.textContent = 'Gem';
|
||||
visFlash(e.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Alle "Ret teksten"-knapper, også dem der oprettes senere
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest && e.target.closest('[data-ret]');
|
||||
if (!b) return;
|
||||
var li = b.closest('[data-id]');
|
||||
var tekstEl = li ? li.querySelector('[data-tekst]') : null;
|
||||
if (!li || !tekstEl) return;
|
||||
var els = [tekstEl];
|
||||
// Er det dagens seneste svar, skal "Dit svar"-kortet følge med
|
||||
if (!ui.svarKort.hidden && ui.svarKort.dataset.id === b.dataset.ret) els.push(ui.svarTekst);
|
||||
retTekst(b.dataset.ret, els, b.parentNode);
|
||||
});
|
||||
|
||||
// Datoer i "Tidligere svar" på læsbart dansk
|
||||
document.querySelectorAll('[data-tid]').forEach(function (s) { s.textContent = datoTekst(s.dataset.tid); });
|
||||
|
||||
/* ---------- Emnevalg ---------- */
|
||||
|
||||
function vaelg(slug) {
|
||||
var kort = ui.emner.querySelector('[data-slug="' + slug + '"]');
|
||||
if (!kort) return;
|
||||
st.projekt = slug;
|
||||
st.titel = kort.dataset.titel || slug;
|
||||
st.samtale = hentHusket();
|
||||
st.sendtIDag = 0;
|
||||
ui.titel.textContent = st.titel;
|
||||
ui.dagens.innerHTML = '';
|
||||
ui.dagensTom.hidden = false;
|
||||
visFlash('');
|
||||
ui.emner.hidden = true;
|
||||
ui.samtale.hidden = false;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
hentSpoergsmaal(true);
|
||||
}
|
||||
|
||||
ui.emner.addEventListener('click', function (e) {
|
||||
var b = e.target.closest && e.target.closest('[data-vaelg]');
|
||||
if (b) vaelg(b.dataset.vaelg);
|
||||
});
|
||||
|
||||
function holdPause() {
|
||||
stopStream();
|
||||
stopTimer();
|
||||
nulstilOptagelse();
|
||||
ui.svarKort.hidden = true;
|
||||
ui.samtale.hidden = true;
|
||||
ui.emner.hidden = false;
|
||||
// Opdatér tællingen på emnekortet
|
||||
var kort = ui.emner.querySelector('[data-slug="' + st.projekt + '"] .er-antal');
|
||||
if (kort) kort.textContent = (parseInt(kort.textContent, 10) || 0) + st.sendtIDag;
|
||||
st.sendtIDag = 0;
|
||||
visFlash('Tak for i dag. Dine svar er gemt.');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
ui.pause.addEventListener('click', holdPause);
|
||||
|
||||
/* ---------- Spørgsmål ---------- */
|
||||
|
||||
function hentSpoergsmaal(foersteForsoeg) {
|
||||
ui.spoergsmaal.textContent = 'Henter spørgsmålet…';
|
||||
ui.nr.textContent = '';
|
||||
ui.hoer.hidden = true;
|
||||
ui.svarKort.hidden = true;
|
||||
nulstilOptagelse();
|
||||
ui.optagelse.hidden = false;
|
||||
status('');
|
||||
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null }).then(function (j) {
|
||||
st.samtale = j.samtale;
|
||||
husk(j.samtale);
|
||||
st.spoergsmaal = j.spoergsmaal || '';
|
||||
st.lydUrl = j.lyd_url || null;
|
||||
ui.spoergsmaal.textContent = st.spoergsmaal;
|
||||
ui.nr.textContent = j.nr ? String(j.nr) : '';
|
||||
ui.hoer.hidden = !st.lydUrl;
|
||||
if (st.lydUrl) {
|
||||
ui.lyd.src = st.lydUrl;
|
||||
// Afspil automatisk, hvis browseren tillader det; ellers kun på klik
|
||||
var p = ui.lyd.play();
|
||||
if (p && p.catch) p.catch(function () {});
|
||||
}
|
||||
}).catch(function (e) {
|
||||
// Et gammelt samtale-id kan være udløbet: prøv én gang forfra
|
||||
if (foersteForsoeg && st.samtale) { st.samtale = null; glem(); return hentSpoergsmaal(false); }
|
||||
ui.spoergsmaal.textContent = 'Vi kunne ikke hente et spørgsmål.';
|
||||
visFlash(e.message + ' Du kan holde pause og prøve igen senere.');
|
||||
});
|
||||
}
|
||||
|
||||
ui.hoer.addEventListener('click', function () {
|
||||
try {
|
||||
ui.lyd.currentTime = 0;
|
||||
var p = ui.lyd.play();
|
||||
if (p && p.catch) p.catch(function () { visFlash('Lyden kunne ikke afspilles. Du kan læse spørgsmålet ovenfor.'); });
|
||||
} catch (e) {}
|
||||
});
|
||||
ui.naeste.addEventListener('click', function () { hentSpoergsmaal(false); });
|
||||
|
||||
/* ---------- Optagelse ---------- */
|
||||
|
||||
function vaelgMime() {
|
||||
if (!window.MediaRecorder || !MediaRecorder.isTypeSupported) return '';
|
||||
for (var i = 0; i < MIMER.length; i++) if (MediaRecorder.isTypeSupported(MIMER[i])) return MIMER[i];
|
||||
return '';
|
||||
}
|
||||
function endelse(mime) {
|
||||
if (mime.indexOf('mp4') > -1) return 'mp4';
|
||||
if (mime.indexOf('ogg') > -1) return 'ogg';
|
||||
return 'webm';
|
||||
}
|
||||
function stopStream() {
|
||||
if (st.stream) { st.stream.getTracks().forEach(function (t) { t.stop(); }); st.stream = null; }
|
||||
}
|
||||
function stopTimer() {
|
||||
if (st.timer) { clearInterval(st.timer); st.timer = null; }
|
||||
}
|
||||
function nulstilOptagelse() {
|
||||
st.blob = null; st.chunks = []; st.sekunder = 0;
|
||||
ui.optag.disabled = false;
|
||||
ui.optag.classList.remove('optager');
|
||||
ui.optag.setAttribute('aria-pressed', 'false');
|
||||
ui.optagTekst.textContent = 'Optag';
|
||||
ui.taeller.hidden = true; ui.taeller.textContent = '0:00';
|
||||
ui.optag.hidden = false;
|
||||
ui.optaget.hidden = true;
|
||||
if (ui.afspil.src) { try { URL.revokeObjectURL(ui.afspil.src); } catch (e) {} ui.afspil.removeAttribute('src'); }
|
||||
ui.skriv.hidden = true; ui.skrivLink.hidden = false;
|
||||
ui.tekstfelt.value = '';
|
||||
ui.send.disabled = false; ui.om.disabled = false; ui.sendTekst.disabled = false;
|
||||
st.sender = false;
|
||||
}
|
||||
|
||||
function startOptagelse() {
|
||||
visFlash('');
|
||||
var mime = vaelgMime();
|
||||
if (!window.MediaRecorder || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
visFlash('Din browser kan ikke optage lyd. Brug "Skriv i stedet" herunder.');
|
||||
visSkriv();
|
||||
return;
|
||||
}
|
||||
ui.optag.disabled = true;
|
||||
status('Gør mikrofonen klar…');
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
||||
st.stream = stream;
|
||||
st.chunks = [];
|
||||
st.mime = mime;
|
||||
st.ext = endelse(mime);
|
||||
try {
|
||||
st.recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
||||
} catch (e) {
|
||||
stopStream();
|
||||
throw new Error('Optageren kunne ikke startes. Brug "Skriv i stedet", eller prøv en anden browser.');
|
||||
}
|
||||
if (!st.mime) st.mime = st.recorder.mimeType || '';
|
||||
st.ext = endelse(st.mime);
|
||||
st.recorder.addEventListener('dataavailable', function (e) { if (e.data && e.data.size) st.chunks.push(e.data); });
|
||||
st.recorder.addEventListener('stop', optagelseFaerdig);
|
||||
st.recorder.addEventListener('error', function () {
|
||||
visFlash('Optagelsen blev afbrudt. Prøv igen, eller brug "Skriv i stedet".');
|
||||
stopOptagelse();
|
||||
});
|
||||
st.recorder.start(1000);
|
||||
st.startet = Date.now();
|
||||
st.sekunder = 0;
|
||||
ui.optag.disabled = false;
|
||||
ui.optag.classList.add('optager');
|
||||
ui.optag.setAttribute('aria-pressed', 'true');
|
||||
ui.optag.setAttribute('aria-label', 'Stop optagelsen');
|
||||
ui.optagTekst.textContent = 'Stop';
|
||||
ui.taeller.hidden = false; ui.taeller.textContent = '0:00';
|
||||
ui.skrivLink.hidden = true;
|
||||
status('Vi optager. Tryk på knappen, når du er færdig.');
|
||||
st.timer = setInterval(function () {
|
||||
st.sekunder = Math.round((Date.now() - st.startet) / 1000);
|
||||
ui.taeller.textContent = mmss(st.sekunder);
|
||||
if (st.sekunder >= MAKS_SEK) {
|
||||
stopOptagelse();
|
||||
visFlash('Vi stoppede optagelsen efter 10 minutter. Du kan sende svaret nu og fortsætte på næste spørgsmål.');
|
||||
}
|
||||
}, 1000);
|
||||
}).catch(function (e) {
|
||||
stopStream();
|
||||
ui.optag.disabled = false;
|
||||
status('');
|
||||
var navn = e && e.name;
|
||||
if (navn === 'NotAllowedError' || navn === 'SecurityError' || navn === 'PermissionDeniedError') {
|
||||
visFlash('Vi fik ikke lov til at bruge mikrofonen. Tillad mikrofonen i browseren, eller brug "Skriv i stedet".');
|
||||
} else if (navn === 'NotFoundError' || navn === 'DevicesNotFoundError') {
|
||||
visFlash('Vi kunne ikke finde en mikrofon. Brug "Skriv i stedet".');
|
||||
} else {
|
||||
visFlash((e && e.message) || 'Mikrofonen kunne ikke startes. Prøv igen, eller brug "Skriv i stedet".');
|
||||
}
|
||||
visSkriv();
|
||||
});
|
||||
}
|
||||
|
||||
function stopOptagelse() {
|
||||
stopTimer();
|
||||
ui.optag.disabled = true;
|
||||
status('Gør optagelsen klar…');
|
||||
try {
|
||||
if (st.recorder && st.recorder.state !== 'inactive') st.recorder.stop();
|
||||
else optagelseFaerdig();
|
||||
} catch (e) { optagelseFaerdig(); }
|
||||
}
|
||||
|
||||
function optagelseFaerdig() {
|
||||
stopStream(); // ellers bliver den røde mikrofon-prik stående på iPhone
|
||||
ui.optag.classList.remove('optager');
|
||||
ui.optag.setAttribute('aria-pressed', 'false');
|
||||
ui.optag.setAttribute('aria-label', 'Optag dit svar');
|
||||
ui.optagTekst.textContent = 'Optag';
|
||||
ui.taeller.hidden = true;
|
||||
if (!st.chunks.length) {
|
||||
nulstilOptagelse();
|
||||
status('');
|
||||
visFlash('Der kom ingen lyd med. Prøv igen, og tal tæt på telefonen.');
|
||||
return;
|
||||
}
|
||||
st.blob = new Blob(st.chunks, { type: st.mime || 'application/octet-stream' });
|
||||
ui.afspil.src = URL.createObjectURL(st.blob);
|
||||
ui.optag.hidden = true;
|
||||
ui.optaget.hidden = false;
|
||||
status('Hør dit svar igennem, og send det, når du er tilfreds.');
|
||||
ui.send.focus();
|
||||
}
|
||||
|
||||
ui.optag.addEventListener('click', function () {
|
||||
if (st.recorder && st.recorder.state === 'recording') stopOptagelse(); else startOptagelse();
|
||||
});
|
||||
ui.om.addEventListener('click', function () {
|
||||
nulstilOptagelse();
|
||||
status('');
|
||||
visFlash('');
|
||||
});
|
||||
|
||||
/* ---------- Skriv i stedet ---------- */
|
||||
|
||||
function visSkriv() {
|
||||
ui.skriv.hidden = false;
|
||||
ui.skrivLink.hidden = true;
|
||||
ui.tekstfelt.focus();
|
||||
}
|
||||
ui.skrivLink.addEventListener('click', visSkriv);
|
||||
|
||||
/* ---------- Send svar ---------- */
|
||||
|
||||
function sendSvar(form) {
|
||||
if (st.sender) return;
|
||||
st.sender = true;
|
||||
ui.send.disabled = true; ui.om.disabled = true; ui.sendTekst.disabled = true; ui.optag.disabled = true;
|
||||
status('Gemmer og skriver af… Det kan tage op til et halvt minut.', true);
|
||||
visFlash('');
|
||||
form.append('projekt', st.projekt);
|
||||
form.append('samtale', st.samtale || '');
|
||||
form.append('spoergsmaal', st.spoergsmaal);
|
||||
kald('/svar', form).then(function (j) {
|
||||
st.sendtIDag++;
|
||||
visSvar(j);
|
||||
}).catch(function (e) {
|
||||
// Behold det optagede, så det kan sendes igen
|
||||
st.sender = false;
|
||||
ui.send.disabled = false; ui.om.disabled = false; ui.sendTekst.disabled = false; ui.optag.disabled = false;
|
||||
status('Svaret blev ikke sendt. Det er stadig her, så prøv igen.');
|
||||
visFlash(e.message);
|
||||
});
|
||||
}
|
||||
|
||||
ui.send.addEventListener('click', function () {
|
||||
if (!st.blob) { visFlash('Der er ikke noget optaget endnu. Tryk på Optag først.'); return; }
|
||||
var form = new FormData();
|
||||
form.append('lyd', st.blob, 'svar.' + st.ext);
|
||||
sendSvar(form);
|
||||
});
|
||||
ui.sendTekst.addEventListener('click', function () {
|
||||
var t = ui.tekstfelt.value.trim();
|
||||
if (!t) { visFlash('Skriv lidt i feltet først.'); ui.tekstfelt.focus(); return; }
|
||||
var form = new FormData();
|
||||
form.append('tekst', t);
|
||||
sendSvar(form);
|
||||
});
|
||||
|
||||
function visSvar(j) {
|
||||
var tekst = j.tekst || '';
|
||||
ui.optagelse.hidden = true;
|
||||
status('');
|
||||
ui.svarTekst.textContent = tekst;
|
||||
ui.svarKort.dataset.id = j.id;
|
||||
ui.svarKort.hidden = false;
|
||||
var dagensTekst = tilfoejDagens(j, tekst);
|
||||
ui.svarRet.innerHTML = '';
|
||||
var ret = document.createElement('button');
|
||||
ret.type = 'button'; ret.className = 'wt-btn'; ret.textContent = 'Ret teksten';
|
||||
ret.addEventListener('click', function () { retTekst(j.id, [ui.svarTekst, dagensTekst], ui.svarRet); });
|
||||
ui.svarRet.appendChild(ret);
|
||||
ui.naeste.focus();
|
||||
nulstilOptagelse();
|
||||
}
|
||||
|
||||
// Sætter svaret øverst i "Dine svar i dag" og returnerer tekst-elementet
|
||||
function tilfoejDagens(j, tekst) {
|
||||
var li = document.createElement('li');
|
||||
li.setAttribute('data-id', j.id);
|
||||
var q = document.createElement('p'); q.className = 'er-q'; q.textContent = st.spoergsmaal;
|
||||
var t = document.createElement('p'); t.className = 'er-tekst'; t.setAttribute('data-tekst', ''); t.textContent = tekst;
|
||||
li.appendChild(q); li.appendChild(t);
|
||||
if (j.varighed) {
|
||||
var m = document.createElement('p'); m.className = 'er-meta'; m.textContent = 'Varighed ' + mmss(j.varighed);
|
||||
li.appendChild(m);
|
||||
}
|
||||
var ret = document.createElement('div'); ret.className = 'er-ret';
|
||||
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);
|
||||
ui.dagens.insertBefore(li, ui.dagens.firstChild);
|
||||
ui.dagensTom.hidden = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
// Slip mikrofonen, hvis siden forlades midt i en optagelse
|
||||
window.addEventListener('pagehide', function () { stopTimer(); stopStream(); });
|
||||
|
||||
// Åbn et projekt med det samme, hvis serveren bad om det
|
||||
if (side.dataset.valgt) vaelg(side.dataset.valgt);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Røgtest af erindringer-modulet med FastAPI TestClient, uden OIDC: sessionen sættes direkte.
|
||||
# Kør i CT151: PORTAL_DATA=/tmp/ef-test/data + env fra /etc/portal.env. Rører ikke prod-data.
|
||||
import io, json, os, sys
|
||||
from fastapi.testclient import TestClient
|
||||
import main, erindringer
|
||||
|
||||
c = TestClient(main.app, base_url="https://weircon.dk")
|
||||
adm = {"username": "agw", "name": "Asger", "groups": [main.config.ADMIN_GROUP, "Familie"]}
|
||||
|
||||
|
||||
def login(client, user):
|
||||
# Starlette-session: skriv via en lille rute er omstændeligt; monkeypatch current_user i stedet.
|
||||
main.current_user = lambda request: user
|
||||
|
||||
|
||||
login(c, adm)
|
||||
r = c.get("/admin/erindringer"); assert r.status_code == 200 and "Erindringer" in r.text, r.status_code
|
||||
r = c.post("/admin/erindringer/projekt", data={"slug": "test-ep", "titel": "Testemne", "beskrivelse": "b", "kontekst": "Personen er Asger. Vi ved at han bor i Sønderlund.", "start": "Hvad husker du fra Rørbæk?\nHvem var din morfar?"}, follow_redirects=False)
|
||||
assert r.status_code == 303, r.text
|
||||
assert erindringer.projekt("test-ep")["start"][0].startswith("Hvad husker")
|
||||
r = c.post("/admin/erindringer/gaest", data={"navn": "Tina Test", "projekter": ["test-ep"]}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
pid = [p for p in erindringer.alle_personer() if p["navn"] == "Tina Test"][0]["pid"]; noegle = erindringer.person(pid)["noegle"]
|
||||
print("gæst", pid)
|
||||
|
||||
# API som gæst (uden session)
|
||||
login(c, None)
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": noegle}); assert r.status_code == 200, r.text
|
||||
q1 = r.json(); print("Q1", q1["spoergsmaal"], "| lyd:", bool(q1["lyd_url"]))
|
||||
if q1["lyd_url"]:
|
||||
assert c.get(q1["lyd_url"]).status_code == 200
|
||||
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": q1["spoergsmaal"], "noegle": noegle, "tekst": "Jeg husker min morfar Gustav i Villa Monika i Hannerup. Han havde en stor have og en motorcykel, som han aldrig kørte på."})
|
||||
assert r.status_code == 200, r.text; s1 = r.json(); print("svar1", s1["id"])
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": q1["samtale"], "noegle": noegle}); assert r.status_code == 200, r.text
|
||||
q2 = r.json(); print("Q2 (Claude)", q2["spoergsmaal"], "| afslut:", q2["afslut"])
|
||||
# lydsvar: 2 s tone som mp3 lavet med ffmpeg
|
||||
os.system("ffmpeg -y -v error -f lavfi -i 'sine=frequency=440:duration=2' -c:a libmp3lame /tmp/ef-test/tone.mp3")
|
||||
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": q2["spoergsmaal"], "noegle": noegle}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")})
|
||||
assert r.status_code == 200, r.text; s2 = r.json(); print("svar2 lyd", s2["id"], "varighed", s2["varighed"], "tekst:", repr(s2["tekst"])[:60])
|
||||
r = c.get(f"/erindringer/g/{noegle}/klip/{s2['id']}.mp3"); assert r.status_code == 200 and r.headers["content-type"].startswith("audio")
|
||||
r = c.post("/erindringer/api/ret", json={"id": s1["id"], "tekst": "Rettet tekst.", "noegle": noegle}); assert r.status_code == 200
|
||||
assert erindringer.klip(pid, s1["id"])["tekst"] == "Rettet tekst."
|
||||
# forkert nøgle -> 404
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": "x" * 40}); assert r.status_code == 404
|
||||
# indlogget familie: dagbog findes, admin-person-side, eksport
|
||||
login(c, adm)
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "dagbog", "samtale": None, "noegle": ""}); assert r.status_code == 200, r.text
|
||||
print("dagbog Q", r.json()["spoergsmaal"])
|
||||
r = c.get(f"/admin/erindringer/p/{pid}"); assert r.status_code == 200 and "Rettet tekst." in r.text
|
||||
r = c.get(f"/admin/erindringer/p/{pid}/eksport.zip"); assert r.status_code == 200 and r.headers["content-type"] == "application/zip" and len(r.content) > 1000
|
||||
r = c.post(f"/admin/erindringer/p/{pid}/klip/{s1['id']}", data={"handling": "favorit", "favorit": "1"}, follow_redirects=False); assert r.status_code == 303
|
||||
assert erindringer.klip(pid, s1["id"])["favorit"] is True
|
||||
r = c.post(f"/admin/erindringer/person/{pid}", data={"handling": "luk"}, follow_redirects=False); assert r.status_code == 303
|
||||
login(c, None)
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": noegle}); assert r.status_code == 404
|
||||
print("ALT OK")
|
||||
@@ -51,6 +51,20 @@ async def main():
|
||||
for e in cyc["errors"]:
|
||||
print(" cyklus-fejl:", e)
|
||||
|
||||
# Kanal-plugin'ets indeks: playlister (højst ugentligt pr. kanal) og så indekset.
|
||||
try:
|
||||
pl = await ytdl.opdater_playlister()
|
||||
print(f"yt_job: playlister opdateret for {pl['kanaler']} kanaler, {pl['playlister']} i alt")
|
||||
for e in pl["fejl"][:10]:
|
||||
print(" playliste-fejl:", e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"yt_job: playlister kunne ikke opdateres: {e}")
|
||||
try:
|
||||
ix = ytdl.skriv_indeks()
|
||||
print(f"yt_job: indeks: {ix['channels']} kanaler, {ix['videos']} videoer, {ix['playlists']} playlister")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"yt_job: indekset kunne ikke skrives: {e}")
|
||||
|
||||
res = await ytdl.run_cycle(dry_run=False)
|
||||
if res.get("error"):
|
||||
print("yt_job FEJL:", res["error"])
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
"""Engangs-flytning af YouTube-biblioteket til kanal = serie (2026-10).
|
||||
|
||||
Før lå alle videoer fladt i download-mappen i et Emby "hjemmevideo"-bibliotek:
|
||||
ingen kanaler, ingen billeder, og datoen var hentetidspunktet. Nu er hver kanal
|
||||
en serie (se ytdl.organiser). Dette script flytter de eksisterende videoer:
|
||||
|
||||
python yt_migrer.py flyt # gem Emby-status, hent info/miniature, flyt, opdatér state
|
||||
python yt_migrer.py emby # genopret biblioteket som "Serier", scan, sæt status på igen
|
||||
|
||||
`flyt` tager kø-låsen, så intet hentes imens. Emby-status (set, favorit, hvor
|
||||
langt man er nået) gemmes i DATA/yt_migrering.json før noget flyttes, fordi
|
||||
Emby ser flyttede filer som nye; `emby` sætter den på igen ud fra video-id'et.
|
||||
Begge trin kan køres igen uden skade.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
import config
|
||||
import ytdl
|
||||
|
||||
MAPPE = config.YT_DOWNLOAD_DIR
|
||||
GEM = config.DATA / "yt_migrering.json"
|
||||
ID_RE = re.compile(r"\[([A-Za-z0-9_-]{11})\]\.(mp4|mkv|webm)$")
|
||||
|
||||
|
||||
def _gem(d):
|
||||
tmp = str(GEM) + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(d, f, ensure_ascii=False, indent=1)
|
||||
os.replace(tmp, GEM)
|
||||
|
||||
|
||||
def _laes():
|
||||
try:
|
||||
return json.loads(GEM.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {"status": {}, "flyttet": {}}
|
||||
|
||||
|
||||
async def _emby_status() -> dict:
|
||||
"""video-id -> {bruger-id: {played, favorite, pos}} for alt i YouTube-biblioteket."""
|
||||
ud = {}
|
||||
async with httpx.AsyncClient(timeout=60) as c:
|
||||
lib = await ytdl._library_id(c, ytdl.get_settings().get("library_name") or "YouTube")
|
||||
if not lib:
|
||||
return ud
|
||||
for u in await ytdl._emby(c, "/Users"):
|
||||
data = await ytdl._emby(c, f"/Users/{u['Id']}/Items", ParentId=lib, Recursive="true",
|
||||
Fields="Path,UserData", IncludeItemTypes="Video,Episode,Movie")
|
||||
for it in data.get("Items", []):
|
||||
m = ID_RE.search(it.get("Path") or "")
|
||||
ud_ = it.get("UserData") or {}
|
||||
if not m or not (ud_.get("Played") or ud_.get("IsFavorite") or ud_.get("PlaybackPositionTicks")):
|
||||
continue
|
||||
ud.setdefault(m.group(1), {})[u["Id"]] = {
|
||||
"played": bool(ud_.get("Played")), "favorite": bool(ud_.get("IsFavorite")),
|
||||
"pos": int(ud_.get("PlaybackPositionTicks") or 0)}
|
||||
return ud
|
||||
|
||||
|
||||
async def _info(vid: str) -> dict:
|
||||
"""Info + miniature fra YouTube uden at hente videoen (med samme nye forsøg som hentning)."""
|
||||
sd = ytdl.staging_dir()
|
||||
for pause, _ in ytdl.FORSOEG:
|
||||
if pause:
|
||||
await asyncio.sleep(pause)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
config.YT_DLP_BIN, "--skip-download", "--write-info-json", "--write-thumbnail",
|
||||
"--convert-thumbnails", "jpg", "--no-warnings", "--no-playlist",
|
||||
"-o", os.path.join(sd, "%(id)s.%(ext)s"), f"https://www.youtube.com/watch?v={vid}",
|
||||
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE)
|
||||
_, err = await proc.communicate()
|
||||
try:
|
||||
with open(os.path.join(sd, f"{vid}.info.json"), encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if not ytdl.midlertidig(err.decode("utf-8", "replace")):
|
||||
break
|
||||
return {}
|
||||
|
||||
|
||||
async def _playliste_ids(url: str) -> set:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
config.YT_DLP_BIN, "--flat-playlist", "--ignore-errors", "--print", "id", "--no-warnings", url,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||
out, _ = await proc.communicate()
|
||||
return set(out.decode().split())
|
||||
|
||||
|
||||
async def flyt():
|
||||
laas = ytdl._koe_laas()
|
||||
while laas is None:
|
||||
print("venter på at køen bliver ledig ...")
|
||||
await asyncio.sleep(30)
|
||||
laas = ytdl._koe_laas()
|
||||
try:
|
||||
gem = _laes()
|
||||
if not gem["status"]:
|
||||
gem["status"] = await _emby_status()
|
||||
_gem(gem)
|
||||
print(f"Emby-status gemt for {len(gem['status'])} videoer")
|
||||
|
||||
flade = sorted(f for f in os.listdir(MAPPE)
|
||||
if os.path.isfile(os.path.join(MAPPE, f)) and ID_RE.search(f))
|
||||
print(f"{len(flade)} videoer ligger stadig fladt")
|
||||
sd = ytdl.staging_dir()
|
||||
for i, f in enumerate(flade, 1):
|
||||
vid = ID_RE.search(f).group(1)
|
||||
gammel = os.path.realpath(os.path.join(MAPPE, f))
|
||||
info = await _info(vid)
|
||||
if not info:
|
||||
# Privat/slettet på YouTube: brug filnavnet, så den stadig får en plads.
|
||||
info = {"title": f.rsplit(" [", 1)[0], "channel": "Ukendt kanal"}
|
||||
stem = f.rsplit(".", 1)[0]
|
||||
for side in os.listdir(MAPPE):
|
||||
if side != f and (side.startswith(stem + ".") or side.startswith(stem + "-thumb.")):
|
||||
rest = side[len(stem) + 1:] if side.startswith(stem + ".") else side[len(stem) + 7:]
|
||||
if rest in ("jpg", "jpeg", "png", "webp") and os.path.exists(os.path.join(sd, f"{vid}.jpg")):
|
||||
os.remove(os.path.join(MAPPE, side)) # YouTubes miniature er bedre
|
||||
continue
|
||||
os.replace(os.path.join(MAPPE, side), os.path.join(sd, f"{vid}.{rest}"))
|
||||
staging = os.path.join(sd, f"{vid}{os.path.splitext(f)[1]}")
|
||||
os.replace(gammel, staging)
|
||||
o = await ytdl.organiser(staging, info)
|
||||
gem["flyttet"][gammel] = {"ny": os.path.realpath(o["path"]), "id": vid,
|
||||
"upload_date": o["upload_date"]}
|
||||
_gem(gem)
|
||||
print(f" {i}/{len(flade)} {o['title'][:60]}")
|
||||
await asyncio.sleep(2) # skån YouTube, det er netop mange kald der giver 403
|
||||
|
||||
# Opdatér portalens state: stier i jobs og "på vej ud".
|
||||
st = ytdl.get_state()
|
||||
flyttet = {k: v["ny"] for k, v in gem["flyttet"].items()}
|
||||
ny_af = lambda p: flyttet.get(os.path.realpath(p), p) if p else p # noqa: E731
|
||||
for j in st["jobs"].values():
|
||||
if j.get("path"):
|
||||
j["path"] = ny_af(j["path"])
|
||||
if j.get("paths"):
|
||||
j["paths"] = [ny_af(p) for p in j["paths"]]
|
||||
for v in j.get("videos") or []:
|
||||
v["path"] = ny_af(v.get("path"))
|
||||
st["leaving"] = {ny_af(k): v for k, v in st["leaving"].items()}
|
||||
|
||||
# Videoer fra en playliste, hvis job fejlede på én privat video, men hvor
|
||||
# resten blev hentet: knyt dem til jobbet, så de er portalens egne igen.
|
||||
# Ejet = stier i færdige job i den state vi lige har opdateret (ikke den på disken).
|
||||
ejet = {os.path.realpath(p) for j in st["jobs"].values() if j["status"] == "done"
|
||||
for p in (j.get("paths") or ([j["path"]] if j.get("path") else [])) if p}
|
||||
uejede = {v["id"]: v for k, v in gem["flyttet"].items() if v["ny"] not in ejet}
|
||||
for j in st["jobs"].values():
|
||||
if not uejede or not j.get("playlist") or j["status"] != "error":
|
||||
continue
|
||||
ids = await _playliste_ids(j["url"])
|
||||
mine = [v for vid, v in uejede.items() if vid in ids]
|
||||
if mine:
|
||||
j.update(status="done", error="", finished=time.time(),
|
||||
note=f"{len(mine)} videoer knyttet til jobbet ved flytningen; private sprunget over",
|
||||
paths=[v["ny"] for v in mine],
|
||||
videos=[{"id": v["id"], "upload_date": v["upload_date"], "path": v["ny"]} for v in mine],
|
||||
path=mine[0]["ny"])
|
||||
print(f"knyttede {len(mine)} videoer til playliste-jobbet {j['id']}")
|
||||
ytdl._log(st, f"Flyttet {len(gem['flyttet'])} videoer til kanal/sæson-struktur")
|
||||
ytdl.save_state(st)
|
||||
print("state opdateret")
|
||||
finally:
|
||||
laas.close()
|
||||
|
||||
|
||||
async def _emby_kald(c, metode, sti, **kw):
|
||||
params = kw.pop("params", {})
|
||||
params["api_key"] = config.EMBY_API_KEY
|
||||
r = await c.request(metode, f"{config.EMBY_URL}/emby{sti}", params=params, **kw)
|
||||
r.raise_for_status()
|
||||
return r
|
||||
|
||||
|
||||
async def emby():
|
||||
navn = ytdl.get_settings().get("library_name") or "YouTube"
|
||||
async with httpx.AsyncClient(timeout=120) as c:
|
||||
mapper = (await _emby_kald(c, "GET", "/Library/VirtualFolders")).json()
|
||||
gl = next((m for m in mapper if (m.get("Name") or "").lower() == navn.lower()), None)
|
||||
if gl and gl.get("CollectionType") != "tvshows":
|
||||
opts = gl.get("LibraryOptions") or {}
|
||||
await _emby_kald(c, "DELETE", "/Library/VirtualFolders",
|
||||
params={"name": gl["Name"], "refreshLibrary": "false"})
|
||||
print(f"fjernede {gl['Name']} ({gl.get('CollectionType')}); filerne røres ikke")
|
||||
else:
|
||||
opts = (gl or {}).get("LibraryOptions") or {}
|
||||
if not gl or gl.get("CollectionType") != "tvshows":
|
||||
# Kun lokale data: NFO'erne og billederne ved siden af filerne. Ingen
|
||||
# opslag hos TheTVDB/TMDb, der ville prøve at gætte kanalerne som tv-serier.
|
||||
opts.update({"EnableInternetProviders": False, "SaveLocalMetadata": False,
|
||||
"MetadataSavers": [], "EnableRealtimeMonitor": True,
|
||||
"EnableAutomaticSeriesGrouping": False,
|
||||
"TypeOptions": [{"Type": t, "MetadataFetchers": [], "ImageFetchers": [],
|
||||
"MetadataFetcherOrder": [], "ImageFetcherOrder": []}
|
||||
for t in ("Series", "Season", "Episode")],
|
||||
"PathInfos": [{"Path": MAPPE}]})
|
||||
await _emby_kald(c, "POST", "/Library/VirtualFolders",
|
||||
params={"name": navn, "collectionType": "tvshows", "refreshLibrary": "true",
|
||||
"paths": MAPPE},
|
||||
json={"Name": navn, "CollectionType": "tvshows", "RefreshLibrary": True,
|
||||
"Paths": [MAPPE], "LibraryOptions": opts})
|
||||
print(f"oprettede {navn} som Serier på {MAPPE}")
|
||||
|
||||
# Vent på at scanningen har fundet afsnittene.
|
||||
antal = sum(1 for _, _, fs in os.walk(MAPPE) for f in fs if ID_RE.search(f)
|
||||
and not f.startswith("."))
|
||||
lib, d = None, {}
|
||||
for _ in range(60):
|
||||
lib = await ytdl._library_id(c, navn)
|
||||
if lib:
|
||||
d = (await _emby_kald(c, "GET", "/Items", params={
|
||||
"ParentId": lib, "Recursive": "true", "IncludeItemTypes": "Episode",
|
||||
"Limit": "0"})).json()
|
||||
if d.get("TotalRecordCount", 0) >= antal:
|
||||
break
|
||||
await asyncio.sleep(10)
|
||||
print(f"Emby ser {d.get('TotalRecordCount', 0)} af {antal} afsnit")
|
||||
|
||||
status = _laes()["status"]
|
||||
sat = 0
|
||||
for uid in {u for v in status.values() for u in v}:
|
||||
data = (await _emby_kald(c, "GET", f"/Users/{uid}/Items", params={
|
||||
"ParentId": lib, "Recursive": "true", "IncludeItemTypes": "Episode",
|
||||
"Fields": "Path"})).json()
|
||||
for it in data.get("Items", []):
|
||||
m = ID_RE.search(it.get("Path") or "")
|
||||
s = status.get(m.group(1), {}).get(uid) if m else None
|
||||
if not s:
|
||||
continue
|
||||
if s["played"]:
|
||||
await _emby_kald(c, "POST", f"/Users/{uid}/PlayedItems/{it['Id']}")
|
||||
if s["favorite"]:
|
||||
await _emby_kald(c, "POST", f"/Users/{uid}/FavoriteItems/{it['Id']}")
|
||||
if s["pos"] and not s["played"]:
|
||||
await _emby_kald(c, "POST", f"/Users/{uid}/Items/{it['Id']}/UserData",
|
||||
json={"PlaybackPositionTicks": s["pos"]})
|
||||
sat += 1
|
||||
print(f"status sat på igen: {sat} (bruger, video)-par af "
|
||||
f"{sum(len(v) for v in status.values())}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
trin = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if trin == "flyt":
|
||||
asyncio.run(flyt())
|
||||
elif trin == "emby":
|
||||
asyncio.run(emby())
|
||||
else:
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
@@ -11,11 +11,17 @@ biblioteket — fx det der lå i /slow/shared/yt i forvejen — er urørligt.
|
||||
"""
|
||||
import asyncio
|
||||
import fcntl
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -112,10 +118,9 @@ def _set_job(jid: str, **kw):
|
||||
|
||||
|
||||
def _build_cmd(job: dict, settings: dict, with_subs: bool, klient: str = "") -> list:
|
||||
dest = config.YT_DOWNLOAD_DIR
|
||||
# %(title)s beholder æøå; [%(id)s] gør navnet unikt og gør det muligt at
|
||||
# genkende videoen igen uden at gemme en sidevogn af metadata.
|
||||
out = os.path.join(dest, "%(title)s [%(id)s].%(ext)s")
|
||||
# yt-dlp henter til en skjult staging-mappe under video-id'et; organiser()
|
||||
# flytter bagefter videoen ind som Kanal/Season ÅÅÅÅ/... med NFO og billede.
|
||||
out = os.path.join(staging_dir(), "%(id)s.%(ext)s")
|
||||
cmd = [config.YT_DLP_BIN,
|
||||
"-f", settings.get("quality") or config.YT_DEFAULTS["quality"],
|
||||
"--merge-output-format", "mp4",
|
||||
@@ -123,6 +128,9 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool, klient: str = "") ->
|
||||
"--concurrent-fragments", "4",
|
||||
"--retries", "5", "--sleep-requests", "1",
|
||||
"--print", "after_move:%(id)s|%(upload_date)s|%(filepath)s",
|
||||
# Titel, kanal, beskrivelse og upload-dato til NFO'en, og YouTubes
|
||||
# miniature som afsnitsbillede i Emby.
|
||||
"--write-info-json", "--write-thumbnail", "--convert-thumbnails", "jpg",
|
||||
"-o", out]
|
||||
|
||||
if settings.get("metadata", True):
|
||||
@@ -149,6 +157,9 @@ def _build_cmd(job: dict, settings: dict, with_subs: bool, klient: str = "") ->
|
||||
|
||||
if not job.get("playlist"):
|
||||
cmd.append("--no-playlist")
|
||||
else:
|
||||
# En enkelt privat/slettet video må ikke vælte resten af playlisten.
|
||||
cmd.append("--ignore-errors")
|
||||
cmd.append(job["url"])
|
||||
return cmd
|
||||
|
||||
@@ -159,13 +170,9 @@ async def _attempt(job: dict, settings: dict, with_subs: bool, klient: str = "")
|
||||
*_build_cmd(job, settings, with_subs, klient),
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
if proc.returncode < 0:
|
||||
# Dræbt udefra, typisk fordi portalen genstartede midt i hentningen.
|
||||
return {"ok": False, "error": f"afbrudt (signal {-proc.returncode})"}
|
||||
err = (stderr.decode("utf-8", "replace").strip().splitlines()
|
||||
or [f"ukendt fejl (kode {proc.returncode})"])[-1]
|
||||
return {"ok": False, "error": err[:300]}
|
||||
if proc.returncode is not None and proc.returncode < 0:
|
||||
# Dræbt udefra, typisk fordi portalen genstartede midt i hentningen.
|
||||
return {"ok": False, "error": f"afbrudt (signal {-proc.returncode})"}
|
||||
|
||||
# --print after_move giver én linje pr. hentet fil: id|upload_date|sti.
|
||||
# Video-id og upload-dato gemmes, fordi cyklussen skal kunne sortere efter
|
||||
@@ -176,14 +183,528 @@ async def _attempt(job: dict, settings: dict, with_subs: bool, klient: str = "")
|
||||
if len(parts) == 3 and parts[2].startswith(dest):
|
||||
videos.append({"id": parts[0], "upload_date": parts[1].strip("NA "),
|
||||
"path": parts[2]})
|
||||
err = (stderr.decode("utf-8", "replace").strip().splitlines()
|
||||
or [f"ukendt fejl (kode {proc.returncode})"])[-1][:300]
|
||||
if proc.returncode != 0 and not (job.get("playlist") and videos):
|
||||
return {"ok": False, "error": err}
|
||||
if not videos:
|
||||
return {"ok": False, "error": "yt-dlp meldte ingen fil — allerede hentet?"}
|
||||
|
||||
# Ind i Kanal/Season ÅÅÅÅ/ med NFO og billede, så Emby viser det som en serie.
|
||||
titler = []
|
||||
for v in videos:
|
||||
o = await organiser(v["path"])
|
||||
v["path"] = o["path"]
|
||||
v["upload_date"] = o["upload_date"] or v["upload_date"]
|
||||
titler.append(o["title"])
|
||||
paths = [v["path"] for v in videos]
|
||||
size = sum(os.path.getsize(p) for p in paths if os.path.exists(p))
|
||||
title = os.path.basename(paths[0]).rsplit(".", 1)[0]
|
||||
size = sum(file_size(p) for p in paths if os.path.exists(p))
|
||||
title = titler[0] if len(titler) == 1 else f"{titler[0]} (+{len(titler) - 1} fra playlisten)"
|
||||
noter = [] if with_subs else ["uden undertekster"]
|
||||
if proc.returncode != 0:
|
||||
noter.append(f"nogle videoer i playlisten blev sprunget over ({err[:120]})")
|
||||
return {"ok": True, "title": title, "path": paths[0], "paths": paths,
|
||||
"videos": videos, "size": size,
|
||||
"note": "" if with_subs else "uden undertekster"}
|
||||
"videos": videos, "size": size, "note": "; ".join(noter)}
|
||||
|
||||
|
||||
# ---------------- Emby-struktur: kanal = serie ----------------
|
||||
# Hver kanal bliver en serie i et Emby "Serier"-bibliotek:
|
||||
# <Kanal>/tvshow.nfo, poster.jpg (kanalens profilbillede), fanart.jpg (banner)
|
||||
# <Kanal>/Season ÅÅÅÅ/<Kanal> - sÅÅÅÅeMMDDnn - <Titel> [<id>].mp4
|
||||
# + .nfo (titel, beskrivelse, upload-dato) + -thumb.jpg + .<sprog>.srt
|
||||
# Afsnitsnummeret er upload-datoen (MMDD) + et løbenummer, så Emby sorterer
|
||||
# efter hvornår videoen kom på YouTube, ikke hvornår vi hentede den.
|
||||
STAGING_NAVN = ".staging"
|
||||
KANAL_FILER = ("tvshow.nfo", "poster.jpg", "fanart.jpg", "banner.jpg")
|
||||
_UGYLDIG = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
|
||||
|
||||
|
||||
def staging_dir() -> str:
|
||||
d = os.path.join(config.YT_DOWNLOAD_DIR, STAGING_NAVN)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _rent(navn: str, maks: int = 120) -> str:
|
||||
s = re.sub(r"\s+", " ", _UGYLDIG.sub("", navn or "")).strip().rstrip(". ")
|
||||
return s[:maks].rstrip(". ") or "Ukendt"
|
||||
|
||||
|
||||
def _dato(d: str) -> str:
|
||||
return f"{d[:4]}-{d[4:6]}-{d[6:8]}" if d and len(d) == 8 and d.isdigit() else ""
|
||||
|
||||
|
||||
def _skriv_tekst(sti: str, tekst: str) -> None:
|
||||
tmp = sti + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(tekst)
|
||||
os.replace(tmp, sti)
|
||||
|
||||
|
||||
def _nfo(rod: str, felter: list) -> str:
|
||||
linjer = ['<?xml version="1.0" encoding="utf-8" standalone="yes"?>', f"<{rod}>"]
|
||||
for tag, v, attr in felter:
|
||||
if v not in (None, ""):
|
||||
linjer.append(f" <{tag}{attr}>{escape(str(v))}</{tag}>")
|
||||
linjer += [" <lockdata>true</lockdata>", f"</{rod}>", ""]
|
||||
return "\n".join(linjer)
|
||||
|
||||
|
||||
async def _hent_billede(url: str, sti: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c:
|
||||
r = await c.get(url)
|
||||
if r.status_code == 200 and r.content:
|
||||
with open(sti + ".tmp", "wb") as f:
|
||||
f.write(r.content)
|
||||
os.replace(sti + ".tmp", sti)
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
async def _kanal_info(kanal_dir: str, kanal: str, info: dict) -> None:
|
||||
"""tvshow.nfo + plakat/baggrund for kanalen (kun når de mangler)."""
|
||||
nfo = os.path.join(kanal_dir, "tvshow.nfo")
|
||||
if os.path.exists(nfo) and os.path.exists(os.path.join(kanal_dir, "poster.jpg")):
|
||||
return
|
||||
url = info.get("channel_url") or (
|
||||
f"https://www.youtube.com/channel/{info['channel_id']}" if info.get("channel_id") else "")
|
||||
meta = {}
|
||||
if url:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
config.YT_DLP_BIN, "--dump-single-json", "--flat-playlist", "--playlist-items", "0",
|
||||
"--no-warnings", url, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
|
||||
meta = json.loads(out.decode("utf-8", "replace") or "{}")
|
||||
except Exception: # noqa: BLE001
|
||||
meta = {}
|
||||
if not os.path.exists(nfo):
|
||||
_skriv_tekst(nfo, _nfo("tvshow", [
|
||||
("title", kanal, ""), ("plot", meta.get("description") or "", ""),
|
||||
("studio", "YouTube", ""),
|
||||
("uniqueid", info.get("channel_id") or "", ' type="youtube" default="true"')]))
|
||||
thumbs = meta.get("thumbnails") or []
|
||||
def find(id_, pred):
|
||||
for t in thumbs:
|
||||
if t.get("id") == id_ and t.get("url"):
|
||||
return t["url"]
|
||||
kandidater = [t for t in thumbs if t.get("url") and pred(t)]
|
||||
kandidater.sort(key=lambda t: (t.get("width") or 0), reverse=True)
|
||||
return kandidater[0]["url"] if kandidater else ""
|
||||
avatar = find("avatar_uncropped", lambda t: t.get("width") and t.get("width") == t.get("height"))
|
||||
banner = find("banner_uncropped", lambda t: (t.get("width") or 0) > 2 * (t.get("height") or 1))
|
||||
if avatar and not os.path.exists(os.path.join(kanal_dir, "poster.jpg")):
|
||||
await _hent_billede(avatar, os.path.join(kanal_dir, "poster.jpg"))
|
||||
if banner and not os.path.exists(os.path.join(kanal_dir, "fanart.jpg")):
|
||||
await _hent_billede(banner, os.path.join(kanal_dir, "fanart.jpg"))
|
||||
|
||||
|
||||
async def organiser(staging_path: str, info: dict | None = None) -> dict:
|
||||
"""Flyt en hentet video fra staging ind i kanalens serie, med NFO og billede.
|
||||
Returnerer {path, title, upload_date}."""
|
||||
sd = os.path.dirname(staging_path)
|
||||
vid = os.path.basename(staging_path).split(".")[0]
|
||||
if info is None:
|
||||
try:
|
||||
with open(os.path.join(sd, f"{vid}.info.json"), encoding="utf-8") as f:
|
||||
info = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
info = {}
|
||||
kanal = _rent(info.get("channel") or info.get("uploader") or "Ukendt kanal", 80)
|
||||
dato = info.get("upload_date") or info.get("release_date") or time.strftime("%Y%m%d")
|
||||
aar, md = dato[:4], dato[4:8]
|
||||
kanal_dir = os.path.join(config.YT_DOWNLOAD_DIR, kanal)
|
||||
saeson_dir = os.path.join(kanal_dir, f"Season {aar}")
|
||||
os.makedirs(saeson_dir, exist_ok=True)
|
||||
await _kanal_info(kanal_dir, kanal, info)
|
||||
|
||||
# Samme video igen (fx nyt forsøg): genbrug dens afsnitsnummer.
|
||||
ep, brugt = 0, set()
|
||||
for f in os.listdir(saeson_dir):
|
||||
m = re.search(rf" - s{aar}e(\d{{6}}) - ", f)
|
||||
if not m:
|
||||
continue
|
||||
if f"[{vid}]" in f:
|
||||
ep = int(m.group(1))
|
||||
brugt.add(int(m.group(1)))
|
||||
if not ep:
|
||||
base = int(md) * 100 if md.isdigit() else 0
|
||||
ep = next(base + n for n in range(1, 100) if base + n not in brugt)
|
||||
titel = info.get("title") or vid
|
||||
stem = f"{kanal} - s{aar}e{ep:06d} - {_rent(titel)} [{vid}]"
|
||||
final = os.path.join(saeson_dir, stem + os.path.splitext(staging_path)[1])
|
||||
os.replace(staging_path, final)
|
||||
for f in os.listdir(sd):
|
||||
if not f.startswith(vid + "."):
|
||||
continue
|
||||
src, rest = os.path.join(sd, f), f[len(vid) + 1:]
|
||||
if rest == "info.json":
|
||||
os.remove(src)
|
||||
elif rest in ("jpg", "jpeg", "png", "webp"):
|
||||
os.replace(src, os.path.join(saeson_dir, f"{stem}-thumb.{'jpg' if rest == 'jpeg' else rest}"))
|
||||
elif not rest.endswith(".part"):
|
||||
os.replace(src, os.path.join(saeson_dir, f"{stem}.{rest}"))
|
||||
varighed = info.get("duration")
|
||||
_skriv_tekst(os.path.join(saeson_dir, stem + ".nfo"), _nfo("episodedetails", [
|
||||
("title", titel, ""), ("showtitle", kanal, ""), ("season", aar, ""), ("episode", ep, ""),
|
||||
("plot", info.get("description") or "", ""), ("aired", _dato(dato), ""),
|
||||
("premiered", _dato(dato), ""), ("year", aar, ""),
|
||||
("runtime", int(varighed // 60) or "" if varighed else "", ""), ("studio", "YouTube", ""),
|
||||
("uniqueid", vid, ' type="youtube" default="true"')]))
|
||||
return {"path": final, "title": titel, "upload_date": dato}
|
||||
|
||||
|
||||
def _ryd_tomme_mapper(saeson_dir: str) -> None:
|
||||
"""Efter en sletning: fjern tom sæsonmappe, og hele kanalen når den ikke har
|
||||
flere videoer. Rører kun mapper under download-mappen og kun kendte filer."""
|
||||
rod = os.path.realpath(config.YT_DOWNLOAD_DIR)
|
||||
s = os.path.realpath(saeson_dir)
|
||||
if s == rod or not s.startswith(rod + os.sep):
|
||||
return
|
||||
try:
|
||||
kanal = os.path.dirname(s) if os.path.basename(s).startswith("Season ") else s
|
||||
if s != kanal and not os.listdir(s):
|
||||
os.rmdir(s)
|
||||
if kanal == rod or not kanal.startswith(rod + os.sep) or os.path.basename(kanal) == STAGING_NAVN:
|
||||
return
|
||||
for _, _, fs in os.walk(kanal):
|
||||
if any(f.lower().endswith((".mp4", ".mkv", ".webm")) for f in fs):
|
||||
return
|
||||
for f in KANAL_FILER:
|
||||
if os.path.exists(os.path.join(kanal, f)):
|
||||
os.remove(os.path.join(kanal, f))
|
||||
# Playlisternes miniaturer (skrevet af opdater_playlister) hører også til kanalen.
|
||||
shutil.rmtree(os.path.join(kanal, ".playlists"), ignore_errors=True)
|
||||
for d in os.listdir(kanal):
|
||||
dd = os.path.join(kanal, d)
|
||||
if d.startswith("Season ") and os.path.isdir(dd) and not os.listdir(dd):
|
||||
os.rmdir(dd)
|
||||
if not os.listdir(kanal):
|
||||
os.rmdir(kanal)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _slet_video(path: str) -> int:
|
||||
"""Slet videoen med alle sidecars og ryd tomme mapper. Returnerer bytes."""
|
||||
size = 0
|
||||
for side in _sidecars(path):
|
||||
size += os.path.getsize(side)
|
||||
os.remove(side)
|
||||
if os.path.exists(path):
|
||||
size += os.path.getsize(path)
|
||||
os.remove(path)
|
||||
_ryd_tomme_mapper(os.path.dirname(path))
|
||||
return size
|
||||
|
||||
|
||||
# ---------------- indeks til Emby-kanal-plugin'et ----------------
|
||||
# Emby kan kun vise serie → sæson → afsnit. Kanal-plugin'et (agw/emby-yt-kanaler)
|
||||
# viser i stedet Kanal → [Alle videoer | Playlister → Playliste] → videoer, hvor
|
||||
# en video kan ligge i flere playlister. Det læser dette indeks, som portalen
|
||||
# skriver ud fra disken (NFO'er og billeder) og playliste-cachen. Plugin'et har
|
||||
# ingen egen logik for YouTube; alt det ligger her.
|
||||
INDEKS_NAVN = ".weircon-index.json"
|
||||
PLAYLISTER_PATH = config.DATA / "yt_playlister.json"
|
||||
PLAYLISTE_MAKS_ALDER = 7 * 86400
|
||||
_VIDEO_ENDELSER = (".mp4", ".mkv", ".webm")
|
||||
_SRT_RE = re.compile(r"\.([A-Za-z0-9_-]+)\.(srt|vtt)$")
|
||||
|
||||
|
||||
def indeks_sti() -> str:
|
||||
return os.path.join(config.YT_DOWNLOAD_DIR, INDEKS_NAVN)
|
||||
|
||||
|
||||
def _laes_nfo(sti: str) -> dict:
|
||||
"""Felter fra en NFO (episodedetails/tvshow): tag -> tekst, uniqueid pr. type."""
|
||||
try:
|
||||
rod = ET.parse(sti).getroot()
|
||||
except (OSError, ET.ParseError):
|
||||
return {}
|
||||
ud = {}
|
||||
for el in rod:
|
||||
if el.tag == "uniqueid":
|
||||
ud[f"uniqueid:{el.get('type') or ''}"] = (el.text or "").strip()
|
||||
elif el.tag not in ud:
|
||||
ud[el.tag] = (el.text or "").strip()
|
||||
return ud
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def _varighed_mp4(sti: str, mtime: float):
|
||||
try:
|
||||
from mutagen import File as _MFile
|
||||
m = _MFile(sti)
|
||||
return int(m.info.length) if m and m.info and m.info.length else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
FFPROBE_PATH = config.DATA / "yt_ffprobe.json"
|
||||
_ffprobe = {"data": None, "aendret": False}
|
||||
|
||||
|
||||
def _brok(v):
|
||||
try:
|
||||
t, n = str(v).split("/")
|
||||
return round(float(t) / float(n), 3) if float(n) else None
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
|
||||
def _heltal(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _stroemme(sti: str, st_) -> dict:
|
||||
"""Video-/lydspor (codec, opløsning, bitrate) via ffprobe, så Emby-plugin'et kan
|
||||
sige til apps at filen kan afspilles direkte. Caches pr. sti+mtime+størrelse."""
|
||||
if _ffprobe["data"] is None:
|
||||
_ffprobe["data"] = _load(FFPROBE_PATH, {}) or {}
|
||||
noegle = f"{sti}|{int(st_.st_mtime)}|{st_.st_size}"
|
||||
if noegle in _ffprobe["data"]:
|
||||
return _ffprobe["data"][noegle]
|
||||
ud = {"bitrate": None, "streams": []}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[shutil.which("ffprobe") or "ffprobe", "-v", "error", "-of", "json", "-show_entries",
|
||||
"stream=codec_type,codec_name,profile,width,height,pix_fmt,avg_frame_rate,bit_rate,"
|
||||
"channels,sample_rate:stream_disposition=attached_pic:stream_tags=language:format=bit_rate", sti],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
d = json.loads(r.stdout or "{}")
|
||||
ud["bitrate"] = _heltal((d.get("format") or {}).get("bit_rate"))
|
||||
for s_ in d.get("streams", []):
|
||||
if (s_.get("disposition") or {}).get("attached_pic"):
|
||||
continue # det indlejrede coverbillede er ikke et videospor
|
||||
if s_.get("codec_type") == "video":
|
||||
ud["streams"].append({"type": "video", "codec": s_.get("codec_name"), "profile": s_.get("profile"),
|
||||
"width": s_.get("width"), "height": s_.get("height"),
|
||||
"fps": _brok(s_.get("avg_frame_rate")), "bitrate": _heltal(s_.get("bit_rate")),
|
||||
"pix_fmt": s_.get("pix_fmt")})
|
||||
elif s_.get("codec_type") == "audio":
|
||||
ud["streams"].append({"type": "audio", "codec": s_.get("codec_name"), "profile": s_.get("profile"),
|
||||
"channels": s_.get("channels"), "sample_rate": _heltal(s_.get("sample_rate")),
|
||||
"bitrate": _heltal(s_.get("bit_rate")),
|
||||
"language": (s_.get("tags") or {}).get("language")})
|
||||
except Exception: # noqa: BLE001
|
||||
return ud # prøv igen næste gang
|
||||
_ffprobe["data"][noegle] = ud
|
||||
_ffprobe["aendret"] = True
|
||||
return ud
|
||||
|
||||
|
||||
def _laes_playlister() -> dict:
|
||||
return _load(PLAYLISTER_PATH, {}) or {}
|
||||
|
||||
|
||||
def skriv_indeks() -> dict:
|
||||
"""Skriv indekset (DOWNLOAD_DIR/.weircon-index.json) ud fra disken. Ingen netværk.
|
||||
Returnerer en lille statistik."""
|
||||
rod = config.YT_DOWNLOAD_DIR
|
||||
kanaler, videoer = [], {}
|
||||
try:
|
||||
navne = sorted(os.listdir(rod), key=str.lower)
|
||||
except OSError:
|
||||
navne = []
|
||||
for navn in navne:
|
||||
kdir = os.path.join(rod, navn)
|
||||
if navn.startswith(".") or not os.path.isdir(kdir):
|
||||
continue
|
||||
tv = _laes_nfo(os.path.join(kdir, "tvshow.nfo"))
|
||||
kid = tv.get("uniqueid:youtube") or f"navn:{navn}"
|
||||
mine = []
|
||||
for d, dirs, fs in os.walk(kdir):
|
||||
dirs[:] = [x for x in dirs if not x.startswith(".")]
|
||||
for f in fs:
|
||||
if not f.lower().endswith(_VIDEO_ENDELSER) or f.startswith("."):
|
||||
continue
|
||||
sti = os.path.join(d, f)
|
||||
stem = f.rsplit(".", 1)[0]
|
||||
nfo = _laes_nfo(os.path.join(d, stem + ".nfo"))
|
||||
m = re.search(r"\[([A-Za-z0-9_-]{11})\]$", stem)
|
||||
vid = nfo.get("uniqueid:youtube") or (m.group(1) if m else "")
|
||||
if not vid:
|
||||
continue
|
||||
st_ = os.stat(sti)
|
||||
thumb = os.path.join(d, stem + "-thumb.jpg")
|
||||
subs = []
|
||||
for side in sorted(fs):
|
||||
if side.startswith(stem + "."):
|
||||
sm = _SRT_RE.search(side[len(stem):])
|
||||
if sm:
|
||||
subs.append({"path": os.path.join(d, side), "lang": sm.group(1)})
|
||||
videoer[vid] = {
|
||||
"title": nfo.get("title") or stem, "channel": kid, "path": sti,
|
||||
"thumb": thumb if os.path.exists(thumb) else None,
|
||||
"description": nfo.get("plot") or "",
|
||||
"upload_date": nfo.get("aired") or nfo.get("premiered") or "",
|
||||
"duration": _varighed_mp4(sti, st_.st_mtime), "added": int(st_.st_mtime),
|
||||
"subtitles": subs, **_stroemme(sti, st_)}
|
||||
mine.append(vid)
|
||||
if not mine:
|
||||
continue
|
||||
mine.sort(key=lambda v: (videoer[v]["upload_date"], videoer[v]["added"]), reverse=True)
|
||||
poster, fanart = os.path.join(kdir, "poster.jpg"), os.path.join(kdir, "fanart.jpg")
|
||||
kanaler.append({"id": kid, "name": tv.get("title") or navn, "description": tv.get("plot") or "",
|
||||
"poster": poster if os.path.exists(poster) else None,
|
||||
"fanart": fanart if os.path.exists(fanart) else None,
|
||||
"videos": mine, "playlists": [], "_dir": kdir})
|
||||
|
||||
# Playlister fra cachen; kun hentede videoer, i YouTubes rækkefølge.
|
||||
cache = _laes_playlister()
|
||||
for k in kanaler:
|
||||
for pl in (cache.get(k["id"]) or {}).get("playlists", []):
|
||||
vids = [v for v in pl.get("videos", []) if v in videoer]
|
||||
if not vids:
|
||||
continue
|
||||
thumb = os.path.join(k["_dir"], ".playlists", f"{pl['id']}.jpg")
|
||||
k["playlists"].append({"id": pl["id"], "title": pl.get("title") or pl["id"],
|
||||
"thumb": thumb if os.path.exists(thumb) else None, "videos": vids})
|
||||
k.pop("_dir", None)
|
||||
|
||||
data = {"version": 1, "generated": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"channels": kanaler, "videos": videoer}
|
||||
sti = indeks_sti()
|
||||
tmp = sti + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=1)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, sti)
|
||||
if _ffprobe["aendret"] and _ffprobe["data"] is not None:
|
||||
stier = {v["path"] for v in videoer.values()}
|
||||
_ffprobe["data"] = {k: v for k, v in _ffprobe["data"].items() if k.split("|", 1)[0] in stier}
|
||||
_save(FFPROBE_PATH, _ffprobe["data"])
|
||||
_ffprobe["aendret"] = False
|
||||
return {"channels": len(kanaler), "videos": len(videoer),
|
||||
"playlists": sum(len(k["playlists"]) for k in kanaler)}
|
||||
|
||||
|
||||
async def _ytdlp_json(url: str) -> dict:
|
||||
"""yt-dlp --flat-playlist -J med samme nye forsøg som hentning (403/429)."""
|
||||
err = ""
|
||||
for pause, _ in FORSOEG:
|
||||
if pause:
|
||||
await asyncio.sleep(pause)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
config.YT_DLP_BIN, "--flat-playlist", "-J", "--no-warnings", "--ignore-errors", url,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
out, e = await proc.communicate()
|
||||
try:
|
||||
return json.loads(out.decode("utf-8", "replace") or "{}")
|
||||
except ValueError:
|
||||
err = e.decode("utf-8", "replace")
|
||||
if not midlertidig(err):
|
||||
break
|
||||
raise RuntimeError((err.strip().splitlines() or ["ukendt fejl"])[-1][:200])
|
||||
|
||||
|
||||
def _stoerste_thumb(thumbs: list) -> str:
|
||||
gode = [t for t in thumbs or [] if t.get("url")]
|
||||
gode.sort(key=lambda t: (t.get("width") or 0) * (t.get("height") or 0), reverse=True)
|
||||
return gode[0]["url"] if gode else ""
|
||||
|
||||
|
||||
async def _hent_playliste(pl_url: str) -> dict:
|
||||
d = await _ytdlp_json(pl_url)
|
||||
return {"id": d.get("id") or "", "title": d.get("title") or "",
|
||||
"channel_id": d.get("channel_id") or d.get("uploader_id") or "",
|
||||
"thumb_url": _stoerste_thumb(d.get("thumbnails")),
|
||||
"videos": [e["id"] for e in (d.get("entries") or []) if e and e.get("id")]}
|
||||
|
||||
|
||||
async def opdater_playlister(kun_nye: bool = False) -> dict:
|
||||
"""Hent kanalernes playlister (og deres video-id'er) til cachen.
|
||||
|
||||
Hver kanal opdateres højst hver 7. dag; kun_nye=True tager kun kanaler uden
|
||||
cache. Portalens egne playliste-job og -abonnementer tages altid med og
|
||||
knyttes til playlistens ejer-kanal. Bagefter skrives indekset.
|
||||
"""
|
||||
res = {"kanaler": 0, "playlister": 0, "fejl": []}
|
||||
cache = _laes_playlister()
|
||||
rod = config.YT_DOWNLOAD_DIR
|
||||
kanal_dirs = {}
|
||||
for navn in sorted(os.listdir(rod)):
|
||||
kdir = os.path.join(rod, navn)
|
||||
if navn.startswith(".") or not os.path.isdir(kdir):
|
||||
continue
|
||||
kid = _laes_nfo(os.path.join(kdir, "tvshow.nfo")).get("uniqueid:youtube")
|
||||
if kid:
|
||||
kanal_dirs[kid] = kdir
|
||||
nu = time.time()
|
||||
|
||||
for kid in kanal_dirs:
|
||||
gl = cache.get(kid) or {}
|
||||
if kun_nye and gl:
|
||||
continue
|
||||
if gl and nu - gl.get("updated", 0) < PLAYLISTE_MAKS_ALDER:
|
||||
continue
|
||||
try:
|
||||
d = await _ytdlp_json(f"https://www.youtube.com/channel/{kid}/playlists")
|
||||
pls = []
|
||||
for e in d.get("entries") or []:
|
||||
if not e or not e.get("id"):
|
||||
continue
|
||||
await asyncio.sleep(2) # skån YouTube
|
||||
try:
|
||||
pls.append(await _hent_playliste(
|
||||
e.get("url") or f"https://www.youtube.com/playlist?list={e['id']}"))
|
||||
except Exception as ex: # noqa: BLE001
|
||||
res["fejl"].append(f"{e.get('title') or e['id']}: {ex}")
|
||||
# Bevar egne playlister (fra job/abonnementer) der ikke står på kanalens fane.
|
||||
egne = [p for p in gl.get("playlists", []) if p.get("egen")]
|
||||
ids = {p["id"] for p in pls}
|
||||
cache[kid] = {"updated": nu, "playlists": pls + [p for p in egne if p["id"] not in ids]}
|
||||
res["kanaler"] += 1
|
||||
except Exception as ex: # noqa: BLE001
|
||||
res["fejl"].append(f"{kid}: {ex}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Portalens egne playlister (job med list= og playliste-abonnementer).
|
||||
st = get_state()
|
||||
urls = {j["url"] for j in st["jobs"].values() if "list=" in (j.get("url") or "")}
|
||||
urls |= {s["url"] for s in st["subs"].values() if s.get("kind") == "playlist"}
|
||||
kendte = {p["id"] for c in cache.values() for p in c.get("playlists", [])}
|
||||
for url in sorted(urls):
|
||||
m = re.search(r"list=([A-Za-z0-9_-]+)", url)
|
||||
if m and m.group(1) in kendte:
|
||||
# Allerede i en kanals cache; markér den som egen, så den bevares.
|
||||
for c in cache.values():
|
||||
for p in c.get("playlists", []):
|
||||
if p["id"] == m.group(1):
|
||||
p["egen"] = True
|
||||
continue
|
||||
try:
|
||||
await asyncio.sleep(2)
|
||||
pl = await _hent_playliste(
|
||||
f"https://www.youtube.com/playlist?list={m.group(1)}" if m else url)
|
||||
pl["egen"] = True
|
||||
ejer = pl.get("channel_id") or ""
|
||||
c = cache.setdefault(ejer, {"updated": 0, "playlists": []})
|
||||
c["playlists"] = [p for p in c["playlists"] if p["id"] != pl["id"]] + [pl]
|
||||
except Exception as ex: # noqa: BLE001
|
||||
res["fejl"].append(f"{url}: {ex}")
|
||||
|
||||
# Miniaturer til <Kanal>/.playlists/<id>.jpg (kun når de mangler).
|
||||
for kid, c in cache.items():
|
||||
kdir = kanal_dirs.get(kid)
|
||||
if not kdir:
|
||||
continue
|
||||
for p in c.get("playlists", []):
|
||||
mappe = os.path.join(kdir, ".playlists")
|
||||
sti = os.path.join(mappe, f"{p['id']}.jpg")
|
||||
if p.get("thumb_url") and not os.path.exists(sti):
|
||||
os.makedirs(mappe, exist_ok=True)
|
||||
await _hent_billede(p["thumb_url"], sti)
|
||||
res["playlister"] += 1
|
||||
_save(PLAYLISTER_PATH, cache)
|
||||
skriv_indeks()
|
||||
return res
|
||||
|
||||
|
||||
# Fejl der ofte går over af sig selv: YouTube afviser en enkelt forespørgsel
|
||||
@@ -247,7 +768,8 @@ def _sidecars(path: str) -> list:
|
||||
try:
|
||||
for f in os.listdir(folder):
|
||||
full = os.path.join(folder, f)
|
||||
if full != path and f.startswith(base + ".") and os.path.isfile(full):
|
||||
if full != path and (f.startswith(base + ".") or f.startswith(base + "-thumb.")) \
|
||||
and os.path.isfile(full):
|
||||
out.append(full)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -553,10 +1075,7 @@ async def enforce_cycles() -> dict:
|
||||
files.sort(key=lambda f: (f["upload_date"] or "", f["finished"]), reverse=True)
|
||||
for f in files[int(sub["cycle"]):]:
|
||||
try:
|
||||
size = file_size(f["path"])
|
||||
for side in _sidecars(f["path"]):
|
||||
os.remove(side)
|
||||
os.remove(f["path"])
|
||||
size = _slet_video(f["path"])
|
||||
st["leaving"].pop(f["path"], None)
|
||||
job = st["jobs"].get(f["job"])
|
||||
if job:
|
||||
@@ -584,11 +1103,7 @@ def delete_video(path: str) -> dict:
|
||||
return {"ok": False, "error": "Den video har portalen ikke selv hentet, så den røres ikke"}
|
||||
st = get_state()
|
||||
try:
|
||||
size = file_size(real)
|
||||
for side in _sidecars(real):
|
||||
os.remove(side)
|
||||
if os.path.exists(real):
|
||||
os.remove(real)
|
||||
size = _slet_video(real)
|
||||
except OSError as e:
|
||||
return {"ok": False, "error": f"Kunne ikke slette: {e}"}
|
||||
st["leaving"].pop(real, None)
|
||||
@@ -638,35 +1153,79 @@ def _marked_played(ud: dict) -> bool:
|
||||
return bool((ud or {}).get("Played"))
|
||||
|
||||
|
||||
async def _kanal_brugerdata(c, users: list, merged: dict, navn: str) -> bool:
|
||||
"""Læg set/favorit fra Emby-kanalen (kanal-plugin'et) ind i `merged`, nøglet på
|
||||
filens sti. Kanalens elementer bærer video-id'et i ProviderIds["youtube"]; stien
|
||||
findes via indekset. Samme video ligger i "Alle videoer" og i playlister, og
|
||||
kopierne deler brugerdata, så det er ligegyldigt hvilken vi læser.
|
||||
|
||||
False hvis kanalen eller indekset mangler. Fejler opslaget for en bruger, kastes
|
||||
der: uden alle brugeres favoritter må slettereglen ikke køre."""
|
||||
try:
|
||||
with open(indeks_sti(), encoding="utf-8") as f:
|
||||
stier = {vid: os.path.realpath(v["path"]) for vid, v in json.load(f)["videos"].items()}
|
||||
except (OSError, ValueError, KeyError):
|
||||
return False
|
||||
if not users:
|
||||
return False
|
||||
kanaler = await _emby(c, "/Channels", UserId=users[0])
|
||||
kanal = next((k for k in (kanaler.get("Items") or [])
|
||||
if (k.get("Name") or "").lower() == navn.lower()), None)
|
||||
if not kanal:
|
||||
return False
|
||||
for uid in users:
|
||||
data = await _emby(c, f"/Users/{uid}/Items", ParentId=kanal["Id"],
|
||||
Recursive="true", IncludeItemTypes="Video", Fields="ProviderIds,UserData")
|
||||
for it in data.get("Items", []):
|
||||
pid = it.get("ProviderIds") or {}
|
||||
vid = pid.get("youtube") or pid.get("YouTube") or pid.get("Youtube")
|
||||
sti = stier.get(vid or "")
|
||||
if not sti:
|
||||
continue
|
||||
m = merged.setdefault(sti, {"id": it["Id"], "name": it.get("Name") or os.path.basename(sti),
|
||||
"path": sti, "played": False, "favorite": False})
|
||||
ud = it.get("UserData") or {}
|
||||
if _marked_played(ud):
|
||||
m["played"] = True
|
||||
if ud.get("IsFavorite"):
|
||||
m["favorite"] = True
|
||||
return True
|
||||
|
||||
|
||||
async def emby_yt_items() -> dict:
|
||||
"""Alle videoer i YouTube-biblioteket med set/favorit aggregeret over ALLE brugere."""
|
||||
"""Alle videoer i Emby med set/favorit aggregeret over ALLE brugere.
|
||||
|
||||
Kilden er kanalen fra kanal-plugin'et og/eller et bibliotek med samme navn
|
||||
(standard "YouTube"). Findes ingen af dem, er svaret ok=False, så intet slettes."""
|
||||
settings = get_settings()
|
||||
navn = settings.get("library_name") or "YouTube"
|
||||
out = {"ok": False, "items": [], "error": ""}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=45) as c:
|
||||
lib = await _library_id(c, settings.get("library_name") or "YouTube")
|
||||
if not lib:
|
||||
out["error"] = (f"Fandt ikke Emby-biblioteket "
|
||||
f"'{settings.get('library_name')}'")
|
||||
return out
|
||||
users = [u["Id"] for u in await _emby(c, "/Users")]
|
||||
merged = {}
|
||||
for uid in users:
|
||||
data = await _emby(c, f"/Users/{uid}/Items", ParentId=lib,
|
||||
Recursive="true", Fields="Path,UserData")
|
||||
for it in data.get("Items", []):
|
||||
path = it.get("Path") or ""
|
||||
if not path:
|
||||
continue
|
||||
key = os.path.realpath(path)
|
||||
m = merged.setdefault(key, {
|
||||
"id": it["Id"], "name": it.get("Name") or os.path.basename(path),
|
||||
"path": key, "played": False, "favorite": False})
|
||||
ud = it.get("UserData") or {}
|
||||
if _marked_played(ud):
|
||||
m["played"] = True
|
||||
if ud.get("IsFavorite"):
|
||||
m["favorite"] = True
|
||||
lib = await _library_id(c, navn)
|
||||
if lib:
|
||||
for uid in users:
|
||||
data = await _emby(c, f"/Users/{uid}/Items", ParentId=lib,
|
||||
Recursive="true", Fields="Path,UserData")
|
||||
for it in data.get("Items", []):
|
||||
path = it.get("Path") or ""
|
||||
if not path:
|
||||
continue
|
||||
key = os.path.realpath(path)
|
||||
m = merged.setdefault(key, {
|
||||
"id": it["Id"], "name": it.get("Name") or os.path.basename(path),
|
||||
"path": key, "played": False, "favorite": False})
|
||||
ud = it.get("UserData") or {}
|
||||
if _marked_played(ud):
|
||||
m["played"] = True
|
||||
if ud.get("IsFavorite"):
|
||||
m["favorite"] = True
|
||||
kanal = await _kanal_brugerdata(c, users, merged, navn)
|
||||
if not lib and not kanal:
|
||||
out["error"] = f"Fandt hverken Emby-kanalen eller -biblioteket '{navn}'"
|
||||
return out
|
||||
out["items"] = list(merged.values())
|
||||
out["ok"] = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -675,6 +1234,11 @@ async def emby_yt_items() -> dict:
|
||||
|
||||
|
||||
async def refresh_library():
|
||||
# Kanal-plugin'ets indeks følger altid disken: efter hentning og sletning.
|
||||
try:
|
||||
skriv_indeks()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
await c.post(f"{config.EMBY_URL}/emby/Library/Refresh",
|
||||
@@ -750,11 +1314,7 @@ async def run_cycle(dry_run: bool = False) -> dict:
|
||||
continue
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
for side in _sidecars(path):
|
||||
res["freed"] += os.path.getsize(side)
|
||||
os.remove(side)
|
||||
os.remove(path)
|
||||
res["freed"] += e.get("size") or 0
|
||||
res["freed"] += _slet_video(path)
|
||||
st["leaving"].pop(path, None)
|
||||
# marker jobbet som slettet så owned_paths ikke tæller den igen
|
||||
for j in st["jobs"].values():
|
||||
|
||||
Reference in New Issue
Block a user