Skip to content

fix(trailer): resolve trailers without the rate-limited watch page and reject PO-token-gated stream URLs - #552

Open
aneeshtigga wants to merge 1 commit into
NuvioMedia:Devfrom
aneeshtigga:fix/trailer-resolution
Open

aneeshtigga wants to merge 1 commit into
NuvioMedia:Devfrom
aneeshtigga:fix/trailer-resolution

Conversation

@aneeshtigga

@aneeshtigga aneeshtigga commented Aug 30, 2026

Copy link
Copy Markdown

Summary

Trailer resolution no longer depends on the watch page, and stream URLs that YouTube has PO token gated are now rejected before they reach the player. Resolution falls back through HLS, separate audio/video and finally ANDROID progressive itag 18, so trailers keep playing (at lower quality) instead of failing outright.

PR type

  • Behavior bug/regression fix

Why

Two independent problems, both measured from a real machine and described in detail in #584.

1. Extraction required the watch page, which is the first thing YouTube rate limits.
extractPlaybackSourceInternal fetched https://www.youtube.com/watch?v=<id>&hl=en (1.1-1.4 MB) before every player call, only to scrape INNERTUBE_API_KEY and VISITOR_DATA, and threw if it failed:

[TrailerDebug] extract start videoId=8ZYhuvIv1pA
[TrailerDebug] blocked stage=extraction error=IllegalStateException message=Failed to fetch watch page (429)

At that same moment the player endpoint still worked and the streams downloaded in full:

Video Player API (ANDROID) Progressive itag 18
MAbjq7ydKDI OK with and without a key full GET 200, 10,139,298 bytes
8ZYhuvIv1pA OK with and without a key full GET 200, 4,282,937 bytes

INNERTUBE_API_KEY is a public constant present in every YouTube page (the same value this code was already scraping), so it is used directly now and the watch page is only consulted if every client returns nothing.

2. A single head range probe cannot detect PO token gating.
ANDROID and IOS adaptive URLs serve the first range and then reject every later range, independent of User-Agent (tested Chrome, Safari, Lavf and each client's own UA):

Client that minted the URL bytes=0-65535 tail range full GET
IOS (itag 137) 206 403 403
ANDROID (itag 137, 136, 134, 140, 251) 206 403 403
ANDROID (itag 18, progressive) 206 n/a 200, downloads fully
VISIONOS (itag 137) 206 206 200

The head always succeeds, so gated URLs passed validation and then failed inside the player.

Desktop scope

Affects macOS, Windows and Linux equally: the change is in the shared trailer extractor plus the desktop TrailerExtractionPlatform actual. Verified on macOS 26.6 (arm64).

composeApp/src/androidFull and composeApp/src/iosFull are touched only because buildPlaybackSource now takes candidate lists instead of four pre-picked candidates. Those actuals live in this repository, so they have to match or the android and ios targets stop compiling. No behavior beyond the same fix was added there.

Issue or approval

Fixes #584.

(The first filing, #551, was closed by the unlabeled-issue bot before triage because an outside contributor cannot set labels from the API. #584 is the same report submitted through the bug template so it carries the bug label.)

UI / behavior impact

  • No UI change
  • Behavior changed only to fix a documented bug/regression

Policy check

  • I have read and understood CONTRIBUTING.md.
  • This PR is small, focused, and limited to one problem.
  • This PR is scoped to the desktop app, desktop packaging, desktop documentation, or shared code required for desktop behavior.
  • This PR is not cosmetic-only.
  • Any UI change fixes a linked glitch/bug and includes visual proof, or this PR has no UI change.
  • Any behavior change fixes a linked bug/regression or has explicit approval, or this PR has no behavior change.
  • This PR does not bundle unrelated refactors, cleanups, formatting, or drive-by changes.
  • This PR does not add dependencies, architecture changes, migrations, or product-direction changes without explicit approval.
  • I listed the testing performed below.

What changed

  • Watch page is no longer on the hot path: the public constant key is used, and WatchConfigCache scrapes the page only as a last resort when every client returned empty.
  • Reachability requires head and tail ranges (the tail is only checked when clen is present), so gated URLs are rejected. One rejection condemns the rest of that client's adaptive formats.
  • Candidate lists are passed to the platform instead of one pre-picked candidate per kind, and resolution tries HLS, separate streams, then progressive, taking the first that probes clean. A video only source is kept as a last resort so a silent trailer never wins over one with audio.
  • Clients whose URLs are not gated (visionos, android_vr) are preferred, and remaining clients are skipped once one of them produced candidates. Typical extraction is now one player call instead of three.
  • Client versions refreshed: android 20.10.35 to 21.26.364, ios 20.10.1 to 21.26.4, plus android_vr added. Stale versions attract bot challenges by themselves.
  • UNPLAYABLE and ERROR are treated as final for that video (geo blocks, removed videos) instead of being retried.
  • After an HTTP 429 the extractor backs off for 15 minutes instead of retrying per trailer, per hover and per hero. This is deliberate: request volume from one residential IP is what gets the whole network flagged ([Feature]: Opt-in "Open Trailers Externally" fallback for YouTube-flagged IPs NuvioTV#2451 reports YouTube blocking every device behind the IP).
  • Resolved URL cache TTL raised from 10 minutes to 3 hours, since googlevideo URLs carry their own expire of roughly 6 hours.

Scope boundaries

Intentionally not included:

  • No UI or copy changes. The existing error strings are untouched, even though "No playable trailer stream found." is currently shown both when extraction returns nothing and when the player fails to open a good URL. Happy to fix that separately if you want it.
  • No persistent (on disk) cache for resolved URLs, so a restart still re-extracts. That would help further but is a larger change.
  • No PO token / BotGuard support and no cookie support. Both would address the underlying bot check more completely and both deserve their own feature request.
  • No changes to hero trailer or hover preview call sites.

Testing

Manual, macOS 26.6 (Darwin 25.6.0), Apple Silicon:

  1. Built unsigned release dmg from this branch (packageReleaseDmg), installed, and played trailers across several titles. Also built from the 0.1.21-alpha tag to compare against the published release.
  2. Reproduced the original failure first: with the previous code every trailer failed while the IP was flagged, logging Failed to fetch watch page (429).
  3. With this branch, trailers resolve and play on the same flagged IP. NUVIO_TRAILER_DEBUG=1 shows the expected path: playability client=visionos status=LOGIN_REQUIRED, then a rejected android adaptive probe, then selected mode=combined_fallback on itag 18.
  4. Verified the gate behaviour independently of the app by replaying the same requests with curl and comparing head range, tail range and full GET per client and per itag (tables above).
  5. Checked a geo blocked trailer ("The uploader has not made this video available in your country"): it now fails on its own with blocked stage=unplayable and does not affect the next trailer.
  6. ./gradlew :composeApp:compileKotlinDesktop passes.

The same fix is filed for mobile as NuvioMedia/NuvioMobile#1857, where I also verified it in an iOS build.

Screenshots / Video

Not a UI change.

Breaking changes

None.

Linked issues

Fixes #584. Related context: NuvioMedia/NuvioMobile#1857, NuvioMedia/NuvioTV#2451.

…URLs

Extraction fetched the 1.1-1.4 MB watch page before every player call just to
scrape the public INNERTUBE_API_KEY, so a rate limited device failed with 429
before reaching a player endpoint that still worked. The constant key is used
directly now and the page is a last resort.

A head-only range probe cannot detect PO token gated URLs, so gated ANDROID
and IOS formats passed validation and 403'd during playback. Probes check head
and tail, gated clients are dropped after one rejection, and resolution falls
back to ANDROID progressive itag 18.

Also prefers ungated clients, skips the rest once one resolves, refreshes
stale client versions, treats UNPLAYABLE as final, backs off after a 429, and
raises the resolved URL cache TTL to 3 hours.

Details and measurements in NuvioMedia#551.
@KhooLy

KhooLy commented Sep 3, 2026

Copy link
Copy Markdown
Member

Try to move this PR to NuvioMobile and get core code merged in NuvioMobile first and only include desktop-specific code in here. I'll merge your implementation if your code gets merged in NuvioMobile.

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.

[Bug]: Trailers stop resolving: extraction requires the rate-limited watch page and selects PO-token-gated stream URLs

2 participants