From d5082639463f8624a55d4a16914ec058247e7603 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 5 Sep 2026 20:41:31 +0000 Subject: [PATCH] Don't re-resolve a favorite whose URL is already the media file Some channels hand back the media URL itself as an item's url. Since the favorites fix, opening one of those sent it to /api/resolve first, so yt-dlp fetched the media just to report the URL we already had. On a signed link (`?secure=-`) that is a second request against something that may be single-use or IP-bound, and the request that matters -- the playback fetch -- is then refused. Such URLs now play directly, with no resolve round trip, as they did before. Alongside that, three things that make expiry survivable: /api/stream, after its existing referer-less retry, now retries a 403 completely bare (Range only). Signed CDN links are routinely served to a plain browser request and refused when it carries extras -- a `Sec-Fetch-Mode: navigate` on a media subresource, say, which is what yt-dlp's generic extractor hands back and no real player would send. When every source fails, the player re-resolves once and retries instead of giving up, since the likeliest cause is that signed URLs went stale in a long-open tab rather than the video being gone. A manual quality pick is dropped for that retry, as it names one of the URLs that just failed. Favorites stored by older versions still carry a `meta` blob of resolved formats, long expired; it's now stripped on read so nothing can reach for one. Verified: a favorite whose url is a .mp4 plays with zero /api/resolve calls, straight from that URL; playback, prefetch, feed paging, HUD, rotation, momentum and the version check all still pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd --- backend/main.py | 13 +++++++++++++ frontend/js/favorites.js | 14 +++++++++++++- frontend/js/player.js | 21 +++++++++++++++++++++ frontend/js/videos.js | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index bebbe42..bd2fa6b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -644,6 +644,19 @@ def stream_video(): resp.close() referer_less = {k: v for k, v in safe_request_headers.items() if k not in ('Referer', 'Origin')} resp = impersonate_get(target_url, headers=referer_less, stream=True, timeout=30, allow_redirects=True) + # Still refused: strip everything the extractor asked us to relay and go + # in bare (Range only, plus whatever impersonation supplies). Signed CDN + # links are often served fine to a plain browser request and refused when + # it carries extras -- a `Sec-Fetch-Mode: navigate` on a media + # subresource, say, which is exactly what yt-dlp's generic extractor + # hands back and what a real player would never send. + if resp.status_code == 403 and len(safe_request_headers) > (1 if 'Range' in safe_request_headers else 0): + dbg("upstream still 403; retrying bare (range only)") + resp.close() + bare = {} + if 'Range' in safe_request_headers: + bare['Range'] = safe_request_headers['Range'] + resp = impersonate_get(target_url, headers=bare, stream=True, timeout=30, allow_redirects=True) if debug_enabled: dbg(f"upstream status={resp.status_code} content_type={resp.headers.get('Content-Type')} content_length={resp.headers.get('Content-Length')}") diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index cf32fee..312571b 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -9,7 +9,19 @@ App.favorites = App.favorites || {}; try { const raw = localStorage.getItem(FAVORITES_KEY); const parsed = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? parsed : []; + if (!Array.isArray(parsed)) return []; + // Favorites saved by older versions carry a `meta` blob of resolved + // formats whose URLs are signed and long expired. Drop it on the way + // in so no code path can reach for one; everything re-resolves from + // `url` at play time, and normalize() no longer stores it. + return parsed.map((item) => { + if (item && typeof item === 'object' && item.meta) { + const clean = Object.assign({}, item); + delete clean.meta; + return clean; + } + return item; + }); } catch (err) { return []; } diff --git a/frontend/js/player.js b/frontend/js/player.js index ad8b6b0..f358a86 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -698,6 +698,27 @@ App.player = App.player || {}; if (settled || token !== cp.attemptToken) return; settled = true; if (hasNext) attempt(index + 1); + else if (!(opts && opts.refreshed) && App.videos && + typeof App.videos.refreshFormats === 'function') { + // Every candidate failed. Media URLs are signed with an + // expiry, so the most likely cause is that these ones went + // stale (a tab left open, or formats resolved a while ago), + // not that the video is gone. Re-resolve and try once more + // before telling the viewer it can't be played. + App.videos.refreshFormats(videoData).then((meta) => { + if (token !== cp.attemptToken) return; + // A manually picked format points at one of the URLs + // that just failed, so the retry goes back to automatic + // selection over the freshly resolved list. + cp.formatOverride = null; + const retryOpts = Object.assign({}, opts, { refreshed: true, resumeAt }); + if (meta) playSources(videoData, retryOpts); + else { + clearLoading(); + showError(message, () => playSources(videoData, retryOpts), sourceUrl); + } + }); + } else { clearLoading(); showError(message, () => playSources(videoData, opts), sourceUrl); diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 5583fd9..d0697d3 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -345,6 +345,19 @@ App.videos = App.videos || {}; return prefetch.inFlight; }; + // Throws away a video's resolved formats and asks the server again. Media + // URLs are commonly signed with an expiry (`?secure=-`), so + // formats resolved earlier -- in a long-open tab, or held in the session + // cache -- eventually start returning 403 even though nothing is wrong with + // the video itself. The player calls this once before giving up. + App.videos.refreshFormats = function(video) { + if (!video || typeof video !== 'object') return Promise.resolve(null); + const cacheKey = video.id || video.url; + if (cacheKey) metaCache.delete(cacheKey); + video.meta = null; + return App.videos.ensureFormats(video); + }; + // Called by the virtualizer once the reader is within two rows of the end. App.videos.releasePrefetched = function() { if (!prefetch.batch || state.isLoading) return false; @@ -1415,12 +1428,36 @@ App.videos = App.videos || {}; // Resolves a video's real media formats (once per session) and attaches them // as `video.meta`. Returns the resolved meta, or null when it can't be had. + // Mirrors the backend's is_direct_media(): a URL whose path (query ignored, + // trailing slash tolerated) already names a media file. + const DIRECT_MEDIA_RE = /\.(mp4|m4v|m4s|webm|mov|ts|m3u8|mpd)$/i; + + const isDirectMediaUrl = function(url) { + if (!url) return false; + try { + return DIRECT_MEDIA_RE.test(new URL(url, window.location.href).pathname.replace(/\/+$/, '')); + } catch (err) { + return false; + } + }; + App.videos.ensureFormats = function(video) { if (!video || typeof video !== 'object') return Promise.resolve(null); if (hasFormats(video.meta)) return Promise.resolve(video.meta); const cacheKey = video.id || video.url; if (!cacheKey || !video.url) return Promise.resolve(null); + // Some channels hand back the media URL itself as the item URL. Sending + // that to /api/resolve makes yt-dlp fetch the media just to tell us what + // we already know: slow, and on a signed URL it's a second request + // against a link that may be single-use or IP-bound -- after which the + // one that matters, the actual playback fetch, is refused. Play it as-is. + if (isDirectMediaUrl(video.url)) { + const meta = { url: video.url, formats: [{ url: video.url }], http_headers: {} }; + video.meta = meta; + return Promise.resolve(meta); + } + let promise = metaCache.get(cacheKey); if (!promise) { promise = (async () => {