Skip to content

Player opening, custom source patterns and request(): five small fixes - #569

Merged
Andrews54757 merged 7 commits into
Andrews54757:mainfrom
Nawid3333:upstream-pr/detection-fixes
Oct 9, 2026
Merged

Andrews54757 merged 7 commits into
Andrews54757:mainfrom
Nawid3333:upstream-pr/detection-fixes

Conversation

@Nawid3333

Copy link
Copy Markdown
Contributor

Five small fixes in how FastStream opens the player over a page, matches
custom source patterns, and makes requests. There are seven commits, and any
of them can be dropped, except that 2 goes after 1, and 4 is three commits
that go together (reasons below). With whitespace ignored, the diff is +38/-7.

What breaks

1. A frame whose OPEN_PLAYER fails never gets a player again.
openPlayer() sets frame.playerOpening and skips any frame that has it.
The reply clears it only on 'no_video' (background.mjs:1234). When the
message fails, because the frame has no content script or content.js throws
before replying, the reply is undefined and the flag stays set. After
that, no click or new source opens a player in that frame until it loads a
new document.

2. Toolbar Off doesn't remove a player that is still opening. Off
reloads the page only if a frame has isPlayer, which PLAYER_LOADED sets.
An Off before that leaves the player on a page whose toolbar says off:

  • Off while openPlayersWithSources() is still measuring the videos (an
    await on content.js): the player is opened afterwards anyway;
  • Off after OPEN_PLAYER: the player iframe is already in the page, but only
    playerOpening is set, and Off doesn't look at it.

3. A frame sent to the player page plays nothing. When a video fills an
iframe that may not go fullscreen, handlePlayerOpen() navigates the frame
itself to the player (content.js:255). That fires the page's
beforeunload, which sends FRAME_REMOVED, and the background drops the
frame and its sources. The player then loads in the same frame, asks for
sources, finds none, and stays on "Welcome to FastStream".

4. Custom source patterns match the wrong type, or nothing.
MultiRegexMatcher joins the patterns into (a|b)|(c) and picks the output
from the position of the first non-empty group. Three things go wrong with
patterns a user can write:

  • a. A lone type line (hls with no /regex/) becomes the empty regex.
    It matches at the start of every URL with an empty group, so match()
    returns undefined. Every other pattern with the same flags silently
    stops matching.
  • b. With g, str.match() returns no groups, so the pattern never
    matches. With y, lastIndex carries over, so the pattern alternates
    between matching and not.
  • c. A capture group in the user's own pattern shifts the positions:
    with m3u8 /(foo|bar)\.php/ and mp4 /baz/, a baz URL returns
    undefined. A lookahead-only pattern is lost too, since its group is ''.

5. RequestUtils.request() never settles if anything throws before
send().
The XHR is set up in an async Promise executor, so a throw from
open(), setRequestHeader() or the SET_HEADERS message only rejects a
promise nobody sees. For example, Subtitles → "From URL" accepts
https://example.com:99999/sub.vtt, the URL parser rejects it, and the
toast says "Downloading" forever.

The fix

  1. The reply keeps playerOpening only for 'redirect', 'replaceall' and
    'replace', the answers that mean a player is coming. Anything else,
    including no answer, clears it.
  2. Off also counts a frame with playerOpening, and
    openPlayersWithSources() returns if the tab was turned off during the
    measuring. (2 needs 1: otherwise a frame whose OPEN_PLAYER failed would
    keep the flag, and Off would reload a page with no player.)
  3. handlePlayerOpen() sets RedirectingToPlayer just before the redirect,
    and beforeunload skips FRAME_REMOVED when it is set. The frame isn't
    going away: the player takes it over, and finds the frame's sources.
  4. Three commits:
    • a. addRegex() refuses the empty regex, as it already refuses an
      invalid one. loadCustomPatternsFile() catches that, warns, and skips
      the line.
    • b. match() uses regex.lastIndex = 0; regex.exec(str), which is
      the same as str.match() without g/y, and correct with them.
    • c. Each output's patterns go in a named group,
      (?<__fsOutputN>...), and match() returns the output whose group
      took part (!== undefined), ignoring the user's own groups. c needs
      b, for the groups of a g regex. It also needs a: without it, an empty
      pattern would now route every URL to its type.
  5. The executor's body is wrapped in try { ... } catch (e) { reject(e); }.
    The rest is only re-indented (git diff -w: +4).

Why this can't break anything else

  • 1: content.js is the only handler of OPEN_PLAYER, and its real answers
    are unchanged. Only undefined (a failure) now clears the flag.
  • 2: after 1, playerOpening is set only while a player is on its way,
    so reloading it away is what Off already does for a loaded player.
    openPlayer()'s own guard still stops a frame from being opened twice
    after a quick On → Off → On.
  • 3: the flag is set only on the redirect branch. Every other unload
    still sends FRAME_REMOVED, and nested frames still report themselves.
  • 4: patterns without g/y, without groups of their own, and not
    matching the empty string give the same results. Named groups need
    Chrome 64 / Firefox 78, and the manifests ask for 114 / 113.
  • 5: the catch only runs when setup throws, before any listener can
    resolve. requestSimple() already catches a rejection and calls back with
    the error, and the other callers already handle a failed request.

Chrome: 1, 2 and 4 use the same in-memory background state as the code
around them. 3 assumes the iframe keeps its frameId when it navigates to
the extension page. I haven't checked that on Chrome. If it doesn't, the
player gets no sources, as today.

Verification

Not run in Chrome or Firefox. Node scripts ran the real code from main and
from this branch:

  • for 1-3, the background's own functions and message listener, lifted as
    text, with the real TabTracker and a stubbed chrome.tabs;
  • for 4 and 5, the real modules, with XMLHttpRequest stubbed for 5.
case main this branch
1. OPEN_PLAYER fails, then two more tries flag stays set, 1 sent cleared, 3 sent
2. Off while the videos are measured player opened after Off none
2. Off after a 'replace' answer no reload, player stays reload
3. sources the player gets after the redirect [] ["https://cdn.test/v.m3u8"]
4a. hls alone + mp4 /baz/, URL …/baz undefined mp4 (and a warning)
4b. mp4 /baz/g, same URL twice undefined, undefined mp4, mp4
4b. mp4 /https:/y, same URL three times mp4, null, mp4 mp4 ×3
4c. m3u8 /(foo|bar)\.php/ + mp4 /baz/, URL …/baz undefined mp4
4c. m3u8 /(?=.*master)/, URL …/master.txt undefined m3u8
5. open() throws pending forever rejects, callback gets the error
5. plain request 200 200
  • Per commit: each row changes at its own commit and at no other. With
    2 applied without 1, "On, OPEN_PLAYER fails, Off" reloads a page that has
    no player.
  • Build: npm ci + npm run build: all targets build, with every change
    in each zip.
  • Lint: eslint at each of the seven commits: no problems.
  • Merges: no conflicts with Detection: an HTML page is not a stream because its query string names one #563, which also touches background.mjs.

🤖 Generated with Claude Code

Nawid3333 and others added 7 commits September 29, 2026 04:56
openPlayer() set frame.playerOpening and cleared it again only on a
'no_video' answer. When the message failed instead (no content script in
the frame, or content.js never answered), the answer is undefined and the
flag stayed set; openPlayer() returns early for such a frame, so it never
got a player again until it navigated.

Keep the flag only for the three answers that mean a player is on its way
('redirect', 'replaceall', 'replace'); PLAYER_LOADED clears it as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A toolbar click that turns a tab on measures the page's videos
(GET_VIDEO_SIZE), then sends OPEN_PLAYER, and the player announces itself
with PLAYER_LOADED. Off only reloaded the page when a frame had
isPlayer set, so an Off clicked before PLAYER_LOADED left the player on
the page with the toolbar showing off:

- during the measuring, openPlayersWithSources() went on to open the
  player after the await although the tab was off by then;
- after OPEN_PLAYER, the Off path did not look at playerOpening.

Off now counts a frame with playerOpening as having a player, and
openPlayersWithSources() stops if the tab was turned off while it
measured. Relies on the previous commit: without it, a frame whose
OPEN_PLAYER failed kept playerOpening and Off would reload a page that
has no player.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a video fills its frame and the frame may not go fullscreen (an
iframe without allowfullscreen), handlePlayerOpen() sends the frame
itself to the player page. That navigation fires beforeunload, which
sends FRAME_REMOVED, and the background resets the frame, drops its
sources and forgets it. The player then loads in the same frame and asks
for its sources (REQUEST_SOURCES) and gets none: it stays on "Welcome to
FastStream".

Skip FRAME_REMOVED for the redirect to the player. The frame is not going
away; PLAYER_LOADED and REQUEST_SOURCES come from it next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A custom source pattern line with an extension and no /regex/ (a lone
"hls") parses to the empty regex. It is joined in as "()", which matches
the empty string at the start of every URL, so match() found a match with
no non-empty group and returned undefined: every other pattern with the
same flags stopped matching, silently.

addRegex() now throws for it, as it does for an invalid regex;
loadCustomPatternsFile() logs the warning and skips the line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
match() used str.match(regex). For a regex with the g flag that returns
every match and no groups, so the output lookup found nothing and the
pattern never matched. With y, the compiled regex kept lastIndex from the
previous call, so a pattern that matched one URL failed on the next.

Use regex.exec() with lastIndex reset to 0, which returns the groups of
the first match whatever the flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compile() wraps each output's patterns in a plain group, joins them with
|, and match() took the output from the position of the first non-empty
group. A capture group inside a user's own pattern shifts every later
group, so a match for a later output landed on the wrong index: with
"m3u8 /(foo|bar)\.php/" and "mp4 /baz/", a URL containing baz returned
undefined and was not treated as a source. A pattern whose match is
empty (a lookahead) also returned undefined, as its group was ''.

Wrap each output in a named group (__fsOutput0, 1, ...) and return the
output whose group took part in the match.

Needs the two previous commits: exec() for the groups of a g regex, and
the empty-regex check, since an empty pattern would now match every URL
instead of disabling the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
request() builds the XHR inside an async Promise executor. A throw in
there (xhr.open() on a URL the parser rejects, setRequestHeader() with an
invalid header, a rejected SET_HEADERS message) only rejects the
executor's own promise, which nobody sees; the promise request() returns
stays pending forever and requestSimple()'s try/catch never runs. In the
subtitles menu, "From URL" with a URL like https://example.com:99999/a.vtt
(accepted by the dialog, refused by the URL parser) showed "Downloading"
and nothing after.

Wrap the executor's body in try/catch and reject with the error.
Whitespace aside, the change is the four lines of the try/catch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants