Skip to content

Play the preferred audio language of YouTube web manifests (plain URLs from Safari) - #602

Open
midzdotdev wants to merge 2 commits into
FDH2:masterfrom
midzdotdev:yt-web-manifest-lang
Open

midzdotdev wants to merge 2 commits into
FDH2:masterfrom
midzdotdev:yt-web-manifest-lang

Conversation

@midzdotdev

Copy link
Copy Markdown

Addresses #601.

What I saw: a YouTube video with AI-dubbed audio, sent with the AirPlay button in Safari (macOS, youtube.com), plays its French dub. -lang and $LANGUAGE make no difference.

Cause: Safari hands over a plain https://manifest.googlevideo.com/api/manifest/hls_variant/... URL, which goes straight to playbin. That master playlist has no EXT-X-MEDIA audio renditions: each resolution is listed once per audio language as a separate variant stream, marked only by YT-EXT-AUDIO-CONTENT-ID and YT-EXT-XTAGS (acont=original / acont=dubbed-auto, lang=...). The player takes the highest BANDWIDTH, which is a dub by a few bits per second. Details and numbers are in #601.

What this does

  • select_yt_web_manifest_language() in lib/airplay_video.c filters such a master playlist down to the variant streams of one audio language. It reuses strict_match_language() / match_language(), so the choice follows the app path: a strict match with the -lang list, else a match with the system languages, else the language marked acont=original. Variant streams with no language attributes are the original audio: they are kept with the original language, and are all that is kept when nothing is marked as the original. It returns NULL ("play unchanged") when there is nothing to choose, and for anything malformed (a variant without a URI line, relative URIs, more than 64 languages).
  • For plain URLs of the form https://[*.]googlevideo.com/.../manifest/hls_variant/..., uxplay.cpp fetches the playlist (video_renderer_fetch_text(): source element into appsink, 5 s overall limit, 4 MiB cap), filters it, writes the copy to a 0600 file in the user runtime directory and gives playbin its file:// URI. The file is removed at exit. The fetch runs in the main thread before the renderer is rebuilt, not in on_video_play(), which runs in the httpd thread.
  • If the fetch fails or times out, or the filter returns NULL, the URL is played unchanged, as now. Other plain URLs are not touched.
  • README: one paragraph under -lang.

About #581 / hlspi

#581 adds a pad probe for master playlists that GStreamer fetches itself. That is a better place for this than a second fetch and a temporary file: the language filter could be one more call inside the probe. It is not in master, so this PR is self-contained against master. If hlspi is going to land, I would rather redo the wiring on top of the probe and drop video_renderer_fetch_text() and the temp file; the filter function and its test would carry over as they are. That is why this is a draft: tell me which base you want.

Tests

The second commit adds tests/test_yt_web_manifest.c (30 cases on synthetic playlists: language choice, one exact-output comparison, malformed input) as a target that is not built by default and not installed. It is a separate commit, so it is easy to drop.

Validation

  • Debian 13 (trixie), GStreamer 1.26.2, on arm64 (VM, software GL) and x86_64 (Intel, VA-API): builds with no new warnings; the test passes, also under ASan and UBSan.
  • A captured real manifest (54 variant streams, 8 languages plus untagged): English system languages keep 12 (6 en-US original + 6 untagged); -lang fr keeps the 6 fr-FR.
  • Which track actually plays, read from GST_DEBUG=hlsdemux2:6 and the duration in /playback-info: unpatched master fetches a dubbed variant with no option, bare -lang and -lang en; the patched build fetches the original with LANGUAGE=en_GB:en, and the French dub with -lang fr.
  • Real sender: Safari on macOS to the x86_64 host, two videos. Log: YouTube web manifest: available audio languages fr-FR,de-DE,en-US (original: en-US), selected: en-US, then normal playback.
  • A non-YouTube HLS URL plays as before, with no fetch. A googlevideo URL that cannot be fetched is played unchanged. A server that hangs or drip-feeds is given up on at the time limit.
  • The filter was also run over about 2.6 million mutated playlists under ASan and UBSan without a report.

Not tested / known gaps

  • macOS and Windows builds (I only have the CI for those).
  • Live-stream manifests.
  • A second /play that arrives while the first manifest is being fetched wins, as it should, but is played unfiltered.
  • No GLib main loop runs during the fetch (typically about 0.2 s, at most 5 s).
  • If a dubbed language ever has more than one content id, the choice between them is still by bandwidth.
  • uxplay.1 and the -h text are unchanged.

🤖 Generated with Claude Code

midzdotdev and others added 2 commits October 13, 2026 00:42
A YouTube web page (e.g. Safari's AirPlay button) sends POST /play with a
plain https Content-Location of a HLS master playlist, which is passed
unchanged to playbin.  That playlist has no EXT-X-MEDIA AUDIO renditions:
each EXT-X-STREAM-INF variant stream is listed once per audio language
(the original and its AI-dubbed versions), marked only by the
YouTube-private attributes YT-EXT-AUDIO-CONTENT-ID and YT-EXT-XTAGS.
The player picks the highest BANDWIDTH, which is often a dubbed language,
and the -lang / $LANGUAGE preferences are never consulted.

For urls https://[*.]googlevideo.com/.../manifest/hls_variant/..., UxPlay
now fetches the playlist, keeps only the variant streams of one language,
and plays the filtered copy from a file in the user runtime directory
(removed at exit).  The language is chosen as for YouTube app playlists:
a strict match with the -lang list, else a match with the system
languages, else the original language (acont=original in YT-EXT-XTAGS).
Variant streams with no language have the original audio: they are kept
only with the original language, and are all that is kept when nothing
else is marked as the original.  If the original language has several
content ids (e.g. an audio description), only the original one is kept.

The playlist is fetched in the main thread (not in on_video_play, which
runs in the httpd thread), with a 5 second limit.  If anything fails, the
playlist is malformed (a variant stream without a uri, relative uris, more
than 64 languages) or there is nothing to filter, the url is played
unchanged as before.  Other plain urls are not touched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
tests/test_yt_web_manifest.c checks select_yt_web_manifest_language() with
synthetic master playlists (language choice, exact output, malformed
input); it can also filter a playlist in a file.  Fetching the playlist
(errors, timeout) is not covered.  The target is not built by default and
is not installed:

    cmake --build [build_dir] --target test_yt_web_manifest

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@midzdotdev
midzdotdev marked this pull request as ready for review October 13, 2026 07:09
@fduncanh

fduncanh commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

So you have found (or got Claude to find?) how to intercept the m3u8 hls playlists that gstreamer fetches directly.

The simplified strategy we used with the playlists pushed by the client to UxPlay was to edit the playlists to filter out all languages (and subtitle languages) except the one we actually want to render. (one audio language, 0 or 1 subtitle language) This was because it was difficult to get gstreamer to choose the right one, so we don't offer it a choice. Are you trying to to something similar with the gstreamer playbin -downloaded playlists?

@fduncanh
fduncanh force-pushed the master branch 6 times, most recently from a0da077 to 517cbb1 Compare October 22, 2026 21:13
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