diff --git a/chrome/background/NetRequestRuleManager.mjs b/chrome/background/NetRequestRuleManager.mjs index 6bbbf41f3..7d994f840 100644 --- a/chrome/background/NetRequestRuleManager.mjs +++ b/chrome/background/NetRequestRuleManager.mjs @@ -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); } } @@ -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'); @@ -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 diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index 5720e21f6..5203ae02e 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -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) => { diff --git a/chrome/player/players/DirectVideoPlayer.mjs b/chrome/player/players/DirectVideoPlayer.mjs index 0de2a07a8..db7f3a746 100644 --- a/chrome/player/players/DirectVideoPlayer.mjs +++ b/chrome/player/players/DirectVideoPlayer.mjs @@ -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']); @@ -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} */ + this.headerRenewal = null; } load() { @@ -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, @@ -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} 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() { @@ -130,6 +153,7 @@ export default class DirectVideoPlayer extends EventEmitter { } destroy() { + this.stopHeaderRenewal(); VideoUtils.destroyVideo(this.video); this.video = /** @type {any} */ (null); diff --git a/chrome/player/players/mp4/MP4Player.mjs b/chrome/player/players/mp4/MP4Player.mjs index 3aa60957c..1b686d420 100644 --- a/chrome/player/players/mp4/MP4Player.mjs +++ b/chrome/player/players/mp4/MP4Player.mjs @@ -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'; @@ -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(); @@ -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) { diff --git a/chrome/player/utils/ContainerSniff.mjs b/chrome/player/utils/ContainerSniff.mjs new file mode 100644 index 000000000..cce1f0b4c --- /dev/null +++ b/chrome/player/utils/ContainerSniff.mjs @@ -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