YouTube-Kanäle: zufällige 404 abfangen; „Automatisch“ in der Android-App
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 9m7s
Test / e2e (push) Successful in 2m10s

- YouTube antwortet auf Kanal-Feeds derzeit bei etwa zwei von drei Abrufen
  grundlos mit 404 (Kanäle zeigten „lädt nicht: HTTP 404“): ein YouTube-Feed
  wird bis zu 12-mal im Abstand von 0,25 s gefragt. Tests dazu.
- Erscheinungsbild „Automatisch“: in der Android-App folgt die WebView nicht
  dem System, sondern dem (dunklen) App-Theme – die Seite fragt dort
  DioraNative.systemScheme() und hört __dioraSystemScheme bei einem Wechsel.
sw.js: diora-v129.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-19 08:16:09 +02:00
parent 3c7be76a93
commit a399374216
6 changed files with 86 additions and 14 deletions

View file

@ -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.

View file

@ -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=<YouTube address>` 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.

View file

@ -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'<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Kanal</title>'
b'<entry><id>yt:video:abcdefghijk</id><title>Folge</title></entry></feed>')
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)

View file

@ -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

View file

@ -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',

View file

@ -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());
})();