From c5b0c5b126f274a7ec6fa7fbf873ad569f309d26 Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Sat, 22 Aug 2026 23:42:33 -0600 Subject: [PATCH] Use YouTube player clients that are not bot-checked Five of every six YouTube tracks failed to load. The error was "sign in to confirm you're not a bot", and it reads to a user as the bot being slow: a track fails, the bot reports it and skips, and they retry until one works. The chain was "default,android_vr,tv_embedded". Testing every client yt-dlp offers against the same video from the production host, all three of those are now bot-checked - so yt-dlp exhausted the chain and gave up. Only two clients still work: web_embedded 6/6 tracks, 3.2s mean mweb 6/6 tracks, 9.3s mean Order is latency, not preference: yt-dlp tries each client in turn, so a blocked client at the front costs a full round trip before anything plays. web_embedded goes first, mweb behind it, and tv_embedded stays last because it needs no JS runtime and is the only option on a host where Deno is missing. The list was also duplicated - once as a comma-joined string for the streaming subprocess, once as a list for metadata extraction - with nothing keeping them in sync. Both now derive from one definition, and a test asserts they agree. Verified against the real bot code on the host: 6/6, 4.09s mean. Also corrects an overstated claim in the previous comments: a residential IP reduces YouTube's bot-checking but does not eliminate it. Cookies remain supported; a correct client chain avoids needing them. Closes #24 --- services/media.py | 37 +++++++++++++++---------- tests/test_media_helpers.py | 54 +++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/services/media.py b/services/media.py index fd12076..2cd18e2 100644 --- a/services/media.py +++ b/services/media.py @@ -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, @@ -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)}, }, } @@ -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") diff --git a/tests/test_media_helpers.py b/tests/test_media_helpers.py index 38723b9..a899a91 100644 --- a/tests/test_media_helpers.py +++ b/tests/test_media_helpers.py @@ -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