advanced probing
This commit is contained in:
@@ -290,9 +290,6 @@ App.videos = App.videos || {};
|
||||
items.forEach(v => {
|
||||
if (state.renderedVideoIds.has(v.id)) return;
|
||||
state.loadedVideos.push(v);
|
||||
// Probe in the background whether this video's best source plays
|
||||
// directly, so playback can bypass the proxy when proven.
|
||||
App.videos.probeVideoSources(v);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'video-card';
|
||||
@@ -410,6 +407,11 @@ App.videos = App.videos || {};
|
||||
App.player.open(v, { originEl: card });
|
||||
};
|
||||
grid.appendChild(card);
|
||||
// Resolve formats + probe direct playability on demand: when the
|
||||
// card scrolls near the viewport, or the moment it's hovered.
|
||||
cardVideo.set(card, v);
|
||||
probeObserver.observe(card);
|
||||
card.addEventListener('pointerenter', () => App.videos.resolveAndProbe(v), { once: true });
|
||||
state.renderedVideoIds.add(v.id);
|
||||
});
|
||||
|
||||
@@ -650,7 +652,7 @@ App.videos = App.videos || {};
|
||||
const isLive = !!(videoOrUrl && typeof videoOrUrl === 'object' &&
|
||||
(videoOrUrl.isLive || (videoOrUrl.meta && videoOrUrl.meta.isLive)));
|
||||
if (typeof videoOrUrl === 'string') {
|
||||
return videoOrUrl ? [{ url: videoOrUrl, referer: deriveReferer(videoOrUrl), userAgent: '', isLive }] : [];
|
||||
return videoOrUrl ? [{ url: videoOrUrl, referer: deriveReferer(videoOrUrl), userAgent: '', isLive, refererRequired: false }] : [];
|
||||
}
|
||||
if (!videoOrUrl || typeof videoOrUrl !== 'object') return [];
|
||||
|
||||
@@ -664,9 +666,14 @@ App.videos = App.videos || {};
|
||||
}
|
||||
|
||||
const sources = App.videos.rankFormats(meta.formats, preferredHeight).map((fmt) => {
|
||||
const referer = headerValue(fmt.http_headers, 'Referer') || metaReferer || deriveReferer(fmt.url);
|
||||
// An *explicit* Referer (from the extractor) signals the upstream
|
||||
// enforces it; deriveReferer is only a best-effort fallback. The
|
||||
// browser can't set a cross-origin Referer, so refererRequired tells
|
||||
// callers (the probe) that direct playback can't work.
|
||||
const explicitReferer = headerValue(fmt.http_headers, 'Referer') || metaReferer;
|
||||
const referer = explicitReferer || deriveReferer(fmt.url);
|
||||
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
||||
return { url: fmt.url, referer, userAgent, isLive };
|
||||
return { url: fmt.url, referer, userAgent, isLive, refererRequired: !!explicitReferer };
|
||||
});
|
||||
|
||||
if (!sources.length) {
|
||||
@@ -676,7 +683,8 @@ App.videos = App.videos || {};
|
||||
url: fallbackUrl,
|
||||
referer: metaReferer || deriveReferer(fallbackUrl),
|
||||
userAgent: metaUserAgent,
|
||||
isLive
|
||||
isLive,
|
||||
refererRequired: !!metaReferer
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -685,37 +693,41 @@ App.videos = App.videos || {};
|
||||
|
||||
App.videos.resolveStreamSource = function(videoOrUrl, options) {
|
||||
const sources = App.videos.resolveStreamSources(videoOrUrl, options);
|
||||
return sources.length ? sources[0] : { url: '', referer: '', userAgent: '', isLive: false };
|
||||
return sources.length ? sources[0] : { url: '', referer: '', userAgent: '', isLive: false, refererRequired: false };
|
||||
};
|
||||
|
||||
// Background "direct playability" probe. The backend proxy exists to work
|
||||
// around CORS, hotlink (403) protection, and TLS fingerprinting. When the
|
||||
// browser can fetch a media URL cross-origin and actually read the response
|
||||
// (CORS allowed, not blocked/403), playing it directly works and the proxy
|
||||
// is pure overhead. We probe each loaded video's best source in the
|
||||
// background and, only when proven, let the player skip the proxy.
|
||||
// is pure overhead. CORS is an origin-level policy, so the answer is the
|
||||
// same for every media URL served by a given host: we probe (and cache)
|
||||
// once per host and let the player skip the proxy for any URL on a host
|
||||
// that's been proven.
|
||||
const DIRECT_PROBE_TIMEOUT_MS = 8000;
|
||||
// Only URLs that the player can hand straight to <video>/hls.js are worth
|
||||
// probing; anything else (e.g. a live channel page URL) is resolved
|
||||
// server-side and must keep going through the proxy.
|
||||
const DIRECT_PLAYABLE_RE = /\.(mp4|m4v|m4s|webm|ts|mov|m3u8)($|\?)/i;
|
||||
|
||||
// url -> true (proven directly playable) | false (proven not). Absent means
|
||||
// unknown/unprobed, in which case the proxy is used.
|
||||
const directHostOf = (url) => {
|
||||
try { return new URL(url).host; } catch (err) { return ''; }
|
||||
};
|
||||
|
||||
// host -> true (proven directly playable) | false (proven not). Absent
|
||||
// means unknown/unprobed, in which case the proxy is used.
|
||||
App.videos._directStatus = new Map();
|
||||
const directPending = new Map();
|
||||
|
||||
App.videos.isDirectProven = function(url) {
|
||||
return App.videos._directStatus.get(url) === true;
|
||||
return App.videos._directStatus.get(directHostOf(url)) === true;
|
||||
};
|
||||
|
||||
App.videos.probeDirect = function(url) {
|
||||
if (!url) return Promise.resolve(false);
|
||||
if (App.videos._directStatus.has(url)) {
|
||||
return Promise.resolve(App.videos._directStatus.get(url));
|
||||
const host = directHostOf(url);
|
||||
if (!host) return Promise.resolve(false);
|
||||
if (App.videos._directStatus.has(host)) {
|
||||
return Promise.resolve(App.videos._directStatus.get(host));
|
||||
}
|
||||
if (directPending.has(url)) {
|
||||
return directPending.get(url);
|
||||
if (directPending.has(host)) {
|
||||
return directPending.get(host);
|
||||
}
|
||||
const promise = (async () => {
|
||||
const controller = new AbortController();
|
||||
@@ -742,19 +754,24 @@ App.videos = App.videos || {};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
App.videos._directStatus.set(url, ok);
|
||||
directPending.delete(url);
|
||||
console.log(`[direct-probe] ${ok ? 'DIRECT' : 'PROXY '} (${detail}) ${url}`);
|
||||
App.videos._directStatus.set(host, ok);
|
||||
directPending.delete(host);
|
||||
console.log(`[direct-probe] ${ok ? 'DIRECT' : 'PROXY '} (${detail}) ${host}`);
|
||||
return ok;
|
||||
})();
|
||||
directPending.set(url, promise);
|
||||
directPending.set(host, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
// Kicks off a background probe of a video's best (first-played) source so a
|
||||
// later playback can skip the proxy if the direct URL is proven reachable.
|
||||
// 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);
|
||||
@@ -763,10 +780,68 @@ App.videos = App.videos || {};
|
||||
}
|
||||
const best = sources && sources[0];
|
||||
if (!best || !best.url || best.isLive) return;
|
||||
if (!DIRECT_PLAYABLE_RE.test(best.url)) 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.
|
||||
const cardVideo = new WeakMap();
|
||||
const metaResolved = new Set();
|
||||
const metaPending = new Map();
|
||||
|
||||
const probeObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
probeObserver.unobserve(entry.target);
|
||||
const video = cardVideo.get(entry.target);
|
||||
if (video) App.videos.resolveAndProbe(video);
|
||||
});
|
||||
}, { rootMargin: '200px' });
|
||||
|
||||
App.videos.resolveAndProbe = function(video) {
|
||||
if (!video || typeof video !== 'object' || !video.id) return Promise.resolve();
|
||||
// Already have formats (resolved earlier): just (re)probe the best one.
|
||||
if (video.meta && Array.isArray(video.meta.formats) && video.meta.formats.length) {
|
||||
App.videos.probeVideoSources(video);
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (metaResolved.has(video.id)) return Promise.resolve();
|
||||
if (metaPending.has(video.id)) return metaPending.get(video.id);
|
||||
if (!video.url) return Promise.resolve();
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/resolve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: video.url })
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (data && Array.isArray(data.formats) && data.formats.length) {
|
||||
video.meta = data;
|
||||
App.videos.probeVideoSources(video);
|
||||
}
|
||||
} catch (err) {
|
||||
// Best-effort: playback still works through the proxy.
|
||||
} finally {
|
||||
metaResolved.add(video.id);
|
||||
metaPending.delete(video.id);
|
||||
}
|
||||
})();
|
||||
metaPending.set(video.id, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
// Builds a proxied stream URL. Extra params other than `url` are forwarded
|
||||
// by the backend as request headers, so use real header names here.
|
||||
App.videos.buildStreamUrlFromSource = function(resolved) {
|
||||
|
||||
Reference in New Issue
Block a user