mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #1926 from PR0M3TH3AN/codex/add-error-handling-for-media-loading
Improve media failure messaging and avatar fallbacks
This commit is contained in:
3 files changed
+217
-9
No files matched your search
@@ -62,6 +62,159 @@ class SimpleEventEmitter {
|
||||
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 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({
|
||||
@@ -602,10 +755,19 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
throw new Error("No torrent playback handler provided.");
|
||||
}
|
||||
|
||||
const torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
|
||||
fallbackMagnet: this.fallbackMagnet,
|
||||
urlList: webSeedCandidates,
|
||||
});
|
||||
let torrentInstance;
|
||||
try {
|
||||
torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
|
||||
fallbackMagnet: this.fallbackMagnet,
|
||||
urlList: webSeedCandidates,
|
||||
});
|
||||
} 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" });
|
||||
@@ -726,7 +888,11 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
return;
|
||||
}
|
||||
outcomeResolver({ status: "fallback", reason });
|
||||
const errorMessage = getHostedVideoErrorMessage(activeVideoEl);
|
||||
if (errorMessage) {
|
||||
this.emit("status", { message: errorMessage });
|
||||
}
|
||||
outcomeResolver({ status: "fallback", reason, errorMessage });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -792,12 +958,25 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
|
||||
// Fallback triggered
|
||||
const fallbackReason = playbackOutcome?.reason || "watchdog-triggered";
|
||||
return { status: "fallback", reason: fallbackReason };
|
||||
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" };
|
||||
return {
|
||||
status: "fallback",
|
||||
reason: "probe-failed",
|
||||
errorMessage: failureDetails?.message,
|
||||
};
|
||||
};
|
||||
|
||||
// --- Timeout Wrapper ---
|
||||
@@ -829,6 +1008,8 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
|
||||
const effectiveTimeout = forcedSource ? 0 : playbackStartTimeout;
|
||||
|
||||
let hostedErrorMessage = "";
|
||||
|
||||
if (tryUrlFirst) {
|
||||
if (httpsUrl) {
|
||||
// Wrap URL attempt in timeout
|
||||
@@ -842,6 +1023,10 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
return urlResult;
|
||||
}
|
||||
|
||||
if (typeof urlResult?.errorMessage === "string") {
|
||||
hostedErrorMessage = urlResult.errorMessage;
|
||||
}
|
||||
|
||||
// If timeout or failure, proceed to fallback
|
||||
if (urlResult?.reason === "timeout") {
|
||||
// Need to ensure URL probing/playback is effectively cancelled
|
||||
@@ -905,13 +1090,18 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
if (urlResult && urlResult.source === "url") {
|
||||
return urlResult;
|
||||
}
|
||||
|
||||
if (typeof urlResult?.errorMessage === "string") {
|
||||
hostedErrorMessage = urlResult.errorMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
this.magnetProvided && !this.magnetForPlayback
|
||||
hostedErrorMessage ||
|
||||
(this.magnetProvided && !this.magnetForPlayback
|
||||
? unsupportedBtihMessage
|
||||
: "No playable source found.";
|
||||
: "No playable source found.");
|
||||
this.emit("status", { message });
|
||||
this.service.handleAnalyticsEvent("sourcechange", { source: null });
|
||||
this.emit("sourcechange", { source: null });
|
||||
|
||||
@@ -985,6 +985,15 @@ export class SimilarContentCard {
|
||||
avatarImg.decoding = "async";
|
||||
avatarImg.loading = "lazy";
|
||||
avatarImg.alt = "";
|
||||
if (!avatarImg.dataset.avatarFallbackBound) {
|
||||
avatarImg.addEventListener("error", () => {
|
||||
if (avatarImg.getAttribute("src") !== DEFAULT_PROFILE_AVATAR) {
|
||||
avatarImg.src = DEFAULT_PROFILE_AVATAR;
|
||||
}
|
||||
avatarImg.dataset.avatarLoadFailed = "true";
|
||||
});
|
||||
avatarImg.dataset.avatarFallbackBound = "true";
|
||||
}
|
||||
avatarWrapper.appendChild(avatarImg);
|
||||
|
||||
// Append avatar then text
|
||||
|
||||
@@ -916,6 +916,15 @@ export class VideoCard {
|
||||
"rounded-full",
|
||||
"object-cover"
|
||||
);
|
||||
if (!avatar.dataset.avatarFallbackBound) {
|
||||
avatar.addEventListener("error", () => {
|
||||
if (avatar.getAttribute("src") !== DEFAULT_PROFILE_AVATAR) {
|
||||
avatar.src = DEFAULT_PROFILE_AVATAR;
|
||||
}
|
||||
avatar.dataset.avatarLoadFailed = "true";
|
||||
});
|
||||
avatar.dataset.avatarFallbackBound = "true";
|
||||
}
|
||||
if (this.video.pubkey) {
|
||||
avatar.dataset.pubkey = this.video.pubkey;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user