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
37 changes: 23 additions & 14 deletions services/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,29 @@

log = logging.getLogger("loopify.media")

# The player_client fallback chain, as a CLI value for the streaming subprocess.
_PLAYER_CLIENTS = "default,android_vr,tv_embedded"
# YouTube player clients, tried in order. ONE definition — both the metadata
# options below and the streaming subprocess derive from this, because two
# separate lists silently drift and then playback and search disagree about
# which client to use.
#
# Ordered by measured behaviour, not by theory. Every client yt-dlp offers was
# tested against the same video from a residential IP:
#
# web_embedded works, ~3.2s <- fastest that works
# mweb works, ~9.3s <- reliable but slow, kept as a fallback
# tv_embedded bot-checked <- kept last: needs no JS runtime, so it is
# the only option on a host without Deno
# default / web / android_vr / tv / ios / android_music all bot-checked
#
# The previous chain was "default,android_vr,tv_embedded" — every entry of which
# is now bot-checked, so yt-dlp exhausted it and 5 of 6 tracks failed to load.
#
# A residential IP reduces YouTube's bot-checking but does NOT remove it. Valid
# cookies (COOKIES_PATH) still help and remain supported; getting the client
# chain right avoids needing them at all.
_PLAYER_CLIENTS = ("web_embedded", "mweb", "tv_embedded")

# Base yt-dlp config shared by every call.
#
# player_client fallback chain, tried in order:
# * "default" (web) gives clean audio-only formats and works best WITH cookies,
# but needs a JS runtime (Deno/Node) to solve YouTube's signature challenge —
# deploy installs Deno for exactly this (see deploy/setup.sh).
# * "android_vr"/"tv_embedded" need no JS runtime and are the fallback for
# environments without one (e.g. a dev box). yt-dlp automatically advances to
# the next client if one yields no usable formats.
# From a datacenter IP, valid cookies (COOKIES_PATH) are what defeats YouTube's
# "confirm you're not a bot" check — see deploy/README.md.
YTDL_OPTIONS = {
"format": "bestaudio/best",
"noplaylist": True,
Expand All @@ -53,7 +62,7 @@
"source_address": "0.0.0.0", # bind to IPv4; avoids some 403s
"skip_download": True,
"extractor_args": {
"youtube": {"player_client": ["default", "android_vr", "tv_embedded"]},
"youtube": {"player_client": list(_PLAYER_CLIENTS)},
},
}

Expand Down Expand Up @@ -321,7 +330,7 @@ def spawn_stream(track: dict) -> AudioStream:
"-f", "bestaudio/best",
"-o", "-", # write audio to stdout
"-q", "--no-warnings", "--no-playlist",
"--extractor-args", f"youtube:player_client={_PLAYER_CLIENTS}",
"--extractor-args", f"youtube:player_client={','.join(_PLAYER_CLIENTS)}",
"--source-address", "0.0.0.0",
]
cookies = YTDL_OPTIONS.get("cookiefile")
Expand Down
54 changes: 54 additions & 0 deletions tests/test_media_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,57 @@ def test_first_entry_passes_through_a_single_result():
# Process teardown and failure classification moved to AudioStream when the
# stderr-deadlock fix landed; they are covered against real subprocesses in
# tests/test_audio_stream.py.


# -- YouTube player clients --------------------------------------------

def test_the_two_client_usages_cannot_drift():
"""
The chain is used twice: as a list for metadata extraction and as a
comma-joined string for the streaming subprocess. When those were separate
literals they drifted, and search and playback disagreed about which client
to use.
"""
from_options = media.YTDL_OPTIONS["extractor_args"]["youtube"]["player_client"]
assert list(media._PLAYER_CLIENTS) == list(from_options)


def test_spawn_stream_passes_the_same_chain(monkeypatch):
captured = {}
monkeypatch.setattr(media.AudioStream, "launch", classmethod(
lambda cls, cmd: captured.setdefault("cmd", cmd)
))
media.spawn_stream({"url": "https://example.invalid/x", "title": "T"})
cmd = captured["cmd"]
arg = cmd[cmd.index("--extractor-args") + 1]
assert arg == f"youtube:player_client={','.join(media._PLAYER_CLIENTS)}"


def test_a_working_client_comes_first():
"""
Order is latency: yt-dlp tries each client in turn, so a blocked client at
the front costs a full round trip before anything can play. web_embedded
measured 3.2s against mweb's 9.3s, both succeeding 6/6.
"""
assert media._PLAYER_CLIENTS[0] == "web_embedded"


@pytest.mark.parametrize("blocked", [
"default", "web", "android_vr", "tv", "ios", "android_music",
])
def test_clients_known_to_be_bot_checked_are_not_in_the_chain(blocked):
"""
Every one of these was measured returning "sign in to confirm you're not a
bot". The previous chain consisted entirely of such clients, which is why
5 of 6 tracks failed to load.
"""
assert blocked not in media._PLAYER_CLIENTS


def test_a_client_needing_no_js_runtime_remains_as_a_last_resort():
"""
web_embedded and mweb need a JS runtime (Deno) to solve the signature
challenge. tv_embedded does not, so it is the only thing that can work on a
host where Deno failed to install.
"""
assert "tv_embedded" in media._PLAYER_CLIENTS
Loading