Compare commits

..
5 Commits
Author SHA1 Message Date
root 4005860364 feat(erindringer): familien fortæller minder og dagbog til en AI-interviewer
CD / deploy (push) Successful in 15s
CI / check (push) Canceled after 17s
Nyt modul erindringer.py: emner (projekter) med baggrund og startspørgsmål,
personer (indloggede + gæstelinks), Claude stiller opfølgende spørgsmål,
ElevenLabs læser op (cachet) og transskriberer, ffmpeg laver mp3, admin med
klip, favoritter og zip-eksport. Forsidekort og admin-modul. Røgtest i tests/.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-06 22:04:20 +02:00
root 9df3c31e43 Emby-kanalen som førsteklasses kilde: set/favorit virker uden YouTube-bibliotek
CD / deploy (push) Successful in 16s
CI / check (push) Successful in 24s
Biblioteket er nu valgfrit. Findes hverken kanal eller bibliotek, er svaret
ok=False, og fejler opslaget for en bruger, kastes der, så cyklus-sletningen
aldrig kører uden alle brugeres favoritter.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 21:37:07 +02:00
root 6ee54c1248 Indeks: ffprobe-strømme og bitrate pr. video, ryd .playlists med tomme kanaler
Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 18:24:20 +02:00
agw 77f33f7c9e feat(yt): indeks over kanaler, playlister og videoer til Emby-kanal-plugin'et
Emby kan kun vise serie, sæson og afsnit, men på YouTube har en kanal både
videoer og playlister, og en video kan ligge i flere playlister. Kanal-plugin'et
(agw/emby-yt-kanaler) viser Kanal: Alle videoer og Playlister, og det læser
dette indeks, som portalen skriver.

- skriv_indeks(): bygger DOWNLOAD_DIR/.weircon-index.json (version 1) ud fra
  NFO'er, billeder og undertekster på disken og playliste-cachen. Kun hentede
  videoer kommer med i playlisterne, i YouTubes rækkefølge. Skrives atomisk og
  efter hver hentning og sletning (via refresh_library).
- opdater_playlister(): henter kanalernes playlister med yt-dlp (højst
  ugentligt pr. kanal) plus portalens egne playliste-job og -abonnementer, og
  playlistens miniature til <Kanal>/.playlists/<id>.jpg. Køres natligt.
- emby_yt_items(): lægger set/favorit fra Emby-kanalen "YouTube" oven i
  biblioteket for samme fil, så slettereglen og fredningen virker, uanset hvor
  videoen blev set.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 17:56:37 +02:00
agw 776c314b0f feat(yt): YouTube i Emby som serier: kanal = serie, upload-dato = afsnit
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 22s
Videoerne lå fladt i et Emby-hjemmevideobibliotek: ingen kanaler, ingen
billeder, og datoen var hentetidspunktet, så sortering efter premieredato gav
den rækkefølge de blev hentet i.

Nu henter yt-dlp til en skjult .staging-mappe med info-JSON og miniature, og
organiser() lægger videoen i <Kanal>/Season ÅÅÅÅ/ som
"<Kanal> - sÅÅÅÅeMMDDnn - <Titel> [<id>]" med NFO (titel, beskrivelse,
upload-dato som premiere), -thumb.jpg og undertekster. Kanalen får tvshow.nfo,
plakat (profilbillede) og baggrund (banner). Afsnitsnummeret bygger på
upload-datoen, så Emby sorterer som på YouTube.

Sletning (manuel, cyklus og karens) går nu gennem én funktion, der også tager
NFO og billede og rydder tomme sæson- og kanalmapper. Playlister springer
private/slettede videoer over (--ignore-errors), og et job med hentede videoer
regnes som gennemført, selvom yt-dlp sluttede med en fejl.

yt_migrer.py flytter de eksisterende videoer ind i strukturen og genopretter
Emby-biblioteket som "Serier" med kun lokale data; Emby-status (set, favorit,
position) gemmes før og sættes på igen bagefter.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 17:13:34 +02:00
14 changed files with 2451 additions and 55 deletions
+22
View File
@@ -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
+2
View File
@@ -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": "🔑",
+6
View File
@@ -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
View File
@@ -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()
+250 -1
View File
@@ -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)
+83
View File
@@ -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 %}
+54
View File
@@ -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 %}
+1 -1
View File
@@ -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">
+9 -1
View File
@@ -12,7 +12,7 @@
</div>
{% endmacro %}
{% if error %}<div class="err">{{ error }}</div>{% endif %}
{% if podcasts %}
{% if podcasts or erindringer %}
<section class="appgroup" id="podsec">
<h2>🎙️ Familiens podcast</h2>
<div class="grid">
@@ -24,6 +24,14 @@
<div class="go">Lyt →</div>
</div>
{% endfor %}
{% if erindringer %}
<div class="card" data-search="erindringer dagbog minder interview">
<a class="card-link" href="/erindringer" aria-label="Erindringer"></a>
<div class="head"><span class="ico">🎤</span><span class="name">Erindringer</span></div>
<div class="sub">Fortæl dine minder eller før dagbog. En interviewer stiller spørgsmålene.</div>
<div class="go">Fortæl →</div>
</div>
{% endif %}
</div>
</section>
{% endif %}
+606
View File
@@ -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 %}
+56
View File
@@ -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")
+14
View File
@@ -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
View File
@@ -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)
+612 -52
View File
@@ -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():