YouTube-Kanäle: zufällige 404 abfangen; „Automatisch“ in der Android-App
- 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:
parent
3c7be76a93
commit
a399374216
6 changed files with 86 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue