Compare commits

...
4 Commits
Author SHA1 Message Date
agw 902ab6244e fix(podcast): Messenger på iPhone og vandret scroll
CD / deploy (push) Successful in 16s
CI / check (push) Successful in 23s
Messenger på iPhone blokerer også x-safari-https://, så "Åbn i Safari" gjorde
ingenting. I stedet vises to veje, der virker: menuen ⋯ → "Åbn i ekstern
browser", eller "Kopiér adressen" og tilføj feedet i Apple Podcasts
(Bibliotek → ⋯ → Følg en serie via URL). Kopieringen falder tilbage til
execCommand/prompt, hvis clipboard-API'et er spærret. Android beholder
intent://-knappen.

Siden kunne skubbes til siden på iPhone. Afspiller og felter må nu aldrig
blive bredere end kortet, felter har 16 px skrift (ellers zoomer iOS ind),
lange ord brydes, og html/body skjuler vandret overløb.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 08:03:19 +02:00
agw 6d32fd2b71 fix(podcast): lille omslag i link-forhåndsvisningen
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 23s
Med det store omslag (3000 px) viste Messenger et kvadratisk billede i hele
beskedens bredde. Forhåndsvisningen bruger nu cover-lille.jpg (300 px), når
den findes, så den vises som en miniature ved teksten.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 07:54:54 +02:00
agw 176859b15a fix(podcast): fælles link virker i Messenger
CD / deploy (push) Successful in 16s
CI / check (push) Successful in 23s
Messengers indbyggede browser kan ikke åbne podcast-apps (podcast://,
overcast:// osv.), så abonnér-knapperne gjorde ingenting. Siden genkender nu
Facebook-, Messenger- og Instagram-browseren og viser en knap, der åbner
siden i Safari (x-safari-https) eller på Android i den almindelige browser
(intent://), plus en vejledning til menuen. Afspilleren virker stadig direkte.

Link-forhåndsvisningen (facebookexternalhit m.fl.) får en minimal side med
titel, en neutral tekst og omslaget som og:-tags, aldrig episodelisten, og
den tælles ikke som en hentning. Den fulde side har de samme og:-tags.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-06 07:52:11 +02:00
agw 70bc700a11 feat(podcast): også gruppen Gramps må lytte
CD / deploy (push) Successful in 16s
CI / check (push) Successful in 22s
Slægten uden for den nære familie inviteres i gruppen Gramps, der kun giver
Gramps, podcasten og portalen. PODCAST_GROUP er nu kommasepareret
(standard "Familie,Gramps"). Podcast-kortet på forsiden vises også, selvom
brugeren ingen andre apps har.

Invitationssiden forklarer de kendte grupper med en linje, så det er tydeligt
hvad Familie, Gramps og Crawlkit-team giver.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-10-04 22:29:19 +02:00
7 changed files with 140 additions and 27 deletions
+3 -2
View File
@@ -13,8 +13,9 @@ API_TOKEN = os.environ["AUTHENTIK_API_TOKEN"]
SECRET_KEY = os.environ["SESSION_SECRET"] SECRET_KEY = os.environ["SESSION_SECRET"]
REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "https://weircon.dk/auth/callback") REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "https://weircon.dk/auth/callback")
ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "authentik Admins") ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "authentik Admins")
# Hvem der må høre familiens podcasts (/podcast); admins kan altid. # Hvem der må høre familiens podcasts (/podcast); admins kan altid. Kommasepareret:
PODCAST_GROUP = os.environ.get("PODCAST_GROUP", "Familie") # Familie er den nære familie, Gramps er slægten der kun har Gramps + podcast + portal.
PODCAST_GROUPS = {g.strip() for g in os.environ.get("PODCAST_GROUP", "Familie,Gramps").split(",") if g.strip()}
MATOMO_BASE = os.environ.get("MATOMO_BASE", "https://matomo.weircon.dk").rstrip("/") MATOMO_BASE = os.environ.get("MATOMO_BASE", "https://matomo.weircon.dk").rstrip("/")
MATOMO_TOKEN = os.environ.get("MATOMO_TOKEN", "") MATOMO_TOKEN = os.environ.get("MATOMO_TOKEN", "")
+51 -8
View File
@@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
import re
import time import time
import urllib.parse import urllib.parse
@@ -71,14 +72,14 @@ def require_admin(request: Request):
def require_familie(request: Request): def require_familie(request: Request):
"""Logget ind og i PODCAST_GROUP (eller admin). Uden login: login og tilbage hertil.""" """Logget ind og i en af PODCAST_GROUPS (eller admin). Uden login: login og tilbage hertil."""
user = current_user(request) user = current_user(request)
if not user and request.method == "GET": if not user and request.method == "GET":
nxt = request.url.path + (f"?{request.url.query}" if request.url.query else "") 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="")}) raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
if not user: if not user:
raise HTTPException(401, "Log ind først") raise HTTPException(401, "Log ind først")
if not (is_admin(user) or config.PODCAST_GROUP in (user.get("groups") or [])): if not (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])):
raise HTTPException(403, "Kun for familien") raise HTTPException(403, "Kun for familien")
return user return user
@@ -153,7 +154,7 @@ async def dashboard_content(request: Request):
def _podcast_kort(user) -> list[dict]: def _podcast_kort(user) -> list[dict]:
"""Kort til forsiden: én pr. podcast med udgivne episoder (admins ser også kladder).""" """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 [])): if not (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])):
return [] return []
ud = [] ud = []
for name in podcast.shows(): for name in podcast.shows():
@@ -441,16 +442,50 @@ async def podcast_ny_noegle(request: Request):
# Det fælles familielink: ingen login, ingen kladder, noindex. Står før de # Det fælles familielink: ingen login, ingen kladder, noindex. Står før de
# personlige ruter, så "f" aldrig forveksles med en nøgle. # personlige ruter, så "f" aldrig forveksles med en nøgle.
# Messenger/Facebooks link-forhåndsvisning og deres indbyggede browsere.
_FORHAANDSVISNING = re.compile(r"facebookexternalhit|meta-external(agent|fetcher)|Facebot|WhatsApp", re.I)
_INDBYGGET = re.compile(r"FBAN|FBAV|FB_IAB|FBIOS|Messenger|Instagram|LinkedInApp", re.I)
@app.get("/podcast/f/{key}/", response_class=HTMLResponse) @app.get("/podcast/f/{key}/", response_class=HTMLResponse)
async def podcast_faelles_side(request: Request, key: str): async def podcast_faelles_side(request: Request, key: str):
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "side"): ua = request.headers.get("user-agent", "")
forhaandsvisning = bool(_FORHAANDSVISNING.search(ua))
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key, "" if forhaandsvisning else "side"):
_ikke_fundet(request) _ikke_fundet(request)
rod = f"/podcast/f/{key}" rod = f"/podcast/f/{key}"
base = str(request.base_url).rstrip("/")
udsendelser = _podcast_kontekst(request, rod, med_kladder=False)
s = udsendelser[0] if udsendelser else {"titel": "Podcast", "slug": "", "har_cover": False}
# Et lille omslag (300 px), så Messenger viser en miniature ved teksten i
# stedet for et billede i hele beskedens bredde.
lille = bool(s.get("slug")) and podcast.cover(s["slug"], lille=True) is not None
og = {"titel": s["titel"], "url": f"{base}{rod}/",
"beskrivelse": "Familiens podcast. Lyt i browseren, eller abonnér i din podcast-app.",
"billede": (f"{base}{rod}/{s['slug']}/cover-lille.jpg" if lille else
f"{base}{rod}/{s['slug']}/cover.jpg" if s.get("har_cover") else ""),
"str": 300 if lille else 3000}
headers = {"X-Robots-Tag": "noindex, nofollow, noarchive", "Referrer-Policy": "no-referrer",
"Cache-Control": "private, no-store"}
if forhaandsvisning:
# Forhåndsvisningen får kun titel, en neutral tekst og omslaget, aldrig
# episodelisten (den nævner familiemedlemmer ved navn).
return templates.TemplateResponse(request, "podcast_og.html", {"request": request, "og": og}, headers=headers)
indbygget = None
if _INDBYGGET.search(ua):
# Messenger på iPhone blokerer også x-safari-https://, så dér er der ingen
# knap ud af appen: vejledning til menuen + kopiér feedet til Apple Podcasts.
# Android-udgaven af Facebooks browser følger intent://-links.
sti = f"{request.url.hostname}{rod}/"
if re.search(r"iPhone|iPad|iOS", ua):
indbygget = {"platform": "ios"}
elif "Android" in ua:
indbygget = {"platform": "android", "href": f"intent://{sti}#Intent;scheme=https;end"}
else:
indbygget = {"platform": "andet"}
return templates.TemplateResponse(request, "podcast.html", { return templates.TemplateResponse(request, "podcast.html", {
"request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "request": request, "user": None, "is_admin": False, "rod": rod, "delt": True, "og": og,
"udsendelser": _podcast_kontekst(request, rod, med_kladder=False), "saved": ""}, "indbygget": indbygget, "udsendelser": udsendelser, "saved": ""}, headers=headers)
headers={"X-Robots-Tag": "noindex, nofollow, noarchive", "Referrer-Policy": "no-referrer",
"Cache-Control": "private, no-store"})
@app.get("/podcast/f/{key}/{name}.xml") @app.get("/podcast/f/{key}/{name}.xml")
@@ -464,6 +499,14 @@ async def podcast_faelles_feed(request: Request, key: str, name: str):
headers={"Cache-Control": "private, max-age=300", "X-Robots-Tag": "noindex, nofollow"}) headers={"Cache-Control": "private, max-age=300", "X-Robots-Tag": "noindex, nofollow"})
@app.get("/podcast/f/{key}/{name}/cover-lille.jpg")
async def podcast_faelles_cover_lille(request: Request, key: str, name: str):
p = podcast.cover(name, lille=True)
if podcast.blokeret(_klient_ip(request)) or not podcast.faelles_slaa_op(key) or not p:
_ikke_fundet(request)
return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"})
@app.get("/podcast/f/{key}/{name}/cover.jpg") @app.get("/podcast/f/{key}/{name}/cover.jpg")
async def podcast_faelles_cover(request: Request, key: str, name: str): async def podcast_faelles_cover(request: Request, key: str, name: str):
p = podcast.cover(name) p = podcast.cover(name)
+3 -2
View File
@@ -115,8 +115,9 @@ def saet_kladde(name: str, slug: str, kladde: bool) -> bool:
return True return True
def cover(name: str) -> Path | None: def cover(name: str, lille: bool = False) -> Path | None:
p = ROOT / name / "cover.jpg" """Omslaget; lille = 300 px-udgaven til link-forhåndsvisninger (cover-lille.jpg)."""
p = ROOT / name / ("cover-lille.jpg" if lille else "cover.jpg")
return p if SHOW.match(name or "") and p.exists() else None return p if SHOW.match(name or "") and p.exists() else None
+6 -1
View File
@@ -24,13 +24,18 @@
<p style="margin:.9rem 0 .4rem"><strong>Grupper de skal med i</strong></p> <p style="margin:.9rem 0 .4rem"><strong>Grupper de skal med i</strong></p>
<div class="wt-grid wt-grid--tight"> <div class="wt-grid wt-grid--tight">
{% set gruppe_hjaelp = {
"Familie": "den nære familie: Sønderlund, Mealie, Emby, Gramps (redigér), podcast m.m.",
"Gramps": "kun Gramps (læse), familiens podcast og portalen",
"Crawlkit-team": "kun crawlkit",
} %}
{% for g in grupper %} {% for g in grupper %}
<label class="wt-select-card"> <label class="wt-select-card">
<input type="checkbox" name="grupper" value="{{ g.pk }}" {% if g.name == "Familie" %}checked{% endif %}> <input type="checkbox" name="grupper" value="{{ g.pk }}" {% if g.name == "Familie" %}checked{% endif %}>
<span class="wt-select-card-ini">{{ g.name[0]|upper }}</span> <span class="wt-select-card-ini">{{ g.name[0]|upper }}</span>
<span class="wt-select-card-body"> <span class="wt-select-card-body">
<span class="wt-select-card-name">{{ g.name }}</span> <span class="wt-select-card-name">{{ g.name }}</span>
<span class="wt-select-card-sub">{{ g.members }} medlemmer{% if g.name == "authentik Admins" %} · ⚠️ fuld admin-adgang{% endif %}</span> <span class="wt-select-card-sub">{{ g.members }} medlemmer{% if g.name == "authentik Admins" %} · ⚠️ fuld admin-adgang{% elif g.name in gruppe_hjaelp %} · {{ gruppe_hjaelp[g.name] }}{% endif %}</span>
</span> </span>
<span class="wt-select-card-chk">✓</span> <span class="wt-select-card-chk">✓</span>
</label> </label>
+13 -13
View File
@@ -12,19 +12,6 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% if error %}<div class="err">{{ error }}</div>{% endif %} {% if error %}<div class="err">{{ error }}</div>{% endif %}
{% if groups %}
<div class="toolbar">
<div class="filter"><span class="mag">⌕</span>
<input id="appfilter" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester"></div>
<div class="viewtoggle" role="group" aria-label="Visning">
<button type="button" data-view="grouped" class="{{ 'on' if view != 'alpha' }}">Grupperet</button>
<button type="button" data-view="alpha" class="{{ 'on' if view == 'alpha' }}">Alfabetisk</button>
</div>
</div>
<section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}>
<h2>★ Favoritter</h2>
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
</section>
{% if podcasts %} {% if podcasts %}
<section class="appgroup" id="podsec"> <section class="appgroup" id="podsec">
<h2>🎙️ Familiens podcast</h2> <h2>🎙️ Familiens podcast</h2>
@@ -40,6 +27,19 @@
</div> </div>
</section> </section>
{% endif %} {% endif %}
{% if groups %}
<div class="toolbar">
<div class="filter"><span class="mag">⌕</span>
<input id="appfilter" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester"></div>
<div class="viewtoggle" role="group" aria-label="Visning">
<button type="button" data-view="grouped" class="{{ 'on' if view != 'alpha' }}">Grupperet</button>
<button type="button" data-view="alpha" class="{{ 'on' if view == 'alpha' }}">Alfabetisk</button>
</div>
</div>
<section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}>
<h2>★ Favoritter</h2>
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
</section>
{% if mc_servers %} {% if mc_servers %}
<section class="appgroup" id="mcsec"> <section class="appgroup" id="mcsec">
<h2>⛏️ Minecraft-servere</h2> <h2>⛏️ Minecraft-servere</h2>
+45 -1
View File
@@ -1,8 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% if delt and udsendelser %}{{ udsendelser[0].titel }}{% else %}Podcast{% endif %}{% endblock %} {% block title %}{% if delt and udsendelser %}{{ udsendelser[0].titel }}{% else %}Podcast{% endif %}{% endblock %}
{% block head %}{% if delt %}<meta name="robots" content="noindex, nofollow, noarchive"> {% block head %}{% if delt %}<meta name="robots" content="noindex, nofollow, noarchive">
<meta name="referrer" content="no-referrer">{% endif %}{% endblock %} <meta name="referrer" content="no-referrer">
{% if og %}<meta property="og:type" content="website">
<meta property="og:title" content="{{ og.titel }}">
<meta property="og:description" content="{{ og.beskrivelse }}">
<meta property="og:url" content="{{ og.url }}">
{% if og.billede %}<meta property="og:image" content="{{ og.billede }}">{% endif %}{% endif %}{% endif %}{% endblock %}
{% block body %} {% block body %}
<style>
/* Ingen vandret scroll på iPhone: afspiller og felter må aldrig blive bredere end
kortet, og felter skal have mindst 16 px skrift, ellers zoomer iOS ind. */
html, body { overflow-x: hidden; }
.pod-side { max-width: 100%; overflow-wrap: anywhere; }
.pod-side img, .pod-side audio, .pod-side input, .pod-side .wt-card { max-width: 100%; box-sizing: border-box; }
.pod-side audio { display: block; width: 100%; min-width: 0; }
.pod-side input { font-size: 16px !important; min-width: 0; }
.pod-side .wt-btn { white-space: normal; }
</style>
<div class="pod-side">
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %} {% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
{% if not udsendelser %} {% if not udsendelser %}
<h1>Podcast</h1> <h1>Podcast</h1>
@@ -23,6 +39,19 @@
<div class="wt-card" style="margin:1.4rem 0;padding:1rem 1.2rem"> <div class="wt-card" style="margin:1.4rem 0;padding:1rem 1.2rem">
<h2 style="font-size:1.05rem;margin:0 0 .5rem">Abonnér i din podcast-app</h2> <h2 style="font-size:1.05rem;margin:0 0 .5rem">Abonnér i din podcast-app</h2>
{% if indbygget %}
<div class="wt-flash" style="margin:.4rem 0 .9rem">
<p style="margin:0 0 .5rem">Du har åbnet siden inde i Messenger, og den kan ikke åbne podcast-apps. Du kan stadig lytte herunder. Vil du abonnere:</p>
{% if indbygget.platform == "android" %}
<p style="margin:.4rem 0"><a class="wt-btn wt-btn--primary" href="{{ indbygget.href }}">Åbn i Chrome</a></p>
<p style="margin:.4rem 0">Virker det ikke: tryk på ⋮ øverst til højre og vælg "Åbn i ekstern browser".</p>
{% else %}
<p style="margin:.4rem 0"><strong>1.</strong> Tryk på ⋯ øverst til højre og vælg "Åbn i ekstern browser" (eller "Åbn i Safari"). Så virker knapperne herunder.</p>
<p style="margin:.6rem 0 .3rem"><strong>2.</strong> Eller kopiér adressen og tilføj den selv i Apple Podcasts: <em>Bibliotek → ⋯ øverst → Følg en serie via URL</em>.</p>
<button class="wt-btn wt-btn--primary" type="button" data-kopier-tekst="{{ udsendelser[0].feed if udsendelser else '' }}">Kopiér adressen</button>
{% endif %}
</div>
{% endif %}
<p class="wt-muted" style="margin:.2rem 0 .8rem">{% if delt %}Tryk på din app, så kommer nye episoder af sig selv. Linket er familiens fælles, så del det kun inden for familien.{% else %}Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.{% endif %}</p> <p class="wt-muted" style="margin:.2rem 0 .8rem">{% if delt %}Tryk på din app, så kommer nye episoder af sig selv. Linket er familiens fælles, så del det kun inden for familien.{% else %}Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.{% endif %}</p>
<div style="display:flex;flex-wrap:wrap;gap:.5rem"> <div style="display:flex;flex-wrap:wrap;gap:.5rem">
<a class="wt-btn wt-btn--primary" href="podcast://{{ u.uden_skema }}">Apple Podcasts</a> <a class="wt-btn wt-btn--primary" href="podcast://{{ u.uden_skema }}">Apple Podcasts</a>
@@ -68,6 +97,20 @@
</form> </form>
{% endif %} {% endif %}
<script> <script>
function kopierTekst(t, knap) {
var ok = function () { knap.textContent = 'Kopieret'; };
var fallback = function () {
var ta = document.createElement('textarea'); ta.value = t; ta.setAttribute('readonly', '');
ta.style.position = 'fixed'; ta.style.top = '0'; ta.style.opacity = '0'; ta.style.fontSize = '16px';
document.body.appendChild(ta); ta.focus(); ta.setSelectionRange(0, t.length);
try { document.execCommand('copy'); ok(); } catch (e) { prompt('Kopiér adressen:', t); }
document.body.removeChild(ta);
};
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(ok, fallback); else fallback();
}
document.querySelectorAll('[data-kopier-tekst]').forEach(function (b) {
b.addEventListener('click', function () { kopierTekst(b.dataset.kopierTekst, b); });
});
document.querySelectorAll('[data-kopier]').forEach(function (b) { document.querySelectorAll('[data-kopier]').forEach(function (b) {
b.addEventListener('click', function () { b.addEventListener('click', function () {
var i = document.getElementById(b.dataset.kopier); var i = document.getElementById(b.dataset.kopier);
@@ -78,4 +121,5 @@ document.querySelectorAll('[data-kopier]').forEach(function (b) {
}); });
}); });
</script> </script>
</div>
{% endblock %} {% endblock %}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<title>{{ og.titel }}</title>
<meta name="robots" content="noindex, nofollow, noarchive">
<meta name="description" content="{{ og.beskrivelse }}">
<meta property="og:type" content="website">
<meta property="og:title" content="{{ og.titel }}">
<meta property="og:description" content="{{ og.beskrivelse }}">
<meta property="og:url" content="{{ og.url }}">
<meta property="og:locale" content="da_DK">
{% if og.billede %}<meta property="og:image" content="{{ og.billede }}">
<meta property="og:image:width" content="{{ og.str }}">
<meta property="og:image:height" content="{{ og.str }}">
<meta name="twitter:card" content="summary">{% endif %}
</head>
<body><h1>{{ og.titel }}</h1><p>{{ og.beskrivelse }}</p></body>
</html>