Files
weircon-front/podcast.py
T
agw 4274047158
CD / deploy (push) Successful in 16s
CI / check (push) Successful in 22s
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
2026-10-04 19:31:11 +02:00

257 lines
8.6 KiB
Python

"""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>/show.json titel, beskrivelse, forfatter
DATA/podcast/<show>/cover.jpg kvadratisk, 1400-3000 px
DATA/podcast/<show>/episodes/<slug>.mp3 selve episoden
DATA/podcast/<show>/episodes/<slug>.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""" <item>
<title>{escape(titel)}</title>
<description>{escape(e.get("beskrivelse", ""))}</description>
<guid isPermaLink="false">{escape(name)}/{escape(e["slug"])}</guid>
<pubDate>{_rfc822(e.get("udgivet", ""))}</pubDate>
<enclosure url={quoteattr(f"{pre}/{e['slug']}.mp3")} length="{e['bytes']}" type="audio/mpeg"/>
<itunes:duration>{_hms(e["varighed"])}</itunes:duration>
<itunes:episode>{int(e.get("nr", 0))}</itunes:episode>
<itunes:episodeType>full</itunes:episodeType>
</item>""")
cover_url = f"{pre}/cover.jpg"
return f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{escape(s.get("titel", name))}</title>
<link>{escape(base)}/podcast</link>
<atom:link href={quoteattr(f"{pre}.xml")} rel="self" type="application/rss+xml"/>
<language>{escape(s.get("sprog", "da"))}</language>
<description>{escape(s.get("beskrivelse", ""))}</description>
<itunes:summary>{escape(s.get("beskrivelse", ""))}</itunes:summary>
<itunes:author>{escape(s.get("forfatter", ""))}</itunes:author>
<itunes:image href={quoteattr(cover_url)}/>
<image><url>{escape(cover_url)}</url><title>{escape(s.get("titel", name))}</title><link>{escape(base)}/podcast</link></image>
<itunes:category text="History"/>
<itunes:explicit>false</itunes:explicit>
<itunes:type>serial</itunes:type>
<itunes:block>Yes</itunes:block>
<podcast:locked>yes</podcast:locked>
{chr(10).join(items)}
</channel>
</rss>
"""