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:
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — podcast{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin" class="wt-muted">← Admin</a></p>
|
||||
<h1>Podcast</h1>
|
||||
<p class="wt-lead">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 <a href="/podcast">/podcast</a>.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
{% for u in udsendelser %}
|
||||
<h2 style="font-size:1.15rem;margin-top:1.6rem">{{ u.titel }}</h2>
|
||||
<table class="wt-table">
|
||||
<thead><tr><th>Nr</th><th>Titel</th><th>Varighed</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in u.episoder %}
|
||||
<tr>
|
||||
<td>{{ e.nr }}</td>
|
||||
<td>{{ e.titel }}<div class="wt-muted" style="font-size:.8rem">{{ e.slug }}.mp3 · {{ (e.bytes / 1048576) | round(1) }} MB</div></td>
|
||||
<td>{{ (e.varighed // 60) }}:{{ '%02d' % (e.varighed % 60) }}</td>
|
||||
<td>{% if e.kladde %}Kladde{% else %}Udgivet {{ (e.udgivet or '')[:10] }}{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/podcast/{{ u.slug }}/{{ e.slug }}/udgiv" style="margin:0">
|
||||
{% if e.kladde %}
|
||||
<button class="wt-btn wt-btn--primary" type="submit">Udgiv</button>
|
||||
{% else %}
|
||||
<input type="hidden" name="kladde" value="1"><button class="wt-btn" type="submit">Gør til kladde</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="wt-muted">Ingen episoder. Udgiv med publish.py i skillen slaegtens-stemmer.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Lytternes adresser</h2>
|
||||
<table class="wt-table">
|
||||
<thead><tr><th>Bruger</th><th>Oprettet</th><th>Sidst hentet</th><th>Hentninger</th><th>App</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for k in noegler %}
|
||||
<tr{% if k.tilbagekaldt %} style="opacity:.55"{% endif %}>
|
||||
<td>{{ k.user }}{% if k.admin %} <span class="wt-muted">(admin)</span>{% endif %}</td>
|
||||
<td data-ts="{{ k.oprettet }}"></td>
|
||||
<td data-ts="{{ k.sidst or '' }}"></td>
|
||||
<td title="{% for h, n in (k.hentet or {}).items() %}{{ h }}: {{ n }} {% endfor %}">{{ k.hentninger or 0 }}</td>
|
||||
<td class="wt-muted" style="font-size:.8rem;max-width:16rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ k.sidst_ua or '' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/podcast/noegle/{{ k.key }}" style="margin:0">
|
||||
{% if k.tilbagekaldt %}
|
||||
<input type="hidden" name="aktiv" value="1"><button class="wt-btn" type="submit">Genaktivér</button>
|
||||
{% else %}
|
||||
<button class="wt-btn" type="submit" onclick="return confirm('Træk {{ k.user }}s adresse tilbage?')">Træk tilbage</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="wt-muted">Ingen endnu. En lytter får sin adresse første gang de åbner /podcast.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<script>
|
||||
document.querySelectorAll('[data-ts]').forEach(function (td) {
|
||||
var t = parseInt(td.dataset.ts, 10);
|
||||
td.textContent = t ? new Date(t * 1000).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'short' }) : '–';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -25,6 +25,21 @@
|
||||
<h2>★ Favoritter</h2>
|
||||
<div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
|
||||
</section>
|
||||
{% if podcasts %}
|
||||
<section class="appgroup" id="podsec">
|
||||
<h2>🎙️ Familiens podcast</h2>
|
||||
<div class="grid">
|
||||
{% for p in podcasts %}
|
||||
<div class="card" data-search="podcast {{ (p.titel ~ ' ' ~ p.nyeste.titel)|lower }}">
|
||||
<a class="card-link" href="/podcast" aria-label="{{ p.titel }}"></a>
|
||||
<div class="head"><span class="ico">🎙️</span><span class="name">{{ p.titel }}</span></div>
|
||||
<div class="sub" title="{{ p.nyeste.titel }}">{{ p.antal }} episode{{ 'r' if p.antal != 1 }} · nyeste: {{ p.nyeste.titel }}</div>
|
||||
<div class="go">Lyt →</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if mc_servers %}
|
||||
<section class="appgroup" id="mcsec">
|
||||
<h2>⛏️ Minecraft-servere</h2>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Podcast{% endblock %}
|
||||
{% block body %}
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
{% if not udsendelser %}
|
||||
<h1>Podcast</h1>
|
||||
<p class="wt-muted">Der er ingen podcasts endnu.</p>
|
||||
{% endif %}
|
||||
{% for u in udsendelser %}
|
||||
<section style="margin:1rem 0 2.5rem">
|
||||
<div style="display:flex;gap:1.2rem;align-items:flex-start;flex-wrap:wrap">
|
||||
{% if u.har_cover %}
|
||||
<img src="/podcast/{{ key }}/{{ u.slug }}/cover.jpg" alt="" width="160" height="160"
|
||||
style="border-radius:14px;flex:0 0 auto;box-shadow:0 4px 18px rgba(0,0,0,.25)">
|
||||
{% endif %}
|
||||
<div style="flex:1 1 18rem;min-width:0">
|
||||
<h1 style="margin:.1rem 0 .4rem">{{ u.titel }}</h1>
|
||||
<p class="wt-lead" style="margin:0">{{ u.beskrivelse }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p class="wt-muted" style="margin:.2rem 0 .8rem">Tryk på din app. Nye episoder kommer så af sig selv. Adressen er din egen, så del den ikke; familien får hver sin.</p>
|
||||
<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" href="overcast://x-callback-url/add?url={{ u.feed_enc }}">Overcast</a>
|
||||
<a class="wt-btn" href="pktc://subscribe/{{ u.uden_skema }}">Pocket Casts</a>
|
||||
<a class="wt-btn" href="https://antennapod.org/deeplink/subscribe?url={{ u.feed_enc }}">AntennaPod</a>
|
||||
</div>
|
||||
<details style="margin-top:.9rem">
|
||||
<summary class="wt-muted" style="cursor:pointer">Anden app? Kopiér adressen</summary>
|
||||
<div style="display:flex;gap:.5rem;margin-top:.5rem;flex-wrap:wrap">
|
||||
<input class="wt-input" readonly value="{{ u.feed }}" id="feed-{{ u.slug }}"
|
||||
style="flex:1 1 20rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" data-kopier="feed-{{ u.slug }}">Kopiér</button>
|
||||
</div>
|
||||
<p class="wt-muted" style="font-size:.85rem;margin:.5rem 0 0">Spotify kan ikke abonnere på private podcasts. På Android virker AntennaPod og Pocket Casts.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem">Episoder</h2>
|
||||
{% for e in u.episoder %}
|
||||
<article class="wt-card" style="margin:.8rem 0;padding:1rem 1.2rem">
|
||||
<div style="display:flex;justify-content:space-between;gap:1rem;flex-wrap:wrap">
|
||||
<h3 style="margin:0;font-size:1.05rem">{% if e.nr %}{{ e.nr }}. {% endif %}{{ e.titel }}</h3>
|
||||
<span class="wt-muted" style="font-size:.85rem">
|
||||
{% if e.kladde %}<strong>Kladde, kun du kan se den</strong> · {% endif %}{{ (e.varighed // 60) }} min
|
||||
</span>
|
||||
</div>
|
||||
{% if e.beskrivelse %}<p style="margin:.5rem 0">{{ e.beskrivelse }}</p>{% endif %}
|
||||
<audio controls preload="none" style="width:100%;margin-top:.4rem"
|
||||
src="/podcast/{{ key }}/{{ u.slug }}/{{ e.slug }}.mp3"></audio>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen episoder endnu.</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
{% if udsendelser %}
|
||||
<form method="post" action="/podcast/ny-noegle" style="margin:2rem 0"
|
||||
onsubmit="return confirm('Din gamle adresse holder op med at virke, og du skal abonnere igen i din app. Fortsæt?')">
|
||||
<button class="wt-btn" type="submit">Lav en ny adresse</button>
|
||||
<span class="wt-muted" style="font-size:.85rem">Hvis du har delt din adresse ved et uheld.</span>
|
||||
</form>
|
||||
{% endif %}
|
||||
<script>
|
||||
document.querySelectorAll('[data-kopier]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var i = document.getElementById(b.dataset.kopier);
|
||||
i.select();
|
||||
(navigator.clipboard ? navigator.clipboard.writeText(i.value) : Promise.reject())
|
||||
.catch(function () { document.execCommand('copy'); })
|
||||
.finally(function () { b.textContent = 'Kopieret'; });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user