From 2b56c5c2eb8ec56544f3ccdf8f4fec93ac20a188 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 16 Sep 2026 04:29:13 +0200 Subject: [PATCH] fix: name the error yt-dlp actually raises for a cookie-emptied client set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review round read yt-dlp's source and found the comment behind the previous commit's safety-net marker did not match it. Measured in the worker with a real account cookie and `player_client=android_vr`: yt-dlp logs `Skipping client "android_vr" since it does not support cookies`, drops it, and the request left with no client fails with "Requested format is not available" — not "No player clients have been requested". That raise sits EARLIER in yt-dlp, before the cookie-capability removal, so the cookie path can never reach it; and an unknown or renamed client is skipped with a warning and replaced by yt-dlp's defaults, so the other scenario the comment named cannot reach it either. So the marker bought nothing: the symptom that does occur was already retriable here before this branch. It is dropped, the comment now states what was measured, and the test asserts the real symptom instead of a message the code cannot produce. The cookie-capability filter itself is vindicated by the same measurement — without it every signed-in download would burn its first attempt on a client yt-dlp discards. Also from the round: an enqueue test for a `source_kind="youtube"` job, the one join nothing else made (a YouTube job stores a bare 11-char ref and the URL the cookie gate keys on is rebuilt by the adapter — both ends were pinned apart, the join was not, and a break there would stop every YouTube cookie silently while signed-out downloads kept working). And copy corrections: the group's only unhedged claim is hedged, "described the video" loses the pipeline vocabulary, and four Hungarian phrasings that read as translated English are rewritten. --- backend/app/downloads/adapters.py | 20 +++++++++----------- backend/tests/test_adapters.py | 14 +++++++++++--- backend/tests/test_download_credentials.py | 16 ++++++++++++++++ frontend/src/i18n/locales/en/downloads.json | 4 ++-- frontend/src/i18n/locales/hu/downloads.json | 8 ++++---- 5 files changed, 42 insertions(+), 20 deletions(-) diff --git a/backend/app/downloads/adapters.py b/backend/app/downloads/adapters.py index 5aead80d..3f0c4ca5 100644 --- a/backend/app/downloads/adapters.py +++ b/backend/app/downloads/adapters.py @@ -93,10 +93,6 @@ class YouTubeAdapter(SourceAdapter): "Requested format is not available", "Only images are available", "Sign in to confirm", - # A set yt-dlp emptied because none of its clients take cookies. `client_sets` filters those - # out below, so this is the safety net for a yt-dlp that renames a client or an admin who - # configures both knobs with cookie-incapable clients only. - "No player clients have been requested", # Measured 2026-09-16: a gated video whose EXTRACTION succeeds and whose media fetch is then # refused by the CDN ("unable to download video data: HTTP Error 403"). The wall moves # between the player response and the stream, so this is the same per-client flakiness one @@ -105,13 +101,15 @@ class YouTubeAdapter(SourceAdapter): # them, so the next client set is a real second chance. "unable to download video data", ) - # Measured against yt-dlp's own INNERTUBE_CLIENTS table (2026-09-16, on 2026.07.04 and - # 2026.08.19): with account cookies attached yt-dlp REMOVES every client whose SUPPORTS_COOKIES - # is False, and a set left empty raises "No player clients have been requested". Our primary set - # is android_vr — one of those — so a signed-in download would fail on the first attempt and - # never reach the POT-backed web fallback that the cookie exists for. Pinned by test_adapters - # against the installed yt-dlp, so a version bump that moves the table turns the gate red - # instead of silently un-authing every YouTube download. + # Measured in the worker against a REAL account cookie (2026-09-16, yt-dlp 2026.07.04): with + # `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support + # cookies`, drops it, and the request that is left with no client fails with "Requested format is + # not available" — which is already a retriable marker above, so the cost is a wasted attempt + # rather than a hard failure. Our primary set is android_vr ALONE, so without this filter every + # signed-in download would burn its first attempt that way before reaching the POT-backed web + # fallback the cookie exists for. The list is yt-dlp's own INNERTUBE_CLIENTS[...] + # ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump + # that moves the table turns the gate red instead of silently un-authing every YouTube download. _COOKIE_CAPABLE = frozenset( { "mweb", diff --git a/backend/tests/test_adapters.py b/backend/tests/test_adapters.py index a437d453..4aa6ca58 100644 --- a/backend/tests/test_adapters.py +++ b/backend/tests/test_adapters.py @@ -112,9 +112,17 @@ def test_client_sets_and_retriable_are_per_source(): assert SourceAdapter().retriable("Sign in to confirm you're not a bot") is False assert YouTubeAdapter().retriable("Sign in to confirm you're not a bot") is True assert YouTubeAdapter().retriable("some unrelated network blip") is False - # The empty-set error of a cookie-incapable client list (see _COOKIE_CAPABLE): retriable, so an - # admin who configures one still gets the next set tried instead of a hard failure. - assert YouTubeAdapter().retriable("ERROR: No player clients have been requested") is True + # A client list yt-dlp empties because none of its clients take cookies does NOT raise "No + # player clients have been requested" (that raise sits earlier in yt-dlp, before the + # cookie-capability removal). Measured with a real cookie: the emptied request fails with + # "Requested format is not available", which is retriable here already — so the wasted attempt + # is recoverable and `_COOKIE_CAPABLE` exists to avoid spending it at all. + assert ( + YouTubeAdapter().retriable( + "ERROR: [youtube] abc: Requested format is not available. Use --list-formats" + ) + is True + ) # The media fetch refused after a successful extraction: the format URLs belong to the client # that minted them, so the next client set is a real second chance (it used to end the job). assert ( diff --git a/backend/tests/test_download_credentials.py b/backend/tests/test_download_credentials.py index 1b072dc0..3cc8af39 100644 --- a/backend/tests/test_download_credentials.py +++ b/backend/tests/test_download_credentials.py @@ -185,6 +185,22 @@ class TestCachePrivacy: asset = db.get(MediaAsset, job.asset_id) assert asset.format_sig == formats.format_sig(formats.normalize({})) + def test_a_youtube_job_reaches_the_gate_through_its_rebuilt_watch_url(self, db): + # The one join nothing else makes. A YouTube job stores a bare 11-char ref, and the URL the + # cookie gate keys on is REBUILT by the adapter (service.source_url -> YouTubeAdapter). Both + # ends are pinned apart — the watch-URL shape in test_adapters, the host_key in + # TestHostKeyForUrl — so this welds them: an enqueue that ever handed the gate the raw ref + # would silently stop salting, every YouTube cookie would quietly stop being attached, and + # signed-out downloads would keep working, so nothing else here would go red. + u = _user(db) + credentials.upsert_credential(db, u.id, "youtube", COOKIES) + db.commit() + job = service.enqueue(db, u.id, "youtube", "VIDID12345_", {}) + asset = db.get(MediaAsset, job.asset_id) + spec = formats.normalize({}) + assert asset.format_sig == credentials.authed_format_sig(spec, u.id, "youtube") + assert asset.format_sig != formats.format_sig(spec) + def test_two_users_never_share_the_authed_asset(self, db): a = _user(db, "a@example.com") b = _user(db, "b@example.com") diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 8a517eba..1e750cba 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -308,7 +308,7 @@ "youtube": { "bot_wall": { "short": "YouTube sign-in required", - "body": "YouTube only serves this video to a signed-in viewer — it asks the downloader to confirm it isn't a bot. Other YouTube videos are unaffected. Add your YouTube cookies under Settings → Account → Download authentication, then retry the download from the list." + "body": "YouTube only serves this video to a signed-in viewer — it asks the downloader to confirm it isn't a bot. Other YouTube videos are usually unaffected. Add your YouTube cookies under Settings → Account → Download authentication, then retry the download from the list." }, "cookie_rejected": { "short": "YouTube didn't accept the cookies", @@ -316,7 +316,7 @@ }, "media_forbidden": { "short": "YouTube refused the video stream", - "body": "YouTube described the video but then refused to hand over the file to a signed-out download. It comes and goes, so a retry from the list is worth one try — and adding your YouTube cookies under Settings → Account → Download authentication is what gets a stubborn one through." + "body": "YouTube found the video but refused to send the file to a signed-out download. It comes and goes, so a retry from the list is worth one try — and adding your YouTube cookies under Settings → Account → Download authentication is what gets a stubborn one through." } } } diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index b4ae4080..7c1ec57e 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -308,15 +308,15 @@ "youtube": { "bot_wall": { "short": "YouTube-bejelentkezés szükséges", - "body": "A YouTube ezt a videót csak bejelentkezett nézőnek adja ki — a letöltőtől azt kéri, igazolja, hogy nem robot. A többi YouTube-videót ez nem érinti. Illeszd be a YouTube-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést." + "body": "A YouTube ezt a videót csak bejelentkezett nézőnek adja ki — a letöltőprogramtól azt kéri, igazolja, hogy nem robot. A többi YouTube-videót ez általában nem érinti. Illeszd be a YouTube-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést." }, "cookie_rejected": { "short": "A YouTube nem fogadta el a cookie-kat", - "body": "A YouTube biztonsági okból cserélgeti az exportált cookie-kat, ezért a tiéid valószínűleg lejártak. Exportáld őket újra egy privát böngészőablakból, majd zárd be azt az ablakot további böngészés nélkül, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést." + "body": "A YouTube biztonsági okból lecseréli az exportált cookie-kat, ezért a tiéid valószínűleg lejártak. Exportáld őket újra egy privát böngészőablakból, majd zárd be azt az ablakot további böngészés nélkül, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést." }, "media_forbidden": { - "short": "A YouTube megtagadta a videó adatfolyamát", - "body": "A YouTube leírta a videót, de a fájlt már nem adta ki egy kijelentkezett letöltésnek. Ez jön-megy, ezért egy újrapróbálkozás a listából megér egy kísérletet — a makacs eseteken pedig a YouTube-cookie-k segítenek, a Beállítások → Fiók → Letöltés-hitelesítés alatt." + "short": "A YouTube nem adta ki a videófájlt", + "body": "A YouTube visszaadta a videó adatait, de a fájlt már nem adta ki bejelentkezés nélkül. Ez hol van, hol nincs, ezért érdemes újrapróbálni a listából — a makacs eseteken pedig a YouTube-cookie-k segítenek, a Beállítások → Fiók → Letöltés-hitelesítés alatt." } } }