From c781acd71aeec6c8f54fd161e787f52aa42c459a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 23:44:42 +0000 Subject: [PATCH] Hide browser chrome + skip YouTube cookie banner Operator after the join flow finally worked end to end: > man sah oben die nav bar vom browser und ein cookie Fenster > von yt ist aufgetaucht Two things our captured frame was carrying that shouldn't have been: 1. Chromium's own chrome (URL bar, tabs, menu) at the top. The browser launches with --kiosk, but Xvfb has no window manager in our container so the EWMH _NET_WM_STATE_FULLSCREEN hint that --kiosk relies on falls on the floor. Chromium opens at the configured --window-size with default chrome instead. Add fluxbox (~1 MB) and start it before the app: tiniest WM that honours kiosk hints, and the rest of our launch args now actually take effect. 2. YouTube's EU cookie-consent dialog covering the player on the first frame. We loaded the watch UI directly, which always serves the gate. Switch to the embed URL form ``youtube.com/embed/?autoplay=1&rel=0``: the embed player bypasses the consent dialog by design, has no header / sidebar / related-videos clutter, and autoplays from a query param so we don't depend on locator clicks. Belt-and-suspenders: also pre-seed the ``CONSENT=YES+`` cookie on .youtube.com / .google.com for the rare regions where the gate still triggers on embed. ts6-manager solves both by skipping the browser entirely (yt-dlp resolves the watch URL, then ffmpeg pulls the media stream directly). We can't take that shortcut because the project's contract is "any URL the operator hands the bot" - Twitch, browser games, arbitrary pages - so the browser is here to stay. These fixes make the browser-rendered output as clean as the direct one. Tests: * ``test_youtube_to_embed_url_extracts_id`` covers the URL forms that hit the controller in practice: watch?v=, youtu.be/, m.youtube.com, watch URLs with extra params (timestamps, lists), and inputs that are already embed URLs. * ``test_youtube_to_embed_url_passes_through_unrecognised`` guards the non-YouTube fallback path. --- docker/Dockerfile | 6 +++ docker/entrypoint.sh | 15 ++++++ src/ts6_stream_bot/sources/youtube.py | 77 ++++++++++++++++++++++++--- tests/test_sources.py | 34 +++++++++++- 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 72782de..a3816ca 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -11,6 +11,11 @@ ENV DEBIAN_FRONTEND=noninteractive \ # System dependencies: # - xvfb: virtual X display +# - fluxbox: minimal window manager. Without one, Chromium's --kiosk +# flag is silently ignored (it relies on EWMH _NET_WM_STATE_FULLSCREEN +# hints that no-WM Xvfb has no one to honour) and the captured frame +# carries the browser chrome at the top. fluxbox is ~1 MB and the +# smallest WM that handles kiosk hints correctly. # - pulseaudio + utils: virtual audio sink + monitor source + parec # - ffmpeg: x11grab + pulse capture, fed through PyAV/aiortc # - libsodium / libssl: needed by PyNaCl + cryptography (most wheels bundle @@ -19,6 +24,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ # - dbus, fonts, etc: chromium runtime needs RUN apt-get update && apt-get install -y --no-install-recommends \ xvfb \ + fluxbox \ pulseaudio \ pulseaudio-utils \ ffmpeg \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index fcc3c4d..affd823 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -22,6 +22,7 @@ PULSE_PIDS=( cleanup() { log "shutting down ..." + [[ -n "${FLUXBOX_PID:-}" ]] && kill "$FLUXBOX_PID" 2>/dev/null || true [[ -n "${XVFB_PID:-}" ]] && kill "$XVFB_PID" 2>/dev/null || true [[ -n "${PULSE_PID:-}" ]] && kill "$PULSE_PID" 2>/dev/null || true # Belt-and-suspenders: wipe leftovers so the next start doesn't trip on @@ -40,6 +41,7 @@ trap cleanup EXIT INT TERM # alive?" probe returns true and we get "Daemon already running". pkill -9 -x pulseaudio 2>/dev/null || true pkill -9 -x Xvfb 2>/dev/null || true +pkill -9 -x fluxbox 2>/dev/null || true sleep 0.2 rm -f "$XVFB_LOCK" "$XVFB_SOCKET" 2>/dev/null || true rm -f "${PULSE_PIDS[@]}" 2>/dev/null || true @@ -69,6 +71,19 @@ if [[ ! -S "$XVFB_SOCKET" ]]; then fi log "Xvfb ready" +# --- fluxbox window manager ----------------------------------------------- +# Without a WM, Chromium's --kiosk flag is silently dropped (it sends +# EWMH _NET_WM_STATE_FULLSCREEN and there's no one to interpret it), +# so the captured frame includes the browser chrome at the top. +# fluxbox is the smallest WM that handles kiosk hints correctly. +log "starting fluxbox" +fluxbox -display "$DISPLAY" >/dev/null 2>&1 & +FLUXBOX_PID=$! + +# Give it a moment to register as the WM. fluxbox doesn't expose a +# ready signal but registration is fast - 200 ms is plenty. +sleep 0.2 + # --- PulseAudio ------------------------------------------------------------ log "starting PulseAudio (per-container, no system mode)" # Use the bundled config which sets up the bot_sink and its monitor source diff --git a/src/ts6_stream_bot/sources/youtube.py b/src/ts6_stream_bot/sources/youtube.py index a759dc5..441cae0 100644 --- a/src/ts6_stream_bot/sources/youtube.py +++ b/src/ts6_stream_bot/sources/youtube.py @@ -1,7 +1,20 @@ """YouTube source. -Loads a video in the standard YouTube watch UI and drives play/pause/seek via -the IFrame Player API exposed on the page (window.movie_player or the HTML5 video). +Loads a video via YouTube's embed player URL (``/embed/``) instead +of the watch UI. The embed player has three big advantages for our +capture pipeline: + +* No header / search bar / sidebar - we're capturing the X11 + framebuffer, so anything outside the video frame ends up in the + viewer's stream. +* No EU cookie-consent dialog - that lives on the watch page, not + the embed. +* Stable autoplay via ``?autoplay=1`` query params; no need to + programmatically click around the YouTube watch UI. + +We still set the ``CONSENT`` cookie pre-emptively as a backup for +edge cases where YouTube serves the consent gate even on embeds +(rare but reported in some EU regions). """ from __future__ import annotations @@ -23,6 +36,32 @@ r"^https?://(?:www\.|m\.)?(?:youtube\.com/watch\?v=|youtu\.be/)", re.IGNORECASE, ) +# Used to pull a video id out of any of the URL forms YouTube uses. +# Matches: youtube.com/watch?v=ID (with optional extra params), +# youtu.be/ID, m.youtube.com/watch?v=ID, youtube.com/embed/ID. +_YOUTUBE_ID_PATTERN = re.compile( + r"(?:youtube\.com/(?:watch\?(?:[^&]*&)*v=|embed/)|youtu\.be/)([A-Za-z0-9_-]{11})", + re.IGNORECASE, +) + + +def _extract_video_id(url: str) -> str | None: + match = _YOUTUBE_ID_PATTERN.search(url) + return match.group(1) if match else None + + +def _to_embed_url(url: str) -> str: + """Rewrite a watch URL to the embed equivalent. If the input doesn't + look like a YouTube URL we recognise, fall back to returning it + unchanged - the caller will hit the same "no video element" error + it would have gotten anyway, just with a clearer breadcrumb.""" + video_id = _extract_video_id(url) + if video_id is None: + return url + # autoplay=1 lets us skip the click; mute=0 because we explicitly + # want audio (the bot routes it through PulseAudio); rel=0 stops + # YouTube from showing related-video thumbnails after playback. + return f"https://www.youtube.com/embed/{video_id}?autoplay=1&rel=0" class YoutubeSource(StreamSource): @@ -33,14 +72,40 @@ def can_handle(cls, url: str) -> bool: return bool(_YOUTUBE_HOST_PATTERN.match(url)) async def open(self, context: BrowserContext, url: str) -> None: - log.info("youtube.open", url=url) + embed_url = _to_embed_url(url) + log.info("youtube.open", original=url, embed=embed_url) + + # Pre-seed the CONSENT cookie for both YouTube and the parent + # google.com domain. The embed page normally bypasses the + # consent dialog entirely, but a few EU regions still gate it + # - this cookie skips the gate without a click. + with suppress(Exception): + await context.add_cookies( + [ + { + "name": "CONSENT", + "value": "YES+", + "domain": ".youtube.com", + "path": "/", + }, + { + "name": "CONSENT", + "value": "YES+", + "domain": ".google.com", + "path": "/", + }, + ] + ) + page = await context.new_page() self._page = page - await page.goto(url, wait_until="domcontentloaded") + await page.goto(embed_url, wait_until="domcontentloaded") - # Dismiss the cookie consent banner if present (EU). Banner is optional. + # Defence-in-depth: if a consent banner did slip through, click + # past it. The embed shouldn't show one but we're paying the + # cheap price of a 1-second timeout to catch the edge cases. with suppress(Exception): - await page.locator('button:has-text("Accept all")').first.click(timeout=3000) + await page.locator('button:has-text("Accept all")').first.click(timeout=1000) # Wait for the video element to exist await page.wait_for_selector("video", timeout=15000) diff --git a/tests/test_sources.py b/tests/test_sources.py index 760be93..2f2bd12 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -8,7 +8,7 @@ from ts6_stream_bot.sources.browser_url import BrowserUrlSource from ts6_stream_bot.sources.direct_file import DirectFileSource from ts6_stream_bot.sources.twitch import TwitchSource -from ts6_stream_bot.sources.youtube import YoutubeSource +from ts6_stream_bot.sources.youtube import YoutubeSource, _to_embed_url @pytest.mark.parametrize( @@ -37,6 +37,38 @@ def test_browser_url_source_is_last() -> None: assert SOURCES[-1] is BrowserUrlSource +@pytest.mark.parametrize( + "input_url,expected_id", + [ + # Standard watch URL + ("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"), + # Short youtu.be link + ("https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"), + # Mobile m.youtube.com + ("https://m.youtube.com/watch?v=abc123XYZ_-", "abc123XYZ_-"), + # Watch URL with extra params (timestamps, lists, etc.) + ("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s&list=RDxyz", "dQw4w9WgXcQ"), + # Already in embed form - leave the id intact + ("https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"), + ], +) +def test_youtube_to_embed_url_extracts_id(input_url: str, expected_id: str) -> None: + """The embed-URL rewrite must pull the right video id out of every + flavour of YouTube link the controller might be handed. Wrong id = + wrong video shipped to viewers.""" + embed = _to_embed_url(input_url) + assert f"/embed/{expected_id}" in embed + assert "autoplay=1" in embed + + +def test_youtube_to_embed_url_passes_through_unrecognised() -> None: + """If we can't parse a video id (someone passed a non-YouTube URL + via this code path by mistake), don't silently rewrite it to a + broken embed link.""" + weird = "https://example.com/notyoutube" + assert _to_embed_url(weird) == weird + + def test_browser_url_source_accepts_anything_http() -> None: assert BrowserUrlSource.can_handle("https://anything.example/") assert BrowserUrlSource.can_handle("http://10.0.0.1/path")