Compare commits
2 Commits
a251b274db
...
80476a8a42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80476a8a42 | ||
|
|
785b991d01 |
@@ -75,6 +75,19 @@ App.player = App.player || {};
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expand the candidate sources into an ordered playback plan. When a
|
||||||
|
// source has been proven (in the background) to play directly, try the
|
||||||
|
// raw upstream URL first and keep the proxy as the immediate fallback;
|
||||||
|
// unproven sources go straight through the proxy.
|
||||||
|
const directProven = (url) => !!(App.videos && App.videos.isDirectProven && App.videos.isDirectProven(url));
|
||||||
|
const playbackPlan = [];
|
||||||
|
sources.forEach((resolved) => {
|
||||||
|
if (directProven(resolved.url)) {
|
||||||
|
playbackPlan.push({ resolved, direct: true });
|
||||||
|
}
|
||||||
|
playbackPlan.push({ resolved, direct: false });
|
||||||
|
});
|
||||||
|
|
||||||
if (useMobileFullscreen) {
|
if (useMobileFullscreen) {
|
||||||
const host = getMobileVideoHost();
|
const host = getMobileVideoHost();
|
||||||
if (video.parentElement !== host) {
|
if (video.parentElement !== host) {
|
||||||
@@ -120,8 +133,9 @@ App.player = App.player || {};
|
|||||||
// Attempts to play a single source. On a fatal failure it advances to
|
// Attempts to play a single source. On a fatal failure it advances to
|
||||||
// the next candidate, or reports an error once the list is exhausted.
|
// the next candidate, or reports an error once the list is exhausted.
|
||||||
const attempt = async (index) => {
|
const attempt = async (index) => {
|
||||||
const resolved = sources[index];
|
const entry = playbackPlan[index];
|
||||||
const hasNext = index + 1 < sources.length;
|
const resolved = entry.resolved;
|
||||||
|
const hasNext = index + 1 < playbackPlan.length;
|
||||||
let playbackStarted = false;
|
let playbackStarted = false;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
|
||||||
@@ -137,10 +151,17 @@ App.player = App.player || {};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
// Proven-direct entries hit the upstream URL straight from the
|
||||||
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
|
// browser; everything else is wrapped in the backend stream proxy.
|
||||||
const liveParam = resolved.isLive ? '&live=1' : '';
|
let streamUrl;
|
||||||
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`;
|
if (entry.direct) {
|
||||||
|
streamUrl = resolved.url;
|
||||||
|
} else {
|
||||||
|
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
||||||
|
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
|
||||||
|
const liveParam = resolved.isLive ? '&live=1' : '';
|
||||||
|
streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`;
|
||||||
|
}
|
||||||
let isHls = /\.m3u8($|\?)/i.test(resolved.url);
|
let isHls = /\.m3u8($|\?)/i.test(resolved.url);
|
||||||
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url);
|
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url);
|
||||||
// Live cam streams resolve (server-side) to HLS; treat them as HLS up
|
// Live cam streams resolve (server-side) to HLS; treat them as HLS up
|
||||||
@@ -165,7 +186,7 @@ App.player = App.player || {};
|
|||||||
video.removeAttribute('src');
|
video.removeAttribute('src');
|
||||||
video.load();
|
video.load();
|
||||||
|
|
||||||
if (!isHls) {
|
if (!isHls && !entry.direct) {
|
||||||
try {
|
try {
|
||||||
const headResp = await fetch(streamUrl, { method: 'HEAD' });
|
const headResp = await fetch(streamUrl, { method: 'HEAD' });
|
||||||
const contentType = headResp.headers.get('Content-Type') || '';
|
const contentType = headResp.headers.get('Content-Type') || '';
|
||||||
|
|||||||
@@ -290,6 +290,9 @@ App.videos = App.videos || {};
|
|||||||
items.forEach(v => {
|
items.forEach(v => {
|
||||||
if (state.renderedVideoIds.has(v.id)) return;
|
if (state.renderedVideoIds.has(v.id)) return;
|
||||||
state.loadedVideos.push(v);
|
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');
|
const card = document.createElement('div');
|
||||||
card.className = 'video-card';
|
card.className = 'video-card';
|
||||||
@@ -685,6 +688,85 @@ App.videos = App.videos || {};
|
|||||||
return sources.length ? sources[0] : { url: '', referer: '', userAgent: '', isLive: false };
|
return sources.length ? sources[0] : { url: '', referer: '', userAgent: '', isLive: 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.
|
||||||
|
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.
|
||||||
|
App.videos._directStatus = new Map();
|
||||||
|
const directPending = new Map();
|
||||||
|
|
||||||
|
App.videos.isDirectProven = function(url) {
|
||||||
|
return App.videos._directStatus.get(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));
|
||||||
|
}
|
||||||
|
if (directPending.has(url)) {
|
||||||
|
return directPending.get(url);
|
||||||
|
}
|
||||||
|
const promise = (async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), DIRECT_PROBE_TIMEOUT_MS);
|
||||||
|
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).
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'omit',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
ok = res.ok || res.status === 206;
|
||||||
|
detail = `HTTP ${res.status}`;
|
||||||
|
controller.abort();
|
||||||
|
} catch (err) {
|
||||||
|
ok = false;
|
||||||
|
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'fetch failed';
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
App.videos._directStatus.set(url, ok);
|
||||||
|
directPending.delete(url);
|
||||||
|
console.log(`[direct-probe] ${ok ? 'DIRECT' : 'PROXY '} (${detail}) ${url}`);
|
||||||
|
return ok;
|
||||||
|
})();
|
||||||
|
directPending.set(url, 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.
|
||||||
|
App.videos.probeVideoSources = function(video) {
|
||||||
|
if (!video || typeof video !== 'object') return;
|
||||||
|
let sources;
|
||||||
|
try {
|
||||||
|
sources = App.videos.resolveStreamSources(video);
|
||||||
|
} catch (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const best = sources && sources[0];
|
||||||
|
if (!best || !best.url || best.isLive) return;
|
||||||
|
if (!DIRECT_PLAYABLE_RE.test(best.url)) return;
|
||||||
|
App.videos.probeDirect(best.url);
|
||||||
|
};
|
||||||
|
|
||||||
// Builds a proxied stream URL. Extra params other than `url` are forwarded
|
// Builds a proxied stream URL. Extra params other than `url` are forwarded
|
||||||
// by the backend as request headers, so use real header names here.
|
// by the backend as request headers, so use real header names here.
|
||||||
App.videos.buildStreamUrlFromSource = function(resolved) {
|
App.videos.buildStreamUrlFromSource = function(resolved) {
|
||||||
|
|||||||
Reference in New Issue
Block a user