Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 \
Expand Down
15 changes: 15 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
77 changes: 71 additions & 6 deletions src/ts6_stream_bot/sources/youtube.py
Original file line number Diff line number Diff line change
@@ -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/<id>``) 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
Expand All @@ -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):
Expand All @@ -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)
Expand Down
34 changes: 33 additions & 1 deletion tests/test_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down
Loading