mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
The video's hosted CDN URL was added to WebTorrent's `urlList`, but a standalone hosted-file URL is only a valid BEP19 webseed for a SINGLE-file torrent. For a multi-file torrent WebTorrent treats it as a base directory and appends "<torrent name>/<file path>", producing a doubled URL that 404s on every request — the flood seen on e.g. archive.org videos. The CDN URL is no longer placed in urlList. It's forwarded separately as `hostedUrlWebSeed` and attached post-metadata (handleTorrentStream) via torrent.addWebSeed ONLY when the torrent is a single file, where the URL maps directly to the file. Multi-file torrents get no file-pointing webseed, so no flood. Magnet-declared `ws=` webseeds are unaffected (passed via urlList as before). With CDN-first, dual-source videos play the CDN directly anyway, so this only affects forced-torrent / URL-less paths. Tests: a single-file torrent receives the hosted URL via addWebSeed; a multi-file torrent does not. The single-file guard is mutation-verified. Co-Authored-By: Claude Opus 4.8 <[email protected]>
338 lines
13 KiB
JavaScript
338 lines
13 KiB
JavaScript
|
|
import "./test-helpers/setup-localstorage.mjs";
|
|
import { test, describe, it, before, after } from "node:test";
|
|
import assert from "node:assert";
|
|
import EventEmitter from "events";
|
|
import { TorrentClient } from "../js/webtorrent.js";
|
|
|
|
// Mock JSDOM-like environment if needed
|
|
if (!global.window) {
|
|
global.window = new EventEmitter();
|
|
global.window.navigator = { userAgent: "Node" };
|
|
}
|
|
|
|
class MockFile {
|
|
constructor(name) {
|
|
this.name = name;
|
|
}
|
|
streamTo(elem, opts) {
|
|
elem.opts = opts; // Capture opts for verification
|
|
elem.src = "blob:mock-stream";
|
|
// Simulate ready state progression
|
|
setTimeout(() => {
|
|
elem.readyState = 3;
|
|
elem.emit('canplay');
|
|
elem.emit('loadeddata');
|
|
}, 10);
|
|
}
|
|
}
|
|
|
|
class MockTorrent extends EventEmitter {
|
|
constructor(magnet, opts) {
|
|
super();
|
|
this.magnet = magnet;
|
|
this.files = [new MockFile("video.mp4")];
|
|
this._opts = opts || {};
|
|
}
|
|
destroy() {}
|
|
}
|
|
|
|
class MockVideoElement extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.playCalled = false;
|
|
this.crossOrigin = null;
|
|
this.readyState = 0;
|
|
this.muted = false;
|
|
this.error = null;
|
|
}
|
|
play() {
|
|
this.playCalled = true;
|
|
return Promise.resolve();
|
|
}
|
|
addEventListener(event, handler, opts) {
|
|
this.on(event, handler);
|
|
}
|
|
removeEventListener(event, handler) {
|
|
this.off(event, handler);
|
|
}
|
|
}
|
|
|
|
describe("TorrentClient Handlers", () => {
|
|
let client;
|
|
let videoElement;
|
|
let torrent;
|
|
|
|
before(() => {
|
|
client = new TorrentClient();
|
|
// Silence logs for tests
|
|
client.log = () => {};
|
|
});
|
|
|
|
it("handleTorrentStream (chrome) should set up video correctly", async () => {
|
|
videoElement = new MockVideoElement();
|
|
torrent = new MockTorrent("magnet:?xt=urn:btih:test");
|
|
|
|
await new Promise((resolve, reject) => {
|
|
client.handleTorrentStream(torrent, videoElement, resolve, reject, "chrome");
|
|
});
|
|
|
|
// Wait for events to fire
|
|
await new Promise(r => setTimeout(r, 20));
|
|
|
|
assert.strictEqual(videoElement.crossOrigin, "anonymous", "crossOrigin should be anonymous");
|
|
assert.strictEqual(videoElement.playCalled, true, "play() should be called");
|
|
assert.ok(videoElement.src, "src should be set");
|
|
assert.strictEqual(client.currentTorrent, torrent, "currentTorrent should be set");
|
|
});
|
|
|
|
it("handleTorrentStream (firefox) should set up video correctly with highWaterMark", async () => {
|
|
videoElement = new MockVideoElement();
|
|
torrent = new MockTorrent("magnet:?xt=urn:btih:test");
|
|
|
|
await new Promise((resolve, reject) => {
|
|
client.handleTorrentStream(torrent, videoElement, resolve, reject, "firefox");
|
|
});
|
|
|
|
// Wait for events to fire
|
|
await new Promise(r => setTimeout(r, 20));
|
|
|
|
assert.strictEqual(videoElement.crossOrigin, "anonymous", "crossOrigin should be anonymous");
|
|
assert.strictEqual(videoElement.playCalled, true, "play() should be called");
|
|
assert.ok(videoElement.src, "src should be set");
|
|
// Check options directly on videoElement which our mock sets
|
|
assert.ok(videoElement.opts, "opts should be set");
|
|
assert.strictEqual(videoElement.opts.highWaterMark, 256 * 1024, "highWaterMark should be set for Firefox");
|
|
assert.strictEqual(client.currentTorrent, torrent, "currentTorrent should be set");
|
|
});
|
|
|
|
it("handleTorrentStream (chrome) should handle CORS warning logic", () => {
|
|
videoElement = new MockVideoElement();
|
|
torrent = new MockTorrent("magnet:?xt=urn:btih:test", {
|
|
urlList: ["http://distribution.bbb3d.renderfarming.net/video.mp4", "http://good.com/video.mp4"],
|
|
announce: ["ws://fastcast.nz", "ws://good.com"]
|
|
});
|
|
|
|
// We don't await because we are testing the event listener setup which happens synchronously
|
|
// But we need a dummy resolve/reject
|
|
client.handleTorrentStream(torrent, videoElement, () => {}, () => {}, "chrome");
|
|
|
|
// Emit warning
|
|
torrent.emit('warning', { message: 'CORS policy' });
|
|
|
|
assert.strictEqual(torrent._opts.urlList.length, 1, "Should filter urlList");
|
|
assert.strictEqual(torrent._opts.urlList[0], "http://good.com/video.mp4", "Should keep good url");
|
|
assert.strictEqual(torrent._opts.announce.length, 1, "Should filter announce");
|
|
assert.strictEqual(torrent._opts.announce[0], "ws://good.com", "Should keep good tracker");
|
|
});
|
|
});
|
|
|
|
// --- Hardened streaming behavior (audit follow-ups) ---
|
|
|
|
class SelectableFile {
|
|
constructor(name, length) {
|
|
this.name = name;
|
|
this.length = length;
|
|
this.selected = false;
|
|
this.deselected = false;
|
|
}
|
|
select() { this.selected = true; }
|
|
deselect() { this.deselected = true; }
|
|
streamTo(elem) { elem.src = "blob:mock-stream"; }
|
|
}
|
|
|
|
class MultiFileTorrent extends EventEmitter {
|
|
constructor(files, { numPeers = 1 } = {}) {
|
|
super();
|
|
this.files = files;
|
|
this.numPeers = numPeers;
|
|
this._opts = {};
|
|
}
|
|
destroy() {}
|
|
}
|
|
|
|
function freshClient() {
|
|
const c = new TorrentClient();
|
|
c.log = () => {};
|
|
return c;
|
|
}
|
|
|
|
describe("TorrentClient streaming hardening", () => {
|
|
it("streams the LARGEST playable file and deselects the rest (multi-file)", () => {
|
|
const client = freshClient();
|
|
const small = new SelectableFile("sample.mp4", 100);
|
|
const big = new SelectableFile("feature.mp4", 100000);
|
|
const other = new SelectableFile("readme.txt", 5);
|
|
const torrent = new MultiFileTorrent([small, big, other]);
|
|
const videoElement = new MockVideoElement();
|
|
|
|
client.handleTorrentStream(torrent, videoElement, () => {}, () => {}, "chrome");
|
|
|
|
assert.strictEqual(big.selected, true, "largest video file is selected");
|
|
assert.strictEqual(small.deselected, true, "smaller video file is deselected");
|
|
assert.strictEqual(other.deselected, true, "non-video file is deselected");
|
|
assert.strictEqual(videoElement.src, "blob:mock-stream", "streamTo ran on the chosen file");
|
|
});
|
|
|
|
it("does NOT resolve until the video is actually ready to play", async () => {
|
|
const client = freshClient();
|
|
// streamTo here does NOT advance readyState or emit canplay.
|
|
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
|
|
const torrent = new MultiFileTorrent([file]);
|
|
const videoElement = new MockVideoElement();
|
|
|
|
let resolved = false;
|
|
client.handleTorrentStream(torrent, videoElement, () => { resolved = true; }, () => {}, "chrome");
|
|
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
assert.strictEqual(resolved, false, "no premature success while playback is not viable");
|
|
assert.strictEqual(client.currentTorrent, torrent, "currentTorrent set up front so cleanup can tear it down");
|
|
|
|
// Once real playback data arrives, it resolves.
|
|
videoElement.emit("loadeddata");
|
|
assert.strictEqual(resolved, true, "resolves once playback is viable");
|
|
});
|
|
|
|
it("pre-playback torrent error rejects (so the caller can fall back)", async () => {
|
|
const client = freshClient();
|
|
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
|
|
const torrent = new MultiFileTorrent([file]);
|
|
const videoElement = new MockVideoElement();
|
|
|
|
let rejected = null;
|
|
let resolved = false;
|
|
client.handleTorrentStream(torrent, videoElement, () => { resolved = true; }, (e) => { rejected = e; }, "chrome");
|
|
|
|
torrent.emit("error", new Error("swarm dead"));
|
|
assert.ok(rejected, "rejects before playback starts");
|
|
assert.strictEqual(resolved, false, "does not also resolve");
|
|
});
|
|
|
|
it("post-playback torrent error goes to onPlaybackError, not a dead reject", async () => {
|
|
const client = freshClient();
|
|
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
|
|
const torrent = new MultiFileTorrent([file]);
|
|
const videoElement = new MockVideoElement();
|
|
|
|
const playbackErrors = [];
|
|
let rejectedAfter = false;
|
|
client.handleTorrentStream(
|
|
torrent,
|
|
videoElement,
|
|
() => {},
|
|
() => { rejectedAfter = true; },
|
|
"chrome",
|
|
{ onPlaybackError: (e) => playbackErrors.push(e) }
|
|
);
|
|
|
|
videoElement.emit("canplay"); // becomes ready -> resolved
|
|
torrent.emit("error", new Error("peers dropped"));
|
|
|
|
assert.strictEqual(playbackErrors.length, 1, "post-start error surfaced via hook");
|
|
assert.strictEqual(rejectedAfter, false, "promise was already settled; no dead reject");
|
|
});
|
|
|
|
it("surfaces a stall via onStall when playback buffers without progress", async () => {
|
|
const client = freshClient();
|
|
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
|
|
const torrent = new MultiFileTorrent([file], { numPeers: 0 });
|
|
const videoElement = new MockVideoElement();
|
|
|
|
const stalls = [];
|
|
client.handleTorrentStream(
|
|
torrent,
|
|
videoElement,
|
|
() => {},
|
|
() => {},
|
|
"chrome",
|
|
{ onStall: (info) => stalls.push(info), stallMs: 15 }
|
|
);
|
|
|
|
videoElement.emit("canplay"); // ready
|
|
videoElement.emit("waiting"); // buffering with no progress
|
|
await new Promise((r) => setTimeout(r, 40));
|
|
|
|
assert.strictEqual(stalls.length, 1, "stall surfaced once");
|
|
assert.strictEqual(stalls[0].peers, 0, "stall reports current peer count");
|
|
});
|
|
|
|
it("clears the stall timer when playback progresses", async () => {
|
|
const client = freshClient();
|
|
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
|
|
const torrent = new MultiFileTorrent([file]);
|
|
const videoElement = new MockVideoElement();
|
|
|
|
const stalls = [];
|
|
client.handleTorrentStream(
|
|
torrent, videoElement, () => {}, () => {}, "chrome",
|
|
{ onStall: (i) => stalls.push(i), stallMs: 30 }
|
|
);
|
|
videoElement.emit("canplay");
|
|
videoElement.emit("waiting");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
videoElement.emit("timeupdate"); // progress -> clears the pending stall
|
|
await new Promise((r) => setTimeout(r, 40));
|
|
|
|
assert.strictEqual(stalls.length, 0, "no stall fired because playback resumed");
|
|
});
|
|
|
|
it("does not leak <video> listeners across streams (reused element)", () => {
|
|
const client = freshClient();
|
|
const videoElement = new MockVideoElement();
|
|
const mk = () => new MultiFileTorrent([
|
|
{ name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } },
|
|
]);
|
|
|
|
client.handleTorrentStream(mk(), videoElement, () => {}, () => {}, "chrome");
|
|
client.handleTorrentStream(mk(), videoElement, () => {}, () => {}, "chrome");
|
|
client.handleTorrentStream(mk(), videoElement, () => {}, () => {}, "chrome");
|
|
|
|
assert.strictEqual(
|
|
videoElement.listenerCount("error"),
|
|
1,
|
|
"previous streams' error listeners are torn down (no accumulation)",
|
|
);
|
|
});
|
|
|
|
it("attaches the hosted CDN URL as a webseed for a SINGLE-file torrent", () => {
|
|
const client = freshClient();
|
|
const torrent = new MultiFileTorrent([
|
|
{ name: "v.mp4", length: 1000, streamTo: (el) => { el.src = "blob:x"; } },
|
|
]);
|
|
const added = [];
|
|
torrent.addWebSeed = (url) => added.push(url);
|
|
const videoElement = new MockVideoElement();
|
|
|
|
client.handleTorrentStream(torrent, videoElement, () => {}, () => {}, "chrome", {
|
|
hostedUrlWebSeed: "https://cdn.example.com/v.mp4",
|
|
});
|
|
|
|
assert.deepEqual(
|
|
added,
|
|
["https://cdn.example.com/v.mp4"],
|
|
"a single-file torrent gets the hosted URL as a webseed",
|
|
);
|
|
});
|
|
|
|
it("does NOT attach the hosted CDN URL for a MULTI-file torrent (no doubled-URL flood)", () => {
|
|
const client = freshClient();
|
|
const torrent = new MultiFileTorrent([
|
|
{ name: "feature.mp4", length: 100000, streamTo: (el) => { el.src = "blob:x"; } },
|
|
{ name: "extra.mp4", length: 100 },
|
|
]);
|
|
const added = [];
|
|
torrent.addWebSeed = (url) => added.push(url);
|
|
const videoElement = new MockVideoElement();
|
|
|
|
client.handleTorrentStream(torrent, videoElement, () => {}, () => {}, "chrome", {
|
|
hostedUrlWebSeed: "https://cdn.example.com/feature.mp4",
|
|
});
|
|
|
|
assert.equal(
|
|
added.length,
|
|
0,
|
|
"a multi-file torrent must NOT get a file-pointing webseed (it would double + 404)",
|
|
);
|
|
});
|
|
});
|