diff --git a/js/app/playbackCoordinator.js b/js/app/playbackCoordinator.js index b471c916..78dbedab 100644 --- a/js/app/playbackCoordinator.js +++ b/js/app/playbackCoordinator.js @@ -362,7 +362,7 @@ export function createPlaybackCoordinator(deps) { async playViaWebTorrent( magnet, - { fallbackMagnet = "", urlList = [] } = {} + { fallbackMagnet = "", urlList = [], hostedUrlWebSeed = "" } = {} ) { const sanitizedUrlList = Array.isArray(urlList) ? urlList @@ -371,6 +371,11 @@ export function createPlaybackCoordinator(deps) { ) .filter((entry) => /^https?:\/\//i.test(entry)) : []; + const sanitizedHostedWebSeed = + typeof hostedUrlWebSeed === "string" && + /^https?:\/\//i.test(hostedUrlWebSeed.trim()) + ? hostedUrlWebSeed.trim() + : ""; const attemptStream = async (candidate) => { const trimmedCandidate = @@ -418,6 +423,7 @@ export function createPlaybackCoordinator(deps) { this.modalVideo, { urlList: sanitizedUrlList, + hostedUrlWebSeed: sanitizedHostedWebSeed, hooks: { // Surface a mid-stream stall instead of silently freezing on a // buffering frame: tell the user whether the swarm is just slow diff --git a/js/services/playbackService.js b/js/services/playbackService.js index cc557d4f..1828b788 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -868,10 +868,12 @@ class PlaybackSession extends SimpleEventEmitter { webSeedCandidates.push(trimmed); }; - if (httpsUrl) { - addWebSeedCandidate(httpsUrl); - } - + // NOTE: the hosted CDN URL is intentionally NOT added here. As a + // WebTorrent `urlList` entry it is only valid for single-file torrents; + // for multi-file torrents WebTorrent appends the torrent's internal path + // and floods the network with doubled, 404-ing requests. It is forwarded + // separately as `hostedUrlWebSeed` and attached post-metadata only when + // the torrent is a single file (see TorrentClient.handleTorrentStream). const magnetWebSeeds = extractWebSeedsFromMagnet(this.magnetForPlayback); if (magnetWebSeeds.length > 0) { magnetWebSeeds.forEach((seed) => addWebSeedCandidate(seed)); @@ -948,6 +950,7 @@ class PlaybackSession extends SimpleEventEmitter { torrentInstance = await playViaWebTorrent(this.magnetForPlayback, { fallbackMagnet: this.fallbackMagnet, urlList: webSeedCandidates, + hostedUrlWebSeed: httpsUrl, }); } catch (err) { const errorMessage = getTorrentErrorMessage(err); diff --git a/js/webtorrent.js b/js/webtorrent.js index 6c2c2bbd..90164652 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -796,6 +796,12 @@ export class TorrentClient { hooks && Number.isFinite(hooks.stallMs) && hooks.stallMs > 0 ? hooks.stallMs : TORRENT_PLAYBACK_STALL_MS; + const hostedUrlWebSeed = + hooks && + typeof hooks.hostedUrlWebSeed === "string" && + /^https?:\/\//i.test(hooks.hostedUrlWebSeed.trim()) + ? hooks.hostedUrlWebSeed.trim() + : ""; // Chrome-specific: Prune demo web seeds/trackers that chronically trip Chromium CORS // and deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround. @@ -853,6 +859,26 @@ export class TorrentClient { } } + // Attach the hosted CDN URL as a webseed ONLY for single-file torrents. + // A standalone hosted-file URL is a valid BEP19 webseed only when the + // torrent is a single file (the URL maps directly to it). For a multi-file + // torrent, WebTorrent treats the URL as a base directory and appends + // "/", producing a doubled URL that 404s on every + // request — the webseed flood. Adding it post-metadata (when files.length + // is known) avoids that entirely. Magnet-declared `ws=` webseeds are passed + // separately via urlList and untouched. + if ( + hostedUrlWebSeed && + files.length === 1 && + typeof torrent.addWebSeed === "function" + ) { + try { + torrent.addWebSeed(hostedUrlWebSeed); + } catch (err) { + this.log(`Failed to attach hosted webseed (${context} path):`, err); + } + } + // Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots). videoElement.crossOrigin = "anonymous"; @@ -1106,7 +1132,14 @@ export class TorrentClient { const isFirefoxBrowser = this.isFirefox(); const streamHooks = - opts && typeof opts.hooks === "object" && opts.hooks ? opts.hooks : {}; + opts && typeof opts.hooks === "object" && opts.hooks + ? { ...opts.hooks } + : {}; + // Hosted CDN URL to use as a single-file webseed (handleTorrentStream + // attaches it post-metadata, only when the torrent is a single file). + if (typeof opts?.hostedUrlWebSeed === "string" && opts.hostedUrlWebSeed) { + streamHooks.hostedUrlWebSeed = opts.hostedUrlWebSeed; + } const candidateUrls = Array.isArray(opts?.urlList) ? opts.urlList .map((entry) => (typeof entry === "string" ? entry.trim() : "")) diff --git a/tests/webtorrent-handlers.test.mjs b/tests/webtorrent-handlers.test.mjs index 74d2fac3..e9932050 100644 --- a/tests/webtorrent-handlers.test.mjs +++ b/tests/webtorrent-handlers.test.mjs @@ -293,4 +293,45 @@ describe("TorrentClient streaming hardening", () => { "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)", + ); + }); });