Feeds abrufen: unveränderte Feeds nicht mehr parsen, ⟳ antwortet nach höchstens 40 s; sw v83
All checks were successful
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 6m59s

Der stündliche Abruf der 502 Feeds dauerte 114 s, fast nur Parsen: viele Server
ignorieren ETag/Last-Modified und schicken jedes Mal den ganzen Feed (Lage der Nation
8,4 MB = 5,9 s). ⟳ in der App lief damit in ihr 60-s-Zeitlimit und schlug fehl.

- Fingerabdruck des Feed-Inhalts (ohne lastBuildDate/Kommentare, http_body_hash,
  Migration 0017): derselbe Inhalt zählt als unverändert und wird nicht geparst
- _refresh_feeds: ein Pool für alle Feeds (16 parallel), jeder Feed wird geschrieben,
  sobald er da ist, optionales Zeitlimit
- refresh-all wartet höchstens 40 s (done/left in der Antwort); der Browser-Host
  holt den Stand dann eine Minute später noch einmal, und alle 15 Minuten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-04 10:22:01 +02:00
parent 986ccad5c9
commit 14720b3669
6 changed files with 151 additions and 51 deletions

View file

@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-10-04 08:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0016_playerstate_sleep'),
]
operations = [
migrations.AddField(
model_name='podcastfeed',
name='http_body_hash',
field=models.CharField(blank=True, max_length=64),
),
]

View file

@ -36,6 +36,10 @@ class PodcastFeed(models.Model):
# If-None-Match / If-Modified-Since so an unchanged feed costs a 304. # If-None-Match / If-Modified-Since so an unchanged feed costs a 304.
http_etag = models.CharField(max_length=500, blank=True) http_etag = models.CharField(max_length=500, blank=True)
http_last_modified = models.CharField(max_length=100, blank=True) http_last_modified = models.CharField(max_length=100, blank=True)
# Fingerprint of the last feed body that was read: many servers ignore the
# validators above and send the whole feed every time; the same body again
# then counts as unchanged and isn't parsed (views._body_hash).
http_body_hash = models.CharField(max_length=64, blank=True)
# The last failed refresh (network, HTTP status, not a feed); cleared on # The last failed refresh (network, HTTP status, not a feed); cleared on
# success. error_count counts failures in a row and drives the backoff. # success. error_count counts failures in a row and drives the backoff.
last_error = models.CharField(max_length=500, blank=True) last_error = models.CharField(max_length=500, blank=True)

View file

@ -307,7 +307,7 @@ class RefreshAllTests(TestCase):
def test_refresh_all_reports_counts_and_skips_recent(self): def test_refresh_all_reports_counts_and_skips_recent(self):
with patch('podcasts.views._fetch_feed', self.fake_fetch): with patch('podcasts.views._fetch_feed', self.fake_fetch):
r = self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json') r = self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
self.assertEqual(r.json(), {'ok': True, 'refreshed': 1, 'failed': 1, 'new_episodes': 1}) self.assertEqual(r.json(), {'ok': True, 'refreshed': 1, 'failed': 1, 'new_episodes': 1, 'done': True, 'left': 0})
self.assertEqual(sorted(self.fetched), ['https://example.org/a', 'https://example.org/b']) self.assertEqual(sorted(self.fetched), ['https://example.org/a', 'https://example.org/b'])
# A was just refreshed: a second call within max_age only retries B. # A was just refreshed: a second call within max_age only retries B.
self.fetched.clear() self.fetched.clear()
@ -315,6 +315,27 @@ class RefreshAllTests(TestCase):
self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json') self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
self.assertEqual(self.fetched, ['https://example.org/b']) self.assertEqual(self.fetched, ['https://example.org/b'])
def test_refresh_stops_at_its_deadline(self):
import time as _time
from .views import _refresh_feeds
def slow(url, **kw):
_time.sleep(.5)
return self.fake_fetch(url, **kw)
with patch('podcasts.views._fetch_feed', slow):
refreshed, failed, new = _refresh_feeds([self.a, self.b], deadline=_time.monotonic() + .05)
self.assertEqual((refreshed, failed), (0, 0))
self.a.refresh_from_db()
self.assertIsNone(self.a.last_refreshed_at) # not done: left as it was
def test_the_body_fingerprint_is_kept_and_sent_again(self):
from .views import _validators
with patch('podcasts.views._fetch_feed', lambda url, **kw: FeedFetch(200, _parse(_rss([('x', 'Mon, 05 Jan 2026 10:00:00 GMT')])), body_hash='abc')):
self.client.post('/podcasts/feeds/refresh-all/', '{"max_age":0}', content_type='application/json')
self.a.refresh_from_db()
self.assertEqual(self.a.http_body_hash, 'abc')
self.assertEqual(_validators([self.a]), {'body_hash': 'abc'})
def test_a_shared_feed_url_is_fetched_once(self): def test_a_shared_feed_url_is_fetched_once(self):
from .views import _refresh_feeds from .views import _refresh_feeds
with patch('podcasts.views._fetch_feed', self.fake_fetch): with patch('podcasts.views._fetch_feed', self.fake_fetch):
@ -1491,6 +1512,25 @@ class FetchFeedTests(TestCase):
self.fetch(response) self.fetch(response)
return str(cm.exception) return str(cm.exception)
def test_the_same_body_again_counts_as_unchanged_without_parsing(self):
first, _ = self.fetch(_HttpResponse('https://example.org/feed', content=self.RSS))
self.assertEqual(first.status, 200)
self.assertTrue(first.body_hash)
# The server ignores validators and sends the feed again: a 304, unparsed.
with patch('podcasts.views.feedparser.parse') as parse:
again, _ = self.fetch(_HttpResponse('https://example.org/feed', content=self.RSS), body_hash=first.body_hash)
parse.assert_not_called()
self.assertEqual((again.status, again.parsed, again.body_hash), (304, None, first.body_hash))
# Only the build date or a comment differs: still the same.
stamped = self.RSS.replace(b'<channel>', b'<channel><lastBuildDate>Sun, 04 Oct 2026 08:00:00 GMT</lastBuildDate><!-- 0.12 s -->', 1)
same, _ = self.fetch(_HttpResponse('https://example.org/feed', content=stamped), body_hash=first.body_hash)
self.assertEqual(same.status, 304)
# A new episode: parsed.
newer = _rss([('b', 'Tue, 06 Jan 2026 10:00:00 GMT'), ('a', 'Mon, 05 Jan 2026 10:00:00 GMT')]).encode()
changed, _ = self.fetch(_HttpResponse('https://example.org/feed', content=newer), body_hash=first.body_hash)
self.assertEqual(changed.status, 200)
self.assertNotEqual(changed.body_hash, first.body_hash)
def test_200_returns_parsed_feed_and_validators(self): def test_200_returns_parsed_feed_and_validators(self):
r, headers = self.fetch(_HttpResponse('https://example.org/feed', content=self.RSS, headers={ r, headers = self.fetch(_HttpResponse('https://example.org/feed', content=self.RSS, headers={
'ETag': '"v1"', 'Last-Modified': 'Mon, 05 Jan 2026 10:00:00 GMT'})) 'ETag': '"v1"', 'Last-Modified': 'Mon, 05 Jan 2026 10:00:00 GMT'}))

View file

@ -1,10 +1,12 @@
import hashlib
import json import json
import logging import logging
import math import math
import re
import time import time
import urllib.parse import urllib.parse
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout, as_completed
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta, timezone as dt_timezone from datetime import timedelta, timezone as dt_timezone
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@ -37,7 +39,8 @@ log = logging.getLogger(__name__)
FEED_TIMEOUT = 20 # seconds per feed download FEED_TIMEOUT = 20 # seconds per feed download
NEW_ON_SUBSCRIBE = 1 # episodes of a new subscription that count as new NEW_ON_SUBSCRIBE = 1 # episodes of a new subscription that count as new
FEED_WORKERS = 8 # feeds downloaded at the same time FEED_WORKERS = 16 # feeds downloaded at the same time
REFRESH_WAIT = 40 # seconds a refresh-all (⟳) waits; feeds not done by then wait for the hourly job
MAX_DESCRIPTION = 50_000 # characters of shownotes HTML kept per episode MAX_DESCRIPTION = 50_000 # characters of shownotes HTML kept per episode
MAX_TRANSCRIPTS = 10 MAX_TRANSCRIPTS = 10
@ -302,14 +305,27 @@ class FeedFetch:
last_modified: str = '' last_modified: str = ''
final_url: str = '' final_url: str = ''
permanent_url: str = '' permanent_url: str = ''
body_hash: str = ''
# What changes in a feed on every request without anything in it changing:
# the build date, comments with a generation time.
_VOLATILE = re.compile(rb'<lastBuildDate>.*?</lastBuildDate>|<!--.*?-->', re.S)
def _body_hash(content):
"""Fingerprint of a feed body, without the parts that change on every request."""
return hashlib.sha256(_VOLATILE.sub(b'', content)).hexdigest()
PERMANENT_REDIRECTS = (301, 308) PERMANENT_REDIRECTS = (301, 308)
def _fetch_feed(url, etag='', last_modified=''): def _fetch_feed(url, etag='', last_modified='', body_hash=''):
"""Download and parse a feed (with a timeout, unlike feedparser's own """Download and parse a feed (with a timeout, unlike feedparser's own
fetch). With etag/last_modified it is a conditional GET. Returns a fetch). With etag/last_modified it is a conditional GET; with body_hash a
body like the last one counts as unchanged too (a 304), unparsed —
parsing is what takes the time (seconds for a big feed). Returns a
FeedFetch; raises FeedFetchError when there is nothing usable.""" FeedFetch; raises FeedFetchError when there is nothing usable."""
headers = {'User-Agent': 'diora podcast refresh'} headers = {'User-Agent': 'diora podcast refresh'}
if etag: if etag:
@ -342,6 +358,10 @@ def _fetch_feed(url, etag='', last_modified=''):
if r.status_code >= 400: if r.status_code >= 400:
raise FeedFetchError(f'HTTP {r.status_code}') raise FeedFetchError(f'HTTP {r.status_code}')
new_hash = _body_hash(r.content)
if body_hash and new_hash == body_hash:
return FeedFetch(304, None, etag, last_modified, r.url, permanent_url, body_hash)
parsed = feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers}) parsed = feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers})
if parsed.get('bozo') and not parsed.entries and not parsed.feed.get('title'): if parsed.get('bozo') and not parsed.entries and not parsed.feed.get('title'):
raise FeedFetchError('not a feed') raise FeedFetchError('not a feed')
@ -352,7 +372,7 @@ def _fetch_feed(url, etag='', last_modified=''):
# A validator too long to store is dropped (a truncated one never matches). # A validator too long to store is dropped (a truncated one never matches).
new_etag if len(new_etag) <= 500 else '', new_etag if len(new_etag) <= 500 else '',
new_modified if len(new_modified) <= 100 else '', new_modified if len(new_modified) <= 100 else '',
r.url, permanent_url, r.url, permanent_url, new_hash,
) )
@ -361,13 +381,14 @@ def _validators(feeds):
`feeds` (several users' subscriptions of the same URL): only when every `feeds` (several users' subscriptions of the same URL): only when every
one of them was fetched before and stored the same validators, because a one of them was fetched before and stored the same validators, because a
304 then skips all of them. Otherwise {} (a full response).""" 304 then skips all of them. Otherwise {} (a full response)."""
pairs = {(f.http_etag, f.http_last_modified) for f in feeds} pairs = {(f.http_etag, f.http_last_modified, f.http_body_hash) for f in feeds}
if len(pairs) != 1 or any(f.last_refreshed_at is None for f in feeds): if len(pairs) != 1 or any(f.last_refreshed_at is None for f in feeds):
return {} return {}
etag, last_modified = pairs.pop() etag, last_modified, body_hash = pairs.pop()
if not (etag or last_modified): out = {'etag': etag, 'last_modified': last_modified} if etag or last_modified else {}
return {} if body_hash:
return {'etag': etag, 'last_modified': last_modified} out['body_hash'] = body_hash
return out
def _error_text(e): def _error_text(e):
@ -425,45 +446,50 @@ AUDIO_EXTENSIONS = ('.mp3', '.m4a', '.m4b', '.ogg', '.oga', '.opus', '.aac', '.f
VIDEO_EXTENSIONS = ('.mp4', '.m4v', '.mov', '.webm') VIDEO_EXTENSIONS = ('.mp4', '.m4v', '.mov', '.webm')
def _refresh_feeds(feeds, chunk=32, full=False): def _refresh_feeds(feeds, full=False, deadline=None):
"""Refresh many feeds at once: the downloads run in parallel and each """Refresh many feeds at once: the downloads run in parallel and each
feed URL is fetched once, even when several users subscribed to it; the feed URL is fetched once, even when several users subscribed to it; each
database writes then happen one by one. Returns (refreshed, failed, new). download is written as soon as it's there (the writes one by one, while
the others still load). Returns (refreshed, failed, new).
`full`: download every feed again (no 304) and take in episodes the `full`: download every feed again (no 304) and take in episodes the
reader skipped before (see _apply_feed).""" reader skipped before (see _apply_feed). `deadline` (time.monotonic()):
stop there; feeds not done by then stay as they are."""
feeds = list(feeds) feeds = list(feeds)
refreshed = failed = new = 0 refreshed = failed = new = 0
for start in range(0, len(feeds), chunk): by_url = {}
part = feeds[start:start + chunk] for f in feeds:
by_url = {} by_url.setdefault(f.rss_url, []).append(f)
for f in part: if not by_url:
by_url.setdefault(f.rss_url, []).append(f) return 0, 0, 0
results = {}
def fetch(url): def fetch(url):
try: try:
return url, _fetch_feed(url, **({} if full else _validators(by_url[url]))), None return url, _fetch_feed(url, **({} if full else _validators(by_url[url]))), None
except Exception as e: # network, HTTP or parser trouble: report and go on except Exception as e: # network, HTTP or parser trouble: report and go on
return url, None, e return url, None, e
urls = sorted(by_url) pool = ThreadPoolExecutor(max_workers=min(FEED_WORKERS, len(by_url)))
with ThreadPoolExecutor(max_workers=min(FEED_WORKERS, len(urls) or 1)) as pool: futures = [pool.submit(fetch, url) for url in sorted(by_url)]
for url, fetched, err in pool.map(fetch, urls): try:
results[url] = (fetched, err) wait = None if deadline is None else max(0.0, deadline - time.monotonic())
for future in as_completed(futures, timeout=wait):
for feed in part: url, fetched, err = future.result()
fetched, err = results[feed.rss_url] for feed in by_url[url]:
if err is not None: if err is not None:
failed += 1 failed += 1
log.warning('refreshing %s failed: %s', feed.rss_url, err) log.warning('refreshing %s failed: %s', feed.rss_url, err)
_record_error(feed, err) _record_error(feed, err)
continue continue
try: try:
new += _refresh_feed(feed, fetched, full=full) new += _refresh_feed(feed, fetched, full=full)
refreshed += 1 refreshed += 1
except Exception as e: except Exception as e:
failed += 1 failed += 1
log.warning('updating %s failed: %s', feed.rss_url, e) log.warning('updating %s failed: %s', feed.rss_url, e)
except FuturesTimeout:
log.info('refresh stopped at its deadline: %d of %d feeds done', refreshed + failed, len(feeds))
finally:
pool.shutdown(wait=False, cancel_futures=True)
return refreshed, failed, new return refreshed, failed, new
@ -681,8 +707,9 @@ def _apply_feed(feed_obj, fetched, full=False):
# skips all of the above) and a clean error state. # skips all of the above) and a clean error state.
feed_obj.http_etag = fetched.etag feed_obj.http_etag = fetched.etag
feed_obj.http_last_modified = fetched.last_modified feed_obj.http_last_modified = fetched.last_modified
feed_obj.http_body_hash = fetched.body_hash
_clear_error(feed_obj) _clear_error(feed_obj)
feed_obj.save(update_fields=['http_etag', 'http_last_modified', *ERROR_FIELDS]) feed_obj.save(update_fields=['http_etag', 'http_last_modified', 'http_body_hash', *ERROR_FIELDS])
_move_to_permanent_url(feed_obj, fetched.permanent_url) _move_to_permanent_url(feed_obj, fetched.permanent_url)
return new_count return new_count
@ -1557,7 +1584,9 @@ def refresh_all_feeds(request):
"""Refresh all of the user's feeds on the server in one go. Feeds the """Refresh all of the user's feeds on the server in one go. Feeds the
hourly job (or an earlier call) refreshed within `max_age` seconds are hourly job (or an earlier call) refreshed within `max_age` seconds are
skipped. Clients then fetch the result once (state/, inbox/ …). skipped. Clients then fetch the result once (state/, inbox/ …).
The user asked, so feeds the hourly job backs off from are tried too.""" The user asked, so feeds the hourly job backs off from are tried too.
It answers within REFRESH_WAIT seconds: `left` feeds weren't done by
then (done: false) and wait for the next call or the hourly job."""
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)
try: try:
@ -1569,9 +1598,11 @@ def refresh_all_feeds(request):
except (TypeError, ValueError): except (TypeError, ValueError):
return JsonResponse({'error': 'max_age must be a number'}, status=400) return JsonResponse({'error': 'max_age must be a number'}, status=400)
cutoff = timezone.now() - timedelta(seconds=max_age) cutoff = timezone.now() - timedelta(seconds=max_age)
feeds = request.user.podcast_feeds.filter(Q(last_refreshed_at__isnull=True) | Q(last_refreshed_at__lt=cutoff)) feeds = list(request.user.podcast_feeds.filter(Q(last_refreshed_at__isnull=True) | Q(last_refreshed_at__lt=cutoff)))
refreshed, failed, new = _refresh_feeds(feeds) refreshed, failed, new = _refresh_feeds(feeds, deadline=time.monotonic() + REFRESH_WAIT)
return JsonResponse({'ok': True, 'refreshed': refreshed, 'failed': failed, 'new_episodes': new}) left = len(feeds) - refreshed - failed
return JsonResponse({'ok': True, 'refreshed': refreshed, 'failed': failed, 'new_episodes': new,
'done': left == 0, 'left': left})
@csrf_exempt @csrf_exempt

View file

@ -164,11 +164,15 @@
try { try {
if (fetchFeeds) { if (fetchFeeds) {
if (!(await flush())) return false; if (!(await flush())) return false;
await fetch('/podcasts/feeds/refresh-all/', { const rr = await fetch('/podcasts/feeds/refresh-all/', {
method: 'POST', credentials: 'same-origin', method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()}, headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: '{"max_age":60}', body: '{"max_age":60}',
}); });
// The server answers after ~40 s at most; when it didn't get to every feed,
// the state once more a minute later.
const done = await rr.json().then(d => d.done !== false, () => true);
if (!done) setTimeout(() => refresh(false), 60000);
} }
// Progress is saved every few seconds while an episode plays; try again // Progress is saved every few seconds while an episode plays; try again
// when it slipped in while the state was on its way. // when it slipped in while the state was on its way.
@ -1817,6 +1821,9 @@
if (!source) listen(); if (!source) listen();
} }
}); });
// Open for long (diora desktop, a pinned tab): the state every 15 minutes too — the
// server fetches the feeds hourly, new episodes show up without a reload.
setInterval(() => { if (document.visibilityState === 'visible') refresh(false); }, 15 * 60000);
window.addEventListener('online', () => { window.addEventListener('online', () => {
flush(); flush();
if (!source) listen(); if (!source) listen();

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-v82'; const CACHE = 'diora-v83';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app.js', '/static/js/app.js',