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.
This commit is contained in:
google-labs-jules[bot] committed 2026-01-26 15:13:56 +00:00
1 parent 0c2afb2a2a
commit c22037a575
5 files changed
+350 -102

No files matched your search

+9
View File
@@ -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";
+2
View File
@@ -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 };
+2 -1
View File
@@ -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,
+110 -101
View File
@@ -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 };
@@ -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");
});
});
});