From 42740471581f2b7a25a45e271f4a485c1888c75e Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 4 Oct 2026 19:31:11 +0200 Subject: [PATCH] feat(podcast): familiens podcast med personlige feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /podcast viser familiens podcasts (Slægtens Stemmer) med afspiller og en abonnér-knap til Apple Podcasts, Overcast, Pocket Casts og AntennaPod. Podcast- apps kan ikke logge ind med Authentik, så hver lytter får sin egen hemmelige nøgle i adressen (/podcast//.xml); mp3-filer og omslag ligger under samme nøgle. En nøgle kan trækkes tilbage i /admin/podcast uden at de andre mærker det, og admin ser hvem der henter hvad. Episoder kan ligge som kladde: så ses de kun af administratorer, også i deres eget feed, indtil de udgives fra admin-siden. Adgang: gruppen PODCAST_GROUP (standard Familie) og admins. Forsiden får et kort, når der er episoder. Data ligger i DATA/podcast/ og kommer aldrig i git. mutagen læser varigheden. Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG --- config.py | 4 + main.py | 127 ++++++++++++++++- podcast.py | 256 ++++++++++++++++++++++++++++++++++ requirements.txt | 1 + templates/admin_podcast.html | 69 +++++++++ templates/dashboard_body.html | 15 ++ templates/podcast.html | 79 +++++++++++ 7 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 podcast.py create mode 100644 templates/admin_podcast.html create mode 100644 templates/podcast.html diff --git a/config.py b/config.py index d9620ca..6cec268 100644 --- a/config.py +++ b/config.py @@ -13,6 +13,8 @@ API_TOKEN = os.environ["AUTHENTIK_API_TOKEN"] SECRET_KEY = os.environ["SESSION_SECRET"] REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "https://weircon.dk/auth/callback") ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "authentik Admins") +# Hvem der må høre familiens podcasts (/podcast); admins kan altid. +PODCAST_GROUP = os.environ.get("PODCAST_GROUP", "Familie") MATOMO_BASE = os.environ.get("MATOMO_BASE", "https://matomo.weircon.dk").rstrip("/") MATOMO_TOKEN = os.environ.get("MATOMO_TOKEN", "") @@ -106,6 +108,8 @@ ADMIN_MODULES = [ "desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."}, {"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏", "desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."}, + {"key": "podcast", "title": "Podcast", "icon": "🎙️", + "desc": "Slægtens Stemmer: udgiv kladder og se/træk lytternes personlige feed-adresser tilbage."}, {"key": "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": "🔑", diff --git a/main.py b/main.py index cf4b351..17203bb 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ Forretningslogik ligger i moduler: config, cache, store, services, authentik.""" from authlib.integrations.starlette_client import OAuth from fastapi import FastAPI, Form, HTTPException, Request -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware @@ -23,6 +23,7 @@ import mcp_server import mcp_gateway import secretdrop import claudelogin +import podcast # Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True # => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated. @@ -69,6 +70,19 @@ def require_admin(request: Request): return user +def require_familie(request: Request): + """Logget ind og i PODCAST_GROUP (eller admin). Uden login: login og tilbage hertil.""" + user = current_user(request) + if not user and request.method == "GET": + nxt = request.url.path + (f"?{request.url.query}" if request.url.query else "") + raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")}) + if not user: + raise HTTPException(401, "Log ind først") + if not (is_admin(user) or config.PODCAST_GROUP in (user.get("groups") or [])): + raise HTTPException(403, "Kun for familien") + return user + + def _sikker_next(nxt: str | None) -> str: # Kun stier på denne side; "//host" og "/\\host" ville sende videre ud af huset. if nxt and nxt.startswith("/") and not nxt.startswith(("//", "/\\")): @@ -133,7 +147,20 @@ async def dashboard_content(request: Request): "request": request, "error": error, "groups": ak.group_apps(apps), "apps_sorted": by_name, "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}) + "mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL, + "podcasts": _podcast_kort(user)}) + + +def _podcast_kort(user) -> list[dict]: + """Kort til forsiden: én pr. podcast med udgivne episoder (admins ser også kladder).""" + if not (is_admin(user) or config.PODCAST_GROUP in (user.get("groups") or [])): + return [] + ud = [] + for name in podcast.shows(): + eps = podcast.episodes(name, med_kladder=is_admin(user)) + if eps: + ud.append({**podcast.show(name), "antal": len(eps), "nyeste": eps[0]}) + return ud # ---------------- admin ---------------- @@ -370,6 +397,102 @@ async def admin_secrets_delete(request: Request, navn: str): return RedirectResponse(f"/admin/secrets?saved={'Slettet' if ok else 'Fandtes ikke'}", status_code=303) +# ---------------- podcast (Slægtens Stemmer) ---------------- +@app.get("/podcast", response_class=HTMLResponse) +async def podcast_side(request: Request, saved: str = ""): + user = require_familie(request) + admin = is_admin(user) + key = podcast.noegle_til(user["username"], admin) + base = str(request.base_url).rstrip("/") + udsendelser = [] + for name in podcast.shows(): + s = podcast.show(name) + feed = f"{base}/podcast/{key}/{name}.xml" + uden = feed.split("://", 1)[1] + udsendelser.append({**s, "feed": feed, "uden_skema": uden, + "feed_enc": urllib.parse.quote(feed, safe=""), + "episoder": podcast.episodes(name, med_kladder=admin)}) + return templates.TemplateResponse(request, "podcast.html", { + "request": request, "user": user, "is_admin": admin, "key": key, + "udsendelser": udsendelser, "saved": saved}) + + +@app.post("/podcast/ny-noegle") +async def podcast_ny_noegle(request: Request): + user = require_familie(request) + podcast.ny_noegle(user["username"], is_admin(user)) + return RedirectResponse("/podcast?saved=Ny+adresse+lavet.+Abonnér+igen+i+din+app;+den+gamle+virker+ikke+længere.", + status_code=303) + + +@app.get("/podcast/{key}/{name}.xml") +async def podcast_feed(request: Request, key: str, name: str): + v = podcast.slaa_op(key, request.headers.get("user-agent", ""), "feed") + if not v: + raise HTTPException(404) + xml = podcast.feed(name, key, str(request.base_url).rstrip("/"), med_kladder=bool(v.get("admin"))) + if xml is None: + raise HTTPException(404) + return Response(xml, media_type="application/rss+xml; charset=utf-8", + headers={"Cache-Control": "private, max-age=300", "X-Robots-Tag": "noindex, nofollow"}) + + +@app.get("/podcast/{key}/{name}/cover.jpg") +async def podcast_cover(key: str, name: str): + if not podcast.KEY.match(key) or not any(k["key"] == key and not k.get("tilbagekaldt") for k in podcast.alle_noegler()): + raise HTTPException(404) + p = podcast.cover(name) + if not p: + raise HTTPException(404) + return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"}) + + +@app.get("/podcast/{key}/{name}/{slug}.mp3") +async def podcast_mp3(request: Request, key: str, name: str, slug: str): + p = podcast.episode_fil(name, slug) + rng = request.headers.get("range", "") + # Apps henter i bidder; kun en hentning fra filens start tælles som en afspilning. + fra_start = not rng or rng.replace(" ", "").startswith("bytes=0-") + if fra_start: + v = podcast.slaa_op(key, request.headers.get("user-agent", ""), slug) + else: + v = next((k for k in podcast.alle_noegler() if k["key"] == key and not k.get("tilbagekaldt")), None) + if not v or not p: + raise HTTPException(404) + meta = podcast.episode_meta(name, slug) or {} + if meta.get("kladde") and not v.get("admin"): + raise HTTPException(404) + return FileResponse(p, media_type="audio/mpeg", + headers={"Cache-Control": "private, max-age=86400", "X-Robots-Tag": "noindex, nofollow"}) + + +@app.get("/admin/podcast", response_class=HTMLResponse) +async def admin_podcast(request: Request, saved: str = ""): + user = require_admin(request) + return templates.TemplateResponse(request, "admin_podcast.html", { + "request": request, "user": user, "is_admin": True, "saved": saved, + "noegler": podcast.alle_noegler(), + "udsendelser": [{**podcast.show(n), "episoder": podcast.episodes(n, med_kladder=True)} for n in podcast.shows()]}) + + +@app.post("/admin/podcast/noegle/{key}") +async def admin_podcast_noegle(request: Request, key: str): + require_admin(request) + f = await request.form() + ok = podcast.tilbagekald(key, aktiv=f.get("aktiv") == "1") + return RedirectResponse(f"/admin/podcast?saved={'Gemt' if ok else 'Ukendt nøgle'}", status_code=303) + + +@app.post("/admin/podcast/{name}/{slug}/udgiv") +async def admin_podcast_udgiv(request: Request, name: str, slug: str): + require_admin(request) + f = await request.form() + kladde = f.get("kladde") == "1" + ok = podcast.saet_kladde(name, slug, kladde) + besked = ("Lagt tilbage som kladde" if kladde else "Udgivet til familien") if ok else "Ukendt episode" + return RedirectResponse(f"/admin/podcast?saved={besked}", status_code=303) + + @app.get("/admin/claude-login", response_class=HTMLResponse) async def admin_claude_login(request: Request): user = require_admin(request) diff --git a/podcast.py b/podcast.py new file mode 100644 index 0000000..7c3fc03 --- /dev/null +++ b/podcast.py @@ -0,0 +1,256 @@ +"""Private podcasts (Slægtens Stemmer): side, personlige feeds og lydfiler. + +Podcast-apps kan ikke logge ind med Authentik, så hver lytter får en personlig, +hemmelig nøgle i adressen, som Patreon og Apple gør det. Nøglen hører til +Authentik-brugeren og kan trækkes tilbage i /admin/podcast uden at de andre +mærker noget. Både feedet og mp3-filerne ligger under nøglen. + +Data (ikke i git, persondata): + DATA/podcast//show.json titel, beskrivelse, forfatter + DATA/podcast//cover.jpg kvadratisk, 1400-3000 px + DATA/podcast//episodes/.mp3 selve episoden + DATA/podcast//episodes/.json titel, beskrivelse, nr, udgivet, kladde + DATA/podcast_keys.json nøgle -> bruger, statistik + +En episode med "kladde": true ses kun af administratorer (i deres eget feed og +på siden), så den kan høres i en rigtig podcast-app før familien får den. +""" +import email.utils +import functools +import json +import os +import re +import secrets +import time +from pathlib import Path +from xml.sax.saxutils import escape, quoteattr + +import config + +ROOT = config.DATA / "podcast" +KEYS = config.DATA / "podcast_keys.json" +SHOW = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") +SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]{0,120}$") +KEY = re.compile(r"^[A-Za-z0-9_-]{32,64}$") + + +# ---------------- shows og episoder ---------------- + +def shows() -> list[str]: + if not ROOT.exists(): + return [] + return sorted(p.name for p in ROOT.iterdir() if p.is_dir() and SHOW.match(p.name) and (p / "show.json").exists()) + + +def show(name: str) -> dict | None: + if not SHOW.match(name or ""): + return None + try: + s = json.loads((ROOT / name / "show.json").read_text()) + except (OSError, ValueError): + return None + s["slug"] = name + s["har_cover"] = (ROOT / name / "cover.jpg").exists() + return s + + +@functools.lru_cache(maxsize=256) +def _varighed(sti: str, mtime: float) -> int: + try: + from mutagen.mp3 import MP3 + return int(MP3(sti).info.length) + except Exception: + return 0 + + +def episodes(name: str, med_kladder: bool) -> list[dict]: + """Episoder, nyeste først. Kladder kun med med_kladder.""" + d = ROOT / name / "episodes" + if not SHOW.match(name or "") or not d.exists(): + return [] + ud = [] + for meta in d.glob("*.json"): + slug = meta.stem + mp3 = d / f"{slug}.mp3" + if not SLUG.match(slug) or not mp3.exists(): + continue + try: + e = json.loads(meta.read_text()) + except ValueError: + continue + if e.get("kladde") and not med_kladder: + continue + st = mp3.stat() + e.update(slug=slug, bytes=st.st_size, varighed=_varighed(str(mp3), st.st_mtime)) + ud.append(e) + return sorted(ud, key=lambda e: (e.get("nr", 0), e.get("udgivet", "")), reverse=True) + + +def episode_fil(name: str, slug: str) -> Path | None: + if not SHOW.match(name or "") or not SLUG.match(slug or ""): + return None + p = ROOT / name / "episodes" / f"{slug}.mp3" + return p if p.exists() else None + + +def episode_meta(name: str, slug: str) -> dict | None: + if not episode_fil(name, slug): + return None + try: + return json.loads((ROOT / name / "episodes" / f"{slug}.json").read_text()) + except (OSError, ValueError): + return None + + +def saet_kladde(name: str, slug: str, kladde: bool) -> bool: + e = episode_meta(name, slug) + if e is None: + return False + e["kladde"] = kladde + if not kladde and not e.get("udgivet"): + e["udgivet"] = time.strftime("%Y-%m-%dT%H:%M:%S%z") + _skriv(ROOT / name / "episodes" / f"{slug}.json", e) + return True + + +def cover(name: str) -> Path | None: + p = ROOT / name / "cover.jpg" + return p if SHOW.match(name or "") and p.exists() else None + + +# ---------------- nøgler ---------------- + +def _skriv(p: Path, data) -> None: + tmp = p.with_name(f".{p.name}.tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump(data, f, ensure_ascii=False, indent=1) + os.replace(tmp, p) + + +def _noegler() -> dict: + try: + return json.loads(KEYS.read_text()) + except (OSError, ValueError): + return {} + + +def noegle_til(user: str, admin: bool) -> str: + """Brugerens aktive nøgle; laves første gang. Admin-status opdateres her, + fordi den afgør om feedet viser kladder.""" + k = _noegler() + for key, v in k.items(): + if v["user"] == user and not v.get("tilbagekaldt"): + if v.get("admin") != admin: + v["admin"] = admin + _skriv(KEYS, k) + return key + key = secrets.token_urlsafe(32) + k[key] = {"user": user, "admin": admin, "oprettet": int(time.time()), "hentninger": 0} + _skriv(KEYS, k) + return key + + +def ny_noegle(user: str, admin: bool) -> str: + k = _noegler() + for v in k.values(): + if v["user"] == user and not v.get("tilbagekaldt"): + v["tilbagekaldt"] = int(time.time()) + _skriv(KEYS, k) + return noegle_til(user, admin) + + +def slaa_op(key: str, ua: str = "", hvad: str = "") -> dict | None: + """Gyldig nøgle -> dens post (og registrér hentningen), ellers None.""" + if not KEY.match(key or ""): + return None + k = _noegler() + v = k.get(key) + if not v or v.get("tilbagekaldt"): + return None + v["hentninger"] = v.get("hentninger", 0) + 1 + v["sidst"] = int(time.time()) + v["sidst_ua"] = (ua or "")[:160] + if hvad: + v.setdefault("hentet", {}) + v["hentet"][hvad] = v["hentet"].get(hvad, 0) + 1 + _skriv(KEYS, k) + return v + + +def alle_noegler() -> list[dict]: + return sorted(({"key": key, **v} for key, v in _noegler().items()), + key=lambda v: (bool(v.get("tilbagekaldt")), -(v.get("sidst") or 0))) + + +def tilbagekald(key: str, aktiv: bool) -> bool: + k = _noegler() + if key not in k: + return False + if aktiv: + # Genaktivering: brugeren må kun have én aktiv nøgle. + for other, v in k.items(): + if other != key and v["user"] == k[key]["user"] and not v.get("tilbagekaldt"): + v["tilbagekaldt"] = int(time.time()) + k[key].pop("tilbagekaldt", None) + else: + k[key]["tilbagekaldt"] = int(time.time()) + _skriv(KEYS, k) + return True + + +# ---------------- feed ---------------- + +def _rfc822(iso: str) -> str: + try: + t = time.mktime(time.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S")) + except (ValueError, TypeError): + t = time.time() + return email.utils.formatdate(t, localtime=True) + + +def _hms(sek: int) -> str: + return f"{sek // 3600:d}:{sek // 60 % 60:02d}:{sek % 60:02d}" + + +def feed(name: str, key: str, base: str, med_kladder: bool) -> str | None: + s = show(name) + if not s: + return None + pre = f"{base}/podcast/{key}/{name}" + items = [] + for e in episodes(name, med_kladder): + titel = ("[Kladde] " if e.get("kladde") else "") + e.get("titel", e["slug"]) + items.append(f""" + {escape(titel)} + {escape(e.get("beskrivelse", ""))} + {escape(name)}/{escape(e["slug"])} + {_rfc822(e.get("udgivet", ""))} + + {_hms(e["varighed"])} + {int(e.get("nr", 0))} + full + """) + cover_url = f"{pre}/cover.jpg" + return f""" + + + {escape(s.get("titel", name))} + {escape(base)}/podcast + + {escape(s.get("sprog", "da"))} + {escape(s.get("beskrivelse", ""))} + {escape(s.get("beskrivelse", ""))} + {escape(s.get("forfatter", ""))} + + {escape(cover_url)}{escape(s.get("titel", name))}{escape(base)}/podcast + + false + serial + Yes + yes +{chr(10).join(items)} + + +""" diff --git a/requirements.txt b/requirements.txt index 565b2a2..be782fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ httpx itsdangerous python-multipart maxminddb +mutagen diff --git a/templates/admin_podcast.html b/templates/admin_podcast.html new file mode 100644 index 0000000..8d221e4 --- /dev/null +++ b/templates/admin_podcast.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}Admin — podcast{% endblock %} +{% block body %} +

← Admin

+

Podcast

+

Kladder ses kun af administratorer, også i deres podcast-app. Udgiv en episode, når den er klar til familien. Lytternes adresser er personlige; trækker du en tilbage, holder den op med at virke, og de andre mærker intet. Personen får en ny adresse næste gang de åbner siden, så længe de er i gruppen Familie; skal nogen helt ud, så fjern dem fra gruppen i Authentik. Siden for familien er /podcast.

+{% if saved %}
{{ saved }}
{% endif %} + +{% for u in udsendelser %} +

{{ u.titel }}

+ + + + {% for e in u.episoder %} + + + + + + + + {% else %} + + {% endfor %} + +
NrTitelVarighedStatus
{{ e.nr }}{{ e.titel }}
{{ e.slug }}.mp3 · {{ (e.bytes / 1048576) | round(1) }} MB
{{ (e.varighed // 60) }}:{{ '%02d' % (e.varighed % 60) }}{% if e.kladde %}Kladde{% else %}Udgivet {{ (e.udgivet or '')[:10] }}{% endif %} +
+ {% if e.kladde %} + + {% else %} + + {% endif %} +
+
Ingen episoder. Udgiv med publish.py i skillen slaegtens-stemmer.
+{% endfor %} + +

Lytternes adresser

+ + + + {% for k in noegler %} + + + + + + + + + {% else %} + + {% endfor %} + +
BrugerOprettetSidst hentetHentningerApp
{{ k.user }}{% if k.admin %} (admin){% endif %}{{ k.hentninger or 0 }}{{ k.sidst_ua or '' }} +
+ {% if k.tilbagekaldt %} + + {% else %} + + {% endif %} +
+
Ingen endnu. En lytter får sin adresse første gang de åbner /podcast.
+ +{% endblock %} diff --git a/templates/dashboard_body.html b/templates/dashboard_body.html index ef01110..2ddea99 100644 --- a/templates/dashboard_body.html +++ b/templates/dashboard_body.html @@ -25,6 +25,21 @@

★ Favoritter

{% for a in fav_apps %}{{ card(a) }}{% endfor %}
+{% if podcasts %} +
+

🎙️ Familiens podcast

+
+ {% for p in podcasts %} +
+ +
🎙️{{ p.titel }}
+
{{ p.antal }} episode{{ 'r' if p.antal != 1 }} · nyeste: {{ p.nyeste.titel }}
+
Lyt →
+
+ {% endfor %} +
+
+{% endif %} {% if mc_servers %}

⛏️ Minecraft-servere

diff --git a/templates/podcast.html b/templates/podcast.html new file mode 100644 index 0000000..0d06b1a --- /dev/null +++ b/templates/podcast.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}Podcast{% endblock %} +{% block body %} +{% if saved %}
{{ saved }}
{% endif %} +{% if not udsendelser %} +

Podcast

+

Der er ingen podcasts endnu.

+{% endif %} +{% for u in udsendelser %} +
+
+ {% if u.har_cover %} + + {% endif %} +
+

{{ u.titel }}

+

{{ u.beskrivelse }}

+
+
+ +
+

Abonnér i din podcast-app

+

Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.

+ +
+ Anden app? Kopiér adressen +
+ + +
+

Spotify kan ikke abonnere på private podcasts. På Android virker AntennaPod og Pocket Casts.

+
+
+ +

Episoder

+ {% for e in u.episoder %} +
+
+

{% if e.nr %}{{ e.nr }}. {% endif %}{{ e.titel }}

+ + {% if e.kladde %}Kladde, kun du kan se den · {% endif %}{{ (e.varighed // 60) }} min + +
+ {% if e.beskrivelse %}

{{ e.beskrivelse }}

{% endif %} + +
+ {% else %} +

Ingen episoder endnu.

+ {% endfor %} +
+{% endfor %} + +{% if udsendelser %} +
+ + Hvis du har delt din adresse ved et uheld. +
+{% endif %} + +{% endblock %}