mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[playback] harden WebTorrent streaming + add forced-source test deep links
WebTorrent (handleTorrentStream) hardening:
- Resolve only when playback is actually viable (loadeddata/canplay/playing),
not the instant streamTo() is called. A dead/too-slow swarm is no longer
reported as success, so the caller's fallback can run.
- Pre-playback torrent errors reject (caller falls back); post-playback errors
go to an onPlaybackError hook instead of a dead reject().
- Mid-stream stall watchdog surfaces an onStall hook ("waiting for peers").
- Fix a <video> listener leak: stream listeners are tracked and torn down
before the next stream and on cleanup() (the modal reuses one element).
- Stream the largest decodable file from a multi-file torrent and deselect the
rest (wider ext set: mp4/m4v/webm/mkv/mov/ogv/ogg) so the swarm isn't spent
downloading files we never play.
playbackCoordinator wires onStall/onPlaybackError to user-facing status.
Forced-source deep links (test affordance):
- The main app honors ?v=<nevent>&playback=torrent|url (mirrors the embed
player) to force a playback path; a missing source shows a clear message.
- The video share menu gains "Copy WebTorrent URL" / "Copy CDN URL" items that
copy the forced-source deep link (disabled with a reason when unavailable).
Tests: webtorrent-handlers (resolve-on-readiness, pre/post-ready errors, stall
fires/clears, largest-file select/deselect, no listener accumulation),
playback-forced-source (param normalization), videoMenuRenderers (copy items +
availability). All mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
577ea5af15
commit
4e5bfaa6af
7 files changed
+678
-21
No files matched your search
@@ -9,6 +9,28 @@
|
||||
* Methods use `this` which is bound to the Application instance.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize a deep-link / caller "force this playback source" value to the
|
||||
* canonical 'url' | 'torrent' the playback pipeline understands, or null when
|
||||
* unset/unrecognized. Accepts a few friendly aliases (the in-modal toggle
|
||||
* labels CDN/P2P, plus webtorrent/hosted) so shared links are forgiving.
|
||||
* @param {unknown} value
|
||||
* @returns {'url' | 'torrent' | null}
|
||||
*/
|
||||
export function normalizeForcedSource(value) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["torrent", "p2p", "webtorrent", "magnet"].includes(normalized)) {
|
||||
return "torrent";
|
||||
}
|
||||
if (["url", "cdn", "hosted", "http", "https"].includes(normalized)) {
|
||||
return "url";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} deps - Injected dependencies.
|
||||
* @returns {object} Methods to be bound to the Application instance.
|
||||
@@ -47,6 +69,14 @@ export function createPlaybackCoordinator(deps) {
|
||||
const maybeNevent = urlParams.get("v");
|
||||
if (!maybeNevent) return; // no link param
|
||||
|
||||
// Optional deep-link override that forces a playback source, mirroring the
|
||||
// embed player's `playback` param. Lets you test a specific path
|
||||
// consistently: `?v=<nevent>&playback=torrent` always streams via
|
||||
// WebTorrent, `&playback=url` always uses the hosted CDN URL. If the
|
||||
// requested source is missing for the video, playVideoByEventId surfaces
|
||||
// a message instead of silently using the other source.
|
||||
const forcedSource = normalizeForcedSource(urlParams.get("playback"));
|
||||
|
||||
try {
|
||||
const decoded = window.NostrTools.nip19.decode(maybeNevent);
|
||||
if (decoded.type === "nevent" && decoded.data.id) {
|
||||
@@ -58,13 +88,13 @@ export function createPlaybackCoordinator(deps) {
|
||||
// 1) check local map
|
||||
let localMatch = this.videosMap.get(eventId);
|
||||
if (localMatch) {
|
||||
this.playVideoByEventId(eventId, { relay });
|
||||
this.playVideoByEventId(eventId, { relay, forcedSource });
|
||||
} else {
|
||||
// 2) fallback => getOldEventById
|
||||
this.getOldEventById(eventId)
|
||||
.then((video) => {
|
||||
if (video) {
|
||||
this.playVideoByEventId(eventId, { relay });
|
||||
this.playVideoByEventId(eventId, { relay, forcedSource });
|
||||
} else {
|
||||
this.showError("No matching video found for that link.");
|
||||
}
|
||||
@@ -386,7 +416,37 @@ export function createPlaybackCoordinator(deps) {
|
||||
const torrentInstance = await torrentClient.streamVideo(
|
||||
cacheBustedMagnet,
|
||||
this.modalVideo,
|
||||
{ urlList: sanitizedUrlList }
|
||||
{
|
||||
urlList: sanitizedUrlList,
|
||||
hooks: {
|
||||
// Surface a mid-stream stall instead of silently freezing on a
|
||||
// buffering frame: tell the user whether the swarm is just slow
|
||||
// or has no seeders at all.
|
||||
onStall: ({ peers } = {}) => {
|
||||
if (!this.videoModal) {
|
||||
return;
|
||||
}
|
||||
this.videoModal.updateStatus(
|
||||
peers > 0
|
||||
? "Buffering — the swarm is slow right now."
|
||||
: "Waiting for peers — this video may have no active seeders."
|
||||
);
|
||||
},
|
||||
// A torrent error after playback started can't reject the (already
|
||||
// settled) start promise, so report it here.
|
||||
onPlaybackError: (error) => {
|
||||
this.log(
|
||||
"[playViaWebTorrent] Torrent error during playback:",
|
||||
error
|
||||
);
|
||||
if (this.videoModal) {
|
||||
this.videoModal.updateStatus(
|
||||
"WebTorrent playback hit an error — the swarm may have dropped."
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (torrentClient.isServiceWorkerUnavailable()) {
|
||||
@@ -801,6 +861,29 @@ export function createPlaybackCoordinator(deps) {
|
||||
playbackOptions.trigger = this.lastModalTrigger;
|
||||
}
|
||||
|
||||
// Honor a forced-source deep link (e.g. ?playback=torrent). If the video
|
||||
// actually has that source, force it so the test path is deterministic.
|
||||
// If it does not, tell the user clearly and fall back to normal selection
|
||||
// rather than opening a dead player.
|
||||
const requestedSource = normalizeForcedSource(hint.forcedSource);
|
||||
if (requestedSource === "torrent") {
|
||||
if (sanitizedMagnet) {
|
||||
playbackOptions.forcedSource = "torrent";
|
||||
} else {
|
||||
this.showError(
|
||||
"This video has no WebTorrent source — nothing to stream over P2P.",
|
||||
);
|
||||
}
|
||||
} else if (requestedSource === "url") {
|
||||
if (trimmedUrl) {
|
||||
playbackOptions.forcedSource = "url";
|
||||
} else {
|
||||
this.showError(
|
||||
"This video has no hosted CDN URL — only a WebTorrent source is available.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let playbackPromise = null;
|
||||
if (this.shouldDeferModeratedPlayback(this.currentVideo)) {
|
||||
const pendingVideoId =
|
||||
|
||||
@@ -3290,6 +3290,35 @@ export class VideoModal {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `?v=<nevent>&playback=<source>` deep link for the given video that
|
||||
* forces a specific playback path (used by the share-menu "Copy WebTorrent
|
||||
* URL" / "Copy CDN URL" items). Returns "" if a link can't be built.
|
||||
* @param {object} video
|
||||
* @param {'torrent'|'url'} playback
|
||||
* @returns {string}
|
||||
*/
|
||||
buildForcedSourceShareUrl(video, playback) {
|
||||
const eventId =
|
||||
video && typeof video.id === "string" ? video.id.trim() : "";
|
||||
if (!eventId) {
|
||||
return "";
|
||||
}
|
||||
const win = this.window || globalThis;
|
||||
const neventEncode = win?.NostrTools?.nip19?.neventEncode;
|
||||
const loc = win?.location;
|
||||
if (typeof neventEncode !== "function" || !loc) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const nevent = neventEncode({ id: eventId });
|
||||
const base = `${loc.origin}${loc.pathname}`;
|
||||
return `${base}?v=${encodeURIComponent(nevent)}&playback=${playback}`;
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
setupModalSharePopover() {
|
||||
if (!this.playerModal || !this.shareBtn) {
|
||||
return;
|
||||
@@ -3319,6 +3348,8 @@ export class VideoModal {
|
||||
hasSigner: this.shareNostrAuthState.hasSigner,
|
||||
hasMagnet: Boolean(this.modalMoreMenuContext.playbackMagnet), // Re-use magnet from context
|
||||
hasCdn: Boolean(this.modalMoreMenuContext.playbackUrl),
|
||||
buildForcedSourceUrl: (playback) =>
|
||||
this.buildForcedSourceShareUrl(this.activeVideo, playback),
|
||||
});
|
||||
if (panel) {
|
||||
container.appendChild(panel);
|
||||
|
||||
@@ -316,6 +316,13 @@ export function createVideoShareMenuPanel({
|
||||
hasSigner = false,
|
||||
hasMagnet = false,
|
||||
hasCdn = false,
|
||||
// Optional builder for forced-source test deep links: fn('torrent'|'url') ->
|
||||
// a `?v=<nevent>&playback=...` URL (or "" when unavailable). When provided,
|
||||
// the menu offers "Copy WebTorrent URL" / "Copy CDN URL" items that copy a
|
||||
// link which always plays via that source — handy for testing one path
|
||||
// consistently. `clipboard` is injectable for tests.
|
||||
buildForcedSourceUrl = null,
|
||||
clipboard = null,
|
||||
designSystem = null,
|
||||
} = {}) {
|
||||
const doc = resolveDocument(documentRef);
|
||||
@@ -370,6 +377,71 @@ export function createVideoShareMenuPanel({
|
||||
cdnBtn.title = "No CDN link available";
|
||||
}
|
||||
|
||||
// Forced-source test deep links. Self-contained: they copy a link via the
|
||||
// injected builder + clipboard rather than routing through the shared menu
|
||||
// dispatch, so they work regardless of how the share popover is wired.
|
||||
if (typeof buildForcedSourceUrl === "function") {
|
||||
const resolveClipboard = () =>
|
||||
clipboard ||
|
||||
(typeof navigator !== "undefined" ? navigator.clipboard : null);
|
||||
|
||||
const wireForcedCopy = (button, playback, available, unavailableTitle) => {
|
||||
if (!available) {
|
||||
button.disabled = true;
|
||||
button.classList.add("opacity-50", "cursor-not-allowed");
|
||||
button.title = unavailableTitle;
|
||||
return;
|
||||
}
|
||||
const originalText = button.textContent;
|
||||
button.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const url = buildForcedSourceUrl(playback) || "";
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const clip = resolveClipboard();
|
||||
try {
|
||||
if (clip && typeof clip.writeText === "function") {
|
||||
await clip.writeText(url);
|
||||
}
|
||||
button.textContent = "Copied!";
|
||||
} catch (error) {
|
||||
button.textContent = "Copy failed";
|
||||
}
|
||||
if (typeof setTimeout === "function") {
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const torrentLinkBtn = appendMenuAction(doc, list, {
|
||||
text: "Copy WebTorrent URL",
|
||||
action: "copy-webtorrent-url",
|
||||
dataset: { eventId: metadata.id },
|
||||
});
|
||||
wireForcedCopy(
|
||||
torrentLinkBtn,
|
||||
"torrent",
|
||||
hasMagnet,
|
||||
"No WebTorrent source for this video",
|
||||
);
|
||||
|
||||
const cdnLinkBtn = appendMenuAction(doc, list, {
|
||||
text: "Copy CDN URL",
|
||||
action: "copy-cdn-url",
|
||||
dataset: { eventId: metadata.id },
|
||||
});
|
||||
wireForcedCopy(
|
||||
cdnLinkBtn,
|
||||
"url",
|
||||
hasCdn,
|
||||
"No CDN source for this video",
|
||||
);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
|
||||
+204
-18
@@ -27,6 +27,12 @@ import { emit } from "./embedDiagnostics.js";
|
||||
const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]);
|
||||
const SERVICE_WORKER_PATH = "/sw.min.js";
|
||||
const SERVICE_WORKER_SCOPE = "/";
|
||||
// Once torrent playback has actually started, treat this long without any
|
||||
// playback progress (while buffering) as a stall worth surfacing to the caller.
|
||||
const TORRENT_PLAYBACK_STALL_MS = 12000;
|
||||
// Files the player can actually decode. Used to pick the file to stream from a
|
||||
// multi-file torrent (largest match wins) and to deselect the rest.
|
||||
const PLAYABLE_VIDEO_PATTERN = /\.(mp4|m4v|webm|mkv|mov|ogv|ogg)$/i;
|
||||
|
||||
function compareServiceWorkerScripts(a = "", b = "") {
|
||||
if (a === b) {
|
||||
@@ -775,10 +781,21 @@ export class TorrentClient {
|
||||
videoElement,
|
||||
resolve,
|
||||
reject,
|
||||
context = "chrome"
|
||||
context = "chrome",
|
||||
hooks = {}
|
||||
) {
|
||||
const isChrome = context === "chrome";
|
||||
const isFirefox = context === "firefox";
|
||||
const onStall =
|
||||
hooks && typeof hooks.onStall === "function" ? hooks.onStall : null;
|
||||
const onPlaybackError =
|
||||
hooks && typeof hooks.onPlaybackError === "function"
|
||||
? hooks.onPlaybackError
|
||||
: null;
|
||||
const stallMs =
|
||||
hooks && Number.isFinite(hooks.stallMs) && hooks.stallMs > 0
|
||||
? hooks.stallMs
|
||||
: TORRENT_PLAYBACK_STALL_MS;
|
||||
|
||||
// Chrome-specific: Prune demo web seeds/trackers that chronically trip Chromium CORS
|
||||
// and deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround.
|
||||
@@ -809,24 +826,170 @@ export class TorrentClient {
|
||||
});
|
||||
}
|
||||
|
||||
const file = torrent.files.find((f) => /\.(mp4|webm|mkv)$/i.test(f.name));
|
||||
// Stream only the *largest* decodable video file. `.find()` picked whatever
|
||||
// came first (could be a sample clip), and leaving the other files selected
|
||||
// makes WebTorrent download the whole multi-file torrent — wasting the
|
||||
// swarm on data we never play.
|
||||
const files = Array.isArray(torrent.files) ? torrent.files : [];
|
||||
const videoFiles = files.filter((f) =>
|
||||
PLAYABLE_VIDEO_PATTERN.test((f && f.name) || "")
|
||||
);
|
||||
const file = videoFiles
|
||||
.slice()
|
||||
.sort((a, b) => (Number(b?.length) || 0) - (Number(a?.length) || 0))[0];
|
||||
if (!file) {
|
||||
return reject(new Error("No compatible video file found in torrent"));
|
||||
}
|
||||
if (typeof file.select === "function") {
|
||||
try {
|
||||
for (const other of files) {
|
||||
if (other !== file && typeof other.deselect === "function") {
|
||||
other.deselect();
|
||||
}
|
||||
}
|
||||
file.select();
|
||||
} catch (err) {
|
||||
this.log(`File selection failed (${context} path; non-fatal):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots).
|
||||
videoElement.crossOrigin = "anonymous";
|
||||
|
||||
videoElement.addEventListener("error", (e) => {
|
||||
this.log(`Video error (${context} path):`, e.target.error);
|
||||
});
|
||||
// The modal reuses a single <video> across plays, so listeners added here
|
||||
// would otherwise accumulate. Track them and tear down a previous stream's
|
||||
// listeners before wiring this one.
|
||||
if (typeof this._teardownTorrentStreamListeners === "function") {
|
||||
try {
|
||||
this._teardownTorrentStreamListeners();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let stallTimerId = null;
|
||||
const videoListeners = [];
|
||||
const addVideoListener = (eventName, handler, options) => {
|
||||
videoElement.addEventListener(eventName, handler, options);
|
||||
videoListeners.push([eventName, handler]);
|
||||
};
|
||||
const onTorrentError = (err) => {
|
||||
this.log(`Torrent error (${context} path):`, err);
|
||||
if (!settled) {
|
||||
// Pre-playback failure: reject so the caller can fall back (e.g. URL).
|
||||
settled = true;
|
||||
teardown();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
// Post-playback failure: the success promise is long gone, so surface it
|
||||
// through the hook instead of a dead reject().
|
||||
if (onPlaybackError) {
|
||||
try {
|
||||
onPlaybackError(toError(err));
|
||||
} catch (hookErr) {
|
||||
this.log("onPlaybackError hook threw:", hookErr);
|
||||
}
|
||||
}
|
||||
};
|
||||
const teardown = () => {
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
stallTimerId = null;
|
||||
}
|
||||
for (const [eventName, handler] of videoListeners) {
|
||||
try {
|
||||
videoElement.removeEventListener(eventName, handler);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
videoListeners.length = 0;
|
||||
if (torrent && typeof torrent.removeListener === "function") {
|
||||
try {
|
||||
torrent.removeListener("error", onTorrentError);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (this._teardownTorrentStreamListeners === teardown) {
|
||||
this._teardownTorrentStreamListeners = null;
|
||||
}
|
||||
};
|
||||
this._teardownTorrentStreamListeners = teardown;
|
||||
|
||||
const onVideoError = () => {
|
||||
this.log(`Video error (${context} path):`, videoElement.error);
|
||||
};
|
||||
addVideoListener("error", onVideoError);
|
||||
|
||||
const tryStart = () => {
|
||||
this.attemptAutoplay(videoElement, context);
|
||||
};
|
||||
addVideoListener("canplay", tryStart, { once: true });
|
||||
addVideoListener("loadeddata", tryStart, { once: true });
|
||||
|
||||
videoElement.addEventListener("canplay", tryStart, { once: true });
|
||||
videoElement.addEventListener("loadeddata", tryStart, { once: true });
|
||||
// Resolve only when playback is actually viable (first frame / can play),
|
||||
// NOT the instant streamTo() is called. Previously success was declared
|
||||
// synchronously, so a swarm that fetched metadata but could never sustain
|
||||
// playback was reported as a success — the caller cleared its fallback
|
||||
// timeout and the user was left on a frozen frame with no recovery. Waiting
|
||||
// for a real readiness signal lets the caller's playback timeout fall back
|
||||
// to the hosted URL (or surface "no peers") for a dead/too-slow swarm.
|
||||
const markReady = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(torrent);
|
||||
};
|
||||
addVideoListener("loadeddata", markReady, { once: true });
|
||||
addVideoListener("canplay", markReady, { once: true });
|
||||
addVideoListener("playing", markReady, { once: true });
|
||||
|
||||
// Mid-stream stall detection: if playback buffers without progress for a
|
||||
// sustained window, notify via the hook (so the UI can say "waiting for
|
||||
// peers" instead of silently freezing). Progress clears the timer.
|
||||
const armStallTimer = () => {
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
}
|
||||
stallTimerId = setTimeout(() => {
|
||||
stallTimerId = null;
|
||||
const peers = Math.max(
|
||||
0,
|
||||
Math.floor(normalizeNumber(torrent?.numPeers, 0))
|
||||
);
|
||||
this.log(
|
||||
`Torrent playback stalled (${context} path); peers=${peers}.`
|
||||
);
|
||||
if (onStall) {
|
||||
try {
|
||||
onStall({ peers });
|
||||
} catch (hookErr) {
|
||||
this.log("onStall hook threw:", hookErr);
|
||||
}
|
||||
}
|
||||
}, stallMs);
|
||||
};
|
||||
const clearStallTimer = () => {
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
stallTimerId = null;
|
||||
}
|
||||
};
|
||||
addVideoListener("waiting", armStallTimer);
|
||||
addVideoListener("stalled", armStallTimer);
|
||||
addVideoListener("timeupdate", clearStallTimer);
|
||||
addVideoListener("playing", clearStallTimer);
|
||||
|
||||
// Set this up front (not gated on readiness) so cleanup() can always tear
|
||||
// down the torrent even if playback never becomes ready and the caller
|
||||
// times out.
|
||||
this.currentTorrent = torrent;
|
||||
|
||||
torrent.on("error", onTorrentError);
|
||||
|
||||
try {
|
||||
const streamOptions = {};
|
||||
@@ -835,21 +998,19 @@ export class TorrentClient {
|
||||
}
|
||||
file.streamTo(videoElement, streamOptions);
|
||||
|
||||
// If the video is already ready (e.g. from cache or fast load), try starting immediately
|
||||
// Already buffered enough (cache/fast load): start + mark ready now.
|
||||
if (videoElement.readyState >= 3) {
|
||||
tryStart();
|
||||
markReady();
|
||||
}
|
||||
this.currentTorrent = torrent;
|
||||
resolve(torrent);
|
||||
} catch (err) {
|
||||
this.log(`Streaming error (${context} path):`, err);
|
||||
reject(err);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
teardown();
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
torrent.on("error", (err) => {
|
||||
this.log(`Torrent error (${context} path):`, err);
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
async destroyWithTimeout(
|
||||
@@ -944,6 +1105,8 @@ export class TorrentClient {
|
||||
}
|
||||
|
||||
const isFirefoxBrowser = this.isFirefox();
|
||||
const streamHooks =
|
||||
opts && typeof opts.hooks === "object" && opts.hooks ? opts.hooks : {};
|
||||
const candidateUrls = Array.isArray(opts?.urlList)
|
||||
? opts.urlList
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||
@@ -969,14 +1132,28 @@ export class TorrentClient {
|
||||
{ ...chromeOptions, maxWebConns: 4 },
|
||||
(torrent) => {
|
||||
this.log("Torrent added (Firefox path):", torrent.name);
|
||||
this.handleTorrentStream(torrent, videoElement, resolve, reject, "firefox");
|
||||
this.handleTorrentStream(
|
||||
torrent,
|
||||
videoElement,
|
||||
resolve,
|
||||
reject,
|
||||
"firefox",
|
||||
streamHooks
|
||||
);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this.log("Starting torrent download (Chrome path)");
|
||||
this.client.add(magnetURI, chromeOptions, (torrent) => {
|
||||
this.log("Torrent added (Chrome path):", torrent.name);
|
||||
this.handleTorrentStream(torrent, videoElement, resolve, reject, "chrome");
|
||||
this.handleTorrentStream(
|
||||
torrent,
|
||||
videoElement,
|
||||
resolve,
|
||||
reject,
|
||||
"chrome",
|
||||
streamHooks
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -993,6 +1170,15 @@ export class TorrentClient {
|
||||
*/
|
||||
async cleanup() {
|
||||
try {
|
||||
if (typeof this._teardownTorrentStreamListeners === "function") {
|
||||
try {
|
||||
this._teardownTorrentStreamListeners();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
this._teardownTorrentStreamListeners = null;
|
||||
}
|
||||
|
||||
if (this.currentTorrent) {
|
||||
try {
|
||||
await this.destroyWithTimeout(this.currentTorrent, {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Unit test for the forced-source deep-link normalizer.
|
||||
//
|
||||
// SCN-forced-source-deep-link:
|
||||
// Given a `?playback=` deep-link value (or a caller hint),
|
||||
// When normalized,
|
||||
// Then it maps to the canonical 'url' | 'torrent' the playback pipeline
|
||||
// understands (accepting the friendly aliases shown in the UI toggle),
|
||||
// And anything unset/unrecognized yields null so normal source selection runs
|
||||
// (a bad value must NEVER silently coerce to a real source).
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizeForcedSource } from "../js/app/playbackCoordinator.js";
|
||||
|
||||
test("maps torrent aliases to 'torrent'", () => {
|
||||
for (const v of ["torrent", "p2p", "P2P", "WebTorrent", "magnet", " torrent "]) {
|
||||
assert.equal(normalizeForcedSource(v), "torrent", `"${v}" -> torrent`);
|
||||
}
|
||||
});
|
||||
|
||||
test("maps CDN/url aliases to 'url'", () => {
|
||||
for (const v of ["url", "cdn", "CDN", "hosted", "http", "https", " URL "]) {
|
||||
assert.equal(normalizeForcedSource(v), "url", `"${v}" -> url`);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null for unset / unrecognized / non-string values", () => {
|
||||
for (const v of ["", "bogus", "torrnt", "0", null, undefined, 42, {}, []]) {
|
||||
assert.equal(
|
||||
normalizeForcedSource(v),
|
||||
null,
|
||||
`${JSON.stringify(v)} must not coerce to a real source`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -170,6 +170,88 @@ test('createVideoShareMenuPanel - magnet/cdn logic', () => {
|
||||
assert.ok(cdnBtn.disabled, 'CDN button should be disabled');
|
||||
});
|
||||
|
||||
test('createVideoShareMenuPanel - forced-source deep links copy via builder', async () => {
|
||||
const { document } = setupDom();
|
||||
const video = { id: 'v1' };
|
||||
|
||||
const built = [];
|
||||
const copied = [];
|
||||
const buildForcedSourceUrl = (playback) => {
|
||||
built.push(playback);
|
||||
return `https://bitvid.test/?v=nevent1&playback=${playback}`;
|
||||
};
|
||||
const clipboard = {
|
||||
writeText: async (text) => {
|
||||
copied.push(text);
|
||||
},
|
||||
};
|
||||
|
||||
const panel = createVideoShareMenuPanel({
|
||||
document,
|
||||
video,
|
||||
hasMagnet: true,
|
||||
hasCdn: true,
|
||||
buildForcedSourceUrl,
|
||||
clipboard,
|
||||
});
|
||||
|
||||
const torrentBtn = panel.querySelector('[data-action="copy-webtorrent-url"]');
|
||||
const cdnBtn = panel.querySelector('[data-action="copy-cdn-url"]');
|
||||
assert.ok(torrentBtn, 'WebTorrent URL item should exist');
|
||||
assert.ok(cdnBtn, 'CDN URL item should exist');
|
||||
assert.ok(!torrentBtn.disabled, 'WebTorrent URL enabled when a magnet exists');
|
||||
assert.ok(!cdnBtn.disabled, 'CDN URL enabled when a CDN url exists');
|
||||
|
||||
torrentBtn.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(built, ['torrent'], 'builder asked for the torrent source');
|
||||
assert.deepEqual(
|
||||
copied,
|
||||
['https://bitvid.test/?v=nevent1&playback=torrent'],
|
||||
'the forced-torrent deep link is copied to the clipboard',
|
||||
);
|
||||
});
|
||||
|
||||
test('createVideoShareMenuPanel - forced-source items disabled (with message) when source missing', () => {
|
||||
const { document } = setupDom();
|
||||
|
||||
const panel = createVideoShareMenuPanel({
|
||||
document,
|
||||
video: { id: 'v1' },
|
||||
hasMagnet: false,
|
||||
hasCdn: true,
|
||||
buildForcedSourceUrl: () => 'https://bitvid.test/?v=nevent1&playback=torrent',
|
||||
});
|
||||
|
||||
const torrentBtn = panel.querySelector('[data-action="copy-webtorrent-url"]');
|
||||
assert.ok(torrentBtn.disabled, 'WebTorrent URL disabled when no magnet');
|
||||
assert.match(
|
||||
torrentBtn.title,
|
||||
/no webtorrent source/i,
|
||||
'disabled WebTorrent URL explains why',
|
||||
);
|
||||
|
||||
const cdnBtn = panel.querySelector('[data-action="copy-cdn-url"]');
|
||||
assert.ok(!cdnBtn.disabled, 'CDN URL enabled when a CDN url exists');
|
||||
});
|
||||
|
||||
test('createVideoShareMenuPanel - no forced-source items without a builder', () => {
|
||||
const { document } = setupDom();
|
||||
const panel = createVideoShareMenuPanel({
|
||||
document,
|
||||
video: { id: 'v1' },
|
||||
hasMagnet: true,
|
||||
hasCdn: true,
|
||||
});
|
||||
assert.equal(
|
||||
panel.querySelector('[data-action="copy-webtorrent-url"]'),
|
||||
null,
|
||||
'no WebTorrent URL item when no builder is provided',
|
||||
);
|
||||
});
|
||||
|
||||
test('createChannelProfileMenuPanel - basic actions', () => {
|
||||
const { document } = setupDom();
|
||||
const panel = createChannelProfileMenuPanel({
|
||||
|
||||
@@ -126,3 +126,171 @@ describe("TorrentClient Handlers", () => {
|
||||
assert.strictEqual(torrent._opts.announce[0], "ws://good.com", "Should keep good tracker");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Hardened streaming behavior (audit follow-ups) ---
|
||||
|
||||
class SelectableFile {
|
||||
constructor(name, length) {
|
||||
this.name = name;
|
||||
this.length = length;
|
||||
this.selected = false;
|
||||
this.deselected = false;
|
||||
}
|
||||
select() { this.selected = true; }
|
||||
deselect() { this.deselected = true; }
|
||||
streamTo(elem) { elem.src = "blob:mock-stream"; }
|
||||
}
|
||||
|
||||
class MultiFileTorrent extends EventEmitter {
|
||||
constructor(files, { numPeers = 1 } = {}) {
|
||||
super();
|
||||
this.files = files;
|
||||
this.numPeers = numPeers;
|
||||
this._opts = {};
|
||||
}
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
function freshClient() {
|
||||
const c = new TorrentClient();
|
||||
c.log = () => {};
|
||||
return c;
|
||||
}
|
||||
|
||||
describe("TorrentClient streaming hardening", () => {
|
||||
it("streams the LARGEST playable file and deselects the rest (multi-file)", () => {
|
||||
const client = freshClient();
|
||||
const small = new SelectableFile("sample.mp4", 100);
|
||||
const big = new SelectableFile("feature.mp4", 100000);
|
||||
const other = new SelectableFile("readme.txt", 5);
|
||||
const torrent = new MultiFileTorrent([small, big, other]);
|
||||
const videoElement = new MockVideoElement();
|
||||
|
||||
client.handleTorrentStream(torrent, videoElement, () => {}, () => {}, "chrome");
|
||||
|
||||
assert.strictEqual(big.selected, true, "largest video file is selected");
|
||||
assert.strictEqual(small.deselected, true, "smaller video file is deselected");
|
||||
assert.strictEqual(other.deselected, true, "non-video file is deselected");
|
||||
assert.strictEqual(videoElement.src, "blob:mock-stream", "streamTo ran on the chosen file");
|
||||
});
|
||||
|
||||
it("does NOT resolve until the video is actually ready to play", async () => {
|
||||
const client = freshClient();
|
||||
// streamTo here does NOT advance readyState or emit canplay.
|
||||
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
|
||||
const torrent = new MultiFileTorrent([file]);
|
||||
const videoElement = new MockVideoElement();
|
||||
|
||||
let resolved = false;
|
||||
client.handleTorrentStream(torrent, videoElement, () => { resolved = true; }, () => {}, "chrome");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.strictEqual(resolved, false, "no premature success while playback is not viable");
|
||||
assert.strictEqual(client.currentTorrent, torrent, "currentTorrent set up front so cleanup can tear it down");
|
||||
|
||||
// Once real playback data arrives, it resolves.
|
||||
videoElement.emit("loadeddata");
|
||||
assert.strictEqual(resolved, true, "resolves once playback is viable");
|
||||
});
|
||||
|
||||
it("pre-playback torrent error rejects (so the caller can fall back)", async () => {
|
||||
const client = freshClient();
|
||||
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
|
||||
const torrent = new MultiFileTorrent([file]);
|
||||
const videoElement = new MockVideoElement();
|
||||
|
||||
let rejected = null;
|
||||
let resolved = false;
|
||||
client.handleTorrentStream(torrent, videoElement, () => { resolved = true; }, (e) => { rejected = e; }, "chrome");
|
||||
|
||||
torrent.emit("error", new Error("swarm dead"));
|
||||
assert.ok(rejected, "rejects before playback starts");
|
||||
assert.strictEqual(resolved, false, "does not also resolve");
|
||||
});
|
||||
|
||||
it("post-playback torrent error goes to onPlaybackError, not a dead reject", async () => {
|
||||
const client = freshClient();
|
||||
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
|
||||
const torrent = new MultiFileTorrent([file]);
|
||||
const videoElement = new MockVideoElement();
|
||||
|
||||
const playbackErrors = [];
|
||||
let rejectedAfter = false;
|
||||
client.handleTorrentStream(
|
||||
torrent,
|
||||
videoElement,
|
||||
() => {},
|
||||
() => { rejectedAfter = true; },
|
||||
"chrome",
|
||||
{ onPlaybackError: (e) => playbackErrors.push(e) }
|
||||
);
|
||||
|
||||
videoElement.emit("canplay"); // becomes ready -> resolved
|
||||
torrent.emit("error", new Error("peers dropped"));
|
||||
|
||||
assert.strictEqual(playbackErrors.length, 1, "post-start error surfaced via hook");
|
||||
assert.strictEqual(rejectedAfter, false, "promise was already settled; no dead reject");
|
||||
});
|
||||
|
||||
it("surfaces a stall via onStall when playback buffers without progress", async () => {
|
||||
const client = freshClient();
|
||||
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
|
||||
const torrent = new MultiFileTorrent([file], { numPeers: 0 });
|
||||
const videoElement = new MockVideoElement();
|
||||
|
||||
const stalls = [];
|
||||
client.handleTorrentStream(
|
||||
torrent,
|
||||
videoElement,
|
||||
() => {},
|
||||
() => {},
|
||||
"chrome",
|
||||
{ onStall: (info) => stalls.push(info), stallMs: 15 }
|
||||
);
|
||||
|
||||
videoElement.emit("canplay"); // ready
|
||||
videoElement.emit("waiting"); // buffering with no progress
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
|
||||
assert.strictEqual(stalls.length, 1, "stall surfaced once");
|
||||
assert.strictEqual(stalls[0].peers, 0, "stall reports current peer count");
|
||||
});
|
||||
|
||||
it("clears the stall timer when playback progresses", async () => {
|
||||
const client = freshClient();
|
||||
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
|
||||
const torrent = new MultiFileTorrent([file]);
|
||||
const videoElement = new MockVideoElement();
|
||||
|
||||
const stalls = [];
|
||||
client.handleTorrentStream(
|
||||
torrent, videoElement, () => {}, () => {}, "chrome",
|
||||
{ onStall: (i) => stalls.push(i), stallMs: 30 }
|
||||
);
|
||||
videoElement.emit("canplay");
|
||||
videoElement.emit("waiting");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
videoElement.emit("timeupdate"); // progress -> clears the pending stall
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
|
||||
assert.strictEqual(stalls.length, 0, "no stall fired because playback resumed");
|
||||
});
|
||||
|
||||
it("does not leak <video> listeners across streams (reused element)", () => {
|
||||
const client = freshClient();
|
||||
const videoElement = new MockVideoElement();
|
||||
const mk = () => new MultiFileTorrent([
|
||||
{ name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } },
|
||||
]);
|
||||
|
||||
client.handleTorrentStream(mk(), videoElement, () => {}, () => {}, "chrome");
|
||||
client.handleTorrentStream(mk(), videoElement, () => {}, () => {}, "chrome");
|
||||
client.handleTorrentStream(mk(), videoElement, () => {}, () => {}, "chrome");
|
||||
|
||||
assert.strictEqual(
|
||||
videoElement.listenerCount("error"),
|
||||
1,
|
||||
"previous streams' error listeners are torn down (no accumulation)",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user