mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
bitvid attached every hosted source with `videoEl.src = url`, which only plays
HLS on Safari-family browsers. Firefox 146 fails with MEDIA_ERR_SRC_NOT_SUPPORTED
(canPlayType returns ""), so every .m3u8 note -- e.g. anything published by
nostube/slidestr -- was unplayable there and the URL-health badge showed
"offline". Chrome/Brave 149 happen to play HLS natively, which is why this
looked browser-specific.
Vendor hls.js (npm run build:hls) and route HLS sources through it:
- js/services/hlsPlayback.js owns detection, engine choice and the hls.js
lifecycle. Engine choice keys off MediaSource, NOT canPlayType: Chrome
returns "maybe" for every HLS type, so a native-first branch would hang.
Native is used only when MSE is genuinely unavailable.
- The bundle is the FULL build, not hls.js/light -- light drops the alternate
audio stream controller and real ladders demux audio into an EXT-X-MEDIA
group. It is lazy-imported only when a .m3u8 is encountered, so ordinary
MP4/WebTorrent playback never pays the ~500KB.
- modalCoordinator tears the instance down first; clearing `src` alone leaves
the transmuxing worker and segment fetches running after the modal closes.
- urlHealthController probes HLS by fetching the playlist for #EXTM3U. The old
bare-<video> probe could not load it and on Chrome emitted no events at all,
burning the full timeout on every HLS card.
- HLS_PLAYBACK_START_TIMEOUT widens the 3s start window, which fired
mid-startup: the bundle, master playlist, variant playlist, init segment and
first media segment are four-plus serial round trips.
Also fixes two bugs found while verifying this:
- A stale MediaError was reported as the current failure. Teardown clears `src`
and calls load(), leaving MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute")
on the element; a later watchdog fallback rendered that as "Hosted playback
failed: source not supported or blocked." over a stream that was playing
fine. Only a MediaError produced by the current attempt is surfaced now.
Sources are also bound before the watchdogs are armed, so hls.js's own
attachMedia() src swap cannot read as a playback failure.
- `audio/x-mpegurl` is a legacy alias for an HLS playlist of any kind, not an
audio-only assertion. isAudioOnlyVideoObject misfiled HLS video ladders
published with it as podcasts and dropped them from every video feed.
Verified end-to-end in Chromium, Chrome 149, Brave 149 and Firefox 146 against
a live HLS stream: Firefox goes from "No playable source found." to playing.
Rollback: stop calling attachHlsSource() and the previous `src` assignment
returns; see docs/playback-fallback.md.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
633 lines
22 KiB
JavaScript
633 lines
22 KiB
JavaScript
// HLS (.m3u8) support — see js/services/hlsPlayback.js.
|
|
//
|
|
// Scenario spec (SCN-hls-*): bitvid used to attach every hosted source with
|
|
// `videoEl.src = url`, which only plays HLS on Safari. Nostr clients publishing
|
|
// bitvid's v3 schema with HLS ladders (nostube/slidestr) were unplayable on
|
|
// Chrome/Firefox, and the URL-health probe reported every healthy HLS stream as
|
|
// offline. These tests pin the observable decisions at those boundaries:
|
|
// detection, engine selection, teardown, and the manifest probe.
|
|
//
|
|
// The hls.js engine itself is never loaded here — the tests exercise the
|
|
// native-HLS branch and the fetch-based manifest probe, which are the two paths
|
|
// that do not require a real MediaSource implementation.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
HLS_MIME_TYPES,
|
|
attachHlsSource,
|
|
detachHls,
|
|
getAttachedHls,
|
|
isHlsMimeType,
|
|
isHlsSource,
|
|
isHlsUrl,
|
|
prefersNativeHls,
|
|
probeHlsManifest,
|
|
supportsMse,
|
|
supportsNativeHls,
|
|
} from "../../js/services/hlsPlayback.js";
|
|
|
|
// --- SCN-hls-detect -------------------------------------------------------
|
|
// Given a hosted URL, when its path ends in .m3u8, then it is treated as HLS —
|
|
// regardless of query strings (signed CDN URLs) or fragments.
|
|
|
|
test("isHlsUrl matches .m3u8 paths, including with query/fragment", () => {
|
|
assert.equal(
|
|
isHlsUrl(
|
|
"https://almond.apps2.slidestr.net/3751b84f27234fc8ce3227d132b422f7e00f4a50c6cdc322080fed89a302d7dd.m3u8"
|
|
),
|
|
true
|
|
);
|
|
assert.equal(isHlsUrl("https://cdn.example/v.M3U8"), true);
|
|
assert.equal(isHlsUrl("https://cdn.example/v.m3u8?token=abc&exp=1"), true);
|
|
assert.equal(isHlsUrl("https://cdn.example/v.m3u8#t=10"), true);
|
|
assert.equal(isHlsUrl(" https://cdn.example/v.m3u8 "), true);
|
|
});
|
|
|
|
test("isHlsUrl rejects non-playlist URLs and incidental .m3u8 substrings", () => {
|
|
assert.equal(isHlsUrl("https://cdn.example/v.mp4"), false);
|
|
assert.equal(isHlsUrl("https://cdn.example/v.webm"), false);
|
|
// .m3u8 must terminate the PATH, not merely appear somewhere in the URL.
|
|
assert.equal(isHlsUrl("https://cdn.example/v.m3u8.mp4"), false);
|
|
assert.equal(isHlsUrl("https://cdn.example/?next=/a.m3u8"), false);
|
|
assert.equal(isHlsUrl(""), false);
|
|
assert.equal(isHlsUrl(null), false);
|
|
assert.equal(isHlsUrl(undefined), false);
|
|
assert.equal(isHlsUrl(42), false);
|
|
});
|
|
|
|
test("isHlsMimeType covers the canonical and legacy playlist types", () => {
|
|
for (const type of HLS_MIME_TYPES) {
|
|
assert.equal(isHlsMimeType(type), true, `${type} should be HLS`);
|
|
assert.equal(isHlsMimeType(type.toUpperCase()), true);
|
|
}
|
|
// Parameters must not defeat the match (servers append charset).
|
|
assert.equal(isHlsMimeType("application/vnd.apple.mpegurl; charset=utf-8"), true);
|
|
assert.equal(isHlsMimeType("video/mp4"), false);
|
|
assert.equal(isHlsMimeType("text/plain"), false);
|
|
assert.equal(isHlsMimeType(""), false);
|
|
assert.equal(isHlsMimeType(null), false);
|
|
});
|
|
|
|
test("isHlsSource accepts either the URL or an imeta mime hint", () => {
|
|
assert.equal(isHlsSource({ url: "https://cdn.example/v.m3u8" }), true);
|
|
assert.equal(
|
|
isHlsSource({ url: "https://cdn.example/stream", mimeType: "application/vnd.apple.mpegurl" }),
|
|
true
|
|
);
|
|
assert.equal(isHlsSource({ url: "https://cdn.example/v.mp4" }), false);
|
|
assert.equal(isHlsSource({}), false);
|
|
assert.equal(isHlsSource(), false);
|
|
});
|
|
|
|
// --- SCN-hls-engine-choice ------------------------------------------------
|
|
// Given a browser that CLAIMS native HLS support via canPlayType, when MSE is
|
|
// also available, then hls.js must win. This is not a preference — it is a
|
|
// correctness requirement measured in real browsers:
|
|
//
|
|
// Firefox 146: canPlayType("application/vnd.apple.mpegurl") === ""
|
|
// bare <video> → MEDIA_ERR_SRC_NOT_SUPPORTED (code 4)
|
|
// Chrome 149: canPlayType(...) === "maybe" ← and it is genuinely true there
|
|
//
|
|
// canPlayType is only ever a hint, so the engine choice keys off MSE (what
|
|
// hls.js actually needs) and falls back to native only when MSE is absent.
|
|
|
|
const makeFakeVideo = (canPlayTypeResult = "") => ({
|
|
src: "",
|
|
canPlayType: () => canPlayTypeResult,
|
|
});
|
|
|
|
// Node has no MediaSource; install/remove a fake to drive supportsMse().
|
|
const withMediaSource = async (isTypeSupported, fn) => {
|
|
const had = "MediaSource" in globalThis;
|
|
const original = globalThis.MediaSource;
|
|
globalThis.MediaSource = { isTypeSupported };
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
if (had) {
|
|
globalThis.MediaSource = original;
|
|
} else {
|
|
delete globalThis.MediaSource;
|
|
}
|
|
}
|
|
};
|
|
|
|
test("supportsNativeHls reflects canPlayType for the playlist types", () => {
|
|
assert.equal(supportsNativeHls(makeFakeVideo("maybe")), true);
|
|
assert.equal(supportsNativeHls(makeFakeVideo("probably")), true);
|
|
assert.equal(supportsNativeHls(makeFakeVideo("")), false);
|
|
});
|
|
|
|
test("supportsNativeHls survives a throwing canPlayType", () => {
|
|
const hostile = {
|
|
canPlayType() {
|
|
throw new Error("nope");
|
|
},
|
|
};
|
|
assert.equal(supportsNativeHls(hostile), false);
|
|
});
|
|
|
|
test("supportsMse tracks MediaSource.isTypeSupported", async () => {
|
|
assert.equal(supportsMse(), false, "no MediaSource in this environment");
|
|
await withMediaSource(() => true, () => {
|
|
assert.equal(supportsMse(), true);
|
|
});
|
|
await withMediaSource(() => false, () => {
|
|
assert.equal(supportsMse(), false);
|
|
});
|
|
await withMediaSource(() => {
|
|
throw new Error("boom");
|
|
}, () => {
|
|
assert.equal(supportsMse(), false, "a throwing check must not propagate");
|
|
});
|
|
});
|
|
|
|
test("prefersNativeHls is false whenever MSE can back hls.js", async () => {
|
|
const chromeLike = makeFakeVideo("maybe");
|
|
await withMediaSource(() => true, () => {
|
|
// Chrome's canPlayType says "maybe" — hls.js must still win.
|
|
assert.equal(prefersNativeHls(chromeLike), false);
|
|
});
|
|
// Only with MSE genuinely gone does native become the choice.
|
|
assert.equal(prefersNativeHls(chromeLike), true);
|
|
assert.equal(prefersNativeHls(makeFakeVideo("")), false, "neither path works");
|
|
});
|
|
|
|
test("attachHlsSource prefers hls.js over a native claim when MSE exists", async () => {
|
|
const video = makeFakeVideo("maybe");
|
|
const { engine, instances } = makeFakeEngine();
|
|
const handle = await withMediaSource(() => true, () =>
|
|
attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => engine,
|
|
})
|
|
);
|
|
assert.equal(handle.mode, "mse");
|
|
assert.equal(video.src, "", "must not use the native src path");
|
|
assert.equal(instances.length, 1);
|
|
});
|
|
|
|
test("attachHlsSource uses native playback when MSE is unavailable", async () => {
|
|
const video = makeFakeVideo("maybe");
|
|
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8");
|
|
assert.ok(handle, "expected a handle");
|
|
assert.equal(handle.mode, "native");
|
|
assert.equal(video.src, "https://cdn.example/v.m3u8");
|
|
// Nothing to destroy on the native path, but destroy() must stay callable.
|
|
assert.doesNotThrow(() => handle.destroy());
|
|
assert.equal(getAttachedHls(video), null);
|
|
});
|
|
|
|
test("attachHlsSource falls back to native when the engine fails to load", async () => {
|
|
const video = makeFakeVideo("maybe");
|
|
const handle = await withMediaSource(() => true, () =>
|
|
attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => null,
|
|
})
|
|
);
|
|
assert.equal(handle.mode, "native", "an attempt beats a guaranteed failure");
|
|
assert.equal(video.src, "https://cdn.example/v.m3u8");
|
|
});
|
|
|
|
test("attachHlsSource trims the URL before assigning it", async () => {
|
|
const video = makeFakeVideo("maybe");
|
|
await attachHlsSource(video, " https://cdn.example/v.m3u8\n");
|
|
assert.equal(video.src, "https://cdn.example/v.m3u8");
|
|
});
|
|
|
|
test("attachHlsSource returns null for missing element or URL", async () => {
|
|
assert.equal(await attachHlsSource(null, "https://cdn.example/v.m3u8"), null);
|
|
assert.equal(await attachHlsSource(makeFakeVideo("maybe"), ""), null);
|
|
assert.equal(await attachHlsSource(makeFakeVideo("maybe"), null), null);
|
|
});
|
|
|
|
// --- MSE test double ------------------------------------------------------
|
|
// A minimal stand-in for the hls.js constructor: enough surface for the
|
|
// attach/teardown/error contract, with hooks to drive events deterministically.
|
|
// `autoManifest` mirrors real hls.js, which parses the manifest asynchronously
|
|
// after loadSource(); tests that drive errors turn it off.
|
|
|
|
const HLS_EVENTS = {
|
|
MANIFEST_PARSED: "hlsManifestParsed",
|
|
ERROR: "hlsError",
|
|
};
|
|
const HLS_ERROR_TYPES = {
|
|
NETWORK_ERROR: "networkError",
|
|
MEDIA_ERROR: "mediaError",
|
|
OTHER_ERROR: "otherError",
|
|
};
|
|
|
|
function makeFakeEngine({ autoManifest = true, levels = [{ height: 240 }] } = {}) {
|
|
const instances = [];
|
|
|
|
class FakeHls {
|
|
static isSupported() {
|
|
return true;
|
|
}
|
|
static get Events() {
|
|
return HLS_EVENTS;
|
|
}
|
|
static get ErrorTypes() {
|
|
return HLS_ERROR_TYPES;
|
|
}
|
|
|
|
constructor(config) {
|
|
this.config = config;
|
|
this.handlers = new Map();
|
|
this.destroyCalls = 0;
|
|
this.startLoadCalls = 0;
|
|
this.recoverMediaErrorCalls = 0;
|
|
this.swapAudioCodecCalls = 0;
|
|
this.attachedMedia = null;
|
|
this.loadedSource = null;
|
|
instances.push(this);
|
|
}
|
|
|
|
on(event, handler) {
|
|
if (!this.handlers.has(event)) {
|
|
this.handlers.set(event, []);
|
|
}
|
|
this.handlers.get(event).push(handler);
|
|
}
|
|
|
|
emit(event, data) {
|
|
for (const handler of this.handlers.get(event) || []) {
|
|
handler(event, data);
|
|
}
|
|
}
|
|
|
|
attachMedia(media) {
|
|
this.attachedMedia = media;
|
|
}
|
|
|
|
loadSource(url) {
|
|
this.loadedSource = url;
|
|
if (autoManifest) {
|
|
// Real hls.js parses asynchronously; keep that ordering.
|
|
queueMicrotask(() => this.emit(HLS_EVENTS.MANIFEST_PARSED, { levels }));
|
|
}
|
|
}
|
|
|
|
startLoad() {
|
|
this.startLoadCalls += 1;
|
|
}
|
|
|
|
recoverMediaError() {
|
|
this.recoverMediaErrorCalls += 1;
|
|
}
|
|
|
|
swapAudioCodec() {
|
|
this.swapAudioCodecCalls += 1;
|
|
}
|
|
|
|
destroy() {
|
|
this.destroyCalls += 1;
|
|
}
|
|
}
|
|
|
|
return { engine: FakeHls, instances };
|
|
}
|
|
|
|
// --- SCN-hls-mse ----------------------------------------------------------
|
|
// Given an MSE browser, when a playlist parses with at least one playable
|
|
// level, then attach resolves; when hls.js drops every level (e.g. an
|
|
// HEVC-only ladder on Firefox) or errors fatally, then attach fails with a
|
|
// specific, user-facing message instead of a bare "no playable source".
|
|
|
|
test("attachHlsSource resolves once the manifest parses, and configures hls.js", async () => {
|
|
const video = makeFakeVideo("");
|
|
const { engine, instances } = makeFakeEngine();
|
|
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => engine,
|
|
});
|
|
|
|
assert.equal(handle.mode, "mse");
|
|
// Native `src` must stay untouched — MSE owns the element now.
|
|
assert.equal(video.src, "");
|
|
// A bounded back-buffer matters for hour-long VOD on low-memory devices.
|
|
assert.equal(instances[0].config.backBufferLength, 90);
|
|
assert.equal(instances[0].config.enableWorker, true);
|
|
});
|
|
|
|
test("attachHlsSource fails when no rendition survives codec filtering", async () => {
|
|
const video = makeFakeVideo("");
|
|
const { engine, instances } = makeFakeEngine({ levels: [] });
|
|
const messages = [];
|
|
|
|
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => engine,
|
|
onFatalError: (message) => messages.push(message),
|
|
});
|
|
|
|
assert.equal(handle, null);
|
|
assert.equal(messages.length, 1);
|
|
assert.match(messages[0], /codec your browser supports/i);
|
|
assert.equal(instances[0].destroyCalls, 1, "must not leak the instance");
|
|
assert.equal(getAttachedHls(video), null);
|
|
});
|
|
|
|
test("attachHlsSource returns null when hls.js is unsupported here", async () => {
|
|
const video = makeFakeVideo("");
|
|
class Unsupported {
|
|
static isSupported() {
|
|
return false;
|
|
}
|
|
}
|
|
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => Unsupported,
|
|
});
|
|
assert.equal(handle, null);
|
|
assert.equal(video.src, "", "must not fall back to an unplayable src");
|
|
});
|
|
|
|
test("attachHlsSource returns null when the engine bundle fails to load", async () => {
|
|
const video = makeFakeVideo("");
|
|
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => null,
|
|
});
|
|
assert.equal(handle, null);
|
|
});
|
|
|
|
test("a fatal network error retries twice before giving up", async () => {
|
|
const video = makeFakeVideo("");
|
|
const { engine, instances } = makeFakeEngine({ autoManifest: false });
|
|
const messages = [];
|
|
|
|
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => engine,
|
|
onFatalError: (message) => messages.push(message),
|
|
});
|
|
await Promise.resolve();
|
|
const instance = instances[0];
|
|
|
|
const networkError = {
|
|
fatal: true,
|
|
type: HLS_ERROR_TYPES.NETWORK_ERROR,
|
|
details: "manifestLoadError",
|
|
};
|
|
instance.emit(HLS_EVENTS.ERROR, networkError);
|
|
assert.equal(instance.startLoadCalls, 1);
|
|
instance.emit(HLS_EVENTS.ERROR, networkError);
|
|
assert.equal(instance.startLoadCalls, 2);
|
|
// Third fatal network error exhausts the retry budget.
|
|
instance.emit(HLS_EVENTS.ERROR, networkError);
|
|
assert.equal(instance.startLoadCalls, 2);
|
|
|
|
assert.equal(await pending, null);
|
|
assert.equal(messages.length, 1);
|
|
assert.match(messages[0], /network or CORS/i);
|
|
assert.equal(instance.destroyCalls, 1);
|
|
});
|
|
|
|
test("a fatal media error recovers, then swaps audio codec, then gives up", async () => {
|
|
const video = makeFakeVideo("");
|
|
const { engine, instances } = makeFakeEngine({ autoManifest: false });
|
|
const messages = [];
|
|
|
|
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => engine,
|
|
onFatalError: (message) => messages.push(message),
|
|
});
|
|
await Promise.resolve();
|
|
const instance = instances[0];
|
|
|
|
const mediaError = {
|
|
fatal: true,
|
|
type: HLS_ERROR_TYPES.MEDIA_ERROR,
|
|
details: "bufferAppendError",
|
|
};
|
|
instance.emit(HLS_EVENTS.ERROR, mediaError);
|
|
assert.equal(instance.recoverMediaErrorCalls, 1);
|
|
assert.equal(instance.swapAudioCodecCalls, 0);
|
|
|
|
instance.emit(HLS_EVENTS.ERROR, mediaError);
|
|
assert.equal(instance.recoverMediaErrorCalls, 2);
|
|
assert.equal(instance.swapAudioCodecCalls, 1, "second attempt swaps codec");
|
|
|
|
instance.emit(HLS_EVENTS.ERROR, mediaError);
|
|
assert.equal(instance.recoverMediaErrorCalls, 2, "budget exhausted");
|
|
|
|
assert.equal(await pending, null);
|
|
assert.match(messages[0], /codec your browser cannot decode/i);
|
|
});
|
|
|
|
test("non-fatal errors never tear the stream down", async () => {
|
|
const video = makeFakeVideo("");
|
|
const { engine, instances } = makeFakeEngine({ autoManifest: false });
|
|
const messages = [];
|
|
|
|
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => engine,
|
|
onFatalError: (message) => messages.push(message),
|
|
});
|
|
await Promise.resolve();
|
|
const instance = instances[0];
|
|
|
|
instance.emit(HLS_EVENTS.ERROR, {
|
|
fatal: false,
|
|
type: HLS_ERROR_TYPES.NETWORK_ERROR,
|
|
details: "fragLoadError",
|
|
});
|
|
|
|
assert.equal(instance.destroyCalls, 0);
|
|
assert.equal(messages.length, 0);
|
|
|
|
// A transient fragment error must still let the stream come up.
|
|
instance.emit(HLS_EVENTS.MANIFEST_PARSED, { levels: [{ height: 720 }] });
|
|
const handle = await pending;
|
|
assert.equal(handle.mode, "mse");
|
|
});
|
|
|
|
test("a stuck manifest fails via the attach timeout rather than hanging", async () => {
|
|
const video = makeFakeVideo("");
|
|
const { engine, instances } = makeFakeEngine({ autoManifest: false });
|
|
const messages = [];
|
|
|
|
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => engine,
|
|
manifestTimeoutMs: 20,
|
|
onFatalError: (message) => messages.push(message),
|
|
});
|
|
|
|
assert.equal(handle, null);
|
|
assert.match(messages[0], /did not load in time/i);
|
|
assert.equal(instances[0].destroyCalls, 1);
|
|
});
|
|
|
|
test("attachMedia throwing is reported, not propagated", async () => {
|
|
const video = makeFakeVideo("");
|
|
const { engine } = makeFakeEngine({ autoManifest: false });
|
|
const messages = [];
|
|
class Hostile extends engine {
|
|
attachMedia() {
|
|
throw new Error("MediaSource unavailable");
|
|
}
|
|
}
|
|
|
|
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => Hostile,
|
|
onFatalError: (message) => messages.push(message),
|
|
});
|
|
|
|
assert.equal(handle, null);
|
|
assert.match(messages[0], /could not be attached/i);
|
|
});
|
|
|
|
// --- SCN-hls-teardown -----------------------------------------------------
|
|
// Given an attached hls.js instance, when the modal tears the element down,
|
|
// then destroy() runs exactly once and the element is unbound — otherwise the
|
|
// transmuxing worker and segment fetches keep running after the modal closes.
|
|
|
|
test("detachHls destroys the bound hls.js instance exactly once", async () => {
|
|
const video = makeFakeVideo("");
|
|
const { engine, instances } = makeFakeEngine();
|
|
|
|
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => engine,
|
|
});
|
|
|
|
assert.ok(handle, "expected an MSE handle");
|
|
assert.equal(handle.mode, "mse");
|
|
const instance = instances[0];
|
|
assert.equal(getAttachedHls(video), instance);
|
|
assert.equal(instance.loadedSource, "https://cdn.example/v.m3u8");
|
|
assert.equal(instance.attachedMedia, video);
|
|
|
|
assert.equal(detachHls(video), true);
|
|
assert.equal(instance.destroyCalls, 1);
|
|
assert.equal(getAttachedHls(video), null);
|
|
|
|
// Second detach (e.g. modal teardown after the session already reset) must
|
|
// not double-destroy — hls.js throws on a destroyed instance.
|
|
assert.equal(detachHls(video), false);
|
|
assert.equal(instance.destroyCalls, 1);
|
|
});
|
|
|
|
test("handle.destroy() tears the instance down too", async () => {
|
|
const video = makeFakeVideo("");
|
|
const { engine, instances } = makeFakeEngine();
|
|
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
|
|
createEngine: async () => engine,
|
|
});
|
|
handle.destroy();
|
|
assert.equal(instances[0].destroyCalls, 1);
|
|
assert.equal(getAttachedHls(video), null);
|
|
});
|
|
|
|
test("re-attaching to the same element destroys the previous instance", async () => {
|
|
const video = makeFakeVideo("");
|
|
const { engine, instances } = makeFakeEngine();
|
|
await attachHlsSource(video, "https://cdn.example/a.m3u8", {
|
|
createEngine: async () => engine,
|
|
});
|
|
await attachHlsSource(video, "https://cdn.example/b.m3u8", {
|
|
createEngine: async () => engine,
|
|
});
|
|
assert.equal(instances.length, 2);
|
|
assert.equal(instances[0].destroyCalls, 1, "first instance must be destroyed");
|
|
assert.equal(instances[1].destroyCalls, 0);
|
|
assert.equal(instances[1].loadedSource, "https://cdn.example/b.m3u8");
|
|
assert.equal(getAttachedHls(video), instances[1]);
|
|
});
|
|
|
|
test("detachHls is a safe no-op for non-HLS elements and null", () => {
|
|
assert.equal(detachHls(null), false);
|
|
assert.equal(detachHls(undefined), false);
|
|
assert.equal(detachHls(makeFakeVideo("")), false);
|
|
});
|
|
|
|
test("getAttachedHls returns null when nothing is bound", () => {
|
|
assert.equal(getAttachedHls(makeFakeVideo("")), null);
|
|
assert.equal(getAttachedHls(null), null);
|
|
});
|
|
|
|
// --- SCN-hls-probe --------------------------------------------------------
|
|
// Given the URL-health badge probing an HLS URL on an MSE browser, when the
|
|
// playlist is CORS-readable and starts with #EXTM3U, then the source is "ok".
|
|
// A bare <video> probe (the old path) reported every such URL as offline, which
|
|
// combined with the magnet-less "unhealthy" stream badge marks the card
|
|
// confirmed-dead and hides it from the grid.
|
|
|
|
const withFetch = async (impl, fn) => {
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = impl;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
};
|
|
|
|
test("probeHlsManifest returns ok for a readable #EXTM3U playlist", async () => {
|
|
const result = await withFetch(
|
|
async () => ({
|
|
ok: true,
|
|
text: async () =>
|
|
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=584301,CODECS="avc1.640015"\nlow.m3u8\n',
|
|
}),
|
|
() => probeHlsManifest("https://cdn.example/v.m3u8")
|
|
);
|
|
assert.equal(result, "ok");
|
|
});
|
|
|
|
test("probeHlsManifest tolerates leading whitespace/BOM-ish padding", async () => {
|
|
const result = await withFetch(
|
|
async () => ({ ok: true, text: async () => "\n\n #EXTM3U\n" }),
|
|
() => probeHlsManifest("https://cdn.example/v.m3u8")
|
|
);
|
|
assert.equal(result, "ok");
|
|
});
|
|
|
|
test("probeHlsManifest reports error for a non-playlist body", async () => {
|
|
const result = await withFetch(
|
|
async () => ({ ok: true, text: async () => "<html>404</html>" }),
|
|
() => probeHlsManifest("https://cdn.example/v.m3u8")
|
|
);
|
|
assert.equal(result, "error");
|
|
});
|
|
|
|
test("probeHlsManifest reports error for a non-2xx response", async () => {
|
|
const result = await withFetch(
|
|
async () => ({ ok: false, status: 403, text: async () => "#EXTM3U" }),
|
|
() => probeHlsManifest("https://cdn.example/v.m3u8")
|
|
);
|
|
assert.equal(result, "error");
|
|
});
|
|
|
|
test("probeHlsManifest reports error when the fetch rejects (CORS/DNS)", async () => {
|
|
const result = await withFetch(
|
|
async () => {
|
|
throw new TypeError("Failed to fetch");
|
|
},
|
|
() => probeHlsManifest("https://cdn.example/v.m3u8")
|
|
);
|
|
assert.equal(result, "error");
|
|
});
|
|
|
|
test("probeHlsManifest distinguishes a timeout from a hard error", async () => {
|
|
const result = await withFetch(
|
|
(_url, options) =>
|
|
new Promise((_resolve, reject) => {
|
|
// Never settles on its own; the probe's own AbortController must fire.
|
|
options?.signal?.addEventListener("abort", () => {
|
|
reject(new DOMException("Aborted", "AbortError"));
|
|
});
|
|
}),
|
|
() => probeHlsManifest("https://cdn.example/v.m3u8", 30)
|
|
);
|
|
assert.equal(result, "timeout");
|
|
});
|
|
|
|
test("probeHlsManifest rejects empty input without touching the network", async () => {
|
|
let called = false;
|
|
const result = await withFetch(
|
|
async () => {
|
|
called = true;
|
|
return { ok: true, text: async () => "#EXTM3U" };
|
|
},
|
|
() => probeHlsManifest(" ")
|
|
);
|
|
assert.equal(result, "error");
|
|
assert.equal(called, false);
|
|
});
|