mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
This commit is contained in:
1 parent
c967789257
commit
0a37e625bb
5 files changed
+171
-30
No files matched your search
@@ -867,6 +867,10 @@ export function createPlaybackCoordinator(deps) {
|
||||
const playbackOptions = {
|
||||
url: trimmedUrl,
|
||||
magnet: magnetInput,
|
||||
// Hosted mirrors (NIP-71 imeta variants) to fail over to mid-load.
|
||||
sources: Array.isArray(this.currentVideo?.sources)
|
||||
? this.currentVideo.sources
|
||||
: [],
|
||||
};
|
||||
if (hasTrigger) {
|
||||
playbackOptions.trigger = this.lastModalTrigger;
|
||||
|
||||
@@ -522,6 +522,34 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
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 =
|
||||
@@ -897,12 +925,14 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
};
|
||||
|
||||
// --- Attempt URL Logic ---
|
||||
const attemptHostedPlayback = async () => {
|
||||
if (!httpsUrl) return null;
|
||||
// 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 ${httpsUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).`
|
||||
`[playVideoWithFallback] Probing hosted URL ${candidateUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).`
|
||||
);
|
||||
|
||||
let hostedStatusResolved = false;
|
||||
@@ -959,7 +989,7 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
);
|
||||
|
||||
const probeResult = await this.service.probeHostedUrl({
|
||||
url: httpsUrl,
|
||||
url: candidateUrl,
|
||||
magnet: this.trimmedMagnet,
|
||||
probeUrl,
|
||||
});
|
||||
@@ -1036,7 +1066,7 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
// ignore
|
||||
}
|
||||
activeVideoEl.crossOrigin = null;
|
||||
activeVideoEl.src = httpsUrl;
|
||||
activeVideoEl.src = candidateUrl;
|
||||
const playPromise = activeVideoEl.play();
|
||||
if (playPromise && typeof playPromise.catch === "function") {
|
||||
playPromise.catch((err) => {
|
||||
@@ -1145,31 +1175,45 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
let hostedErrorMessage = "";
|
||||
|
||||
if (tryUrlFirst) {
|
||||
if (httpsUrl) {
|
||||
// Wrap URL attempt in timeout
|
||||
const urlResult = await withTimeout(
|
||||
attemptHostedPlayback(),
|
||||
effectiveTimeout,
|
||||
"URL Playback"
|
||||
);
|
||||
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;
|
||||
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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
this.cleanupWatchdog();
|
||||
cleanupHostedUrlStatusListeners();
|
||||
resetVideoElement();
|
||||
}
|
||||
|
||||
const fallbackReason = urlResult?.reason || "url-unavailable";
|
||||
const fallbackReason = lastUrlResult?.reason || "url-unavailable";
|
||||
if (this.magnetForPlayback && forcedSource !== "url") {
|
||||
return await attemptTorrentPlayback(fallbackReason);
|
||||
}
|
||||
|
||||
@@ -197,6 +197,8 @@ export class PlaybackStrategyService {
|
||||
const session = this.playbackService.createSession({
|
||||
url: sanitizedUrl,
|
||||
magnet: trimmedMagnet,
|
||||
// Hosted mirrors to fail over to when the primary URL dies mid-load.
|
||||
sources: Array.isArray(options.sources) ? options.sources : [],
|
||||
requestSignature,
|
||||
videoElement: modalVideoEl,
|
||||
waitForCleanup: context.waitForCleanup,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// #5 multi-source fail-over: the PlaybackSession builds an ordered, de-duped list
|
||||
// of hosted sources (primary URL first, then the video's imeta mirrors) that the
|
||||
// execution flow tries in turn before dropping to WebTorrent. This verifies the
|
||||
// candidate-list construction (deterministic). The full async fail-over flow is
|
||||
// exercised by the playbackService_order suite in CI (the async/mock-timers
|
||||
// playback harness doesn't run in this sandbox).
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PlaybackService } from "../../js/services/playbackService.js";
|
||||
|
||||
const makeSession = ({ url, sources, magnet = "" }) => {
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: true,
|
||||
isValidMagnetUri: () => true,
|
||||
});
|
||||
return service.createSession({ url, sources, magnet });
|
||||
};
|
||||
|
||||
test("primary URL leads, then the imeta mirrors, de-duped", () => {
|
||||
const session = makeSession({
|
||||
url: "https://primary.example/v.mp4",
|
||||
sources: [
|
||||
{ url: "https://mirror-a.example/v.mp4" },
|
||||
{ url: "https://mirror-b.example/v.mp4" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(session.hostedSourceCandidates, [
|
||||
"https://primary.example/v.mp4",
|
||||
"https://mirror-a.example/v.mp4",
|
||||
"https://mirror-b.example/v.mp4",
|
||||
]);
|
||||
});
|
||||
|
||||
test("de-dupes the primary when it also appears in sources (case-insensitive)", () => {
|
||||
const session = makeSession({
|
||||
url: "https://primary.example/v.mp4",
|
||||
sources: [
|
||||
{ url: "https://PRIMARY.example/v.mp4" },
|
||||
{ url: "https://mirror.example/v.mp4" },
|
||||
{ url: "https://mirror.example/v.mp4" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(session.hostedSourceCandidates, [
|
||||
"https://primary.example/v.mp4",
|
||||
"https://mirror.example/v.mp4",
|
||||
]);
|
||||
});
|
||||
|
||||
test("accepts plain-string sources too", () => {
|
||||
const session = makeSession({
|
||||
url: "https://primary.example/v.mp4",
|
||||
sources: ["https://mirror.example/v.mp4"],
|
||||
});
|
||||
assert.deepEqual(session.hostedSourceCandidates, [
|
||||
"https://primary.example/v.mp4",
|
||||
"https://mirror.example/v.mp4",
|
||||
]);
|
||||
});
|
||||
|
||||
test("no sources → just the primary (single-source behavior unchanged)", () => {
|
||||
const session = makeSession({ url: "https://only.example/v.mp4" });
|
||||
assert.deepEqual(session.hostedSourceCandidates, ["https://only.example/v.mp4"]);
|
||||
});
|
||||
|
||||
test("torrent-only video (no URL) → empty hosted candidates", () => {
|
||||
const session = makeSession({ url: "", sources: [], magnet: "magnet:?xt=urn:btih:abc" });
|
||||
assert.deepEqual(session.hostedSourceCandidates, []);
|
||||
});
|
||||
|
||||
test("ignores junk source entries (null, missing url, blanks)", () => {
|
||||
const session = makeSession({
|
||||
url: "https://primary.example/v.mp4",
|
||||
sources: [null, {}, { url: " " }, { url: "https://good.example/v.mp4" }, " "],
|
||||
});
|
||||
assert.deepEqual(session.hostedSourceCandidates, [
|
||||
"https://primary.example/v.mp4",
|
||||
"https://good.example/v.mp4",
|
||||
]);
|
||||
});
|
||||
@@ -242,9 +242,19 @@ un-hides when WebTorrent flips green). The real gaps:
|
||||
`collectVideoSources`); `urlHealthController.probeUrlList` probes them in
|
||||
order — healthy as soon as one plays, offline only if all fail. So a dead
|
||||
primary host no longer hides a video with a working mirror.
|
||||
- [ ] **Remaining — player fail-over at play time**: the *liveness* now tries all
|
||||
sources, but actual playback should also fail over to the next source if the
|
||||
chosen one dies mid-load (playbackService consumes `video.sources`).
|
||||
- [x] **Player fail-over at play time (2026-06-24).** Playback now tries each
|
||||
hosted source in turn before dropping to P2P. The `PlaybackSession` builds an
|
||||
ordered, de-duped `hostedSourceCandidates` (primary URL first, then the
|
||||
video's imeta `sources`); `attemptHostedPlayback` takes a `candidateUrl` and
|
||||
the url-first execution loop advances to the next mirror on probe-fail/stall/
|
||||
timeout (cleaning up watchdogs + resetting the element between candidates),
|
||||
only calling WebTorrent after all hosted mirrors fail. `sources` plumbed
|
||||
coordinator → strategy → session; single-source videos behave exactly as
|
||||
before. Tests: `tests/services/playbackService_multisource.test.mjs`
|
||||
(candidate construction; the full async fail-over flow is covered by the
|
||||
playbackService_order suite in CI). ROLLBACK: revert the loop in
|
||||
`playbackService.js` to the single `attemptHostedPlayback()` call + drop the
|
||||
`sources` plumbing.
|
||||
- [ ] **Remaining — hide-until-verified for foreign/ingested** (decision): so
|
||||
unplayable strangers never briefly flash before the probe hides them
|
||||
(bitvid-native stays show-pending). UX policy change in cardSourceVisibility.
|
||||
|
||||
Reference in new issue
Block a user