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/`, are private), the others on the server (`/podcasts/folders/`, `/feeds/folders/`,
`{order: [names]}`). `{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) ### Shared code: `static/js/folders.js` (diora-web, every platform)
``` ```

View file

@ -5,6 +5,7 @@ from unittest.mock import patch
import feedparser import feedparser
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.cache import cache
from django.test import TestCase from django.test import TestCase
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
@ -756,6 +757,7 @@ class EpisodeListTests(TestCase):
"""GET /podcasts/episodes/: all feeds, filters, history, search, pages.""" """GET /podcasts/episodes/: all feeds, filters, history, search, pages."""
def setUp(self): 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.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user) self.client.force_login(self.user)
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha', 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): def ids(self, **params):
return [e['id'] for e in self.get(**params)['episodes']] 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): def test_all_newest_first_without_date_last(self):
data = self.get() data = self.get()
self.assertEqual([e['id'] for e in data['episodes']], 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}') 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') 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) _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) feeds = _feeds_data(self.user)
self.assertEqual(len(feeds), 13) 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}) self.assertEqual({f['unplayed_count'] for f in feeds if f['title'].startswith('N')}, {0})
def test_feed_page_has_favorites_and_info(self): def test_feed_page_has_favorites_and_info(self):

View file

@ -14,11 +14,12 @@ import xml.etree.ElementTree as ET
import feedparser import feedparser
import requests import requests
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import ( 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.http import HttpResponse, JsonResponse, StreamingHttpResponse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt 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}} 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): def _feeds_data(user):
"""The user's feeds with their settings and, per feed, how many episodes """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: 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. neither played nor dismissed), and when the user last listened to one.
Two queries, however many feeds: the feeds with their episode count, and 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( feeds = list(
user.podcast_feeds user.podcast_feeds
.annotate(latest_episode_at=Max('episodes__pub_date'), episode_count=Count('episodes')) .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: for f in feeds:
f['feed_id'] = f['id'] # as in feeds/<id>/settings/ f['feed_id'] = f['id'] # as in feeds/<id>/settings/
f['interval_days'] = intervals.get(f['id'])
mine = listened.get(f['id'], {}) mine = listened.get(f['id'], {})
f['unplayed_count'] = max(f['episode_count'] - mine.get('played_count', 0), 0) 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) 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, 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 """(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 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). 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 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 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 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 = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
names = [n for n in names if n and n != 'all'] names = [n for n in names if n and n != 'all']
qs = ( 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]) qs = qs.filter(EPISODE_FILTERS[name])
if feed_id is not None: if feed_id is not None:
qs = qs.filter(feed_id=feed_id) 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: if ids is not None:
qs = qs.filter(id__in=ids) qs = qs.filter(id__in=ids)
if feed_id is None and ids is None: # audiobooks are listened to in the library 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, """Episodes of all the user's feeds, filtered (all, unplayed, in_progress,
played, favorites, history, queued; several joined by commas all have to played, favorites, history, queued; several joined by commas all have to
match), optionally of one feed, of some episodes (`ids`, comma-separated) 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 or matching `q` (in the title; with notes=1 also in the shownotes), of the
by `sort` (new, old, long, short, title, played).""" feeds in `folder` (empty: in none), sorted by `sort` (new, old, long, short,
title, played)."""
if not request.user.is_authenticated: if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401) return JsonResponse({'error': 'authentication required'}, status=401)
names = [n.strip() for n in request.GET.get('filter', '').split(',') if n.strip()] 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( episodes, total = _episode_list_data(
request.user, names, feed_id, request.GET.get('q', '').strip(), offset, limit, request.user, names, feed_id, request.GET.get('q', '').strip(), offset, limit,
sort=sort, ids=ids, notes=request.GET.get('notes') == '1', 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}) 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. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v133'; const CACHE = 'diora-v134';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/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 // 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. // 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. // 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 EXCLUDES = {unplayed: ['played'], in_progress: ['played'], played: ['unplayed', 'in_progress']};
const EP_SORTS = [ 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: '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: '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)}, {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'; 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) { function matchesAll(o, filters) {
return filters.every(id => { const f = FILTERS.find(x => x.id === id); return !f || f.test(o); }); 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); if (view === 'queue') folderChips(queue().map(ep), e => e.feedId);
else if (view === 'inbox') folderChips(inbox().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 === 'feeds') folderChips(podcastFeeds(), f => f.id);
else if (view === 'episodes' && !selected) folderChips(podcastFeeds(), f => f.id);
if (view === 'queue') renderQueue(); if (view === 'queue') renderQueue();
else if (view === 'inbox') renderInbox(); else if (view === 'inbox') renderInbox();
else if (view === 'episodes') renderEpisodes(); else if (view === 'episodes') renderEpisodes();
@ -1780,7 +1845,7 @@
root.append(head); root.append(head);
} }
const list = el('div', 'nui-pod-list'); 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); root.append(list);
} }
@ -2333,12 +2398,11 @@
} }
if (!items.length) { empty(all.length ? noneText(filters) : 'Dieser Podcast hat noch keine Folgen.'); return; } if (!items.length) { empty(all.length ? noneText(filters) : 'Dieser Podcast hat noch keine Folgen.'); return; }
const list = el('div', 'nui-pod-list'); const list = el('div', 'nui-pod-list');
for (const e of items) { appendRows(list, items, sort, e => {
const o = e.raw;
const row = episodeRow(e, {showDate: true, list: downloadsOnly(filters) ? 'downloads' : 'feed', filters}); 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', '★ ')); if (e.raw.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
list.append(row); return row;
} });
root.append(list); root.append(list);
} }
@ -2510,7 +2574,7 @@
// Filters combine (all have to match); "Geladen" asks for the episodes downloaded here. // Filters combine (all have to match); "Geladen" asks for the episodes downloaded here.
let epFilters = []; let epFilters = [];
let epSort = 'new'; let epSort = 'rare';
try { try {
const kept = localStorage.getItem('nui_pod_filters'); const kept = localStorage.getItem('nui_pod_filters');
if (kept != null) epFilters = JSON.parse(kept); if (kept != null) epFilters = JSON.parse(kept);
@ -2521,20 +2585,24 @@
if (old === 'history') epSort = 'played'; if (old === 'history') epSort = 'played';
} }
epSort = localStorage.getItem('nui_pod_epsort') || epSort; 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) {} } catch (e) {}
if (!Array.isArray(epFilters)) epFilters = []; if (!Array.isArray(epFilters)) epFilters = [];
epFilters = epFilters.filter(id => FILTERS.some(f => f.id === id && (!f.app || DL))); 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} 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) { function setEpisodeList(filters, sort) {
// The history goes by the last listening; leaving it, by date again. // The history goes by the last listening; leaving it, by date again.
if (sort == null) { if (sort == null) {
sort = epSort; sort = epSort;
if (filters.includes('history') && !epFilters.includes('history') && sort === 'new') sort = 'played'; if (filters.includes('history') && !epFilters.includes('history') && (sort === 'new' || sort === 'rare')) sort = 'played';
if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'new'; if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'rare';
} }
epFilters = filters; epFilters = filters;
epSort = sort; epSort = sort;
@ -2551,9 +2619,12 @@
if (!append) epList = {key, items: [], total: 0, loading: true}; if (!append) epList = {key, items: [], total: 0, loading: true};
else epList.loading = true; else epList.loading = true;
if (!quiet) render(); if (!quiet) render();
const sorting = EP_SORTS.find(s => s.id === epSort) || EP_SORTS[0];
const params = new URLSearchParams({ 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')) { if (epFilters.includes('downloaded')) {
const ids = Object.keys(downloads).filter(id => downloads[id].state === 'done'); const ids = Object.keys(downloads).filter(id => downloads[id].state === 'done');
if (!ids.length) { epList = {key, items: [], total: 0}; render(); return; } 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 items = epList.items.filter(o => matchesAll(o, epFilters)).map(ep);
const total = Math.max(items.length, epList.total - (epList.items.length - items.length)); const total = Math.max(items.length, epList.total - (epList.items.length - items.length));
if (!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; return;
} }
if (selected) { if (selected) {
@ -2597,7 +2668,7 @@
root.append(head); root.append(head);
} }
const list = el('div', 'nui-pod-list'); const list = el('div', 'nui-pod-list');
for (const e of items) { appendRows(list, items, epSort, e => {
const o = e.raw; const o = e.raw;
const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes', const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes',
filters: epFilters}); filters: epFilters});
@ -2608,8 +2679,8 @@
if (mark) m.prepend(mark); if (mark) m.prepend(mark);
} }
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ ')); if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
list.append(row); return row;
} });
root.append(list); root.append(list);
if (epList.items.length < epList.total) { if (epList.items.length < epList.total) {
const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden'); 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. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ 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-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; } .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 { .nui-pod-row {
display: flex; display: flex;
align-items: center; align-items: center;