Podcasts: Folgen einer Kategorie (?folder=), wie oft ein Podcast erscheint (interval_days) für „Nach Zeitraum, seltene zuerst“
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Has been cancelled

- GET /podcasts/episodes/?folder=<name> ('' = ohne Ordner)
- /podcasts/state/: interval_days je Feed (mittlerer Abstand der neuesten 10 Folgen,
  eine Abfrage per Window, eine Stunde gemerkt)
- Podcast-Oberfläche aus diora-android bd98557 (Zeiträume, Ordner-Chips in „Folgen“)
- docs/library.md, sw v134

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-19 17:23:06 +02:00
parent 86a6bb52d7
commit ba3c6ff905
6 changed files with 175 additions and 27 deletions

View file

@ -31,6 +31,17 @@ The folder order per area: e-books in the encrypted user settings blob (names
are private), the others on the server (`/podcasts/folders/`, `/feeds/folders/`,
`{order: [names]}`).
### Podcasts: a category's episodes, by time
The folder chips stand over „Folgen“ too: with one chosen it lists every episode of
that category (`GET /podcasts/episodes/?folder=<name>`, empty: the podcasts in none).
„Folgen“, „Neu“ and one podcast's page group their episodes by time — Heute, Gestern,
Diese Woche (the last 7 days), Diesen Monat (30 days), then the months — after the
Kino feed. In „Folgen“ (sort „Nach Zeitraum, seltene zuerst“, the default) and „Neu“
the podcasts that seldom come go first within a group: `interval_days` of each feed
in `/podcasts/state/`, the mean gap between its newest 10 episodes (one episode: its
age, at least 30 days; kept an hour). Heard is a filter chip, not an order.
### Shared code: `static/js/folders.js` (diora-web, every platform)
```

View file

@ -5,6 +5,7 @@ from unittest.mock import patch
import feedparser
from django.contrib.auth.models import User
from django.core.cache import cache
from django.test import TestCase
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
@ -756,6 +757,7 @@ class EpisodeListTests(TestCase):
"""GET /podcasts/episodes/: all feeds, filters, history, search, pages."""
def setUp(self):
cache.clear() # how often feeds come (_intervals) is kept per user id
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha',
@ -795,6 +797,19 @@ class EpisodeListTests(TestCase):
def ids(self, **params):
return [e['id'] for e in self.get(**params)['episodes']]
def test_folder_shows_its_feeds_episodes(self):
self.fa.folders = ['Politik']
self.fa.save()
self.assertEqual(sorted(self.ids(folder='Politik')), sorted([self.a1.id, self.a2.id, self.a3.id, self.a4.id]))
self.assertEqual(sorted(self.ids(folder='')), sorted([self.b1.id, self.b2.id])) # the ones in none
self.assertEqual(self.ids(folder='Sport'), [])
def test_state_says_how_often_a_feed_comes(self):
feeds = {f['id']: f for f in self.client.get('/podcasts/state/').json()['feeds']}
# A1-A3 a day apart (A4 has no date); Beta's two a day apart too.
self.assertAlmostEqual(feeds[self.fa.id]['interval_days'], (_at(1, 3) - _at(1, 1)).total_seconds() / 86400 / 2, places=1)
self.assertAlmostEqual(feeds[self.fb.id]['interval_days'], (_at(1, 5) - _at(1, 4)).total_seconds() / 86400, places=1)
def test_all_newest_first_without_date_last(self):
data = self.get()
self.assertEqual([e['id'] for e in data['episodes']],
@ -2403,9 +2418,12 @@ class FeedCountsTests(TestCase):
f = PodcastFeed.objects.create(user=self.user, rss_url=f'https://example.org/n{i}', title=f'N{i}')
e = PodcastEpisode.objects.create(feed=f, guid='e', title='E', audio_url='https://example.org/e.mp3')
_progress(self.user, e, _at(2, 2), played=True)
with self.assertNumQueries(2):
cache.clear()
with self.assertNumQueries(3): # feeds, progress counts, how often they come
feeds = _feeds_data(self.user)
self.assertEqual(len(feeds), 13)
with self.assertNumQueries(2): # that one is kept
_feeds_data(self.user)
self.assertEqual({f['unplayed_count'] for f in feeds if f['title'].startswith('N')}, {0})
def test_feed_page_has_favorites_and_info(self):

View file

@ -14,11 +14,12 @@ import xml.etree.ElementTree as ET
import feedparser
import requests
from django.conf import settings
from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.db.models import (
Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When,
Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When, Window,
)
from django.db.models.functions import Coalesce, Lower
from django.db.models.functions import Coalesce, Lower, RowNumber
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
@ -809,12 +810,47 @@ def _feed_settings(feed):
return {'feed_id': feed.id, **{name: getattr(feed, name) for name in FEED_SETTING_FIELDS}}
INTERVAL_EPISODES = 10 # a feed's newest episodes that tell how often it comes
INTERVAL_SINGLE_DAYS = 30
def _intervals(user):
"""How often each feed brings an episode, in days: the mean gap between its newest
INTERVAL_EPISODES. One episode only: its age, at least INTERVAL_SINGLE_DAYS. The lists
put the seldom ones first within a day or week (after Kino's feed; feeds.js does the
same for blogs). One query (the newest episodes per feed by a window), kept an hour."""
key = f'podcasts:intervals:{user.id}'
got = cache.get(key)
if got is not None:
return got
rows = (
PodcastEpisode.objects.filter(feed__user=user, pub_date__isnull=False)
.annotate(n=Window(RowNumber(), partition_by=[F('feed_id')], order_by=F('pub_date').desc()))
.filter(n__lte=INTERVAL_EPISODES)
.values_list('feed_id', 'pub_date')
)
dates = {}
for feed_id, when in rows:
dates.setdefault(feed_id, []).append(when)
now = timezone.now()
out = {}
for feed_id, ds in dates.items():
if len(ds) == 1:
days = max((now - ds[0]).total_seconds() / 86400, INTERVAL_SINGLE_DAYS)
else:
days = (max(ds) - min(ds)).total_seconds() / 86400 / (len(ds) - 1)
out[feed_id] = round(days, 2)
cache.set(key, out, 3600)
return out
def _feeds_data(user):
"""The user's feeds with their settings and, per feed, how many episodes
it has, how many of them are unplayed and how many new (as in the inbox:
neither played nor dismissed), and when the user last listened to one.
Two queries, however many feeds: the feeds with their episode count, and
the user's progress rows counted per feed."""
the user's progress rows counted per feed (and, once an hour, how often each
feed comes: _intervals)."""
feeds = list(
user.podcast_feeds
.annotate(latest_episode_at=Max('episodes__pub_date'), episode_count=Count('episodes'))
@ -834,8 +870,10 @@ def _feeds_data(user):
)
}
intervals = _intervals(user)
for f in feeds:
f['feed_id'] = f['id'] # as in feeds/<id>/settings/
f['interval_days'] = intervals.get(f['id'])
mine = listened.get(f['id'], {})
f['unplayed_count'] = max(f['episode_count'] - mine.get('played_count', 0), 0)
f['new_count'] = max(f['episode_count'] - mine.get('hidden_count', 0), 0)
@ -1479,7 +1517,7 @@ def _int_param(request, name, default):
def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT,
sort=None, ids=None, notes=False):
sort=None, ids=None, notes=False, folder=None):
"""(episodes, total) for the episode list. Two queries, whatever the
page size: the count and the page. The user's progress row comes in by
a join, queue membership by a subquery. No shownotes (up to 50 KB each).
@ -1488,7 +1526,8 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
commas), which all have to match. sort: one of EPISODE_SORTS; by default
the newest first, in the history the last listened first. ids: only these
episodes (the ones downloaded on a device). notes: `q` also matches the
shownotes; title matches come first."""
shownotes; title matches come first. folder: only the feeds in this folder ('': the feeds
in none)."""
names = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
names = [n for n in names if n and n != 'all']
qs = (
@ -1506,6 +1545,10 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
qs = qs.filter(EPISODE_FILTERS[name])
if feed_id is not None:
qs = qs.filter(feed_id=feed_id)
if folder is not None:
qs = qs.filter(feed_id__in=[
fid for fid, fs in PodcastFeed.objects.filter(user=user).values_list('id', 'folders')
if (folder in (fs or []) if folder else not fs)])
if ids is not None:
qs = qs.filter(id__in=ids)
if feed_id is None and ids is None: # audiobooks are listened to in the library
@ -1537,8 +1580,9 @@ def episode_list(request):
"""Episodes of all the user's feeds, filtered (all, unplayed, in_progress,
played, favorites, history, queued; several joined by commas all have to
match), optionally of one feed, of some episodes (`ids`, comma-separated)
or matching `q` (in the title; with notes=1 also in the shownotes), sorted
by `sort` (new, old, long, short, title, played)."""
or matching `q` (in the title; with notes=1 also in the shownotes), of the
feeds in `folder` (empty: in none), sorted by `sort` (new, old, long, short,
title, played)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
names = [n.strip() for n in request.GET.get('filter', '').split(',') if n.strip()]
@ -1558,6 +1602,7 @@ def episode_list(request):
episodes, total = _episode_list_data(
request.user, names, feed_id, request.GET.get('q', '').strip(), offset, limit,
sort=sort, ids=ids, notes=request.GET.get('notes') == '1',
folder=folders_mod.clean_name(request.GET['folder']) if 'folder' in request.GET else None,
)
return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit, 'total': total})

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v133';
const CACHE = 'diora-v134';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',

View file

@ -1,4 +1,4 @@
// Copied from diora-android 449118c 2026-10-16: web-native/static/native-podcast.js
// Copied from diora-android bd98557 2026-10-19: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js.
@ -997,6 +997,8 @@
// Filters that can't both be on: choosing one takes the others off.
const EXCLUDES = {unplayed: ['played'], in_progress: ['played'], played: ['unplayed', 'in_progress']};
const EP_SORTS = [
// Days and weeks (Heute, Gestern, …), in each the podcasts that seldom come first (timeSections).
{id: 'rare', label: 'Nach Zeitraum, seltene zuerst', server: 'new', all: true, key: o => -(Date.parse(o.pub_date) || -Infinity)},
{id: 'new', label: 'Neueste zuerst', key: o => -(Date.parse(o.pub_date) || -Infinity)},
{id: 'old', label: 'Älteste zuerst', key: o => Date.parse(o.pub_date) || Infinity},
{id: 'long', label: 'Längste zuerst', key: o => (o.duration_seconds ? -o.duration_seconds : Infinity)},
@ -1010,6 +1012,68 @@
return DL && !!downloads[id] && downloads[id].state === 'done';
}
// ---- by time: Heute, Gestern, Diese Woche, Diesen Monat, then the months (after Kino's feed;
// diora-web feeds.js the same for blogs). Within one, with rare: the podcasts that seldom come
// first (feed.interval_days from the server: the mean gap of its newest episodes), so a weekly
// show isn't lost among the daily news.
const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober',
'November', 'Dezember'];
function timeLabel(iso, now) {
const t = Date.parse(iso);
if (!t) return 'Ohne Datum';
const midnight = new Date(now);
midnight.setHours(0, 0, 0, 0);
const days = Math.floor((midnight.getTime() - t) / 86400000) + 1; // 0: today, 1: yesterday
if (t > now) return 'Demnächst';
if (t >= midnight.getTime()) return 'Heute';
if (days <= 1) return 'Gestern';
if (days < 7) return 'Diese Woche';
if (days < 30) return 'Diesen Monat';
const d = new Date(t);
return MONTHS[d.getMonth()] + (d.getFullYear() === new Date(now).getFullYear() ? '' : ' ' + d.getFullYear());
}
function intervalOf(feedId) {
const f = feeds().find(x => x.id === feedId);
return f && typeof f.interval_days === 'number' ? f.interval_days : 0;
}
// items (ep() shapes), newest first already: [{label, items}].
function timeSections(items, rare) {
const now = Date.now();
const out = [];
for (const e of items) {
const label = timeLabel(e.date, now);
const last = out[out.length - 1];
if (last && last.label === label) last.items.push(e);
else out.push({label, items: [e]});
}
if (rare) {
const gap = new Map();
for (const sec of out) {
sec.items = sec.items.map((e, i) => [e, i]).sort((a, b) => {
for (const e of [a[0], b[0]]) if (!gap.has(e.feedId)) gap.set(e.feedId, intervalOf(e.feedId));
return (gap.get(b[0].feedId) - gap.get(a[0].feedId)) || a[1] - b[1];
}).map(x => x[0]);
}
}
return out;
}
// The list's rows, under headings by time when sorted so.
function appendRows(list, items, sort, rowOf) {
if (sort !== 'new' && sort !== 'rare') {
for (const e of items) list.append(rowOf(e));
return;
}
for (const sec of timeSections(items, sort === 'rare')) {
list.append(el('div', 'nui-pod-when', sec.label));
for (const e of sec.items) list.append(rowOf(e));
}
}
function matchesAll(o, filters) {
return filters.every(id => { const f = FILTERS.find(x => x.id === id); return !f || f.test(o); });
}
@ -1148,6 +1212,7 @@
if (view === 'queue') folderChips(queue().map(ep), e => e.feedId);
else if (view === 'inbox') folderChips(inbox().map(ep), e => e.feedId);
else if (view === 'feeds') folderChips(podcastFeeds(), f => f.id);
else if (view === 'episodes' && !selected) folderChips(podcastFeeds(), f => f.id);
if (view === 'queue') renderQueue();
else if (view === 'inbox') renderInbox();
else if (view === 'episodes') renderEpisodes();
@ -1780,7 +1845,7 @@
root.append(head);
}
const list = el('div', 'nui-pod-list');
for (const e of items) list.append(episodeRow(e, {showFeed: true, showDate: true, list: 'inbox'}));
appendRows(list, items, 'rare', e => episodeRow(e, {showFeed: true, showDate: true, list: 'inbox'}));
root.append(list);
}
@ -2333,12 +2398,11 @@
}
if (!items.length) { empty(all.length ? noneText(filters) : 'Dieser Podcast hat noch keine Folgen.'); return; }
const list = el('div', 'nui-pod-list');
for (const e of items) {
const o = e.raw;
appendRows(list, items, sort, e => {
const row = episodeRow(e, {showDate: true, list: downloadsOnly(filters) ? 'downloads' : 'feed', filters});
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
list.append(row);
}
if (e.raw.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
return row;
});
root.append(list);
}
@ -2510,7 +2574,7 @@
// Filters combine (all have to match); "Geladen" asks for the episodes downloaded here.
let epFilters = [];
let epSort = 'new';
let epSort = 'rare';
try {
const kept = localStorage.getItem('nui_pod_filters');
if (kept != null) epFilters = JSON.parse(kept);
@ -2521,20 +2585,24 @@
if (old === 'history') epSort = 'played';
}
epSort = localStorage.getItem('nui_pod_epsort') || epSort;
// Since 0.5.71 by time with the seldom podcasts first; who had the newest first gets that once.
if (!localStorage.getItem('nui_pod_rare') && epSort === 'new') epSort = 'rare';
localStorage.setItem('nui_pod_rare', '1');
} catch (e) {}
if (!Array.isArray(epFilters)) epFilters = [];
epFilters = epFilters.filter(id => FILTERS.some(f => f.id === id && (!f.app || DL)));
if (!EP_SORTS.some(s => s.id === epSort)) epSort = 'new';
if (!EP_SORTS.some(s => s.id === epSort)) epSort = 'rare';
let epList = null; // {key, items, total, loading, error}
const epKey = () => epFilters.slice().sort().join(',') + '|' + epSort;
// The folder too: "Folgen" with a folder chosen is all episodes of that category.
const epKey = () => epFilters.slice().sort().join(',') + '|' + epSort + '|' + JSON.stringify(podFolder);
function setEpisodeList(filters, sort) {
// The history goes by the last listening; leaving it, by date again.
if (sort == null) {
sort = epSort;
if (filters.includes('history') && !epFilters.includes('history') && sort === 'new') sort = 'played';
if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'new';
if (filters.includes('history') && !epFilters.includes('history') && (sort === 'new' || sort === 'rare')) sort = 'played';
if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'rare';
}
epFilters = filters;
epSort = sort;
@ -2551,9 +2619,12 @@
if (!append) epList = {key, items: [], total: 0, loading: true};
else epList.loading = true;
if (!quiet) render();
const sorting = EP_SORTS.find(s => s.id === epSort) || EP_SORTS[0];
const params = new URLSearchParams({
filter: epFilters.filter(id => id !== 'downloaded').join(','), sort: epSort, limit: '100', offset: String(offset),
filter: epFilters.filter(id => id !== 'downloaded').join(','), sort: sorting.server || sorting.id, limit: '100',
offset: String(offset),
});
if (podFolder != null && hasFolders()) params.set('folder', podFolder);
if (epFilters.includes('downloaded')) {
const ids = Object.keys(downloads).filter(id => downloads[id].state === 'done');
if (!ids.length) { epList = {key, items: [], total: 0}; render(); return; }
@ -2582,7 +2653,7 @@
const items = epList.items.filter(o => matchesAll(o, epFilters)).map(ep);
const total = Math.max(items.length, epList.total - (epList.items.length - items.length));
if (!items.length) {
empty(epList.loading ? 'Wird geladen …' : noneText(epFilters));
empty(epList.loading ? 'Wird geladen …' : podFolder != null && !epFilters.length ? folderEmpty('zu hören') : noneText(epFilters));
return;
}
if (selected) {
@ -2597,7 +2668,7 @@
root.append(head);
}
const list = el('div', 'nui-pod-list');
for (const e of items) {
appendRows(list, items, epSort, e => {
const o = e.raw;
const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes',
filters: epFilters});
@ -2608,8 +2679,8 @@
if (mark) m.prepend(mark);
}
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
list.append(row);
}
return row;
});
root.append(list);
if (epList.items.length < epList.total) {
const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden');

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 449118c 2026-10-16: the podcast parts of web-native/static/native.css
/* Copied from diora-android bd98557 2026-10-19: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */
@ -229,6 +229,9 @@ button.nui-pod-link { display: block; width: 100%; border: none; background: non
.nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; font-size: 14px; }
.nui-pod-list { display: flex; flex-direction: column; }
/* Headings by time in the episode lists (Heute, Gestern, Diese Woche, …). */
.nui-pod-when { padding: 14px 2px 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted); font-family: var(--nui-sans); }
.nui-pod-when:first-child { padding-top: 4px; }
.nui-pod-row {
display: flex;
align-items: center;