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:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-24 13:30:30 -04:00
1 parent c967789257
commit 0a37e625bb
5 files changed
+171 -30

No files matched your search

+4
View File
@@ -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;
+71 -27
View File
@@ -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);
}
+2
View File
@@ -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",
]);
});
+13 -3
View File
@@ -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.