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.__dioraTheme()` → the choice; `window.__dioraSetTheme(choice)` sets and applies it;
|
||||||
`window.__dioraThemeChanged` (event `diora-theme` on `window`, `detail` = the scheme in
|
`window.__dioraThemeChanged` (event `diora-theme` on `window`, `detail` = the scheme in
|
||||||
effect) lets native code follow (Android status/navigation bars, Electron's window colour).
|
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.
|
* 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'`):
|
* 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.
|
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
|
once (feed address, name, picture) and stores the feed
|
||||||
`https://www.youtube.com/feeds/videos.xml?channel_id=UC…` (or `?playlist_id=…`).
|
`https://www.youtube.com/feeds/videos.xml?channel_id=UC…` (or `?playlist_id=…`).
|
||||||
`GET /podcasts/search/?q=<YouTube address>` answers with that one channel.
|
`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)
|
videos. **Shorts are left out.** There is no duration (`duration_seconds` 0)
|
||||||
until a player knows it: `POST /podcasts/progress/save/` takes an optional
|
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.
|
`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')
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
||||||
self.client.force_login(bob)
|
self.client.force_login(bob)
|
||||||
self.assertEqual(self.move('https://www.youtube.com/@tagesschau').status_code, 404)
|
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)
|
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'):
|
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
|
"""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
|
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
|
headers['If-None-Match'] = etag
|
||||||
if last_modified:
|
if last_modified:
|
||||||
headers['If-Modified-Since'] = last_modified
|
headers['If-Modified-Since'] = last_modified
|
||||||
try:
|
# YouTube's channel feeds answer 404 (or a 5xx) now and then for no reason — in
|
||||||
r = requests.get(url, timeout=FEED_TIMEOUT, headers=headers)
|
# October 2026 about every other request — and the next request gets the feed: ask
|
||||||
except requests.Timeout:
|
# again a few times before calling it an error.
|
||||||
raise FeedFetchError('timeout')
|
tries = YOUTUBE_FEED_TRIES if youtube_mod.is_feed(url) else 1
|
||||||
except requests.TooManyRedirects:
|
for attempt in range(tries):
|
||||||
raise FeedFetchError('too many redirects')
|
try:
|
||||||
except requests.ConnectionError:
|
r = requests.get(url, timeout=FEED_TIMEOUT, headers=headers)
|
||||||
raise FeedFetchError('connection failed')
|
except requests.Timeout:
|
||||||
except requests.RequestException as e:
|
raise FeedFetchError('timeout')
|
||||||
raise FeedFetchError(f'request failed ({type(e).__name__})')
|
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).
|
# Each hop's target is the next hop's URL (the last one's is r.url).
|
||||||
permanent_url = url
|
permanent_url = url
|
||||||
|
|
|
||||||
|
|
@ -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-v128';
|
const CACHE = 'diora-v129';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app/01-core.js',
|
'/static/js/app/01-core.js',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@
|
||||||
var KEY = 'diora_theme';
|
var KEY = 'diora_theme';
|
||||||
var LABELS = {dark: 'Dunkel', light: 'Hell', auto: 'Automatisch'};
|
var LABELS = {dark: 'Dunkel', light: 'Hell', auto: 'Automatisch'};
|
||||||
var mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
|
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() {
|
function choice() {
|
||||||
var v = null;
|
var v = null;
|
||||||
try { v = localStorage.getItem(KEY); } catch (e) {}
|
try { v = localStorage.getItem(KEY); } catch (e) {}
|
||||||
|
|
@ -26,7 +35,7 @@
|
||||||
}
|
}
|
||||||
function scheme() {
|
function scheme() {
|
||||||
var c = choice();
|
var c = choice();
|
||||||
return c === 'light' || (c === 'auto' && mq && mq.matches) ? 'light' : 'dark';
|
return c === 'light' || (c === 'auto' && systemLight()) ? 'light' : 'dark';
|
||||||
}
|
}
|
||||||
function apply() {
|
function apply() {
|
||||||
var s = scheme();
|
var s = scheme();
|
||||||
|
|
@ -51,6 +60,7 @@
|
||||||
window.__dioraSetTheme(order[(order.indexOf(choice()) + 1) % order.length]);
|
window.__dioraSetTheme(order[(order.indexOf(choice()) + 1) % order.length]);
|
||||||
};
|
};
|
||||||
if (mq && mq.addEventListener) mq.addEventListener('change', function () { if (choice() === 'auto') apply(); });
|
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.addEventListener('DOMContentLoaded', apply);
|
||||||
document.documentElement.setAttribute('data-theme', scheme());
|
document.documentElement.setAttribute('data-theme', scheme());
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue