diff --git a/docs/theme.md b/docs/theme.md index 4126c77..8290410 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -13,7 +13,10 @@ podcast-ui.css, feeds.js) and diora-desktop (desktop.css). * `window.__dioraTheme()` → the choice; `window.__dioraSetTheme(choice)` sets and applies it; `window.__dioraThemeChanged` (event `diora-theme` on `window`, `detail` = the scheme in effect) lets native code follow (Android status/navigation bars, Electron's window colour). -* "auto" follows `prefers-color-scheme` live. +* "auto" follows `prefers-color-scheme` live — except in the Android app, whose WebView + answers that query from the app's own (dark) theme, not the system's: there the page asks + `DioraNative.systemScheme()` ("dark" | "light") and the app calls + `window.__dioraSystemScheme(scheme)` when the system switches (uiMode change, or on return). * E-ink mode (`html.nui-eink`) keeps its own black-on-white rules and ignores this. * The reader has its own themes; its default is now "Wie die App" (`readerSettings.theme = 'app'`): dark → reader dark, light → reader bright. Sepia, Dunkel and Hell stay choosable. diff --git a/docs/youtube.md b/docs/youtube.md index 4e1be4c..b169387 100644 --- a/docs/youtube.md +++ b/docs/youtube.md @@ -12,7 +12,10 @@ sound**: each app finds the audio stream itself. once (feed address, name, picture) and stores the feed `https://www.youtube.com/feeds/videos.xml?channel_id=UC…` (or `?playlist_id=…`). `GET /podcasts/search/?q=` answers with that one channel. -- From then on the feed is refreshed like any other. It carries the latest 15 +- From then on the feed is refreshed like any other — except that YouTube answers its + channel feeds with a 404 (or 5xx) now and then for no reason (October 2026: two + requests in three), so a YouTube feed is asked up to 12 times, 0.25 s apart, before + its refresh counts as failed (`YOUTUBE_FEED_TRIES` in `podcasts/views.py`). It carries the latest 15 videos. **Shorts are left out.** There is no duration (`duration_seconds` 0) until a player knows it: `POST /podcasts/progress/save/` takes an optional `duration_seconds` (number, 1 s … 7 days), stored only while the episode has none. diff --git a/podcasts/test_youtube.py b/podcasts/test_youtube.py index 3722fba..d7e47f1 100644 --- a/podcasts/test_youtube.py +++ b/podcasts/test_youtube.py @@ -263,3 +263,46 @@ class MoveFeedTests(TestCase): bob = User.objects.create_user(username='bob', password='pw12345678') self.client.force_login(bob) self.assertEqual(self.move('https://www.youtube.com/@tagesschau').status_code, 404) + + +class FlakyFeedTests(TestCase): + """YouTube's channel feeds answer 404 now and then; the next request gets them.""" + + FEED = 'https://www.youtube.com/feeds/videos.xml?channel_id=UCabcdefghijklmnopqrstuv' + BODY = (b'Kanal' + b'yt:video:abcdefghijkFolge') + + def _responses(self, codes): + from types import SimpleNamespace + calls = [] + + def get(url, **kw): + code = codes[min(len(calls), len(codes) - 1)] + calls.append(url) + return SimpleNamespace(status_code=code, content=self.BODY if code == 200 else b'', + headers={}, url=url, history=[]) + return get, calls + + def test_a_404_is_asked_again(self): + from .views import _fetch_feed + get, calls = self._responses([404, 404, 200]) + with patch('podcasts.views.requests.get', get), patch('podcasts.views.time.sleep', lambda s: None): + fetched = _fetch_feed(self.FEED) + self.assertEqual(fetched.status, 200) + self.assertEqual(len(calls), 3) + + def test_a_feed_that_stays_away_is_an_error(self): + from .views import _fetch_feed, FeedFetchError, YOUTUBE_FEED_TRIES + get, calls = self._responses([404]) + with patch('podcasts.views.requests.get', get), patch('podcasts.views.time.sleep', lambda s: None): + with self.assertRaises(FeedFetchError): + _fetch_feed(self.FEED) + self.assertEqual(len(calls), YOUTUBE_FEED_TRIES) + + def test_other_feeds_are_asked_once(self): + from .views import _fetch_feed, FeedFetchError + get, calls = self._responses([404, 200]) + with patch('podcasts.views.requests.get', get), patch('podcasts.views.time.sleep', lambda s: None): + with self.assertRaises(FeedFetchError): + _fetch_feed('https://example.org/feed.xml') + self.assertEqual(len(calls), 1) diff --git a/podcasts/views.py b/podcasts/views.py index c67299d..f698995 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -332,6 +332,11 @@ def _body_hash(content): PERMANENT_REDIRECTS = (301, 308) +YOUTUBE_FEED_TRIES = 12 +YOUTUBE_RETRY_STATUS = (404, 500, 502, 503) +YOUTUBE_RETRY_PAUSE = 0.25 # seconds between tries + + def _fetch_feed(url, etag='', last_modified='', body_hash='', user_agent='diora podcast refresh'): """Download and parse a feed (with a timeout, unlike feedparser's own fetch). With etag/last_modified it is a conditional GET; with body_hash a @@ -343,16 +348,24 @@ def _fetch_feed(url, etag='', last_modified='', body_hash='', user_agent='diora headers['If-None-Match'] = etag if last_modified: headers['If-Modified-Since'] = last_modified - try: - r = requests.get(url, timeout=FEED_TIMEOUT, headers=headers) - except requests.Timeout: - raise FeedFetchError('timeout') - except requests.TooManyRedirects: - raise FeedFetchError('too many redirects') - except requests.ConnectionError: - raise FeedFetchError('connection failed') - except requests.RequestException as e: - raise FeedFetchError(f'request failed ({type(e).__name__})') + # YouTube's channel feeds answer 404 (or a 5xx) now and then for no reason — in + # October 2026 about every other request — and the next request gets the feed: ask + # again a few times before calling it an error. + tries = YOUTUBE_FEED_TRIES if youtube_mod.is_feed(url) else 1 + for attempt in range(tries): + try: + r = requests.get(url, timeout=FEED_TIMEOUT, headers=headers) + except requests.Timeout: + raise FeedFetchError('timeout') + except requests.TooManyRedirects: + raise FeedFetchError('too many redirects') + except requests.ConnectionError: + raise FeedFetchError('connection failed') + except requests.RequestException as e: + raise FeedFetchError(f'request failed ({type(e).__name__})') + if r.status_code not in YOUTUBE_RETRY_STATUS or attempt == tries - 1: + break + time.sleep(YOUTUBE_RETRY_PAUSE) # Each hop's target is the next hop's URL (the last one's is r.url). permanent_url = url diff --git a/static/js/sw.js b/static/js/sw.js index ddaed8d..89fe278 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-v128'; +const CACHE = 'diora-v129'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', diff --git a/templates/base.html b/templates/base.html index 9d26a8a..822e2e8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -19,6 +19,15 @@ var KEY = 'diora_theme'; var LABELS = {dark: 'Dunkel', light: 'Hell', auto: 'Automatisch'}; var mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null; + // In the Android app the WebView's media query follows the app's own theme, not the + // system: the app tells what the system is set to (DioraNative.systemScheme, and + // __dioraSystemScheme when it switches). + var nat = window.DioraNative && typeof window.DioraNative.systemScheme === 'function' ? window.DioraNative : null; + var sys = null; + function systemLight() { + if (nat) { if (sys === null) { try { sys = nat.systemScheme(); } catch (e) { sys = 'dark'; } } return sys === 'light'; } + return !!(mq && mq.matches); + } function choice() { var v = null; try { v = localStorage.getItem(KEY); } catch (e) {} @@ -26,7 +35,7 @@ } function scheme() { var c = choice(); - return c === 'light' || (c === 'auto' && mq && mq.matches) ? 'light' : 'dark'; + return c === 'light' || (c === 'auto' && systemLight()) ? 'light' : 'dark'; } function apply() { var s = scheme(); @@ -51,6 +60,7 @@ window.__dioraSetTheme(order[(order.indexOf(choice()) + 1) % order.length]); }; if (mq && mq.addEventListener) mq.addEventListener('change', function () { if (choice() === 'auto') apply(); }); + window.__dioraSystemScheme = function (s) { sys = s; if (choice() === 'auto') apply(); }; document.addEventListener('DOMContentLoaded', apply); document.documentElement.setAttribute('data-theme', scheme()); })();