diff --git a/frontend/js/enhance.js b/frontend/js/enhance.js index 17919cb..a9c5d38 100644 --- a/frontend/js/enhance.js +++ b/frontend/js/enhance.js @@ -79,7 +79,7 @@ App.enhance = App.enhance || {}; const ready = meta && Array.isArray(meta.formats) && meta.formats.length; if (!ready) { // Not resolved yet: kick it off so the *next* hover can preview. - if (typeof App.videos.resolveAndProbe === 'function') App.videos.resolveAndProbe(v); + if (typeof App.videos.ensureFormats === 'function') App.videos.ensureFormats(v); return; } let url = ''; diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index 2cc9b5d..b27e066 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -392,7 +392,7 @@ App.favorites = App.favorites || {};
- ${item.title} + ${item.title} @@ -404,8 +404,8 @@ App.favorites = App.favorites || {};
`; const thumb = card.querySelector('img'); - if (App.videos && typeof App.videos.attachNoReferrerRetry === 'function') { - App.videos.attachNoReferrerRetry(thumb); + if (App.videos && typeof App.videos.attachThumbnail === 'function') { + App.videos.attachThumbnail(thumb, item.thumb); } card.onclick = () => { if (card.classList.contains('is-loading')) return; diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 978fb72..2799c6b 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -424,7 +424,7 @@ App.feed = App.feed || {}; const liveBadge = v.isLive ? '● LIVE' : ''; const favKey = App.favorites ? App.favorites.getKey(v) : null; slide.innerHTML = ` - + ${liveBadge} ${favKey ? `` : ''} @@ -446,7 +446,7 @@ App.feed = App.feed || {}; `; const poster = slide.querySelector('.feed-poster'); - App.videos.attachNoReferrerRetry(poster); + App.videos.attachThumbnail(poster, v.thumb); const slideVideo = slide.querySelector('.feed-video'); bindTimeline(slide, slideVideo); bindSharedControls(slide, slideVideo, v); diff --git a/frontend/js/player.js b/frontend/js/player.js index f53ff29..fd6cf0d 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -25,9 +25,40 @@ App.player = App.player || {}; originEl: null, hudHovered: false, // mouse resting on the controls (desktop) activeUrl: '', // media URL actually playing, for the format menu's tick - attemptToken: 0 // bumps on every open()/format switch to void stale async callbacks + attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks + fetchAbort: null // aborts the current attempt's own requests }; + // Stops everything the current attempt has in flight. The token guards keep + // stale *callbacks* from acting, but they don't stop the requests those + // callbacks were waiting on: hls.js goes on pulling segments through the + // proxy, the media element keeps its connection open, and the content-type + // sniff keeps a whole upstream fetch alive on the server. When an attempt is + // superseded -- most of all when the direct route wins the race and the + // proxy has nothing left to do -- that work is pure waste at both ends. + function cancelInFlight() { + if (cp.fetchAbort) { + cp.fetchAbort.abort(); + cp.fetchAbort = null; + } + if (state.hlsPlayer) { + state.hlsPlayer.stopLoad(); + state.hlsPlayer.detachMedia(); + state.hlsPlayer.destroy(); + state.hlsPlayer = null; + } + const video = cp.video; + if (video) { + video.onerror = null; + video.pause(); + // Dropping the source is what closes the connection the media + // element is holding; load() makes the element let go of it now + // rather than whenever it next feels like it. + video.removeAttribute('src'); + video.load(); + } + } + const addCleanup = (fn) => cp.cleanups.push(fn); const runCleanups = () => { cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } }); @@ -652,6 +683,10 @@ App.player = App.player || {}; function playSources(videoData, opts) { const video = cp.video; const token = ++cp.attemptToken; + // Every route into here supersedes whatever was playing or loading: the + // direct route winning its race, a quality switch, a retry, a re-open. + // Void the old attempt's callbacks, then stop its requests. + cancelInFlight(); const resumeAt = (opts && opts.resumeAt) || 0; // Captured once per call rather than read from the shared `cp` // object later: if open() is ever re-entered for a different video @@ -679,6 +714,39 @@ App.player = App.player || {}; plan.push({ resolved, direct: false }); }); + // Whether a CDN will serve the browser directly is asked here, at play + // time, about this video's own media URL -- not in advance about the + // listing's. One provider can spread its media across several CDNs, so + // there is no single answer to pre-compute, and any answer taken from + // another video may not hold for this one. + // + // The question runs *alongside* the proxied playback rather than ahead + // of it, so it never delays anything: the proxy is already carrying the + // video while the direct route is being tested. If the answer comes + // back before any frame has been decoded, the attempt restarts on the + // direct URL -- nothing is on screen yet, so there is nothing to + // interrupt. If playback has already begun, the answer is kept, and the + // next video from that CDN starts direct without asking again. + const raceDirect = function(resolved) { + if (!App.videos || typeof App.videos.probeDirect !== 'function') return; + if (!resolved.url || resolved.isLive) return; + // An origin that demands a Referer can never be fetched directly by + // a browser, so there is nothing to find out. + if (resolved.refererRequired) return; + if (directProven(resolved.url)) return; + App.videos.probeDirect(resolved.url).then((ok) => { + if (!ok || token !== cp.attemptToken) return; + // readyState >= HAVE_CURRENT_DATA means a frame is up; leave a + // playing video alone rather than trading a visible stall for a + // saved hop. + if (!cp.video || cp.video.readyState >= 2) return; + // Direct won. Restarting cancels the proxy's fetch on the way + // in (see cancelInFlight), so the losing route stops pulling + // bytes instead of running to completion behind the winner. + playSources(videoData, Object.assign({}, opts, { resumeAt: resumeAt })); + }); + }; + const attempt = async (index) => { if (token !== cp.attemptToken) return; const entry = plan[index]; @@ -723,16 +791,13 @@ App.player = App.player || {}; let isHls = kind.isHls; let isDirectMedia = kind.isDirectMedia; - video.onerror = null; - if (state.hlsPlayer) { - state.hlsPlayer.stopLoad(); - state.hlsPlayer.detachMedia(); - state.hlsPlayer.destroy(); - state.hlsPlayer = null; - } - video.pause(); - video.removeAttribute('src'); - video.load(); + cancelInFlight(); + const attemptAbort = new AbortController(); + cp.fetchAbort = attemptAbort; + + // Going out through the proxy: find out in parallel whether this + // CDN would have taken the browser directly. + if (!entry.direct) raceDirect(resolved); // Last resort only: a HEAD through the proxy is a whole upstream // connection (handshake included) before the first byte of video is @@ -740,14 +805,23 @@ App.player = App.player || {}; // extractor's protocol says what this source is. if (!isHls && !isDirectMedia && !entry.direct) { try { - const headResp = await fetch(streamUrl, { method: 'HEAD' }); - if (token !== cp.attemptToken) return; + const headResp = await fetch(streamUrl, { + method: 'HEAD', + signal: attemptAbort.signal + }); const contentType = headResp.headers.get('Content-Type') || ''; if (contentType.includes('application/vnd.apple.mpegurl')) isHls = true; else if (contentType.startsWith('video/') || contentType.startsWith('audio/')) isDirectMedia = true; } catch (err) { - // Best-effort sniff only. + // Best-effort sniff only -- including the abort that + // cancelInFlight fires, which lands here rather than at the + // guard below. } + // Outside the catch on purpose: an aborted sniff means this + // attempt has been superseded, and swallowing that with the + // failure of a best-effort sniff would let a dead attempt walk + // on and attach a stream to the player that replaced it. + if (token !== cp.attemptToken) return; } const startPlayback = () => { @@ -845,14 +919,7 @@ App.player = App.player || {}; const reopening = !!(cp.container && cp.container.classList.contains('open')); if (reopening) { cp.attemptToken++; - if (state.hlsPlayer) { - state.hlsPlayer.destroy(); - state.hlsPlayer = null; - } - if (cp.video) { - cp.video.onerror = null; - cp.video.pause(); - } + cancelInFlight(); clearIdleTimer(); if (cp.originEl) cp.originEl.classList.remove('is-loading'); } @@ -944,17 +1011,10 @@ App.player = App.player || {}; App.player.close = function(opts) { if (!cp.container || !cp.container.classList.contains('open')) return; cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks - - if (state.hlsPlayer) { - state.hlsPlayer.destroy(); - state.hlsPlayer = null; - } - if (cp.video) { - cp.video.onerror = null; - cp.video.pause(); - cp.video.removeAttribute('src'); - cp.video.load(); - } + // Closing the player must also stop what it was fetching -- otherwise a + // proxied stream keeps being pulled, and the server keeps an upstream + // connection open, for a video nobody is watching any more. + cancelInFlight(); clearIdleTimer(); runCleanups(); diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 0aaa947..5c7dde6 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -123,27 +123,290 @@ App.videos = App.videos || {}; } }; - App.videos.attachNoReferrerRetry = function(img) { - if (!img) return; - if (!img.dataset.originalSrc) { - img.dataset.originalSrc = img.currentSrc || img.src || ''; + // --------------------------------------------------------------------- + // Thumbnails + // + // A thumbnail can come from two places: the provider's own CDN, or our + // /api/image proxy. Neither is reliably the faster one -- the CDN is a hop + // closer, but plenty of them hotlink-block or rate-limit, and learning that + // used to cost a whole failed request before the proxy was even asked. That + // serial retry is the wait worth removing. + // + // So the first thumbnail from a host is raced: both requests go out at once + // and whichever answers first is the one displayed. The winner is then + // remembered per host -- hotlink and CORS policy are origin-level, the same + // assumption the direct-playability probe below makes -- so every later + // thumbnail from that host goes straight down the route that already + // worked. One race per host, not one per image: racing every card would + // double the image traffic of a whole grid to learn something we already + // know by the second card. + // --------------------------------------------------------------------- + const IMAGE_DIRECT = 'direct'; + const IMAGE_PROXY = 'proxy'; + + const imageRoutes = new Map(); // host -> winning route; absent = unknown + const imageRacing = new Set(); // hosts with a race already deciding + const imageWaiting = new Map(); // host -> images held until it decides + + // A page of cards is built in one go, so every thumbnail from a host is + // attached before the first one has come back. Sending them all down the + // optimistic route is how the old serial retry hurt: on a host that blocks + // us, each card paid its own failed request before asking the proxy. So + // while a host is being decided the rest of its images wait for the answer + // -- at most as long as the fastest route takes -- and then load once, the + // right way round. If the race somehow stalls they go anyway. + const RACE_PATIENCE_MS = 2500; + + // Direct is the route we'd rather settle on, so when the proxy comes home + // first the held images give direct this much longer to answer before + // committing to the proxy. Long enough that a provider merely a little + // slower than same-origin still wins its hosts; short enough that one which + // hangs -- the case this whole thing exists for -- doesn't hold up a grid. + const DIRECT_GRACE_MS = 200; + + const imageHostOf = function(url) { + try { + return new URL(url, window.location.href).host; + } catch (err) { + return ''; } - img.dataset.noReferrerRetry = '0'; - img.addEventListener('error', () => { - if (img.dataset.noReferrerRetry === '1') return; - img.dataset.noReferrerRetry = '1'; - img.referrerPolicy = 'no-referrer'; - img.removeAttribute('crossorigin'); - const original = img.dataset.originalSrc || img.currentSrc || img.src || ''; - const proxyUrl = App.videos.buildImageProxyUrl(original); - if (proxyUrl) { - img.src = proxyUrl; - } else if (original) { - img.src = original; + }; + + // Where a thumbnail from this host should be fetched from. Answers with the + // provider while the host is still unknown -- the optimistic route, and the + // one a race starts on anyway. + App.videos.thumbnailUrl = function(url) { + if (!url) return ''; + return imageRoutes.get(imageHostOf(url)) === IMAGE_PROXY + ? (App.videos.buildImageProxyUrl(url) || url) + : url; + }; + + // A src-less counts as "unavailable", and the browser paints its alt + // text across the thumbnail box. Since a thumbnail now waits for its host's + // route before it gets a src, the caption is held back in a data attribute + // and put on only once there is an image to caption -- otherwise every card + // spells out its own title over the placeholder while the host is being + // decided, and permanently for an item that has no thumbnail at all. + const showThumbnail = function(img, url) { + if (img.dataset.alt !== undefined) { + img.alt = img.dataset.alt; + delete img.dataset.alt; + } + img.src = url; + }; + + // Last resort on a route that normally works: one expired or missing image + // shouldn't be left broken just because its host is fine in general. + const attachProxyFallback = function(img, proxyUrl) { + if (!proxyUrl) return; + img.addEventListener('error', () => { showThumbnail(img, proxyUrl); }, { once: true }); + }; + + // Releases the images held for `host`. `route` is the winner, or null when + // the race told us nothing (both routes failed, or it stalled) -- in which + // case they take the optimistic route with the proxy behind it, exactly as + // an undecided host used to. + const releaseWaiting = function(host, route) { + const waiting = imageWaiting.get(host); + if (!waiting) return; + imageWaiting.delete(host); + waiting.forEach((entry) => { + if (route === IMAGE_PROXY) { + showThumbnail(entry.img, entry.proxyUrl); + return; } + if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl); + showThumbnail(entry.img, entry.directUrl); }); }; + // Two questions, and they don't have the same answer: + // + // which is quicker *now* -> what this image should display + // does direct work at all -> what the host is remembered as + // + // The proxy often wins the first question purely because it's same-origin: + // the browser already holds that connection, while the provider costs a + // fresh DNS lookup and TLS handshake. That says nothing about the provider, + // and pinning a host to the proxy over it would push every thumbnail on the + // page through our own server for no reason. So speed decides the pixels, + // and capability decides the memory: direct is remembered whenever it works + // at all, because it costs no server hop. + // + // Both routes are therefore fetched off-screen, and the visible image is + // pointed at the first one home. Racing on the visible element instead + // would abort the loser -- and the loser is the request that answers the + // second question. + const raceThumbnail = function(img, directUrl, proxyUrl, host) { + let shown = false; + let outstanding = 2; + let routeFinal = false; // the direct verdict is in; nothing can revise it + let abandoned = false; // took too long; a later race owns the host now + let directSettled = false; + let patience = null; + let grace = null; + + const show = function(url) { + if (shown) return; + shown = true; + showThumbnail(img, url); // a cache hit; the probe has the bytes + }; + + // Records the host's route and lets go of everything held for it. The + // direct verdict is final; a route taken because direct was too slow to + // wait for is not, so a direct probe that comes home late still upgrades + // the host rather than leaving it on the proxy for the whole session. + // + // Crucially this happens the moment direct answers, not when both probes + // have finished: every thumbnail attached in the meantime is queued on + // exactly this answer, and making them wait on the *other* probe too + // leaves them blank for no reason. + const settleRoute = function(route, final) { + if (abandoned || routeFinal) return; + routeFinal = !!final; + if (final && patience) { clearTimeout(patience); patience = null; } + imageRoutes.set(host, route); + imageRacing.delete(host); + releaseWaiting(host, route); + }; + + // Nothing usable came home in time. Show whatever the host settled on -- + // broken rather than blank, as it would have been without any of this -- + // and keep the proxy behind an untested direct. + const giveUp = function() { + if (shown) return; + shown = true; + if (imageRoutes.get(host) === IMAGE_PROXY) { + showThumbnail(img, proxyUrl); + return; + } + attachProxyFallback(img, proxyUrl); + showThumbnail(img, directUrl); + }; + + const decide = function() { + if (--outstanding > 0) return; + if (patience) { clearTimeout(patience); patience = null; } + if (grace) { clearTimeout(grace); grace = null; } + giveUp(); // no-op if either route came home + }; + + // Off-screen, and low priority: neither may take bandwidth from anything + // the reader is already looking at. + const newProbe = function() { + const image = new Image(); + image.decoding = 'async'; + image.fetchPriority = 'low'; + return image; + }; + + const directProbe = newProbe(); + directProbe.referrerPolicy = 'no-referrer'; + // The direct verdict alone decides the route -- either way round. One + // probe discovers the host's answer and every held image acts on it, + // instead of each rediscovering it at the cost of its own request. + directProbe.onload = function() { + directSettled = true; + if (grace) { clearTimeout(grace); grace = null; } + show(directUrl); + settleRoute(IMAGE_DIRECT, true); + decide(); + }; + directProbe.onerror = function() { + directSettled = true; + if (grace) { clearTimeout(grace); grace = null; } + // Direct is out for this host, so the proxy is the answer even if it + // hasn't reported yet -- there is nothing else left to be. + settleRoute(IMAGE_PROXY, true); + decide(); + }; + + const proxyProbe = newProbe(); + proxyProbe.onload = function() { + show(proxyUrl); // first one home gets the pixels on screen + // Don't strand anything behind a provider that may never answer: + // give it the grace window, then take the route that works. Marked + // provisional, so a slow-but-working provider still wins its host + // when it finally reports. + if (!directSettled && !grace) { + grace = setTimeout(function() { + grace = null; + settleRoute(IMAGE_PROXY, false); + }, DIRECT_GRACE_MS); + } + decide(); + }; + proxyProbe.onerror = function() { decide(); }; + + imageRacing.add(host); + // A probe that never answers -- a hung connection rather than a refused + // one -- must not leave the host mid-race forever, with every later + // thumbnail queueing behind a decision that will never come. Once that + // happens this race stops touching the shared maps entirely: the next + // thumbnail starts a fresh one, and a late answer here must not reach in + // and overwrite what *that* race decides. + patience = setTimeout(function() { + patience = null; + if (grace) { clearTimeout(grace); grace = null; } + if (!routeFinal) { + abandoned = true; + imageRacing.delete(host); + releaseWaiting(host, imageRoutes.get(host) || null); + } + giveUp(); + }, RACE_PATIENCE_MS); + + directProbe.src = directUrl; + proxyProbe.src = proxyUrl; + }; + + // Points `img` at `url` by whichever route is known to work for its host, + // racing the two the first time that host is seen. + App.videos.attachThumbnail = function(img, url) { + const directUrl = url || (img && img.dataset.thumb) || ''; + if (!img) return; + // Held back until there is an image to caption -- see showThumbnail. An + // item with no thumbnail keeps an empty alt: the card's own title sits + // directly beneath the box, so there is nothing for it to add. + if (img.alt && img.dataset.alt === undefined) { + img.dataset.alt = img.alt; + img.alt = ''; + } + if (!directUrl) return; + // A cross-origin Referer is what most hotlink protection keys on, and an + // image needs none. Sending none is what lets the direct route work at + // all on a fair number of providers -- and the direct route is the one + // that costs us no server hop. + img.referrerPolicy = 'no-referrer'; + + const proxyUrl = App.videos.buildImageProxyUrl(directUrl); + const host = imageHostOf(directUrl); + const route = imageRoutes.get(host); + + if (route === IMAGE_PROXY) { + showThumbnail(img, proxyUrl || directUrl); + return; + } + if (imageRacing.has(host)) { + // A race is already deciding for this host. Wait for it rather than + // guessing: guessing wrong costs this image a whole failed request + // before it even asks the route that was about to be proven. + const waiting = imageWaiting.get(host) || []; + waiting.push({ img: img, directUrl: directUrl, proxyUrl: proxyUrl }); + imageWaiting.set(host, waiting); + return; + } + if (route === IMAGE_DIRECT || !host || !proxyUrl) { + // Known good, or nothing to race against: take the provider and keep + // the proxy as this image's own fallback. + attachProxyFallback(img, proxyUrl); + showThumbnail(img, directUrl); + return; + } + raceThumbnail(img, directUrl, proxyUrl, host); + }; + // Each channel in a group sends back a different number of videos per // page, so a small per-channel count keeps any one channel from // dominating a single interleaved batch. @@ -279,7 +542,11 @@ App.videos = App.videos || {}; }; const warmThumbnails = function(items) { - const urls = (items || []).map((v) => v && v.thumb).filter(Boolean); + // Warm down the route the host has already settled on -- warming a URL + // the cards won't ask for would leave them waiting anyway. + const urls = (items || []) + .map((v) => v && App.videos.thumbnailUrl(v.thumb)) + .filter(Boolean); if (!urls.length) return Promise.resolve(); const loads = urls.map((url) => new Promise((resolve) => { // Off-screen and not needed yet: low priority, started when the @@ -289,6 +556,12 @@ App.videos = App.videos || {}; const img = new Image(); img.decoding = 'async'; img.fetchPriority = 'low'; + // Same terms the card and the probe fetch on. Warming with a + // Referer the real request won't send would warm the wrong + // thing: on a hotlink-protecting host it earns a 403, which is + // both a wasted warm and a cached refusal the probe may then be + // handed -- pinning a host to the proxy that works direct. + img.referrerPolicy = 'no-referrer'; img.onload = resolve; img.onerror = resolve; // the card's own retry handles failures img.src = url; @@ -440,7 +713,7 @@ App.videos = App.videos || {}; // Builds a fully-wired video card element for `v`. Kept separate from // mounting so the virtualizer can create a card the moment it needs to be // on screen and throw it away once it scrolls out of the window. - App.videos.buildCard = function(v) { + App.videos.buildCard = function(v, options) { const favoritesSet = App.favorites.getSet(); const card = document.createElement('div'); card.className = 'video-card'; @@ -462,7 +735,7 @@ App.videos = App.videos || {};
- ${v.title} + ${v.title} @@ -473,7 +746,13 @@ App.videos = App.videos || {}; ${tagsMarkup} `; const thumb = card.querySelector('img'); - App.videos.attachNoReferrerRetry(thumb); + // The layout probe (see shapeHeight) needs the card's shape, never its + // pixels: it measures against the CSS 16:9 placeholder and is removed in + // the same frame, so loading a thumbnail for it -- let alone racing one + // -- would be pure waste. + if (!(options && options.skipThumbnail)) { + App.videos.attachThumbnail(thumb, v.thumb); + } const favoriteBtn = card.querySelector('.favorite-btn'); if (favoriteBtn && favoriteKey) { App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey)); @@ -554,7 +833,7 @@ App.videos = App.videos || {}; App.player.open(v, { originEl: card }); }; cardVideo.set(card, v); - card.addEventListener('pointerenter', () => App.videos.resolveAndProbe(v), { once: true }); + card.addEventListener('pointerenter', () => App.videos.ensureFormats(v), { once: true }); return card; }; @@ -755,7 +1034,7 @@ App.videos = App.videos || {}; if (cached != null) return cached; const el = grid(); if (!el) return 240; - const probe = App.videos.buildCard(v); + const probe = App.videos.buildCard(v, { skipThumbnail: true }); probe.style.position = 'absolute'; probe.style.visibility = 'hidden'; probe.style.left = '-99999px'; @@ -886,13 +1165,13 @@ App.videos = App.videos || {}; } // Marquee + direct-playability probe only matter for on-screen cards. requestAnimationFrame(() => { if (mounted.get(i) === card) measureTitle(card); }); - probeObserver.observe(card); + resolveObserver.observe(card); }; const unmount = function(i) { const card = mounted.get(i); if (!card) return; - probeObserver.unobserve(card); + resolveObserver.unobserve(card); if (titleObserver) { titleObserver.unobserve(card); titleVisibility.delete(card); @@ -1390,22 +1669,42 @@ App.videos = App.videos || {}; let ok = false; let detail = ''; try { - // A simple GET (no custom headers) avoids a CORS preflight. If - // the response is readable and successful, CORS + reachability - // are both proven; we abort immediately so the body isn't - // downloaded (it can be a whole video file). + // A simple GET (no custom headers) avoids a CORS preflight. const res = await fetch(url, { method: 'GET', mode: 'cors', credentials: 'omit', signal: controller.signal }); - ok = res.ok || res.status === 206; - detail = `HTTP ${res.status}`; + if (!(res.ok || res.status === 206)) { + ok = false; + detail = `HTTP ${res.status}`; + } else if (res.body && typeof res.body.getReader === 'function') { + // Run it until actual media bytes arrive. A readable status + // line is weaker evidence than it looks: the question is + // whether this origin will hand *the player* video data + // cross-origin, and that isn't settled until some has + // arrived. Then stop -- the rest of the file is not our + // business, and it can be a whole film. + const reader = res.body.getReader(); + const chunk = await reader.read(); + const bytes = (!chunk.done && chunk.value && chunk.value.length) || 0; + ok = bytes > 0; + detail = `HTTP ${res.status}, ${bytes} bytes`; + reader.cancel().catch(() => {}); + } else { + // No readable stream to sample (an old browser): the status + // line is all the evidence on offer. + ok = true; + detail = `HTTP ${res.status}, headers only`; + } controller.abort(); } catch (err) { + // A CORS refusal lands here as a TypeError with no status -- + // the browser won't say more than "failed" about a response it + // wouldn't let us read. ok = false; - detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'fetch failed'; + detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'blocked (CORS)'; } finally { clearTimeout(timer); } @@ -1418,37 +1717,18 @@ App.videos = App.videos || {}; return promise; }; - // Kicks off a background probe of a video's best (first-played) source so a - // later playback can skip the proxy if its host is proven reachable. Only - // runs once the video has resolved formats (see resolveAndProbe): those are - // real media URLs (or redirects to them), whereas a bare listing item only - // carries a page URL that the player can't use directly. - App.videos.probeVideoSources = function(video) { - if (!video || typeof video !== 'object') return; - const meta = video.meta || video; - if (!meta || !Array.isArray(meta.formats) || !meta.formats.length) return; - let sources; - try { - sources = App.videos.resolveStreamSources(video); - } catch (err) { - return; - } - const best = sources && sources[0]; - if (!best || !best.url || best.isLive) return; - // Sources that require a specific upstream Referer can't be fetched - // directly by the browser (it can't forge a cross-origin Referer), so a - // probe would always fail -- leave them to the proxy. - if (best.refererRequired) return; - App.videos.probeDirect(best.url); - }; - // Listing items arrive without formats (meta is null) -- only a page URL -- - // so there's nothing direct-playable to probe up front. This resolves a - // video's real media formats via the backend (yt-dlp), attaches them as - // `video.meta` so the player and probe can use them, then probes the best - // source. Resolution is per-video and deduped: it runs at most once per - // video, triggered lazily by hover/scroll so we don't resolve cards the - // user never looks at. + // so this resolves a video's real media formats via the backend (yt-dlp) and + // attaches them as `video.meta`, which is what playback, the quality menu + // and the hover preview all need. Resolution is per-video and deduped: it + // runs at most once per video, triggered lazily by hover/scroll so we don't + // resolve cards the user never looks at. + // + // It deliberately does *not* test direct playability. That question belongs + // to the video actually being played (see raceDirect in player.js): a + // provider can spread its media over several CDNs, so an answer taken from + // whichever card happened to scroll past need not hold for the one the + // reader picks. const cardVideo = new WeakMap(); // Session cache of `/api/resolve` results, keyed by video id (falling back @@ -1559,21 +1839,16 @@ App.videos = App.videos || {}; return promise; }; - const probeObserver = new IntersectionObserver((entries) => { + const resolveObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; - probeObserver.unobserve(entry.target); + resolveObserver.unobserve(entry.target); const video = cardVideo.get(entry.target); - if (video) App.videos.resolveAndProbe(video); + if (video) App.videos.ensureFormats(video); }); }, { rootMargin: '200px' }); - App.videos.resolveAndProbe = function(video) { - if (!video || typeof video !== 'object') return Promise.resolve(); - return App.videos.ensureFormats(video).then((meta) => { - if (meta) App.videos.probeVideoSources(video); - }); - }; + // Builds a proxied stream URL. Extra params other than `url` are forwarded // by the backend as request headers, so use real header names here.