From c22037a5757d258e9c0a372f516227ffc17de5bd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:13:56 +0000 Subject: [PATCH] feat: make default playback source configurable - Added `DEFAULT_PLAYBACK_SOURCE` to `config/instance-config.js` to allow choosing between "url" (default) and "torrent" priority. - Updated `js/constants.js` to derive `URL_FIRST_ENABLED` flag from the configuration. - Refactored `PlaybackService.execute()` in `js/services/playbackService.js` to robustly support "Torrent First" logic with fallback to URL, while maintaining "URL First" with fallback to Torrent. - Added unit tests in `tests/services/playbackService_order.test.mjs` to verify playback order and fallback mechanics. --- config/instance-config.js | 9 + js/config.js | 2 + js/constants.js | 3 +- js/services/playbackService.js | 211 ++++++++-------- tests/services/playbackService_order.test.mjs | 227 ++++++++++++++++++ 5 files changed, 350 insertions(+), 102 deletions(-) create mode 100644 tests/services/playbackService_order.test.mjs diff --git a/config/instance-config.js b/config/instance-config.js index 8d255999..b5ecc5ae 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -427,3 +427,12 @@ export const VIEW_COUNT_CACHE_TTL_MS = 5 * 60 * 1000; * still letting operators retry if a publish genuinely failed. */ export const ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS = 5 * 60; + +/** + * Preferred playback source strategy. + * + * Options: + * - "url": Try CDN/URL first, fall back to WebTorrent. + * - "torrent": Try WebTorrent first, fall back to CDN/URL. + */ +export const DEFAULT_PLAYBACK_SOURCE = "url"; diff --git a/js/config.js b/js/config.js index d032e58e..dab99460 100644 --- a/js/config.js +++ b/js/config.js @@ -41,6 +41,7 @@ import { VIEW_COUNT_BACKFILL_MAX_DAYS, VIEW_COUNT_CACHE_TTL_MS, ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS, + DEFAULT_PLAYBACK_SOURCE, } from "../config/instance-config.js"; export const WATCH_HISTORY_VERSION_TAG_VALUE = "2"; @@ -133,3 +134,4 @@ export { VIEW_COUNT_DEDUPE_WINDOW_SECONDS }; export { VIEW_COUNT_BACKFILL_MAX_DAYS }; export { VIEW_COUNT_CACHE_TTL_MS }; export { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS }; +export { DEFAULT_PLAYBACK_SOURCE }; diff --git a/js/constants.js b/js/constants.js index dd602197..53ec4c65 100644 --- a/js/constants.js +++ b/js/constants.js @@ -4,6 +4,7 @@ import { DEFAULT_TRUST_SEED_NPUBS as CONFIG_DEFAULT_TRUST_SEED_NPUBS, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, + DEFAULT_PLAYBACK_SOURCE, IS_DEV_MODE, } from "./config.js"; @@ -144,7 +145,7 @@ export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = export const DEFAULT_TRUST_SEED_NPUBS = SANITIZED_DEFAULT_TRUST_SEED_NPUBS; const DEFAULT_FLAGS = Object.freeze({ - URL_FIRST_ENABLED: true, // try URL before magnet in the player + URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player FEATURE_WATCH_HISTORY_V2: true, FEATURE_PUBLISH_NIP71: false, FEATURE_HASHTAG_PREFERENCES: false, diff --git a/js/services/playbackService.js b/js/services/playbackService.js index c0c0247d..9ef2f2b0 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -550,57 +550,40 @@ class PlaybackSession extends SimpleEventEmitter { ); } - // -- Fallback Logic -- - // This function switches from the "URL" path to the "Torrent" path. - let fallbackStarted = false; - const startTorrentFallback = async (reason) => { - if (fallbackStarted) { - this.service.log( - `[playVideoWithFallback] Duplicate fallback request ignored (${reason}).` - ); - return null; + const resetVideoElement = () => { + if (!activeVideoEl) return; + try { + activeVideoEl.pause(); + } catch (err) { + this.service.log("[playVideoWithFallback] Pause threw during reset:", err); } - fallbackStarted = true; + try { + activeVideoEl.removeAttribute("src"); + } catch (err) { + // ignore + } + activeVideoEl.src = ""; + activeVideoEl.srcObject = null; + try { + activeVideoEl.load(); + } catch (err) { + this.service.log("[playVideoWithFallback] Load threw during reset:", err); + } + }; + + // --- Attempt Torrent Logic --- + let torrentAttempted = false; + const attemptTorrentPlayback = async (reason) => { + if (torrentAttempted) return null; + torrentAttempted = true; + this.cleanupWatchdog(); cleanupHostedUrlStatusListeners(); cleanupDebugListeners(); - - // Reset the video element state so WebTorrent can take over clean. - if (activeVideoEl) { - try { - activeVideoEl.pause(); - } catch (err) { - this.service.log( - "[playVideoWithFallback] Ignoring pause error before torrent fallback:", - err - ); - } - try { - activeVideoEl.removeAttribute("src"); - } catch (err) { - // ignore attribute removal errors - } - activeVideoEl.src = ""; - activeVideoEl.srcObject = null; - try { - activeVideoEl.load(); - } catch (err) { - this.service.log( - "[playVideoWithFallback] Ignoring load error before torrent fallback:", - err - ); - } - } + resetVideoElement(); if (!this.magnetForPlayback) { - const message = - "Hosted playback failed and no magnet fallback is available."; - this.emit("status", { message }); - this.emit("sourcechange", { source: null }); - const error = new Error(message); - this.service.handleAnalyticsEvent("error", { error }); - this.emit("error", { error, message }); - this.result = { source: null, error }; + // No magnet available to try return null; } @@ -609,33 +592,29 @@ class PlaybackSession extends SimpleEventEmitter { this.emit("fallback", { reason }); if (typeof playViaWebTorrent !== "function") { - const error = new Error("No torrent playback handler provided."); - this.service.handleAnalyticsEvent("error", { error }); - this.emit("error", { error, message: error.message }); - this.result = { source: null, error }; - return null; + throw new Error("No torrent playback handler provided."); } - // Delegate to the WebTorrent driver (see js/webtorrent.js interactions in app.js) const torrentInstance = await playViaWebTorrent(this.magnetForPlayback, { fallbackMagnet: this.fallbackMagnet, urlList: webSeedCandidates, }); + this.service.handleAnalyticsEvent("sourcechange", { source: "torrent" }); this.emit("sourcechange", { source: "torrent" }); if (typeof autoplay === "function") { autoplay(); } - this.result = { source: "torrent", torrentInstance }; - return torrentInstance; + + const result = { source: "torrent", torrentInstance }; + this.result = result; + return result; }; - // -- Primary Path: Direct URL -- - if ( - this.service.urlFirstEnabled && - httpsUrl && - forcedSource !== "torrent" - ) { + // --- Attempt URL Logic --- + const attemptHostedPlayback = async () => { + if (!httpsUrl) return null; + this.emit("status", { message: "Checking hosted URL..." }); this.service.log( `[playVideoWithFallback] Probing hosted URL ${httpsUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).` @@ -644,20 +623,18 @@ class PlaybackSession extends SimpleEventEmitter { let hostedStatusResolved = false; const hostedStatusHandlers = []; const addHostedStatusListener = (eventName, handler, options) => { - if (!activeVideoEl) { - return; - } + if (!activeVideoEl) return; activeVideoEl.addEventListener(eventName, handler, options); hostedStatusHandlers.push([eventName, handler, options]); }; + const markHostedUrlAsLive = () => { - if (hostedStatusResolved) { - return; - } + if (hostedStatusResolved) return; hostedStatusResolved = true; this.emit("status", { message: HOSTED_URL_SUCCESS_MESSAGE }); cleanupHostedUrlStatusListeners(); }; + const maybeMarkHostedUrl = () => { if ( hostedStatusResolved || @@ -670,6 +647,7 @@ class PlaybackSession extends SimpleEventEmitter { markHostedUrlAsLive(); } }; + cleanupHostedUrlStatusListeners = () => { if (!hostedStatusHandlers.length || !activeVideoEl) { hostedStatusHandlers.length = 0; @@ -682,6 +660,7 @@ class PlaybackSession extends SimpleEventEmitter { hostedStatusHandlers.length = 0; cleanupHostedUrlStatusListeners = () => {}; }; + addHostedStatusListener("playing", markHostedUrlAsLive, { once: true }); addHostedStatusListener("loadeddata", maybeMarkHostedUrl); addHostedStatusListener("canplay", maybeMarkHostedUrl); @@ -718,23 +697,18 @@ class PlaybackSession extends SimpleEventEmitter { const playbackOutcomePromise = new Promise((resolve) => { outcomeResolver = (value) => { - if (outcomeResolved) { - return; - } + if (outcomeResolved) return; outcomeResolved = true; resolve(value); }; }); - // Install the watchdogs before we assign src and play. const attachWatchdogs = ({ stallMs = 8000 } = {}) => { this.registerWatchdogs(activeVideoEl, { stallMs, onSuccess: () => outcomeResolver({ status: "success" }), onFallback: (reason) => { if (autoplayBlocked && reason === "stall") { - // If browser blocked autoplay, a stall is expected (video is paused). - // Don't fallback yet; wait for user interaction. this.service.log( "[playVideoWithFallback] Autoplay blocked; waiting for user gesture before falling back." ); @@ -742,10 +716,9 @@ class PlaybackSession extends SimpleEventEmitter { message: "Press play to start the hosted video.", }); this.emit("autoplay-blocked", { reason }); - attachWatchdogs({ stallMs: 0 }); // Disable timer + attachWatchdogs({ stallMs: 0 }); return; } - outcomeResolver({ status: "fallback", reason }); }, }); @@ -804,39 +777,74 @@ class PlaybackSession extends SimpleEventEmitter { this.emit("sourcechange", { source: "url" }); cleanupHostedUrlStatusListeners(); cleanupDebugListeners(); - this.result = { source: "url" }; - return this.result; + const result = { source: "url" }; + this.result = result; + return result; } - // If we are here, the watchdog triggered a fallback. - const fallbackReason = - playbackOutcome?.reason || "watchdog-triggered"; - const torrentInstance = await startTorrentFallback(fallbackReason); - this.result = torrentInstance - ? { source: "torrent", torrentInstance } - : this.result; - return this.result; + // Fallback triggered + const fallbackReason = playbackOutcome?.reason || "watchdog-triggered"; + return { status: "fallback", reason: fallbackReason }; } - this.service.log( - `[playVideoWithFallback] Hosted URL probe reported "${probeOutcome}"; deferring to WebTorrent.` - ); cleanupHostedUrlStatusListeners(); cleanupDebugListeners(); + return { status: "fallback", reason: "probe-failed" }; + }; + + // --- Execution Flow --- + + let tryUrlFirst = this.service.urlFirstEnabled; + if (forcedSource === "url") tryUrlFirst = true; + if (forcedSource === "torrent") tryUrlFirst = false; + + if (tryUrlFirst) { + if (httpsUrl) { + const urlResult = await attemptHostedPlayback(); + if (urlResult && urlResult.source === "url") { + return urlResult; + } + // URL Failed, try Torrent + const fallbackReason = urlResult?.reason || "url-unavailable"; + if (this.magnetForPlayback) { + return await attemptTorrentPlayback(fallbackReason); + } + } else if (this.magnetForPlayback) { + // No URL, try Torrent + return await attemptTorrentPlayback("url-missing"); + } + } else { + // Try Torrent First + if (this.magnetForPlayback) { + try { + const torrentResult = await attemptTorrentPlayback("preference"); + if (torrentResult) return torrentResult; + } catch (err) { + this.service.log("[playVideoWithFallback] Torrent preference failed, trying URL:", err); + // Clean up before trying URL + if ( + this.service.torrentClient && + typeof this.service.torrentClient.cleanup === "function" + ) { + await this.service.torrentClient.cleanup(); + } + resetVideoElement(); + } + } + + // Fallback to URL + if (httpsUrl) { + const urlResult = await attemptHostedPlayback(); + if (urlResult && urlResult.source === "url") { + return urlResult; + } + } } - // -- Secondary Path: Magnet / Torrent -- - if (this.magnetForPlayback) { - const torrentInstance = await startTorrentFallback("magnet-primary"); - this.result = torrentInstance - ? { source: "torrent", torrentInstance } - : this.result; - return this.result; - } - - const message = this.magnetProvided && !this.magnetForPlayback - ? unsupportedBtihMessage - : "No playable source found."; + const message = + this.magnetProvided && !this.magnetForPlayback + ? unsupportedBtihMessage + : "No playable source found."; this.emit("status", { message }); this.service.handleAnalyticsEvent("sourcechange", { source: null }); this.emit("sourcechange", { source: null }); @@ -848,9 +856,10 @@ class PlaybackSession extends SimpleEventEmitter { return this.result; } catch (error) { this.service.log("Error in playVideoWithFallback:", error); - const message = error && error.message - ? `Playback error: ${error.message}` - : "Playback error"; + const message = + error && error.message + ? `Playback error: ${error.message}` + : "Playback error"; this.service.handleAnalyticsEvent("error", { error }); this.emit("error", { error, message }); this.result = { source: null, error }; diff --git a/tests/services/playbackService_order.test.mjs b/tests/services/playbackService_order.test.mjs new file mode 100644 index 00000000..8bd22019 --- /dev/null +++ b/tests/services/playbackService_order.test.mjs @@ -0,0 +1,227 @@ +import { describe, test, mock, before, after, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { PlaybackService } from "../../js/services/playbackService.js"; + +describe("PlaybackService Ordering", () => { + let video; + let window; + + before(() => { + // Mock global window/document + global.window = { + HTMLMediaElement: { + HAVE_NOTHING: 0, + HAVE_METADATA: 1, + HAVE_CURRENT_DATA: 2, + HAVE_FUTURE_DATA: 3, + HAVE_ENOUGH_DATA: 4, + }, + Event: class Event { constructor(type) { this.type = type; } }, + localStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {} + } + }; + global.document = { + createElement: (tag) => { + if (tag === 'video') return video; + return {}; + } + }; + window = global.window; + }); + + after(() => { + delete global.window; + delete global.document; + }); + + describe("Playback Execution Order", () => { + let playMock; + let loadMock; + let pauseMock; + let eventListeners; + + beforeEach(() => { + mock.timers.enable({ apis: ['setTimeout'] }); + eventListeners = {}; + + video = { + play: mock.fn(async () => {}), + load: mock.fn(), + pause: mock.fn(), + removeAttribute: mock.fn(), + addEventListener: (event, handler) => { + if (!eventListeners[event]) eventListeners[event] = []; + eventListeners[event].push(handler); + }, + removeEventListener: (event, handler) => { + if (eventListeners[event]) { + eventListeners[event] = eventListeners[event].filter(h => h !== handler); + } + }, + dispatchEvent: (event) => { + if (eventListeners[event.type]) { + eventListeners[event.type].forEach(h => h(event)); + } + }, + readyState: 0, + networkState: 0, + dataset: {}, + muted: false + }; + + playMock = video.play; + loadMock = video.load; + pauseMock = video.pause; + }); + + afterEach(() => { + mock.timers.reset(); + }); + + test("urlFirstEnabled=true: Tries URL first, succeeds", async () => { + const service = new PlaybackService({ + logger: () => {}, + urlFirstEnabled: true, + isValidMagnetUri: () => true, + }); + + const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 })); + const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" })); + + const session = service.createSession({ + url: "https://example.com/vid.mp4", + magnet: "magnet:?xt=urn:btih:123", + videoElement: video, + probeUrl, + playViaWebTorrent, + }); + + const startPromise = session.start(); + await new Promise(resolve => process.nextTick(resolve)); + await new Promise(resolve => process.nextTick(resolve)); + video.dispatchEvent(new window.Event("playing")); + const result = await startPromise; + + assert.equal(probeUrl.mock.callCount(), 1, "Should probe URL"); + assert.equal(playViaWebTorrent.mock.callCount(), 0, "Should NOT call torrent"); + assert.equal(result.source, "url"); + }); + + test("urlFirstEnabled=true: Tries URL first, fails, falls back to Torrent", async () => { + const service = new PlaybackService({ + logger: () => {}, + urlFirstEnabled: true, + isValidMagnetUri: () => true, + }); + + // Probe returns error + const probeUrl = mock.fn(async () => ({ outcome: "bad", status: 404 })); + const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" })); + + const session = service.createSession({ + url: "https://example.com/vid.mp4", + magnet: "magnet:?xt=urn:btih:123", + videoElement: video, + probeUrl, + playViaWebTorrent, + }); + + const result = await session.start(); + + assert.equal(probeUrl.mock.callCount(), 1, "Should probe URL"); + assert.equal(playViaWebTorrent.mock.callCount(), 1, "Should fall back to torrent"); + assert.equal(result.source, "torrent"); + }); + + test("urlFirstEnabled=false: Tries Torrent first, succeeds", async () => { + const service = new PlaybackService({ + logger: () => {}, + urlFirstEnabled: false, + isValidMagnetUri: () => true, + }); + + const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 })); + const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" })); + + const session = service.createSession({ + url: "https://example.com/vid.mp4", + magnet: "magnet:?xt=urn:btih:123", + videoElement: video, + probeUrl, + playViaWebTorrent, + }); + + const result = await session.start(); + + assert.equal(playViaWebTorrent.mock.callCount(), 1, "Should call torrent first"); + assert.equal(probeUrl.mock.callCount(), 0, "Should NOT probe URL"); + assert.equal(result.source, "torrent"); + }); + + test("urlFirstEnabled=false: Tries Torrent first, fails (throws), falls back to URL", async () => { + const service = new PlaybackService({ + logger: () => {}, + urlFirstEnabled: false, + isValidMagnetUri: () => true, + torrentClient: { cleanup: mock.fn() } // Mock cleanup + }); + + const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 })); + const playViaWebTorrent = mock.fn(async () => { + throw new Error("Torrent failed"); + }); + + const session = service.createSession({ + url: "https://example.com/vid.mp4", + magnet: "magnet:?xt=urn:btih:123", + videoElement: video, + probeUrl, + playViaWebTorrent, + }); + + const startPromise = session.start(); + + // Wait for async operations + await new Promise(resolve => process.nextTick(resolve)); + await new Promise(resolve => process.nextTick(resolve)); + + // Simulate successful playback for URL + video.dispatchEvent(new window.Event("playing")); + + const result = await startPromise; + + assert.equal(playViaWebTorrent.mock.callCount(), 1, "Should call torrent first"); + assert.equal(probeUrl.mock.callCount(), 1, "Should fallback to probe URL"); + assert.equal(result.source, "url"); + }); + + test("forcedSource=torrent overrides urlFirstEnabled=true", async () => { + const service = new PlaybackService({ + logger: () => {}, + urlFirstEnabled: true, + isValidMagnetUri: () => true, + }); + + const probeUrl = mock.fn(); + const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" })); + + const session = service.createSession({ + url: "https://example.com/vid.mp4", + magnet: "magnet:?xt=urn:btih:123", + videoElement: video, + probeUrl, + playViaWebTorrent, + forcedSource: "torrent" + }); + + const result = await session.start(); + + assert.equal(playViaWebTorrent.mock.callCount(), 1); + assert.equal(probeUrl.mock.callCount(), 0); + assert.equal(result.source, "torrent"); + }); + }); +});