mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 19:35:28 +00:00
[playback] CDN-first for dual-source videos + reliable hosted playback
Two fixes for hosted (CDN) playback that users hit on a torrent-first instance:
1. Reliability: clear crossOrigin before direct hosted playback (in
resetVideoElement and right before video.src = httpsUrl). A leftover
crossOrigin="anonymous" from a prior WebTorrent attempt on the reused
<video> forced a CORS request that CDNs / redirect targets (e.g. archive.org
storage nodes) reject, failing with MEDIA_ERR_SRC_NOT_SUPPORTED ("source not
supported or blocked"). Direct <video> playback never needs CORS, so any URL
now plays.
2. CDN-first for dual-source: when a hosted URL exists, play it first for
instant, reliable startup; WebTorrent is reserved for URL-less videos (and
explicit forcedSource: "torrent"). Previously a torrent-first instance made
dual-source videos wait on a P2P cold start before falling back to the CDN.
The torrent-first branch is now only reached without a URL, so it waits
indefinitely for peers (no plan B) rather than on a fixed timeout.
Tests: hosted playback clears crossOrigin and plays; a torrent-first instance
plays the URL first when one exists and never calls WebTorrent; URL-less videos
still use WebTorrent. Spec-corrected two playbackService_order tests that
encoded the old torrent-first-for-dual-source behavior. All mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
4e5bfaa6af
commit
89747d7290
3 files changed
+144
-27
No files matched your search
@@ -898,6 +898,20 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
}
|
||||
activeVideoEl.src = "";
|
||||
activeVideoEl.srcObject = null;
|
||||
// Direct hosted playback must NOT require CORS. A leftover
|
||||
// crossOrigin="anonymous" (e.g. set by a prior WebTorrent attempt on
|
||||
// this reused element) makes the browser issue a CORS request that many
|
||||
// CDNs — and redirect targets like archive.org's storage nodes — do not
|
||||
// answer with Access-Control-Allow-Origin, so the load dies with
|
||||
// MEDIA_ERR_SRC_NOT_SUPPORTED ("source not supported or blocked"). We
|
||||
// never read pixels from the hosted <video>, so clear it and let any
|
||||
// URL play.
|
||||
try {
|
||||
activeVideoEl.removeAttribute("crossorigin");
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
activeVideoEl.crossOrigin = null;
|
||||
try {
|
||||
activeVideoEl.load();
|
||||
} catch (err) {
|
||||
@@ -1086,6 +1100,14 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
};
|
||||
|
||||
try {
|
||||
// Guarantee the direct load is CORS-free regardless of how we got
|
||||
// here (see resetVideoElement) so any hosted URL plays reliably.
|
||||
try {
|
||||
activeVideoEl.removeAttribute("crossorigin");
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
activeVideoEl.crossOrigin = null;
|
||||
activeVideoEl.src = httpsUrl;
|
||||
const playPromise = activeVideoEl.play();
|
||||
if (playPromise && typeof playPromise.catch === "function") {
|
||||
@@ -1180,6 +1202,13 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
// --- Execution Flow ---
|
||||
|
||||
let tryUrlFirst = this.service.urlFirstEnabled;
|
||||
// CDN-first for dual-source videos: whenever a hosted URL is available,
|
||||
// play it first for instant, reliable startup. WebTorrent is reserved for
|
||||
// videos that have no URL (or when the viewer explicitly forces P2P). This
|
||||
// intentionally takes precedence over a torrent-first instance default,
|
||||
// because waiting on a P2P cold-start when a working CDN exists is the
|
||||
// slow, flaky path users were hitting.
|
||||
if (httpsUrl) tryUrlFirst = true;
|
||||
if (forcedSource === "url") tryUrlFirst = true;
|
||||
if (forcedSource === "torrent") tryUrlFirst = false;
|
||||
|
||||
@@ -1223,9 +1252,11 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
// Try Torrent First
|
||||
if (this.magnetForPlayback) {
|
||||
try {
|
||||
// If we have a fallback URL, use the effective timeout.
|
||||
// Otherwise, we must wait indefinitely for peers because there is no plan B.
|
||||
const torrentTimeout = httpsUrl ? effectiveTimeout : 0;
|
||||
// We only reach the torrent-first path when there is no hosted URL
|
||||
// to fall back to (CDN-first handles the dual-source case above) or
|
||||
// the viewer explicitly forced P2P. Either way there is no plan B,
|
||||
// so wait indefinitely for peers rather than abandoning playback.
|
||||
const torrentTimeout = 0;
|
||||
|
||||
// Wrap Torrent attempt in timeout
|
||||
const torrentResult = await withTimeout(
|
||||
|
||||
@@ -131,6 +131,86 @@ describe("PlaybackService Forced Source Logic", () => {
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test("Hosted playback clears crossOrigin so a CORS-less CDN URL still plays", async () => {
|
||||
// Simulate a leftover crossOrigin from a prior WebTorrent attempt on the
|
||||
// reused <video> element. Direct hosted playback must not require CORS, or
|
||||
// CDNs/redirect targets without Access-Control-Allow-Origin fail the load
|
||||
// with MEDIA_ERR_SRC_NOT_SUPPORTED ("source not supported or blocked").
|
||||
video.crossOrigin = "anonymous";
|
||||
video.setAttribute("crossorigin", "anonymous");
|
||||
|
||||
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
||||
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://archive.org/download/x/My Video (1080p).mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
forcedSource: "url",
|
||||
});
|
||||
|
||||
const startPromise = session.start();
|
||||
mock.timers.tick(50);
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
|
||||
// By the time the hosted source is assigned, CORS must be cleared.
|
||||
assert.equal(video.crossOrigin, null, "crossOrigin property cleared");
|
||||
assert.equal(
|
||||
video.getAttribute("crossorigin"),
|
||||
null,
|
||||
"crossorigin attribute removed",
|
||||
);
|
||||
|
||||
video.dispatchEvent(new window.Event("playing"));
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
const result = await startPromise;
|
||||
assert.equal(result.source, "url", "hosted URL plays");
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 0, "no torrent fallback");
|
||||
});
|
||||
|
||||
test("CDN-first: a torrent-first instance still plays the URL first when one exists", async () => {
|
||||
// Even with a torrent-first instance default, a video that ALSO has a hosted
|
||||
// URL should play the URL immediately (instant/reliable) instead of waiting
|
||||
// on a P2P cold start. WebTorrent is reserved for URL-less videos.
|
||||
const torrentFirstService = new PlaybackService({
|
||||
logger: () => {},
|
||||
isValidMagnetUri: () => true,
|
||||
playbackStartTimeout: 100,
|
||||
urlFirstEnabled: false, // torrent-first instance
|
||||
});
|
||||
|
||||
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
||||
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
||||
|
||||
const session = torrentFirstService.createSession({
|
||||
url: "https://cdn.example.com/v.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
});
|
||||
|
||||
const startPromise = session.start();
|
||||
mock.timers.tick(10);
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
video.dispatchEvent(new window.Event("playing"));
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
const result = await startPromise;
|
||||
|
||||
assert.equal(
|
||||
result.source,
|
||||
"url",
|
||||
"URL plays first despite a torrent-first instance",
|
||||
);
|
||||
assert.equal(
|
||||
playViaWebTorrent.mock.callCount(),
|
||||
0,
|
||||
"WebTorrent is NOT attempted when a hosted URL exists",
|
||||
);
|
||||
});
|
||||
|
||||
test("Forced Source 'url': Does NOT fallback to Torrent even if URL fails (probe bad)", async () => {
|
||||
const probeUrl = mock.fn(async () => {
|
||||
return { outcome: "bad", status: 404 };
|
||||
|
||||
@@ -136,7 +136,13 @@ describe("PlaybackService Ordering", () => {
|
||||
assert.equal(result.source, "torrent");
|
||||
});
|
||||
|
||||
test("urlFirstEnabled=false: Tries Torrent first, succeeds", async () => {
|
||||
// SPEC CHANGE: dual-source videos are now CDN-first (see PlaybackSession
|
||||
// execute(): `if (httpsUrl) tryUrlFirst = true`). A torrent-first instance
|
||||
// default no longer forces P2P when a hosted URL is also available, because
|
||||
// waiting on a P2P cold start when a working CDN exists was the slow/flaky
|
||||
// path users hit. WebTorrent is now reserved for URL-less videos (and
|
||||
// explicit forcedSource: "torrent", covered below).
|
||||
test("urlFirstEnabled=false but a hosted URL exists: CDN-first plays the URL", async () => {
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: false,
|
||||
@@ -154,48 +160,48 @@ describe("PlaybackService Ordering", () => {
|
||||
playViaWebTorrent,
|
||||
});
|
||||
|
||||
const result = await session.start();
|
||||
const startPromise = session.start();
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
video.dispatchEvent(new window.Event("playing"));
|
||||
const result = await startPromise;
|
||||
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 1, "Should call torrent first");
|
||||
assert.equal(probeUrl.mock.callCount(), 0, "Should NOT probe URL");
|
||||
assert.equal(result.source, "torrent");
|
||||
assert.equal(
|
||||
playViaWebTorrent.mock.callCount(),
|
||||
0,
|
||||
"Should NOT use torrent when a hosted URL exists",
|
||||
);
|
||||
assert.equal(probeUrl.mock.callCount(), 1, "Should probe the URL first");
|
||||
assert.equal(result.source, "url");
|
||||
});
|
||||
|
||||
test("urlFirstEnabled=false: Tries Torrent first, fails (throws), falls back to URL", async () => {
|
||||
test("urlFirstEnabled=false with NO hosted URL: uses WebTorrent", async () => {
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: false,
|
||||
isValidMagnetUri: () => true,
|
||||
torrentClient: { cleanup: mock.fn() } // Mock cleanup
|
||||
});
|
||||
|
||||
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
||||
const playViaWebTorrent = mock.fn(async () => {
|
||||
throw new Error("Torrent failed");
|
||||
});
|
||||
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/vid.mp4",
|
||||
url: "",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
});
|
||||
|
||||
const startPromise = session.start();
|
||||
const result = await session.start();
|
||||
|
||||
// Wait for async operations
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
// Simulate successful playback for URL
|
||||
video.dispatchEvent(new window.Event("playing"));
|
||||
|
||||
const result = await startPromise;
|
||||
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 1, "Should call torrent first");
|
||||
assert.equal(probeUrl.mock.callCount(), 1, "Should fallback to probe URL");
|
||||
assert.equal(result.source, "url");
|
||||
assert.equal(
|
||||
playViaWebTorrent.mock.callCount(),
|
||||
1,
|
||||
"Should use WebTorrent when there is no URL to prefer",
|
||||
);
|
||||
assert.equal(probeUrl.mock.callCount(), 0, "No URL to probe");
|
||||
assert.equal(result.source, "torrent");
|
||||
});
|
||||
|
||||
test("forcedSource=torrent overrides urlFirstEnabled=true", async () => {
|
||||
|
||||
Reference in new issue
Block a user