diff --git a/backend/app/downloads/adapters.py b/backend/app/downloads/adapters.py index 45682446..5aead80d 100644 --- a/backend/app/downloads/adapters.py +++ b/backend/app/downloads/adapters.py @@ -97,6 +97,13 @@ class YouTubeAdapter(SourceAdapter): # 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 + # step later — and it used to end the job on attempt 1, because the text classifies as + # `network` and nothing here matched it. The format URLs belong to the client that minted + # 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 diff --git a/backend/app/downloads/errors.py b/backend/app/downloads/errors.py index 48cae607..eaa57424 100644 --- a/backend/app/downloads/errors.py +++ b/backend/app/downloads/errors.py @@ -236,6 +236,19 @@ _CATALOG: tuple[_Entry, ...] = ( detail="youtube.cookie_rejected", cookie=True, ), + # The same gate one step later: the player response comes back, and then the CDN refuses the + # stream itself ("unable to download video data: HTTP Error 403"). Measured 2026-09-16 on the + # very same video, ~90 minutes after it was answering with the bot message — signed out it + # 403'd on every attempt; with the owner's cookie attached it downloaded in full (519 MB). So + # the category this text carries generically, `network`, is a lie here: nothing was wrong with + # the network. Scoped to a cookie-less YouTube download, where that is what we measured. + _Entry( + host="youtube", + markers=("unable to download video data", "403"), + code="login_required", + detail="youtube.media_forbidden", + cookie=False, + ), ) diff --git a/backend/tests/test_adapters.py b/backend/tests/test_adapters.py index 88880062..a437d453 100644 --- a/backend/tests/test_adapters.py +++ b/backend/tests/test_adapters.py @@ -115,6 +115,17 @@ def test_client_sets_and_retriable_are_per_source(): # 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 + # 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 ( + YouTubeAdapter().retriable( + "ERROR: unable to download video data: HTTP Error 403: Forbidden" + ) + is True + ) + assert ( + SourceAdapter().retriable("ERROR: unable to download video data: HTTP Error 403") is False + ) # Facebook: a second, Chrome-impersonating attempt only WITH the user's cookie, and only behind # the parse wall (without a cookie that wall is the login wall — no disguise gets past it). fb = FacebookAdapter() diff --git a/backend/tests/test_download_errors.py b/backend/tests/test_download_errors.py index e120c765..c97b55f2 100644 --- a/backend/tests/test_download_errors.py +++ b/backend/tests/test_download_errors.py @@ -119,6 +119,29 @@ def test_youtube_bot_wall_with_a_cookie_is_the_cookie_refused(): ) +# Verbatim from the isolated test stack (2026-09-16), the SAME video ~90 minutes later: yt-dlp got +# the player response and picked formats, and the CDN then refused the stream. +_YT_MEDIA_403 = "ERROR: unable to download video data: HTTP Error 403: Forbidden" + + +def test_youtube_media_403_is_the_wall_one_step_later_not_a_network_fault(): + # Generically this text is a network failure — and for every other host it stays one. + assert classify(_YT_MEDIA_403) == "network" + assert dlerrors.diagnose(_YT_MEDIA_403, url="https://vimeo.com/1") == ("network", None) + # On a signed-out YouTube download it is the gate: measured, the same video downloaded in full + # once the owner's cookie was attached. Calling that "network error" is the honest-states bug. + assert dlerrors.diagnose(_YT_MEDIA_403, url=_YT) == ( + "login_required", + "youtube.media_forbidden", + ) + + +def test_youtube_media_403_with_a_cookie_is_not_claimed_as_the_gate(): + # Only the cookie-less case was measured. With a cookie the same text keeps its bare category + # rather than telling a signed-in user to sign in. + assert dlerrors.diagnose(_YT_MEDIA_403, url=_YT, cookie_attached=True) == ("network", None) + + def test_the_youtube_entries_never_match_another_site(): # Host-scoped: the same words about a Facebook link stay the bare category. assert dlerrors.diagnose( diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index d566d29c..8a517eba 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -313,6 +313,10 @@ "cookie_rejected": { "short": "YouTube didn't accept the cookies", "body": "YouTube rotates exported cookies as a security measure, so yours have probably expired. Export them again from a private browsing window, close that window without browsing on, paste them in under Settings → Account → Download authentication, then retry the download from the list." + }, + "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." } } } diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 9c03a6b7..b4ae4080 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -313,6 +313,10 @@ "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." + }, + "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." } } }