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 || {};
-

+
@@ -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.