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 () => {