diff --git a/js/services/playbackService.js b/js/services/playbackService.js index b6bfe7dd..800bde0c 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -827,12 +827,14 @@ class PlaybackSession extends SimpleEventEmitter { if (forcedSource === "url") tryUrlFirst = true; if (forcedSource === "torrent") tryUrlFirst = false; + const effectiveTimeout = forcedSource ? 0 : playbackStartTimeout; + if (tryUrlFirst) { if (httpsUrl) { // Wrap URL attempt in timeout const urlResult = await withTimeout( attemptHostedPlayback(), - playbackStartTimeout, + effectiveTimeout, "URL Playback" ); @@ -849,10 +851,10 @@ class PlaybackSession extends SimpleEventEmitter { } const fallbackReason = urlResult?.reason || "url-unavailable"; - if (this.magnetForPlayback) { + if (this.magnetForPlayback && forcedSource !== "url") { return await attemptTorrentPlayback(fallbackReason); } - } else if (this.magnetForPlayback) { + } else if (this.magnetForPlayback && forcedSource !== "url") { return await attemptTorrentPlayback("url-missing"); } } else { @@ -862,7 +864,7 @@ class PlaybackSession extends SimpleEventEmitter { // Wrap Torrent attempt in timeout const torrentResult = await withTimeout( attemptTorrentPlayback("preference"), - playbackStartTimeout, + effectiveTimeout, "Torrent Playback" ); @@ -871,8 +873,10 @@ class PlaybackSession extends SimpleEventEmitter { } if (torrentResult?.reason === "timeout") { - this.service.log("[playVideoWithFallback] Torrent timed out; cleaning up."); - if ( + this.service.log( + "[playVideoWithFallback] Torrent timed out; cleaning up." + ); + if ( this.service.torrentClient && typeof this.service.torrentClient.cleanup === "function" ) { @@ -880,9 +884,11 @@ class PlaybackSession extends SimpleEventEmitter { } resetVideoElement(); } - } catch (err) { - this.service.log("[playVideoWithFallback] Torrent preference failed, trying URL:", err); + this.service.log( + "[playVideoWithFallback] Torrent preference failed, trying URL:", + err + ); if ( this.service.torrentClient && typeof this.service.torrentClient.cleanup === "function" @@ -894,7 +900,7 @@ class PlaybackSession extends SimpleEventEmitter { } // Fallback to URL - if (httpsUrl) { + if (httpsUrl && forcedSource !== "torrent") { const urlResult = await attemptHostedPlayback(); if (urlResult && urlResult.source === "url") { return urlResult; diff --git a/tests/services/playbackService_forcedSource.test.mjs b/tests/services/playbackService_forcedSource.test.mjs new file mode 100644 index 00000000..35e699a8 --- /dev/null +++ b/tests/services/playbackService_forcedSource.test.mjs @@ -0,0 +1,211 @@ +import { describe, test, mock, before, after, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { createUiDom } from "../ui/helpers/jsdom-test-helpers.mjs"; +import { PlaybackService } from "../../js/services/playbackService.js"; + +describe("PlaybackService Forced Source Logic", () => { + let dom; + let document; + let window; + let service; + let video; + let playMock; + let loadMock; + let pauseMock; + + before(() => { + dom = createUiDom(); + window = dom.window; + document = dom.document; + + if (!window.HTMLMediaElement) { + window.HTMLMediaElement = { + HAVE_NOTHING: 0, + HAVE_METADATA: 1, + HAVE_CURRENT_DATA: 2, + HAVE_FUTURE_DATA: 3, + HAVE_ENOUGH_DATA: 4, + }; + } + }); + + after(() => { + dom.cleanup(); + }); + + beforeEach(() => { + mock.timers.enable({ apis: ['setTimeout'] }); + service = new PlaybackService({ + logger: () => {}, + isValidMagnetUri: () => true, + playbackStartTimeout: 100, + }); + video = document.createElement("video"); + + playMock = mock.fn(async () => {}); + loadMock = mock.fn(); + pauseMock = mock.fn(); + + video.play = playMock; + video.load = loadMock; + video.pause = pauseMock; + + Object.defineProperty(video, 'readyState', { + writable: true, + value: window.HTMLMediaElement.HAVE_NOTHING + }); + Object.defineProperty(video, 'networkState', { + writable: true, + value: 0 + }); + }); + + afterEach(() => { + mock.timers.reset(); + }); + + test("Normal flow: Timeout triggers fallback to Torrent if URL stalls", async () => { + const probeUrl = mock.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 200)); + return { outcome: "good", status: 200 }; + }); + const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" })); + + const session = service.createSession({ + url: "https://example.com/video.mp4", + magnet: "magnet:?xt=urn:btih:123", + videoElement: video, + probeUrl, + playViaWebTorrent, + }); + + const startPromise = session.start(); + + // Tick enough to trigger timeout (100ms) but not enough to resolve probeUrl (200ms) + mock.timers.tick(150); + // await microtasks to ensure timeout callback runs + await new Promise(resolve => process.nextTick(resolve)); + + const result = await startPromise; + + assert.equal(result.source, "torrent"); + assert.equal(playViaWebTorrent.mock.callCount(), 1); + }); + + test("Forced Source 'url': Ignores playbackStartTimeout and does NOT fallback to Torrent", async () => { + const probeUrl = mock.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 200)); + return { outcome: "good", status: 200 }; + }); + const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" })); + + const session = service.createSession({ + url: "https://example.com/video.mp4", + magnet: "magnet:?xt=urn:btih:123", + videoElement: video, + probeUrl, + playViaWebTorrent, + forcedSource: "url", + }); + + const startPromise = session.start(); + + // Advance time past the 100ms default timeout + mock.timers.tick(150); + await new Promise(resolve => process.nextTick(resolve)); + + // The promise should NOT be resolved yet (still probing) + // We can't easily check 'pending' state without race, but we assume it didn't return 'torrent'. + + // Advance time to complete probeUrl (200ms total) + mock.timers.tick(100); + await new Promise(resolve => process.nextTick(resolve)); + + // Now trigger playing + video.dispatchEvent(new window.Event("playing")); + await new Promise(resolve => process.nextTick(resolve)); + + const result = await startPromise; + + assert.equal(result.source, "url"); + assert.equal(playViaWebTorrent.mock.callCount(), 0); + }); + + 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 }; + }); + const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" })); + + const session = service.createSession({ + url: "https://example.com/video.mp4", + magnet: "magnet:?xt=urn:btih:123", + videoElement: video, + probeUrl, + playViaWebTorrent, + forcedSource: "url", + }); + + const result = await session.start(); + + // It should have tried URL, failed, and returned error/null, skipping torrent fallback + assert.equal(result.source, null); // Or maybe 'url' with error? No, execute returns { source: null, error } if everything fails. + assert.equal(playViaWebTorrent.mock.callCount(), 0); + }); + + test("Forced Source 'torrent': Ignores playbackStartTimeout and does NOT fallback to URL", async () => { + const probeUrl = mock.fn(async () => ({ outcome: "good" })); + + // Mock torrent to take longer than timeout + const playViaWebTorrent = mock.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 200)); + return { infoHash: "123" }; + }); + + const session = service.createSession({ + url: "https://example.com/video.mp4", + magnet: "magnet:?xt=urn:btih:123", + videoElement: video, + probeUrl, + playViaWebTorrent, + forcedSource: "torrent", + }); + + const startPromise = session.start(); + + // Advance time past default timeout + mock.timers.tick(150); + await new Promise(resolve => process.nextTick(resolve)); + + // Complete torrent init + mock.timers.tick(100); + await new Promise(resolve => process.nextTick(resolve)); + + const result = await startPromise; + + assert.equal(result.source, "torrent"); + assert.equal(probeUrl.mock.callCount(), 0); + }); + + test("Forced Source 'torrent': Does NOT fallback to URL even if Torrent fails", async () => { + const probeUrl = mock.fn(async () => ({ outcome: "good" })); + + const playViaWebTorrent = mock.fn(async () => { + throw new Error("Torrent failed"); + }); + + const session = service.createSession({ + url: "https://example.com/video.mp4", + magnet: "magnet:?xt=urn:btih:123", + videoElement: video, + probeUrl, + playViaWebTorrent, + forcedSource: "torrent", + }); + + const result = await session.start(); + + assert.equal(result.source, null); + assert.equal(probeUrl.mock.callCount(), 0); + }); +});