mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
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]>
1319 lines
42 KiB
JavaScript
1319 lines
42 KiB
JavaScript
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;
|