From ba3c6ff90567ee5e4890d15b56bb68e8af0e9958 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 19 Oct 2026 17:23:06 +0200 Subject: [PATCH] =?UTF-8?q?Podcasts:=20Folgen=20einer=20Kategorie=20(=3Ffo?= =?UTF-8?q?lder=3D),=20wie=20oft=20ein=20Podcast=20erscheint=20(interval?= =?UTF-8?q?=5Fdays)=20f=C3=BCr=20=E2=80=9ENach=20Zeitraum,=20seltene=20zue?= =?UTF-8?q?rst=E2=80=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /podcasts/episodes/?folder= ('' = 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 --- docs/library.md | 11 ++++ podcasts/tests.py | 20 +++++- podcasts/views.py | 59 +++++++++++++++-- static/js/sw.js | 2 +- static/podcasts/native-podcast.js | 105 +++++++++++++++++++++++++----- static/podcasts/podcast-ui.css | 5 +- 6 files changed, 175 insertions(+), 27 deletions(-) diff --git a/docs/library.md b/docs/library.md index 7d07190..efb871c 100644 --- a/docs/library.md +++ b/docs/library.md @@ -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=`, 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) ``` diff --git a/podcasts/tests.py b/podcasts/tests.py index 43bed26..5e72b01 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -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): diff --git a/podcasts/views.py b/podcasts/views.py index f698995..c569cfb 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -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//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}) diff --git a/static/js/sw.js b/static/js/sw.js index 7764bb1..4b816c2 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -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', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index bf23c65..2ef135b 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -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'); diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index b6b05f3..40b50fa 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -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;