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." } } }