mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
5a7481e30e
commit
68bdee8da5
2 files changed
+88
-7
No files matched your search
@@ -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}`
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
Reference in new issue
Block a user