diff --git a/js/webtorrent.js b/js/webtorrent.js index df95e2dd..50715369 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -706,34 +706,44 @@ export class TorrentClient { }); } - // Handle Chrome-based browsers - handleChromeTorrent(torrent, videoElement, resolve, reject) { - // Prune demo web seeds/trackers that chronically trip Chromium CORS and - // deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround. - torrent.on("warning", (err) => { - if (err && typeof err.message === "string") { - if ( - err.message.includes("CORS") || - err.message.includes("Access-Control-Allow-Origin") - ) { - userLogger.warn( - "CORS warning detected. Attempting to remove the failing webseed/tracker." - ); - if (torrent._opts?.urlList?.length) { - torrent._opts.urlList = torrent._opts.urlList.filter((url) => { - return !url.includes("distribution.bbb3d.renderfarming.net"); - }); - userLogger.warn("Cleaned up webseeds =>", torrent._opts.urlList); - } - if (torrent._opts?.announce?.length) { - torrent._opts.announce = torrent._opts.announce.filter((url) => { - return !url.includes("fastcast.nz"); - }); - userLogger.warn("Cleaned up trackers =>", torrent._opts.announce); + handleTorrentStream( + torrent, + videoElement, + resolve, + reject, + context = "chrome" + ) { + const isChrome = context === "chrome"; + const isFirefox = context === "firefox"; + + // Chrome-specific: Prune demo web seeds/trackers that chronically trip Chromium CORS + // and deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround. + if (isChrome) { + torrent.on("warning", (err) => { + if (err && typeof err.message === "string") { + if ( + err.message.includes("CORS") || + err.message.includes("Access-Control-Allow-Origin") + ) { + userLogger.warn( + "CORS warning detected. Attempting to remove the failing webseed/tracker." + ); + if (torrent._opts?.urlList?.length) { + torrent._opts.urlList = torrent._opts.urlList.filter((url) => { + return !url.includes("distribution.bbb3d.renderfarming.net"); + }); + userLogger.warn("Cleaned up webseeds =>", torrent._opts.urlList); + } + if (torrent._opts?.announce?.length) { + torrent._opts.announce = torrent._opts.announce.filter((url) => { + return !url.includes("fastcast.nz"); + }); + userLogger.warn("Cleaned up trackers =>", torrent._opts.announce); + } } } - } - }); + }); + } const file = torrent.files.find((f) => /\.(mp4|webm|mkv)$/i.test(f.name)); if (!file) { @@ -744,18 +754,23 @@ export class TorrentClient { videoElement.crossOrigin = "anonymous"; videoElement.addEventListener("error", (e) => { - this.log("Video error:", e.target.error); + this.log(`Video error (${context} path):`, e.target.error); }); const tryStart = () => { - this.attemptAutoplay(videoElement, "chrome"); + this.attemptAutoplay(videoElement, context); }; videoElement.addEventListener("canplay", tryStart, { once: true }); videoElement.addEventListener("loadeddata", tryStart, { once: true }); try { - file.streamTo(videoElement); + const streamOptions = {}; + if (isFirefox) { + streamOptions.highWaterMark = 256 * 1024; + } + file.streamTo(videoElement, streamOptions); + // If the video is already ready (e.g. from cache or fast load), try starting immediately if (videoElement.readyState >= 3) { tryStart(); @@ -763,54 +778,12 @@ export class TorrentClient { this.currentTorrent = torrent; resolve(torrent); } catch (err) { - this.log("Streaming error (Chrome path):", err); + this.log(`Streaming error (${context} path):`, err); reject(err); } torrent.on("error", (err) => { - this.log("Torrent error (Chrome path):", err); - reject(err); - }); - } - - // Handle Firefox-based browsers - handleFirefoxTorrent(torrent, videoElement, resolve, reject) { - const file = torrent.files.find((f) => - /\.(mp4|webm|mkv)$/.test(f.name.toLowerCase()) - ); - if (!file) { - return reject(new Error("No compatible video file found in torrent")); - } - - // Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots). - videoElement.crossOrigin = "anonymous"; - - videoElement.addEventListener("error", (e) => { - this.log("Video error (Firefox path):", e.target.error); - }); - - const tryStart = () => { - this.attemptAutoplay(videoElement, "firefox"); - }; - - videoElement.addEventListener("canplay", tryStart, { once: true }); - videoElement.addEventListener("loadeddata", tryStart, { once: true }); - - try { - file.streamTo(videoElement, { highWaterMark: 256 * 1024 }); - // If the video is already ready (e.g. from cache or fast load), try starting immediately - if (videoElement.readyState >= 3) { - tryStart(); - } - this.currentTorrent = torrent; - resolve(torrent); - } catch (err) { - this.log("Streaming error (Firefox path):", err); - reject(err); - } - - torrent.on("error", (err) => { - this.log("Torrent error (Firefox path):", err); + this.log(`Torrent error (${context} path):`, err); reject(err); }); } @@ -932,14 +905,14 @@ export class TorrentClient { { ...chromeOptions, maxWebConns: 4 }, (torrent) => { this.log("Torrent added (Firefox path):", torrent.name); - this.handleFirefoxTorrent(torrent, videoElement, resolve, reject); + this.handleTorrentStream(torrent, videoElement, resolve, reject, "firefox"); } ); } else { this.log("Starting torrent download (Chrome path)"); this.client.add(magnetURI, chromeOptions, (torrent) => { this.log("Torrent added (Chrome path):", torrent.name); - this.handleChromeTorrent(torrent, videoElement, resolve, reject); + this.handleTorrentStream(torrent, videoElement, resolve, reject, "chrome"); }); } }); diff --git a/tests/webtorrent-handlers.test.mjs b/tests/webtorrent-handlers.test.mjs new file mode 100644 index 00000000..1673d268 --- /dev/null +++ b/tests/webtorrent-handlers.test.mjs @@ -0,0 +1,128 @@ + +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"); + }); +});