Skip to content

feat(detection): find manifests by their Content-Type, and play the longest stream - #50

Merged
Nawid3333 merged 2 commits into
mainfrom
claude/stream-choice
Sep 29, 2026
Merged

Nawid3333 merged 2 commits into
mainfrom
claude/stream-choice

Conversation

@Nawid3333

@Nawid3333 Nawid3333 commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

FastStream now finds HLS and DASH manifests whose URL has no extension, by their Content-Type. Of a page's streams, the player now plays the longest, not the newest. This fixes anikage.cc (upstream issue 554), and the ad that played instead of the film on some cineby servers.

Fixes you would notice

anikage.cc plays. The episode is an HLS master playlist at og.bakayaro.live/m3u8/<token>. The URL has no extension; only the Content-Type: application/vnd.apple.mpegurl says what it is, so FastStream never detected it. Instead it took og.bakayaro.live/stream/<token> for an MP4: a media request, labelled image/jpeg, with MPEG-TS inside. That showed "Failed to load video!".

  • Now a URL without a stream extension counts as HLS or DASH when its Content-Type says so: application/vnd.apple.mpegurl, application/x-mpegurl, audio/mpegurl, audio/x-mpegurl or application/dash+xml (chrome/background/ManifestTypes.mjs). Only manifests count: segments say video/mp4 or video/mp2t.
  • Measured: the 23:54 episode plays.

The film plays, not the ad. When a page runs an ad or an intro first, the ad is among the detected streams. The player picked by kind and age alone: the first HLS or DASH stream, or else the newest MP4. That was often the ad. Now the background reads how long each detected stream runs, and the player plays the longest.

  • What is read (chrome/player/utils/StreamLength.mjs, no browser APIs):

    • an HLS playlist; for a master playlist, its first variant;
    • a DASH manifest's mediaPresentationDuration, or the sum of its periods;
    • the header of an MP4 or WebM file, in 64 KiB ranges. When an MP4's movie header comes after its media data, a second read goes there.

    A live stream counts as the longest.

  • With the page's own Referer, Origin and cookies (chrome/background/StreamLengths.mjs). A fetch() can't set those, so a declarativeNetRequest session rule adds them for the stream's URL, as for the player's own requests. The rule is limited to requests in no tab (tabIds: [TAB_ID_NONE]), so only the background's reads get them. A server that answers only the site's pages answers these reads too.

  • When lengths are unknown or close: a stream of unknown length counts as 10 minutes. It beats a short ad and loses to a known full episode. Streams within 10% of the longest tie, and the old rule decides between those. So a page with one stream, or with streams of unknown length, plays what it did before.

  • The Sources menu still lists every stream, to pick one by hand.

  • MPV: the toolbar button and Ctrl+Shift+U's on-play fallback (for a blob: player) send the newest of the longest streams, not the newest one.

Behaviour changes

  • With two or more streams, the player may open up to 2.5 s later. It waits for lengths still being read. The reads start as soon as a stream is detected, so they are usually done by then. The MPV on-play fallback waits the same way.
  • The background reads the start of each stream a tab loads while FastStream is on there. At most 4 reads run at once. Each URL is read once, and the last 300 are remembered. A file gets at most three 64 KiB reads, and an HLS stream at most two playlists. Each read gives up after 8 s.
  • The background's own reads are not detected as streams. They would have been sources of tab -1, pushed to every tab with a player.

Tests

  • Unit: StreamLength (30), StreamLengths (17) and ManifestTypes (4).
  • source-length, a new spec in test:ext (3 cases), on the installed extension with the site on the auto-enable list:
    • the film, not the ad the page loaded after it. The film is an MP4 with its movie header after the media data, as ffmpeg writes one by default;
    • manifests found by their Content-Type, and the longest played: a 9-second intro with an extension, then an HLS episode and a DASH manifest without;
    • a length read with the page's own headers, from a CDN that answers the site's pages only, and a slow read waited for.
  • mpv-shortcut: a new case. An MSE player's film goes to mpv, not the ad it fetched after it.

Checked

  • Full local runs: pnpm run verify and verify:linux.
  • Against main's code: all 3 source-length cases fail there.
  • Each part undone on its own: 48 undos, 40 in the unit tests and 8 in the browser specs. Every one was caught.
  • Measured in Firefox: the rule for TAB_ID_NONE applies to the background's fetch(). The CDN case above depends on it.
  • Measured in Firefox: Firefox reports a response (http-on-examine-response, which onHeadersReceived is built on), and a page's fetch() resolves, only when the body starts, not when the server sends the headers. So a slow manifest is detected late. The spec slows the variant playlist, not the master, for that reason.
  • Your hand test in the dev Firefox, on anikage.cc, aniwaves.ru and cineby: "everything works".

Not in this PR

cineby, all 41 servers on this build, in the test browser:

  • 5 play;
  • 4 find the full-length stream but hit "1 Fragment Failed", on and off;
  • 3 are DASH streams that don't load. Vidsrc's URL says H.265, which Firefox often can't decode;
  • 4 open on a 5-14 s ad, the only stream the page had requested by then;
  • 24 show no stream at all. Finding those needs detection that hooks into the page.

Two ideas for later, not built: waiting out pages that have only short streams, and falling back to the next stream when one fails to load.

The branch also carries fd5f874, the ESLint fix for the error that stopped main's CI (client-setup.e2e.mjs). #51 carries the same commit, so merging both is harmless.

🤖 Generated with Claude Code

Nawid3333 and others added 2 commits September 29, 2026 03:23
client-setup.e2e.mjs waited for optionsApplied with a continuation line that ESLint's
indent rule rejects. CI's first step failed on it, so no test ran on 699dc5d and the
release was skipped. The same check, written as the wait above it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ongest stream

- A URL that names no stream type is HLS or DASH when its Content-Type says so
  (application/vnd.apple.mpegurl, application/x-mpegurl, audio/mpegurl,
  audio/x-mpegurl, application/dash+xml). anikage.cc's episode is such a playlist.
- Of a page's streams, the longest plays: an ad or an intro runs for seconds, the video
  for minutes or hours. The background reads each detected stream's length - an HLS
  playlist (the master's first variant), a DASH manifest, or the header of an MP4 or WebM
  file, in ranged reads - with the page's Referer, Origin and cookies, which a
  declarativeNetRequest rule gives its own requests (tabIds: TAB_ID_NONE). A stream of
  unknown length counts as 10 minutes, and streams within 10% of the longest are left to
  the old rule, so a page with one stream plays what it did before. With two or more, the
  player waits up to 2.5 s for lengths still being read; the reads start at detection.
- MPV: the toolbar and the on-play fallback send the newest of the longest, not the
  newest.
- The background's own reads are not detected as streams of tab -1.

Tests: StreamLength (30), StreamLengths (17) and ManifestTypes (4) unit tests, 40
mutations each caught; source-length (3 cases, all fail on main) and a new mpv-shortcut
case (an MSE player's film, not the ad it fetched after it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Nawid3333
Nawid3333 merged commit 37fe1ed into main Sep 29, 2026
8 checks passed
@Nawid3333
Nawid3333 deleted the claude/stream-choice branch September 29, 2026 11:39
Nawid3333 added a commit that referenced this pull request Sep 29, 2026
… two subtitle bugs (#52)

* fix(headers): match a header rule's URL as it is, with nothing escaped

declarativeNetRequest's urlFilter has no escape character: Firefox's
CompiledUrlFilter splits it at `*` and matches every other character,
a backslash included, literally. So escaping a URL's `*`, `^` and `|`
(7d9053d) made the header rule require a backslash no URL has, and a
stream whose URL contains a `*` - an Akamai `acl=/*` token, say - lost
its Referer and Origin. Unescaped, those characters can only widen the
match, never exclude the URL itself.

header-rules.e2e.mjs checks, on the installed extension, that the
server sees the Referer for a plain URL, one with a `*` and one with
`^` and `|`; the unit test pins the filter to the URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(archive): keep a dropped archive's URL until it has been read

httpGetLarge fetches the file in 250 MB chunks as parseFSA reads it,
but the object URL was revoked (d4d533a) once the first two chunks
had been requested. Every later chunk failed, so any .fsa archive over
500 MB could not be opened. FastStreamArchiveUtils.parseFSAFile now
owns the URL and revokes it after parsing. An unreadable file now ends
in "archive failed" too, instead of an uncaught error.

The chunk size is FastStreamArchiveUtils.fileChunkSize, which
archive-roundtrip.e2e.mjs lowers to 64 KB so a small archive spans
many chunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(archive): keep manifests in the store; drop one only for a reload

To keep live streams moving, #49 removed every HLS playlist and DASH
MPD from the download store right after loading it. But "dump buffer"
writes the store into the .fsa archive, so archives had no manifest,
and a player opened from one had to fetch it again - impossible
offline or once the URL expired. The seek preview, which shares the
store, also downloaded every playlist a second time.

Now a player's first load of a manifest may come from the store and
leaves it there; each player remembers which it has loaded
(loadedManifests), and only a second load by the same player - hls.js
or dash.js refreshing a live stream - drops the stored copy first.

Unit tests cover both sides (another player gets the stored copy; the
same player's reload downloads it again). archive-roundtrip.e2e.mjs
plays HLS and DASH, writes the archive in memory, drops it on the
player, and checks it plays again with no manifest from the network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mp4): end a fragmented file's stream only after its last range

checkEndOfStream ended the MediaSource once every sample mp4box knew of
was appended. A fragmented file's samples become known one moof at a
time, so that also held mid-file whenever a 1 MB range ended between
two fragments (measured with mp4box 2.4.1: true after a cut at or a few
bytes into any moof). The MediaSource then cut the duration to what
was buffered and the video stopped early. For a fragmented file the
range holding its last byte must now be in as well.

mp4-fragmented-end.e2e.mjs builds that case - a `free` box moves the
fixture's second moof to 8 bytes before the first range ends - and
holds the second range back: the stream must stay open, the duration
whole, and the video play to its end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): fix the indent ESLint rejects in client-setup

4dc5ab7 left main's lint red. The same line as PR #50's, so the two
merge cleanly in either order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(subtitles): keep a SubRip cue's text below a line of spaces

c4d765d split cues at a line of spaces or tabs as if it were empty, so
in a cue like "Top / (spaces) / Bottom" the text below that line became
a block of its own with no timestamp and was dropped. ffmpeg and VLC
keep it as part of the cue, as upstream did.

Cues are now split only at empty lines. A line of spaces between two
cues still ends the first: d3fec9b starts a cue at its timestamp line,
and the trailing whitespace line is trimmed there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(subtitles): show nothing for a cue with no text, rather than throw

A SubRip cue with nothing under its timestamp becomes a cue with empty
text, and vtt.js's convertCueToDOMTree returns null for it.
renderSubtitles appended that null, and appendChild(null) throws. While
such a cue was on screen, every timeUpdated() stopped there, so a
subtitle track after it showed nothing and the skip segments were not
updated. The cue is now skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mp4): read a file's length from Content-Range before parsing it

MP4Player set the file's length from the first answer's Content-Range
only after handing the data to mp4box, and parsing the moov runs
onMetadataParsed, which then works the length out from the samples known
so far. For a fragmented file those are only the fragments in the first
range, so the ranges after it were never made. The end-of-stream check
066d962 added took the file to be complete there, and the stream was
ended after the first range.

The length is now read before the data is parsed. A server that gives no
total (no Content-Range, or `bytes a-b/*`) still gets the estimate from
the sample table, as before.

The fragmented-end spec had asked for the whole duration while the last
range was held. An empty_moov file, as ffmpeg writes it, has no mehd box,
so its duration is what has been parsed so far and grows with each
range. The spec now checks what the bug did: the stream is not ended
early, and the video plays to its end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): open the dumped archive on a fresh player page

Dropped on the player still showing the same video, the archive's source
is taken for the one already in the sources list, which is not marked as
loaded from an archive. Opening the archive later, as it is used, means a
fresh page, so the spec now carries the archive over to an empty player
page and drops it there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): a long fragmented MP4 plays, and seeks far into it

The 160 s fixture cut into fragments looked 10 s long before the length
came from Content-Range (the previous commit), and the rest was never
loaded. The note it replaces said such a file stopped after about 3 s.
The limit that is left - past its first 30 ranges, 30 MB, MP4Player
cannot pick a range to load in a fragmented file - is measured and noted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): let the empty-cue test's cues span the whole video

The test sought to 1 s and then paused, with both cues on 0-5 s. In the full
verify the video had played on to 8.33 s before the pause took, so no cue was
on and the second track showed nothing. Both cues now span the whole 10 s
video, and the test only pauses: whatever the time is, the cues are on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Nawid3333 added a commit that referenced this pull request Sep 29, 2026
…#61)

Since 1.3.82.38 (#50), a short HLS or DASH title whose segments are named .mp4 opened as one lone fragment. Init and media segments now measure as PIECE_LENGTH and rank below every stream; the player also waits for the lengths of sources detected while it waits.

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.

1 participant