playback: fix false "No playable source found." on URL-only videos

A bitvid (kind-30078) video with a perfectly playable hosted URL (verified:
archive.org 200 video/mp4 with byte ranges) failed with "No playable source
found." — the video element's src was never even set.

Root cause: the hosted-URL probe (fetch HEAD, mode:no-cors) runs BEFORE src is
assigned, inside the playback-start window — but URL_PROBE_TIMEOUT_MS (4s) is
LONGER than PLAYBACK_START_TIMEOUT (3s). A slow no-cors HEAD (archive.org, and
any host that doesn't answer HEAD fast) consumed the whole start budget, the
withTimeout wrapper aborted the attempt, and the URL was never loaded. A
`<video>` element plays cross-origin URLs WITHOUT CORS anyway, so the fetch
probe was both unnecessary and actively harmful here.

Fix:
- Skip the probe entirely when there's no magnet (URL-only) — nothing to fail
  over to, so the video element loads the URL directly. This is the reported
  case; verified live: the video now plays (readyState 4, currentTime advancing).
- An ambiguous/error probe no longer VETOES the URL (only a definitively-read
  "bad" status deprioritizes it, and even then only when a magnet fallback
  exists) — the video element + watchdogs are the real arbiter.

Tests: spec-corrected the always-probe assertion to a dual-source case + added
a URL-only skip-probe test (tests/services/playbackService.test.mjs). 17 pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-05 00:13:14 -04:00
1 parent 5a7481e30e
commit 68bdee8da5
2 files changed
+88 -7

No files matched your search

+26 -6
View File
@@ -988,15 +988,35 @@ class PlaybackSession extends SimpleEventEmitter {
{ once: true }
);
const probeResult = await this.service.probeHostedUrl({
url: candidateUrl,
magnet: this.trimmedMagnet,
probeUrl,
});
// The probe (fetch HEAD) only helps DECIDE whether to fail over to P2P
// fast. When the URL is the only source there's nothing to fail over
// to — and the probe's timeout (URL_PROBE_TIMEOUT_MS, 4s) is LONGER than
// the playback-start window (PLAYBACK_START_TIMEOUT, 3s), so a slow
// no-cors HEAD (e.g. archive.org) would consume the whole budget and the
// attempt would time out before `src` is ever set → a false "No playable
// source found." So skip the probe when there's no magnet and let the
// video element (the real arbiter) load the URL directly.
const probeResult =
this.magnetForPlayback
? await this.service.probeHostedUrl({
url: candidateUrl,
magnet: this.trimmedMagnet,
probeUrl,
})
: { outcome: "skipped-url-only" };
const probeOutcome = probeResult?.outcome || "error";
const probeStatus = probeResult?.status || "unknown";
// A `<video>` element plays cross-origin URLs WITHOUT CORS, but this
// probe uses fetch (CORS-limited): a benign no-cors HEAD failure or a
// slow HEAD returns "error"/"opaque" for perfectly playable hosts (e.g.
// archive.org — 200 video/mp4 with byte ranges). An ambiguous probe must
// NEVER veto the URL into "No playable source found." Only a
// definitively-READ failure status ("bad") deprioritizes it — and even
// then, if there's no P2P fallback, we still try (a real media error is
// clearer than a false "no source"). The video element + watchdogs are
// the actual arbiter of playability.
const shouldAttemptHosted =
probeOutcome !== "bad" && probeOutcome !== "error";
probeOutcome !== "bad" || !this.magnetForPlayback;
this.service.log(
`[playVideoWithFallback] Hosted URL probe outcome=${probeOutcome} status=${probeStatus} shouldAttemptHosted=${shouldAttemptHosted}`
+62 -1
View File
@@ -129,12 +129,37 @@ describe("PlaybackService", () => {
});
});
test("URL Probe Success starts playback", async () => {
// spec_correction: the URL probe only exists to decide whether to fail over
// to P2P quickly. It used to run for EVERY hosted attempt, but its timeout
// (4s) exceeds the playback-start window (3s), so a slow no-cors HEAD (e.g.
// archive.org) consumed the whole budget and the URL was never even loaded —
// a false "No playable source found." for perfectly playable URL-only
// videos. The probe now runs ONLY when there's a magnet to fall over to.
// This test therefore uses a DUAL-source video so the probe path is still
// exercised; the URL-only skip is covered by the next test.
//
// test_integrity_note:
// change_type: ["spec_correction"]
// scenarios:
// - id: SCN-probe-success-dual-source
// given: "a dual-source (url+magnet) video whose URL probe succeeds"
// when: "playback starts"
// then: "the probe runs, the URL plays, and torrent is not used"
// observable_outcomes:
// - "probe called once; URL playback started; playViaWebTorrent not called"
// determinism_controls: ["jsdom video element; mocked probe/torrent"]
// anti_cheat_rationale:
// prevents: ["over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
// if_true_explain_spec_basis: "moved the probe assertion to the dual-source case it now applies to; url-only skip is separately asserted"
test("URL Probe Success starts playback (dual-source)", async () => {
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
const playViaWebTorrent = mock.fn();
const testSession = service.createSession({
url: "https://example.com/video.mp4",
magnet: "magnet:?xt=urn:btih:1111111111111111111111111111111111111111",
videoElement: video,
probeUrl,
playViaWebTorrent,
@@ -157,6 +182,42 @@ describe("PlaybackService", () => {
assert.equal(playViaWebTorrent.mock.callCount(), 0);
});
// The reported bug: a URL-only video (no magnet) must NOT be gated by the
// probe — the video element is the arbiter. It plays directly, probe skipped.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-url-only-skips-probe
// given: "a URL-only video (no magnet) with a playable URL"
// when: "playback starts"
// then: "the probe is NOT called and the URL plays directly"
// observable_outcomes: ["probe callCount 0; URL playback started"]
// determinism_controls: ["jsdom video element; mocked probe"]
// anti_cheat_rationale: { prevents: ["hard-coded return value"] }
// relaxation: { did_relax_any_assertion: false }
test("URL-only video skips the probe and plays directly", async () => {
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
const playViaWebTorrent = mock.fn();
const testSession = service.createSession({
url: "https://example.com/video.mp4",
videoElement: video,
probeUrl,
playViaWebTorrent,
});
const startPromise = testSession.start();
await new Promise(resolve => process.nextTick(resolve));
await new Promise(resolve => process.nextTick(resolve));
video.dispatchEvent(new window.Event("playing"));
const result = await startPromise;
assert.equal(probeUrl.mock.callCount(), 0, "URL-only: probe not on the critical path");
assert.equal(playMock.mock.callCount(), 1);
assert.equal(result.source, "url");
});
test("URL Probe Failure triggers fallback", async () => {
const probeUrl = mock.fn(async () => ({ outcome: "bad", status: 404 }));
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));