feat(podcast): familiens podcast med personlige feeds
CD / deploy (push) Successful in 16s
CI / check (push) Successful in 22s

/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/<nøgle>/<show>.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
This commit is contained in:
agw
2026-10-04 19:31:11 +02:00
parent 58471b1a4d
commit 4274047158
7 changed files with 549 additions and 2 deletions
+125 -2
View File
@@ -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)