Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions chrome/background/NetRequestRuleManager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,22 @@ function extensionHost() {
return new URL(chrome.runtime.getURL('/')).hostname;
}

// How long a header rule lives: a request's (FetchLoader's, for each one) goes out at once.
export const RULE_LIFETIME_MS = 5000;
// The most a rule may ask for: Firefox's own player (DirectVideoPlayer) makes its requests
// whenever it needs them, and renews its rule while it has the source.
export const MAX_RULE_LIFETIME_MS = 60000;

export class RuleEntry {
/** @param {number} id - The session rule's id. */
constructor(id) {
/**
* @param {number} id - The session rule's id.
* @param {number} [lifetimeMs] - How long it lives: RULE_LIFETIME_MS, up to
* MAX_RULE_LIFETIME_MS.
*/
constructor(id, lifetimeMs) {
this.id = id;
this.expiresAt = Date.now() + 1000 * 5;
const lifetime = Number.isFinite(lifetimeMs) ? /** @type {number} */ (lifetimeMs) : RULE_LIFETIME_MS;
this.expiresAt = Date.now() + Math.min(Math.max(lifetime, RULE_LIFETIME_MS), MAX_RULE_LIFETIME_MS);
}
}

Expand Down Expand Up @@ -120,10 +131,11 @@ export class RuleManager {
* @param {string} url - The request URL the rule applies to.
* @param {number} tabId - The tab whose requests it changes; TAB_ID_NONE for the extension's own.
* @param {FSHeaderCommand[]} requestHeaderCommands - The headers to set or remove.
* @param {number} [lifetimeMs] - How long the rule lives (RuleEntry).
*/
async addHeaderRule(url, tabId, requestHeaderCommands) {
async addHeaderRule(url, tabId, requestHeaderCommands, lifetimeMs) {
await this.initialClear;
const rule = new RuleEntry(this.getNextID());
const rule = new RuleEntry(this.getNextID(), lifetimeMs);
// insert rule in order
const index = this.getInsertionIndex(rule.id);
if (index === -1) throw new Error('Rule already exists');
Expand All @@ -143,7 +155,7 @@ export class RuleManager {
// a `*` made the rule demand a backslash no URL has, and a stream URL
// like an Akamai `acl=/*` token got no Referer/Origin. Unescaped, a
// `*`, `^` or `|` from the URL can only widen the match, never exclude
// the URL itself, and the rule is limited to one tab and 5 seconds.
// the URL itself, and the rule is limited to one tab and its lifetime.
// Only the scheme at the start: an http URL whose query holds an https one
// lost that "https://" instead. A `|` at its very end anchors the end there,
// before that `|`, and the URL itself did not match: a `*` after it keeps it
Expand Down
4 changes: 2 additions & 2 deletions chrome/background/background.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1273,8 +1273,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return;
}
if (msg.commands.length) {
// A tab that sends a message has an id.
ruleManager.addHeaderRule(msg.url, /** @type {number} */ (senderTab.id), msg.commands).then((rule) => {
// A tab that sends a message has an id. The rule's lifetime is capped (RuleEntry).
ruleManager.addHeaderRule(msg.url, /** @type {number} */ (senderTab.id), msg.commands, msg.lifetimeMs).then((rule) => {
if (Logging) console.log('Added rule', msg, rule);
sendResponse();
}).catch((e) => {
Expand Down
32 changes: 28 additions & 4 deletions chrome/player/players/DirectVideoPlayer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export function elementHeaderCommands(headers) {
return commands;
}

// How long the background keeps the element's header rule, and how often the player renews
// it while it has the source. Firefox's player asks for ranges when it needs them: on a
// seek, after a long pause, for a Matroska file's index at its end. A request's rule lives
// 5 s, and those went out without the page's Referer or cookie: a hoster's server answered
// the first one after 5 s with a redirect to a decoy, and the source was reloaded. The most
// the background grants (MAX_RULE_LIFETIME_MS): Firefox may hold a hidden tab's timers back,
// and a renewal up to 50 s late still comes before the rule ends.
export const HEADER_RULE_LIFETIME_MS = 60000;
export const HEADER_RULE_RENEW_MS = 10000;

// What a direct video can be saved as. A URL without an extension (".../watch?v=1") was saved
// as .webm, and one ending in .php as .php: most direct videos are MP4.
const MEDIA_EXTENSIONS = new Set(['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ogv', 'ogg', 'mp3', 'm4a', 'aac', 'wav', 'flac', 'opus', 'weba', 'oga']);
Expand All @@ -57,6 +67,8 @@ export default class DirectVideoPlayer extends EventEmitter {
this.client = client;

this.video = /** @type {HTMLVideoElement} */ (document.createElement(config?.isAudioOnly ? 'audio' : 'video'));
/** @type {?ReturnType<typeof setInterval>} */
this.headerRenewal = null;
}

load() {
Expand All @@ -82,6 +94,7 @@ export default class DirectVideoPlayer extends EventEmitter {
/** @param {import('../VideoSource.mjs').VideoSource} source */
async setSource(source) {
this.source = source;
this.stopHeaderRenewal();
const url = URLUtils.playableUrl(source.url, document.baseURI);
if (!url) {
// As the media element reports a source it cannot load: an ERROR after this call,
Expand All @@ -92,25 +105,35 @@ export default class DirectVideoPlayer extends EventEmitter {
// Firefox's own loader sends the page's headers only if the background's rule for this URL
// sets them (the rule the MP4 player's requests get through FetchLoader): the Referer or
// cookie a site asks for, so a source handed over from the MP4 player (playDirectly) plays.
await this.setHeaderRule(source, url);
// Renewed while this is the source: the element's later requests need it as much.
if (await this.setHeaderRule(source, url) && this.source === source && this.video) {
this.headerRenewal = setInterval(() => this.setHeaderRule(source, url), HEADER_RULE_RENEW_MS);
}
this.video.src = url;
}

stopHeaderRenewal() {
if (this.headerRenewal !== null) clearInterval(this.headerRenewal);
this.headerRenewal = null;
}

/**
* Asks the background to set the source's headers on the requests for its URL.
* @param {?import('../VideoSource.mjs').VideoSource} source
* @param {string} url - The URL the element loads.
* @return {Promise<boolean>} Whether the source has headers to set.
*/
async setHeaderRule(source, url) {
if (!EnvUtils.isExtension() || !/^https?:/i.test(url)) return;
if (!EnvUtils.isExtension() || !/^https?:/i.test(url)) return false;
const commands = elementHeaderCommands(source?.headers);
if (!commands.length) return;
if (!commands.length) return false;
try {
await chrome.runtime.sendMessage({type: MessageTypes.SET_HEADERS, url, commands});
await chrome.runtime.sendMessage({type: MessageTypes.SET_HEADERS, url, commands, lifetimeMs: HEADER_RULE_LIFETIME_MS});
} catch (e) {
// Without the rule it plays as it did before: without the headers.
console.warn('Could not set the headers of the source', e);
}
return true;
}

getSource() {
Expand All @@ -130,6 +153,7 @@ export default class DirectVideoPlayer extends EventEmitter {
}

destroy() {
this.stopHeaderRenewal();
VideoUtils.destroyVideo(this.video);
this.video = /** @type {any} */ (null);

Expand Down
12 changes: 11 additions & 1 deletion chrome/player/players/mp4/MP4Player.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {DownloadStatus} from '../../enums/DownloadStatus.mjs';
import {ReferenceTypes} from '../../enums/ReferenceTypes.mjs';
import {EmitterCancel, EmitterRelay, EventEmitter} from '../../modules/eventemitter.mjs';
import {createFile} from '../../modules/mp4box/mp4box.all.mjs';
import {startsLikeMp4} from '../../utils/ContainerSniff.mjs';
import {Utils} from '../../utils/Utils.mjs';
import {VideoUtils} from '../../utils/VideoUtils.mjs';
import {AudioLevel, VideoLevel} from '../Levels.mjs';
Expand Down Expand Up @@ -704,6 +705,14 @@ export default class MP4Player extends EventEmitter {
return;
}

// Not an MP4 at all, whatever its server and URL call it (a Matroska file served
// as video/mp4): mp4box reads only MP4's boxes, Firefox's own player reads it.
if (frag.rangeStart === 0 && !this.metaData && data?.byteLength &&
!startsLikeMp4(new Uint8Array(data, 0, Math.min(data.byteLength, 8)))) {
this.playDirectly('The file is not an MP4 file');
return;
}

const hadMetaData = !!this.metaData;
this.mp4box.appendBuffer(data);
this.refreshSampleIndex();
Expand Down Expand Up @@ -921,7 +930,8 @@ export default class MP4Player extends EventEmitter {

/**
* Hands the source to Firefox's own player (DirectVideoPlayer), for a server this player
* cannot load in ranges (RangeAnswers.mjs). Once: this player stops loading.
* cannot load in ranges (RangeAnswers.mjs) or a file that is no MP4 (ContainerSniff.mjs).
* Once: this player stops loading.
* @param {string} reason
*/
playDirectly(reason) {
Expand Down
30 changes: 30 additions & 0 deletions chrome/player/utils/ContainerSniff.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// @ts-check

// Whether a file's first bytes can be an MP4's (ISO BMFF, QuickTime), the only files
// MP4Player's mp4box reads. A server and a URL can call a file video/mp4 and .mp4 when it
// is not one: a hoster's Matroska file came as both, mp4box stopped at its first bytes
// ("Invalid data found while parsing box of type '£B'"), and the player failed on a file
// the page's own <video> played. Firefox's own player goes by what the bytes are, so
// MP4Player hands it a file that is no MP4 (playDirectly).
//
// An MP4 is a row of boxes. Each starts with its size, 32 bits (0: to the file's end; 1: a
// 64-bit size follows the type; else at least the box head's 8 bytes), then its type: four
// printable characters ("ftyp", "moov", "mdat", "free", "wide", "pnot"...). The start of
// every other format breaks that: Matroska's and WebM's EBML magic 1A 45 DF A3 is followed
// by bytes no type has, and so are Ogg's "OggS", FLAC's "fLaC", MP3's ID3 tag, WAV's RIFF
// size, FLV's flags and MPEG-TS's packet header.

/**
* @param {Uint8Array} bytes - The file's first bytes.
* @return {boolean} False when they cannot be the start of an MP4's first box; true when
* they can, or when fewer than its 8 bytes tell nothing.
*/
export function startsLikeMp4(bytes) {
if (bytes.length < 8) return true;
const size = new DataView(bytes.buffer, bytes.byteOffset, 8).getUint32(0);
if (size !== 0 && size !== 1 && size < 8) return false;
for (let i = 4; i < 8; i++) {
if (bytes[i] < 0x20 || bytes[i] > 0x7E) return false;
}
return true;
}
38 changes: 38 additions & 0 deletions docs/notes/player.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,44 @@ played a minute in, and one padded to whole ranges that must fire `ended` with n
the one after its end (without `endsAt`: 11 ranges past the end and no end, or 6 retries of the
416 and the load error).

## A file is what its bytes are, and Firefox's own player keeps the page's headers (2026-10-28)

A hoster served a Matroska file (H.264, AAC, SRT; written by mkvmerge) as `video/mp4`, from a
URL ending in `.mp4`, beside an MP4 of the same video on another link. Measured on the installed
extension with a local copy of that shape:

- **The page's own `<video>` played it, FastStream's player failed.** The type and the URL
picked `ACCELERATED_MP4`, mp4box stopped at the first bytes ("Invalid data found while
parsing box of type '£B' at position 0"), and the player showed the load error. Firefox's
element reads a file by its bytes: served as `video/mp4`, `application/octet-stream` or
`video/x-matroska`, it played the Matroska file every time. `MP4Player` now looks at the
first range's first 8 bytes (`ContainerSniff.startsLikeMp4`: a box's size, then four
printable characters) and hands a file that cannot be an MP4 to Firefox's own player
(`playDirectly`), one range in. Every real MP4 and QuickTime file starts with a box, so none
leaves MP4Player; HTML that happens to start like one fails in mp4box, as before.
- **Firefox's own player lost the page's headers after 5 s.** That server answered a request
without the page's Referer with a 302 to a decoy. `DirectVideoPlayer` set its header rule
once, and every rule lived 5 s (`RuleEntry`): the element's first request after that (the
Matroska index at the file's end, read 12 s in, or a seek) went out with no Referer, to the
decoy, and the client's recovery reloaded the source (the buffer fell from 39 s to 9 s). The
element's rule now lives 60 s, the cap (`SET_HEADERS`' `lifetimeMs`), and is renewed every
10 s while the player has the source: Firefox may hold a hidden tab's timers back, and a
renewal up to 50 s late still comes in time (glm's review). A request's rule stays 5 s. After
the player is gone the rule lasts at most 60 s, limited to the extension's own requests to
that URL in that tab.

The background's 64 KB read of the file (`StreamLengths`) sees the same bytes and could pick
the player before MP4Player tries; not done yet: the hand-over costs one range.

`mislabelled-file.e2e.mjs`: a Matroska film served as `video/mp4` from an `.mp4` URL by a server
that sends a request without the page's Referer to a decoy, at 400 KB/s; the player plays it in
Firefox's own player, plays on after a seek 12 s in, and no request lacks the Referer. On main it
failed with the mp4box error; with the hand-over alone, the request 12 s in had no Referer.

The e2e web build (`wdio.conf.mjs`) serves the player with COOP/COEP: Firefox's own player there
loads only files whose server sends `Cross-Origin-Resource-Policy`, which every spec's server
does. The extension's player page is not cross-origin isolated: measure DIRECT there.

## AAC in a channel layout of its own, on Windows (2026-10-12)

Reported on a film site (its hoster: one MP4 per quality, `[360p]url,...`
Expand Down
Loading
Loading