feat(podcast): familiens podcast med personlige feeds
/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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user