diff --git a/js/magnets.js b/js/magnets.js index c280014d..56cb3b94 100644 --- a/js/magnets.js +++ b/js/magnets.js @@ -49,14 +49,36 @@ export function trackersFromMagnet(magnet) { return Array.from(deduped); } +export function webSeedsFromMagnet(magnet) { + if (typeof magnet !== "string") { + return []; + } + const parsed = parseMagnetLite(magnet); + if (!parsed || !Array.isArray(parsed.ws)) { + return []; + } + const deduped = new Set(); + parsed.ws.forEach((url) => { + if (typeof url !== "string") { + return; + } + const trimmed = url.trim(); + if (!trimmed) { + return; + } + deduped.add(trimmed); + }); + return Array.from(deduped); +} + function parseMagnetLite(magnet) { if (typeof magnet !== "string") { - return { infoHash: "", announce: [] }; + return { infoHash: "", announce: [], ws: [] }; } const trimmed = magnet.trim(); if (!trimmed) { - return { infoHash: "", announce: [] }; + return { infoHash: "", announce: [], ws: [] }; } const lower = trimmed.toLowerCase(); @@ -64,6 +86,7 @@ function parseMagnetLite(magnet) { const paramsPart = queryIndex >= 0 ? trimmed.slice(queryIndex + 1) : ""; const params = paramsPart.split("&"); const announce = []; + const ws = []; let infoHash = ""; for (const param of params) { @@ -89,8 +112,12 @@ function parseMagnetLite(magnet) { if (value) { announce.push(value); } + } else if (key === "ws" || key === "webseed") { + if (value) { + ws.push(value); + } } } - return { infoHash, announce }; + return { infoHash, announce, ws }; } diff --git a/js/webtorrent.js b/js/webtorrent.js index d4f8698d..bfe232bf 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -23,7 +23,7 @@ import { WSS_TRACKERS } from "./constants.js"; import { safeDecodeURIComponent } from "./utils/safeDecode.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { emit } from "./embedDiagnostics.js"; -import { infoHashFromMagnet } from "./magnets.js"; +import { infoHashFromMagnet, webSeedsFromMagnet } from "./magnets.js"; const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]); @@ -960,15 +960,18 @@ export class TorrentClient { } const isFirefoxBrowser = this.isFirefox(); - const candidateUrls = Array.isArray(opts?.urlList) + const explicitUrls = Array.isArray(opts?.urlList) ? opts.urlList .map((entry) => (typeof entry === "string" ? entry.trim() : "")) .filter((entry) => /^https?:\/\//i.test(entry)) : []; + const magnetUrls = webSeedsFromMagnet(effectiveMagnetURI); + const combinedUrls = [...new Set([...explicitUrls, ...magnetUrls])]; + const chromeOptions = { strategy: "sequential" }; - if (candidateUrls.length) { - chromeOptions.urlList = candidateUrls; + if (combinedUrls.length) { + chromeOptions.urlList = combinedUrls; } return new Promise((resolve, reject) => { diff --git a/tests/webtorrent-webseeds.test.mjs b/tests/webtorrent-webseeds.test.mjs new file mode 100644 index 00000000..96783b99 --- /dev/null +++ b/tests/webtorrent-webseeds.test.mjs @@ -0,0 +1,76 @@ + +import test from "node:test"; +import assert from "node:assert/strict"; + +// Setup globals required by webtorrent.js +if (typeof self === 'undefined') global.self = global; +if (typeof navigator === 'undefined') { + global.navigator = { + userAgent: 'test', + serviceWorker: { + register: async () => ({ active: true }), + ready: Promise.resolve({ active: true }), + controller: { postMessage: () => {} }, + addEventListener: () => {}, + removeEventListener: () => {}, + } + }; +} +if (typeof window === 'undefined') { + global.window = { + location: { hostname: 'localhost', origin: 'http://localhost' }, + isSecureContext: true, + navigator: global.navigator + } +} +if (typeof global.location === 'undefined') { + global.location = global.window.location; +} + +// Import dynamic to ensure globals are set +const { TorrentClient } = await import('../js/webtorrent.js'); + +class MockWebTorrent { + constructor() { + this.torrents = []; + } + add(magnet, opts, cb) { + this.lastAdd = { magnet, opts }; + const torrent = { + on: () => {}, + files: [{name: 'video.mp4', streamTo: () => {}}], + once: () => {}, + destroy: () => {}, + }; + if (cb) cb(torrent); + return torrent; + } + destroy() {} + createServer() {} +} + +test("streamVideo merges magnet web seeds with explicit web seeds", async () => { + const client = new TorrentClient({ webTorrentClass: MockWebTorrent }); + client.serviceWorkerDisabled = true; + + const magnet = 'magnet:?xt=urn:btih:123&ws=http://magnet-seed.com/file.mp4'; + const urlList = ['http://direct-url.com/video.mp4']; + + const videoElement = { + play: async () => {}, + addEventListener: () => {}, + muted: false, + crossOrigin: null // streamVideo sets this + }; + + try { + await client.streamVideo(magnet, videoElement, { urlList }); + } catch (e) { + console.log(e); + } + + const lastAdd = client.client.lastAdd; + + assert.ok(lastAdd.opts.urlList.includes('http://magnet-seed.com/file.mp4'), "Magnet web seed missing"); + assert.ok(lastAdd.opts.urlList.includes('http://direct-url.com/video.mp4'), "Explicit web seed missing"); +});