Files
bitvid/js/services/playbackService.js
T
thePR0M3TH3ANandClaude Opus 4.8 0a37e625bb playback: fail over across hosted mirrors before dropping to P2P (#5)
The liveness probe already tries all of a video's sources, but actual playback
only ever tried the single primary URL and then fell straight to WebTorrent. Now
playback fails over across hosted mirrors first.

- PlaybackSession builds an ordered, de-duped hostedSourceCandidates (primary URL
  first, then the video's imeta `sources`; accepts string or {url} entries).
- attemptHostedPlayback takes a candidateUrl; the url-first execution loop tries
  each candidate and advances to the next mirror on probe-fail/stall/timeout,
  cleaning up watchdogs + resetting the <video> between candidates, only calling
  WebTorrent after every hosted mirror fails.
- `sources` plumbed coordinator -> strategy -> session. Single-source videos
  (no `sources`) behave exactly as before.

Tests: tests/services/playbackService_multisource.test.mjs (candidate
construction; the full async fail-over flow is covered by the existing
playbackService_order suite in CI — that mock-timers harness doesn't run in this
sandbox). ROLLBACK: revert the loop to the single attemptHostedPlayback() call
and drop the sources plumbing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:30:30 -04:00

1319 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { userLogger } from "../utils/logger.js";
import { PLAYBACK_START_TIMEOUT } from "../constants.js";
import { getCurrentVideo, setCurrentVideo } from "../state/appState.js";
import {
SimpleEventEmitter,
extractWebSeedsFromMagnet,
} from "./playbackHelpers.js";
// js/services/playbackService.js
/**
* PlaybackService acts as the central conductor for video playback.
* It manages the lifecycle of a "PlaybackSession", which encapsulates the complex
* logic of attempting to play a video from a direct URL (HTTP/CDN) first, and
* falling back to a WebTorrent (P2P) stream if the URL is unreachable or stalls.
*
* Key Responsibilities:
* - Singleton-ish orchestration (only one active session at a time usually).
* - "Watchdog" monitoring: It attaches listeners to the <video> element to detect
* frozen playback (stalls) or errors.
* - Fallback logic: If the direct URL fails (404, network error) or playback
* stalls, it seamlessly triggers the P2P engine (WebTorrent).
* - Race Condition Management: Uses "request signatures" to ensure that if the user
* clicks "Play" on a new video while the previous one is still loading, the
* old session is cancelled and doesn't overwrite the new one.
*/
const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL";
const DEFAULT_UNSUPPORTED_BTITH_MESSAGE =
"This magnet link is missing a compatible BitTorrent v1 info hash.";
const AUTH_STATUS_CODES = new Set([401, 403]);
const SSL_ERROR_PATTERN = /(ssl|cert|certificate)/i;
const CORS_ERROR_PATTERN = /cors/i;
const PROBE_CACHE_TTL_MS = 45000;
const getHostedUrlFailureDetails = (probeResult = {}) => {
const outcome = probeResult?.outcome || "error";
const status = Number.isFinite(probeResult?.status)
? probeResult.status
: null;
const error = probeResult?.error;
const isAuthStatus = status !== null && AUTH_STATUS_CODES.has(status);
if (outcome === "timeout") {
return {
category: "external",
message:
"Hosted URL timed out. We’ll try WebTorrent if available.",
status,
};
}
if (outcome === "bad") {
if (isAuthStatus) {
return {
category: "auth",
message:
"Hosted URL requires authorization or a signed request. Please log in or re-sign.",
status,
};
}
if (status === 404) {
return {
category: "external",
message: "Hosted URL not found (404).",
status,
};
}
if (status === 403) {
return {
category: "external",
message: "Hosted URL blocked (403).",
status,
};
}
if (status && status >= 500) {
return {
category: "external",
message: `Hosted URL unavailable (HTTP ${status}).`,
status,
};
}
if (status) {
return {
category: "external",
message: `Hosted URL failed (HTTP ${status}).`,
status,
};
}
}
if (outcome === "opaque" || outcome === "unknown") {
return {
category: "external",
message:
"Hosted URL blocked by browser security (CORS/SSL). We’ll try WebTorrent if available.",
status,
};
}
if (outcome === "error") {
const message = error?.message || "";
if (SSL_ERROR_PATTERN.test(message)) {
return {
category: "external",
message:
"Hosted URL blocked due to SSL certificate issues.",
status,
};
}
if (CORS_ERROR_PATTERN.test(message)) {
return {
category: "external",
message:
"Hosted URL blocked by CORS. We’ll try WebTorrent if available.",
status,
};
}
return {
category: "external",
message:
"Hosted URL failed to load due to network or security restrictions.",
status,
};
}
return {
category: "external",
message: "Hosted URL failed to load.",
status,
};
};
const getHostedVideoErrorMessage = (videoElement) => {
if (!videoElement?.error) {
return "";
}
const error = videoElement.error;
const code = error.code;
const mediaErrorCodes =
typeof MediaError !== "undefined"
? MediaError
: {
MEDIA_ERR_ABORTED: 1,
MEDIA_ERR_NETWORK: 2,
MEDIA_ERR_DECODE: 3,
MEDIA_ERR_SRC_NOT_SUPPORTED: 4,
};
switch (code) {
case mediaErrorCodes.MEDIA_ERR_ABORTED:
return "Hosted playback was interrupted.";
case mediaErrorCodes.MEDIA_ERR_NETWORK:
return "Hosted playback failed due to network/CORS restrictions.";
case mediaErrorCodes.MEDIA_ERR_DECODE:
return "Hosted playback failed to decode the video.";
case mediaErrorCodes.MEDIA_ERR_SRC_NOT_SUPPORTED:
return "Hosted playback failed: source not supported or blocked.";
default:
return "Hosted playback failed to load.";
}
};
const getTorrentErrorMessage = (error) => {
if (!error) {
return "WebTorrent could not start. Please try again.";
}
const message = error.message || "";
if (message.includes(DEFAULT_UNSUPPORTED_BTITH_MESSAGE)) {
return DEFAULT_UNSUPPORTED_BTITH_MESSAGE;
}
if (/tracker|announce|peer/i.test(message)) {
return "WebTorrent could not reach any peers or trackers.";
}
if (/permission|blocked|denied/i.test(message)) {
return "WebTorrent was blocked by the browser or network.";
}
return "WebTorrent could not start. Please try again.";
};
export class PlaybackService {
constructor({
logger,
torrentClient,
deriveTorrentPlaybackConfig,
isValidMagnetUri,
urlFirstEnabled = true,
analyticsCallbacks = {},
playbackStartTimeout = PLAYBACK_START_TIMEOUT,
} = {}) {
if (typeof logger === "function") {
this.logger = logger;
} else if (logger && typeof logger.log === "function") {
this.logger = (...args) => logger.log(...args);
} else {
this.logger = () => {};
}
this.torrentClient = torrentClient;
this.deriveTorrentPlaybackConfig = deriveTorrentPlaybackConfig;
this.isValidMagnetUri = isValidMagnetUri;
this.urlFirstEnabled = !!urlFirstEnabled;
this.analyticsCallbacks = analyticsCallbacks || {};
this.playbackStartTimeout = playbackStartTimeout;
this.currentSession = null;
this.probeCache = new Map();
this.probeInFlight = new Map();
this.probeCacheTtlMs = PROBE_CACHE_TTL_MS;
}
get currentVideo() {
return getCurrentVideo();
}
set currentVideo(value) {
setCurrentVideo(value);
}
log(...args) {
try {
this.logger(...args);
} catch (err) {
userLogger.warn("[PlaybackService] logger threw", err);
}
}
handleAnalyticsEvent(eventName, detail) {
const callbacks = this.analyticsCallbacks || {};
if (typeof callbacks.onEvent === "function") {
try {
callbacks.onEvent(eventName, detail);
} catch (err) {
this.log("[PlaybackService] onEvent callback threw", err);
}
}
const specific = callbacks[eventName];
if (typeof specific === "function") {
try {
specific(detail);
} catch (err) {
this.log(`[PlaybackService] ${eventName} callback threw`, err);
}
}
}
getProbeCacheKey({ url, magnet }) {
const trimmedUrl = typeof url === "string" ? url.trim() : "";
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
return `${trimmedUrl}::${trimmedMagnet}`;
}
pruneProbeCache(now = Date.now()) {
for (const [key, entry] of this.probeCache.entries()) {
if (!entry || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) {
this.probeCache.delete(key);
}
}
}
getCachedProbeResult(cacheKey, now = Date.now()) {
this.pruneProbeCache(now);
const entry = this.probeCache.get(cacheKey);
if (!entry || entry.expiresAt <= now) {
return null;
}
return entry.result;
}
storeProbeResult(cacheKey, result, now = Date.now()) {
const ttl =
Number.isFinite(this.probeCacheTtlMs) && this.probeCacheTtlMs > 0
? this.probeCacheTtlMs
: PROBE_CACHE_TTL_MS;
this.probeCache.set(cacheKey, { result, expiresAt: now + ttl });
}
/**
* Probes a hosted URL to check for reachability (usually via a HEAD request).
*
* @param {Object} options
* @param {string} [options.url] - The HTTPS URL to probe.
* @param {string} [options.magnet] - Optional magnet context for caching keys.
* @param {Function} [options.probeUrl] - The actual probe function (returns { outcome: 'ok'|'bad'|'timeout'|'error', status?: number }).
* @returns {Promise<{outcome: string, status?: number, error?: Error}>}
*/
async probeHostedUrl({ url, magnet, probeUrl } = {}) {
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
if (!sanitizedUrl) {
return { outcome: "invalid" };
}
const cacheKey = this.getProbeCacheKey({
url: sanitizedUrl,
magnet,
});
const cachedResult = this.getCachedProbeResult(cacheKey);
if (cachedResult) {
return cachedResult;
}
const inFlight = this.probeInFlight.get(cacheKey);
if (inFlight) {
return inFlight;
}
const probePromise = (async () => {
try {
const result =
typeof probeUrl === "function"
? await probeUrl(sanitizedUrl)
: { outcome: "error" };
this.storeProbeResult(cacheKey, result);
return result;
} catch (error) {
const result = { outcome: "error", error };
this.storeProbeResult(cacheKey, result);
return result;
} finally {
this.probeInFlight.delete(cacheKey);
}
})();
this.probeInFlight.set(cacheKey, probePromise);
return probePromise;
}
prepareVideoElement(videoElement) {
if (!videoElement) {
return;
}
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
const userWantsUnmuted = storedUnmuted === "true";
videoElement.muted = !userWantsUnmuted;
if (!videoElement.dataset.autoplayBound) {
videoElement.addEventListener("volumechange", () => {
localStorage.setItem(
"unmutedAutoplay",
(!videoElement.muted).toString()
);
});
videoElement.dataset.autoplayBound = "true";
}
}
/**
* Registers a set of event listeners ("watchdogs") on the video element to detect
* when playback has stalled or failed.
*
* This is critical for the "Hybrid" playback strategy. We attempt to play the
* direct URL (which is cheap and fast). If it hangs (e.g., buffering forever)
* or errors out, this watchdog fires the `onFallback` callback, which triggers
* the switch to P2P.
*
* @param {HTMLVideoElement} videoElement
* @param {object} options
* @param {number} options.stallMs - How long to wait without progress before considering it a "stall".
* @param {Function} options.onSuccess - Called if playback starts successfully (clears the watchdog).
* @param {Function} options.onFallback - Called if playback fails or stalls.
* @returns {Function} cleanup function to remove listeners.
*/
registerUrlPlaybackWatchdogs(
videoElement,
{ stallMs = 8000, onSuccess, onFallback } = {}
) {
if (!videoElement || typeof onFallback !== "function") {
return () => {};
}
const normalizedStallMs = Number.isFinite(stallMs) && stallMs > 0 ? stallMs : 0;
this.log(
`[registerUrlPlaybackWatchdogs] Installing watchdogs (stallMs=${normalizedStallMs}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
);
let active = true;
let stallTimerId = null;
const listeners = [];
const cleanup = () => {
if (!active) {
return;
}
active = false;
if (stallTimerId) {
clearTimeout(stallTimerId);
stallTimerId = null;
}
for (const [eventName, handler] of listeners) {
videoElement.removeEventListener(eventName, handler);
}
};
const triggerFallback = (reason) => {
if (!active) {
return;
}
this.log(
`[registerUrlPlaybackWatchdogs] Triggering fallback (${reason}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
);
cleanup();
onFallback(reason);
};
const handleSuccess = () => {
if (!active) {
return;
}
this.log(
"[registerUrlPlaybackWatchdogs] Hosted playback signaled success; clearing watchdogs."
);
cleanup();
if (typeof onSuccess === "function") {
onSuccess();
}
};
// The timer is reset on any "progress" event. If `stallMs` passes without
// a reset, we assume the connection is dead/stalled and fallback.
const resetTimer = () => {
if (!active || !normalizedStallMs) {
return;
}
if (stallTimerId) {
clearTimeout(stallTimerId);
}
stallTimerId = setTimeout(() => triggerFallback("stall"), normalizedStallMs);
};
const addListener = (eventName, handler) => {
videoElement.addEventListener(eventName, handler);
listeners.push([eventName, handler]);
};
// Immediate failure conditions
addListener("error", () => triggerFallback("error"));
addListener("abort", () => triggerFallback("abort"));
addListener("stalled", () => triggerFallback("stalled"));
// Success conditions
addListener("playing", handleSuccess);
addListener("ended", handleSuccess);
// Keep-alive events that reset the stall timer
const timerEvents = [
"timeupdate",
"progress",
"loadeddata",
"canplay",
"canplaythrough",
"suspend",
"waiting",
];
for (const eventName of timerEvents) {
addListener(eventName, resetTimer);
}
if (normalizedStallMs) {
resetTimer();
}
return () => {
this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked.");
cleanup();
};
}
cleanupWatchdog() {
if (this.currentSession) {
this.currentSession.cleanupWatchdog();
}
}
/**
* Creates a new playback session for a specific video request.
*
* @param {Object} options
* @param {string} [options.url] - The hosted video URL.
* @param {string} [options.magnet] - The WebTorrent magnet link.
* @param {string} [options.requestSignature] - Unique identifier for deduplication.
* @param {HTMLVideoElement} [options.videoElement] - Target video element.
* @param {Function} [options.probeUrl] - Probe function override.
* @param {Function} [options.playViaWebTorrent] - Torrent handler.
* @param {string} [options.forcedSource] - 'url' or 'torrent' to skip priority checks.
* @returns {PlaybackSession} The new session instance.
*/
createSession(options = {}) {
const session = new PlaybackSession(this, {
...options,
playbackStartTimeout: this.playbackStartTimeout,
});
this.currentSession = session;
return session;
}
}
/**
* PlaybackSession represents a single attempt to play a specific video.
* It manages the state machine of:
* 1. Probing the direct URL.
* 2. Attempting to play the URL.
* 3. Monitoring for stalls.
* 4. Falling back to WebTorrent if needed.
*
* It uses a `requestSignature` (JSON of url+magnet) to uniquely identify the request.
* This is used by the UI layer to prevent race conditions: if the user clicks a new video,
* the UI can check `matchesRequestSignature` to see if it can reuse the active session
* or if it needs to spin up a new one.
*/
class PlaybackSession extends SimpleEventEmitter {
constructor(service, options = {}) {
super((message, ...args) => service.log(message, ...args));
this.service = service;
this.options = options;
this.watchdogCleanup = null;
this.result = { source: null };
this.finished = false;
this.startPromise = null;
this.sanitizedUrl =
typeof options.url === "string" ? options.url.trim() : "";
this.trimmedMagnet =
typeof options.magnet === "string" ? options.magnet.trim() : "";
// Ordered, de-duped hosted sources to try in turn (primary URL first, then
// any mirrors from the video's imeta `sources`). Lets playback fail over to
// the next mirror when one is dead/stalls mid-load, before dropping to P2P.
this.hostedSourceCandidates = (() => {
const seen = new Set();
const list = [];
const add = (candidate) => {
// Accept plain URL strings or imeta `{ url, ... }` source objects.
const raw =
typeof candidate === "string"
? candidate
: candidate && typeof candidate.url === "string"
? candidate.url
: "";
const trimmed = typeof raw === "string" ? raw.trim() : "";
if (!trimmed) return;
const key = trimmed.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
list.push(trimmed);
};
add(this.sanitizedUrl);
if (Array.isArray(options.sources)) {
options.sources.forEach(add);
}
return list;
})();
// The signature prevents duplicate work if the same video is requested again
// while already loading/playing.
this.requestSignature =
typeof options.requestSignature === "string"
? options.requestSignature
: JSON.stringify({
url: this.sanitizedUrl,
magnet: this.trimmedMagnet,
});
if (typeof service.deriveTorrentPlaybackConfig === "function") {
this.playbackConfig = service.deriveTorrentPlaybackConfig({
magnet: this.trimmedMagnet,
url: this.sanitizedUrl,
logger: (message) => service.log(message),
appProtocol: options.appProtocol,
});
} else {
this.playbackConfig = {
magnet: this.trimmedMagnet,
fallbackMagnet: "",
provided: !!this.trimmedMagnet,
usedInfoHash: false,
originalInput: this.trimmedMagnet,
didMutate: false,
infoHash: "",
};
}
const magnetIsUsable =
typeof service.isValidMagnetUri === "function"
? service.isValidMagnetUri(this.playbackConfig.magnet)
: false;
this.magnetForPlayback = magnetIsUsable
? this.playbackConfig.magnet
: "";
this.fallbackMagnet = magnetIsUsable
? this.playbackConfig.fallbackMagnet
: "";
this.magnetProvided = !!this.playbackConfig.provided;
}
isActive() {
return !this.finished;
}
matchesRequestSignature(signature) {
if (!this.isActive()) {
return false;
}
if (typeof signature !== "string" || !signature) {
return false;
}
return signature === this.requestSignature;
}
getResult() {
return this.result;
}
getPlaybackConfig() {
return this.playbackConfig;
}
getMagnetForPlayback() {
return this.magnetForPlayback;
}
getFallbackMagnet() {
return this.fallbackMagnet;
}
getMagnetProvided() {
return this.magnetProvided;
}
cleanupWatchdog() {
if (typeof this.watchdogCleanup === "function") {
try {
this.watchdogCleanup();
} catch (err) {
this.service.log("[PlaybackSession] Watchdog cleanup threw", err);
} finally {
this.watchdogCleanup = null;
}
}
}
registerWatchdogs(videoElement, options) {
this.cleanupWatchdog();
this.watchdogCleanup = this.service.registerUrlPlaybackWatchdogs(
videoElement,
options
);
}
attachDebugListeners(videoElement) {
if (!videoElement) {
return () => {};
}
const debugEvents = [
"loadedmetadata",
"loadeddata",
"canplay",
"canplaythrough",
"play",
"playing",
"pause",
"stalled",
"suspend",
"waiting",
"ended",
"error",
];
const handlers = [];
for (const eventName of debugEvents) {
const handler = () => {
const { readyState, networkState, currentTime, paused, error } =
videoElement;
let suffix =
`readyState=${readyState} networkState=${networkState} currentTime=${
Number.isFinite(currentTime) ? currentTime.toFixed(2) : currentTime
} paused=${paused}`;
if (eventName === "error" && error) {
const errorMessage = error.message || "";
suffix += ` code=${error.code || ""} message=${errorMessage}`;
}
this.service.log(
`[playVideoWithFallback] <video> event ${eventName}; ${suffix}`
);
};
videoElement.addEventListener(eventName, handler);
handlers.push([eventName, handler]);
}
return () => {
if (!handlers.length) {
return;
}
for (const [eventName, handler] of handlers) {
videoElement.removeEventListener(eventName, handler);
}
handlers.length = 0;
};
}
/**
* Begins the playback flow. Returns a promise that resolves when playback
* has either successfully started (URL or Torrent) or fatally failed.
*
* @returns {Promise<{source: 'url'|'torrent'|null, error?: Error}>}
*/
async start() {
if (this.startPromise) {
return this.startPromise;
}
this.startPromise = this.execute();
return this.startPromise;
}
/**
* The core execution loop for the session.
*
* Flow:
* 1. Check if forced source (e.g. user manually switched to "Torrent")
* 2. If URL available & URL-first enabled:
* a. Probe the URL (HEAD request) to see if it's reachable.
* b. If probe succeeds, attempt to play.
* c. Attach watchdogs.
* d. If watchdog triggers (stall/error), trigger `startTorrentFallback`.
* 3. If URL fails or not available, call `startTorrentFallback`.
*
* @returns {Promise<{source: 'url'|'torrent'|null, error?: Error}>}
*/
async execute() {
const {
videoElement,
waitForCleanup,
cancelPendingViewLogging,
clearActiveIntervals,
showModalWithPoster,
teardownVideoElement,
probeUrl,
playViaWebTorrent,
autoplay,
unsupportedBtihMessage = DEFAULT_UNSUPPORTED_BTITH_MESSAGE,
forcedSource = null,
playbackStartTimeout = PLAYBACK_START_TIMEOUT,
} = this.options;
this.result = { source: null };
this.finished = false;
const detail = {
urlProvided: !!this.sanitizedUrl,
magnetProvided: this.magnetProvided,
magnetUsable: !!this.magnetForPlayback,
};
this.service.handleAnalyticsEvent("session-start", detail);
this.emit("session-start", detail);
try {
if (typeof waitForCleanup === "function") {
await waitForCleanup();
}
if (typeof cancelPendingViewLogging === "function") {
cancelPendingViewLogging();
}
if (!videoElement) {
throw new Error("Video element is not ready for playback.");
}
if (typeof showModalWithPoster === "function") {
await Promise.resolve(showModalWithPoster());
}
this.emit("status", { message: "Preparing video..." });
if (typeof clearActiveIntervals === "function") {
clearActiveIntervals();
}
this.service.prepareVideoElement(videoElement);
if (
this.service.torrentClient &&
typeof this.service.torrentClient.cleanup === "function"
) {
await this.service.torrentClient.cleanup();
}
const activeVideoEl =
typeof teardownVideoElement === "function"
? teardownVideoElement(videoElement) || videoElement
: videoElement;
if (!activeVideoEl) {
throw new Error("Video element is not ready for playback.");
}
this.options.videoElement = activeVideoEl;
this.emit("video-prepared", { videoElement: activeVideoEl });
this.emit("view-logging-request", { videoElement: activeVideoEl });
const cleanupDebugListeners = this.attachDebugListeners(activeVideoEl);
let cleanupHostedUrlStatusListeners = () => {};
const httpsUrl = this.sanitizedUrl;
const webSeedCandidates = [];
const webSeedSet = new Set();
const addWebSeedCandidate = (candidate) => {
if (typeof candidate !== "string") {
return;
}
const trimmed = candidate.trim();
if (!trimmed) {
return;
}
const key = trimmed.toLowerCase();
if (webSeedSet.has(key)) {
return;
}
webSeedSet.add(key);
webSeedCandidates.push(trimmed);
};
// 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));
}
if (webSeedCandidates.length > 0) {
this.service.log(
`[playVideoWithFallback] Adding ${webSeedCandidates.length} web seed candidate(s) for fallback:`,
webSeedCandidates
);
}
const resetVideoElement = () => {
if (!activeVideoEl) return;
try {
activeVideoEl.pause();
} catch (err) {
this.service.log("[playVideoWithFallback] Pause threw during reset:", err);
}
try {
activeVideoEl.removeAttribute("src");
} catch (err) {
// ignore
}
activeVideoEl.src = "";
activeVideoEl.srcObject = null;
// Direct hosted playback must NOT require CORS. A leftover
// crossOrigin="anonymous" (e.g. set by a prior WebTorrent attempt on
// this reused element) makes the browser issue a CORS request that many
// CDNs — and redirect targets like archive.org's storage nodes — do not
// answer with Access-Control-Allow-Origin, so the load dies with
// MEDIA_ERR_SRC_NOT_SUPPORTED ("source not supported or blocked"). We
// never read pixels from the hosted <video>, so clear it and let any
// URL play.
try {
activeVideoEl.removeAttribute("crossorigin");
} catch (err) {
// ignore
}
activeVideoEl.crossOrigin = 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();
resetVideoElement();
if (!this.magnetForPlayback) {
// No magnet available to try
return null;
}
this.emit("status", { message: "Switching to WebTorrent..." });
this.service.handleAnalyticsEvent("fallback", { reason });
this.emit("fallback", { reason });
if (typeof playViaWebTorrent !== "function") {
throw new Error("No torrent playback handler provided.");
}
let torrentInstance;
try {
torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
fallbackMagnet: this.fallbackMagnet,
urlList: webSeedCandidates,
hostedUrlWebSeed: httpsUrl,
});
} catch (err) {
const errorMessage = getTorrentErrorMessage(err);
if (errorMessage) {
this.emit("status", { message: errorMessage });
}
throw new Error(errorMessage, { cause: err });
}
this.service.handleAnalyticsEvent("sourcechange", { source: "torrent" });
this.emit("sourcechange", { source: "torrent" });
if (typeof autoplay === "function") {
autoplay();
}
const result = { source: "torrent", torrentInstance };
this.result = result;
return result;
};
// --- Attempt URL Logic ---
// candidateUrl defaults to the primary; the execution flow calls this once
// per hosted source so a dead/stalled mirror fails over to the next one.
const attemptHostedPlayback = async (candidateUrl = httpsUrl) => {
if (!candidateUrl) return null;
this.emit("status", { message: "Checking hosted URL..." });
this.service.log(
`[playVideoWithFallback] Probing hosted URL ${candidateUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).`
);
let hostedStatusResolved = false;
const hostedStatusHandlers = [];
const addHostedStatusListener = (eventName, handler, options) => {
if (!activeVideoEl) return;
activeVideoEl.addEventListener(eventName, handler, options);
hostedStatusHandlers.push([eventName, handler, options]);
};
const markHostedUrlAsLive = () => {
if (hostedStatusResolved) return;
hostedStatusResolved = true;
this.emit("status", { message: HOSTED_URL_SUCCESS_MESSAGE });
cleanupHostedUrlStatusListeners();
};
const maybeMarkHostedUrl = () => {
if (
hostedStatusResolved ||
!activeVideoEl ||
activeVideoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
) {
return;
}
if (activeVideoEl.currentTime > 0 || !activeVideoEl.paused) {
markHostedUrlAsLive();
}
};
cleanupHostedUrlStatusListeners = () => {
if (!hostedStatusHandlers.length || !activeVideoEl) {
hostedStatusHandlers.length = 0;
cleanupHostedUrlStatusListeners = () => {};
return;
}
for (const [eventName, handler, options] of hostedStatusHandlers) {
activeVideoEl.removeEventListener(eventName, handler, options);
}
hostedStatusHandlers.length = 0;
cleanupHostedUrlStatusListeners = () => {};
};
addHostedStatusListener("playing", markHostedUrlAsLive, { once: true });
addHostedStatusListener("loadeddata", maybeMarkHostedUrl);
addHostedStatusListener("canplay", maybeMarkHostedUrl);
addHostedStatusListener(
"error",
() => {
cleanupHostedUrlStatusListeners();
cleanupDebugListeners();
},
{ once: true }
);
const probeResult = await this.service.probeHostedUrl({
url: candidateUrl,
magnet: this.trimmedMagnet,
probeUrl,
});
const probeOutcome = probeResult?.outcome || "error";
const probeStatus = probeResult?.status || "unknown";
const shouldAttemptHosted =
probeOutcome !== "bad" && probeOutcome !== "error";
this.service.log(
`[playVideoWithFallback] Hosted URL probe outcome=${probeOutcome} status=${probeStatus} shouldAttemptHosted=${shouldAttemptHosted}`
);
if (probeOutcome === "bad") {
this.service.log(
`[playVideoWithFallback] ⚠️ Direct URL probe failed with status ${probeStatus}. WebSeed fallback using this same URL will likely also fail.`
);
}
if (shouldAttemptHosted) {
let outcomeResolved = false;
let outcomeResolver = () => {};
let autoplayBlocked = false;
const playbackOutcomePromise = new Promise((resolve) => {
outcomeResolver = (value) => {
if (outcomeResolved) return;
outcomeResolved = true;
resolve(value);
};
});
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
this.registerWatchdogs(activeVideoEl, {
stallMs,
onSuccess: () => outcomeResolver({ status: "success" }),
onFallback: (reason) => {
if (autoplayBlocked && reason === "stall") {
this.service.log(
"[playVideoWithFallback] Autoplay blocked; waiting for user gesture before falling back."
);
this.emit("status", {
message: "Press play to start the hosted video.",
});
this.emit("autoplay-blocked", { reason });
attachWatchdogs({ stallMs: 0 });
return;
}
const errorMessage = getHostedVideoErrorMessage(activeVideoEl);
if (errorMessage) {
this.emit("status", { message: errorMessage });
}
outcomeResolver({ status: "fallback", reason, errorMessage });
},
});
};
// Use default stall timeout here; the initial start timeout is handled by the race wrapper
attachWatchdogs({ stallMs: 8000 });
const handleFatalPlaybackError = (err) => {
this.service.log(
"[playVideoWithFallback] Direct URL playback threw:",
err
);
outcomeResolver({ status: "fallback", reason: "play-error" });
};
try {
// Guarantee the direct load is CORS-free regardless of how we got
// here (see resetVideoElement) so any hosted URL plays reliably.
try {
activeVideoEl.removeAttribute("crossorigin");
} catch (err) {
// ignore
}
activeVideoEl.crossOrigin = null;
activeVideoEl.src = candidateUrl;
const playPromise = activeVideoEl.play();
if (playPromise && typeof playPromise.catch === "function") {
playPromise.catch((err) => {
if (err?.name === "NotAllowedError") {
autoplayBlocked = true;
this.service.log(
"[playVideoWithFallback] Autoplay blocked by browser; awaiting user interaction.",
err
);
this.emit("status", {
message: "Press play to start the hosted video.",
});
this.cleanupWatchdog();
attachWatchdogs({ stallMs: 0 });
const restoreOnPlay = () => {
activeVideoEl.removeEventListener("play", restoreOnPlay);
this.cleanupWatchdog();
autoplayBlocked = false;
attachWatchdogs({ stallMs: 8000 });
};
activeVideoEl.addEventListener("play", restoreOnPlay, {
once: true,
});
return;
}
handleFatalPlaybackError(err);
});
}
} catch (err) {
handleFatalPlaybackError(err);
}
const playbackOutcome = await playbackOutcomePromise;
if (playbackOutcome?.status === "success") {
this.emit("poster-remove", { reason: "http-success" });
this.service.handleAnalyticsEvent("sourcechange", {
source: "url",
});
this.emit("sourcechange", { source: "url" });
cleanupHostedUrlStatusListeners();
cleanupDebugListeners();
const result = { source: "url" };
this.result = result;
return result;
}
// Fallback triggered
const fallbackReason = playbackOutcome?.reason || "watchdog-triggered";
return {
status: "fallback",
reason: fallbackReason,
errorMessage: playbackOutcome?.errorMessage,
};
}
const failureDetails = getHostedUrlFailureDetails(probeResult);
if (failureDetails?.message) {
this.emit("status", { message: failureDetails.message });
}
cleanupHostedUrlStatusListeners();
cleanupDebugListeners();
return {
status: "fallback",
reason: "probe-failed",
errorMessage: failureDetails?.message,
};
};
// --- Timeout Wrapper ---
const withTimeout = (promise, ms, label = "Operation") => {
if (!ms || ms <= 0) return promise;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.service.log(`[playVideoWithFallback] ${label} timed out after ${ms}ms.`);
resolve({ status: "fallback", reason: "timeout", source: "timeout" });
}, ms);
promise
.then((result) => {
clearTimeout(timer);
resolve(result);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
};
// --- Execution Flow ---
let tryUrlFirst = this.service.urlFirstEnabled;
// CDN-first for dual-source videos: whenever a hosted URL is available,
// play it first for instant, reliable startup. WebTorrent is reserved for
// videos that have no URL (or when the viewer explicitly forces P2P). This
// intentionally takes precedence over a torrent-first instance default,
// because waiting on a P2P cold-start when a working CDN exists is the
// slow, flaky path users were hitting.
if (httpsUrl) tryUrlFirst = true;
if (forcedSource === "url") tryUrlFirst = true;
if (forcedSource === "torrent") tryUrlFirst = false;
const effectiveTimeout = forcedSource ? 0 : playbackStartTimeout;
let hostedErrorMessage = "";
if (tryUrlFirst) {
if (this.hostedSourceCandidates.length) {
// Try each hosted source in turn; a dead/stalled one fails over to the
// next mirror before we drop to P2P.
let lastUrlResult = null;
for (let i = 0; i < this.hostedSourceCandidates.length; i += 1) {
const candidateUrl = this.hostedSourceCandidates[i];
const urlResult = await withTimeout(
attemptHostedPlayback(candidateUrl),
effectiveTimeout,
"URL Playback"
);
if (urlResult && urlResult.source === "url") {
return urlResult;
}
lastUrlResult = urlResult;
if (typeof urlResult?.errorMessage === "string") {
hostedErrorMessage = urlResult.errorMessage;
}
// Clean up watchdogs/listeners and reset the element before the next
// candidate (the single-attempt path relied on attemptTorrentPlayback
// to do this; the loop must do it between mirrors).
const moreCandidatesRemain =
i < this.hostedSourceCandidates.length - 1;
if (urlResult?.reason === "timeout" || moreCandidatesRemain) {
this.cleanupWatchdog();
cleanupHostedUrlStatusListeners();
resetVideoElement();
}
if (moreCandidatesRemain) {
this.service.log(
`[playVideoWithFallback] Hosted source ${candidateUrl} failed (${urlResult?.reason || "unknown"}); trying next mirror.`
);
}
}
const fallbackReason = lastUrlResult?.reason || "url-unavailable";
if (this.magnetForPlayback && forcedSource !== "url") {
return await attemptTorrentPlayback(fallbackReason);
}
} else if (this.magnetForPlayback && forcedSource !== "url") {
return await attemptTorrentPlayback("url-missing");
}
} else {
// Try Torrent First
if (this.magnetForPlayback) {
try {
// We only reach the torrent-first path when there is no hosted URL
// to fall back to (CDN-first handles the dual-source case above) or
// the viewer explicitly forced P2P. Either way there is no plan B,
// so wait indefinitely for peers rather than abandoning playback.
const torrentTimeout = 0;
// Wrap Torrent attempt in timeout
const torrentResult = await withTimeout(
attemptTorrentPlayback("preference"),
torrentTimeout,
"Torrent Playback"
);
if (torrentResult && torrentResult.source === "torrent") {
return torrentResult;
}
if (torrentResult?.reason === "timeout") {
this.service.log(
"[playVideoWithFallback] Torrent timed out; cleaning up."
);
if (
this.service.torrentClient &&
typeof this.service.torrentClient.cleanup === "function"
) {
await this.service.torrentClient.cleanup();
}
resetVideoElement();
}
} catch (err) {
this.service.log(
"[playVideoWithFallback] Torrent preference failed, trying URL:",
err
);
if (
this.service.torrentClient &&
typeof this.service.torrentClient.cleanup === "function"
) {
await this.service.torrentClient.cleanup();
}
resetVideoElement();
}
}
// Fallback to URL
if (httpsUrl && forcedSource !== "torrent") {
const urlResult = await attemptHostedPlayback();
if (urlResult && urlResult.source === "url") {
return urlResult;
}
if (typeof urlResult?.errorMessage === "string") {
hostedErrorMessage = urlResult.errorMessage;
}
}
}
const message =
hostedErrorMessage ||
(this.magnetProvided && !this.magnetForPlayback
? unsupportedBtihMessage
: "No playable source found.");
this.emit("status", { message });
this.service.handleAnalyticsEvent("sourcechange", { source: null });
this.emit("sourcechange", { source: null });
cleanupDebugListeners();
const error = new Error(message);
this.service.handleAnalyticsEvent("error", { error });
this.emit("error", { error, message });
this.result = { source: null, error };
return this.result;
} catch (error) {
this.service.log("Error in playVideoWithFallback:", 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 };
return this.result;
} finally {
this.cleanupWatchdog();
this.finished = true;
if (this.result) {
this.emit("finished", this.result);
}
}
}
}
export default PlaybackService;