A YouTube link can fail with "Sign in to confirm you're not a bot" while every other YouTube download works — YouTube gates INDIVIDUAL videos behind a signed-in session. Measured on prod job 273: all twelve of yt-dlp's player clients fail on that one video while its neighbours download fine, the bgutil PO-token sidecar is live and answering, and the newest yt-dlp fails identically. So the download is not broken by our stack or a stale yt-dlp, and no retry, client switch or upgrade gets past it. A cookie is the only way in. The failure copy already sends the user to Settings -> Account -> Download authentication, but YouTube was not in the cookie host registry, so that dropdown could not help them: the app asked for something it did not offer. credentials: YouTube joins SUPPORTED_HOSTS (youtube.com, youtu.be, youtube-nocookie.com). The Settings dropdown, the SEC-12 host scope and the private-asset cache salt all derive from the row, so that is the whole wiring. adapters: a signed-in YouTube download runs only cookie-capable player clients. Measured against yt-dlp's own INNERTUBE_CLIENTS table: with account cookies attached yt-dlp REMOVES every client whose SUPPORTS_COOKIES is False, and a set it empties that way raises "No player clients have been requested" — which is not a per-client flake it retries past. Our primary set is android_vr, one of those, so without this the cookie would have failed the download on attempt 1 and never reached the POT-backed web fallback it exists for. A test pins the client list against the installed yt-dlp so a version bump turns the gate red instead of silently un-authing every YouTube download, and the empty-set message joins the retriable markers as the second line of defence. errors: catalog entries for the wall with and without a cookie, so a user who has already added cookies is told they were rejected (YouTube rotates exported ones) rather than to add some. The marker is quote-agnostic: YouTube writes the apostrophe as U+2019, so "you're not a bot" with a straight quote never matches the real text. EN + HU copy.
213 lines
11 KiB
Python
213 lines
11 KiB
Python
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
|
||
|
||
from app.downloads import adapters
|
||
from app.downloads import errors as dlerrors
|
||
from app.downloads.errors import classify
|
||
|
||
|
||
def test_real_ytdlp_messages_map_to_categories():
|
||
# Verbatim-shaped yt-dlp / ffmpeg output → the category the UI explains.
|
||
cases = {
|
||
"ERROR: [youtube] xxxxxxxxxxx: Video unavailable": "unavailable",
|
||
"ERROR: [youtube] abc: Private video. Sign in if you've been granted access": "unavailable",
|
||
"ERROR: [youtube] abc: This video is no longer available because the uploader has closed their "
|
||
"account": "unavailable",
|
||
"ERROR: [youtube] abc: Sign in to confirm your age. This video may be inappropriate": "login_required",
|
||
"ERROR: [youtube] abc: Sign in to confirm you're not a bot": "login_required",
|
||
"ERROR: [youtube] abc: Join this channel to get access to members-only content": "login_required",
|
||
"ERROR: [Reddit] 1vi1x56: Account authentication is required.": "login_required",
|
||
# Closed-group / private-post auth walls: yt-dlp either says to sign in or suggests cookies.
|
||
"ERROR: [facebook] 123: You must be logged in to see this post": "login_required",
|
||
"ERROR: [facebook] 123: Login required. Use --cookies for the authentication.": "login_required",
|
||
"ERROR: [instagram] abc: This content is only available for registered users": "login_required",
|
||
"ERROR: [youtube] abc: Sign in to confirm... Use --cookies-from-browser or --cookies": "login_required",
|
||
# A site yt-dlp has no extractor for at all — distinct from an auth wall, still non-retryable.
|
||
"ERROR: Unsupported URL: https://example.com/whatever": "unsupported_host",
|
||
"ERROR: [youtube] abc: The uploader has not made this video available in your country": "geo_blocked",
|
||
"ERROR: [youtube] abc: Requested format is not available": "format_unavailable",
|
||
"ERROR: [youtube] abc: Only images are available for download": "format_unavailable",
|
||
"ERROR: [generic] This video is DRM protected": "drm",
|
||
"ffmpeg failed (1): Conversion failed!": "postprocess",
|
||
"The source download is no longer available.": "source_missing",
|
||
"The source file is missing.": "source_missing",
|
||
"ERROR: unable to download video data: HTTP Error 403: Forbidden": "network",
|
||
"ERROR: Unable to download webpage: <urlopen error timed out>": "network",
|
||
}
|
||
for msg, expected in cases.items():
|
||
assert classify(msg) == expected, f"{msg!r} → {classify(msg)!r}, expected {expected!r}"
|
||
|
||
|
||
def test_unmatched_and_empty_are_unknown():
|
||
assert classify("some totally novel failure text") == "unknown"
|
||
assert classify("") == "unknown"
|
||
assert classify(None) == "unknown"
|
||
|
||
|
||
def test_matching_is_case_insensitive():
|
||
assert classify("VIDEO UNAVAILABLE") == "unavailable"
|
||
assert classify("drm protected") == "drm"
|
||
|
||
|
||
def test_login_beats_generic_unavailable_when_both_present():
|
||
# A members-only video also reads as "unavailable" to a signed-out client; the sign-in cause is
|
||
# the more useful, more specific one, so it must win (rule order guards this).
|
||
msg = (
|
||
"ERROR: [youtube] abc: Join this channel to get access to members-only content. "
|
||
"This video is unavailable"
|
||
)
|
||
assert classify(msg) == "login_required"
|
||
|
||
|
||
def test_our_own_worker_refusals_classify_as_themselves():
|
||
# The worker raises these itself (not yt-dlp), and the rule that catches each one matches only a
|
||
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
|
||
# from silently orphaning the category — the UI would then fall back to "Download failed".
|
||
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
|
||
|
||
|
||
def test_the_redacted_network_text_classifies_back_to_network():
|
||
# The redacted text is also what a failed asset keeps, and a second job that meets that asset is
|
||
# diagnosed from it — without its own rule it would read "Download failed".
|
||
assert classify(dlerrors.NETWORK_REDACTED_MESSAGE) == "network"
|
||
|
||
|
||
# Verbatim yt-dlp 2026.07.04 output from prod jobs, measured on dev 2026-09-13 (E11.5).
|
||
_FB_PARSE = (
|
||
"ERROR: [facebook] 1736360027626346: Cannot parse data; please report this issue on "
|
||
"https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template."
|
||
)
|
||
_FB_NO_FORMATS = (
|
||
"ERROR: [facebook] 1755988225525547: No video formats found!; please report this issue on "
|
||
"https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template."
|
||
)
|
||
_REEL = "https://www.facebook.com/reel/1736360027626346/?fs=e&fs=e"
|
||
_GROUP_POST = "https://www.facebook.com/groups/983554479435596/permalink/1755988225525547/?"
|
||
|
||
|
||
# Verbatim from prod job 273 (2026-09-15). YouTube writes the apostrophe as U+2019, which is why
|
||
# neither the rule nor the catalog entry may spell out "you’re" with a straight quote.
|
||
_YT_BOT_WALL = (
|
||
"ERROR: [youtube] 7JPmS_bWjhI: Sign in to confirm you’re not a bot. Use "
|
||
"--cookies-from-browser or --cookies for the authentication. See "
|
||
"https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp for how to manually "
|
||
"pass cookies."
|
||
)
|
||
_YT = "https://www.youtube.com/watch?v=7JPmS_bWjhI"
|
||
|
||
|
||
def test_youtube_bot_wall_is_that_video_own_signed_out_gate():
|
||
# Measured on prod: all 12 player clients fail on this video while its neighbours download, the
|
||
# POT sidecar answers, and the newest yt-dlp fails identically — a cookie is the only way in.
|
||
assert classify(_YT_BOT_WALL) == "login_required"
|
||
for url in (
|
||
_YT,
|
||
"https://youtu.be/7JPmS_bWjhI",
|
||
"https://www.youtube-nocookie.com/embed/7JPmS_bWjhI",
|
||
"https://music.youtube.com/watch?v=7JPmS_bWjhI",
|
||
):
|
||
assert dlerrors.diagnose(_YT_BOT_WALL, url=url) == (
|
||
"login_required",
|
||
"youtube.bot_wall",
|
||
), url
|
||
|
||
|
||
def test_youtube_bot_wall_with_a_cookie_is_the_cookie_refused():
|
||
# Telling a user who already gave a cookie to add one is what this entry exists to avoid.
|
||
assert dlerrors.diagnose(_YT_BOT_WALL, url=_YT, cookie_attached=True) == (
|
||
"login_required",
|
||
"youtube.cookie_rejected",
|
||
)
|
||
|
||
|
||
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(
|
||
"ERROR: [facebook] 1: Sign in to confirm you’re not a bot",
|
||
url="https://www.facebook.com/reel/1/",
|
||
) == ("login_required", None)
|
||
|
||
|
||
def test_facebook_cannot_parse_data_is_the_login_wall_without_a_cookie():
|
||
for url in (
|
||
_REEL,
|
||
_GROUP_POST,
|
||
"https://fb.watch/Ia_iJYI2Vw/?fs=e",
|
||
"https://m.facebook.com/x",
|
||
):
|
||
assert dlerrors.diagnose(_FB_PARSE, url=url) == ("login_required", "facebook.login_wall")
|
||
|
||
|
||
def test_facebook_cannot_parse_data_with_a_cookie_is_the_cookie_refused():
|
||
# Telling a user who already gave a cookie to add one is the failure this entry exists to avoid.
|
||
assert dlerrors.diagnose(_FB_PARSE, url=_REEL, cookie_attached=True) == (
|
||
"login_required",
|
||
"facebook.cookie_rejected",
|
||
)
|
||
|
||
|
||
def test_a_facebook_group_post_without_formats_names_the_link():
|
||
# Without a cookie yt-dlp is given the post link itself (measured, prod jobs 66 and 145).
|
||
assert dlerrors.diagnose(_FB_NO_FORMATS, url=_GROUP_POST) == (
|
||
"format_unavailable",
|
||
"facebook.group_post_video",
|
||
)
|
||
# With one it is only ever given the post's VIDEO link, so the text is about that video: the adapter
|
||
# found it, and "this post has no video" would be false.
|
||
assert dlerrors.diagnose(_FB_NO_FORMATS, url=_GROUP_POST, cookie_attached=True) == (
|
||
"format_unavailable",
|
||
None,
|
||
)
|
||
# A reel, or a group link that is not a post, is another failure: only the category.
|
||
for url in (_REEL, "https://www.facebook.com/groups/983554479435596/"):
|
||
assert dlerrors.diagnose(_FB_NO_FORMATS, url=url) == ("format_unavailable", None), url
|
||
|
||
|
||
def test_our_post_not_shown_verdict_is_the_refused_cookie():
|
||
# The adapter's text when a signed-in read of the post got a page without the post (the login page).
|
||
msg = adapters.POST_NOT_SHOWN_MESSAGE
|
||
assert classify(msg) == "login_required"
|
||
assert dlerrors.diagnose(msg, url=_GROUP_POST, cookie_attached=True) == (
|
||
"login_required",
|
||
"facebook.cookie_rejected",
|
||
)
|
||
|
||
|
||
def test_our_post_without_a_video_verdict_names_the_group_link():
|
||
# The adapter's own text when the post page holds no single video (S4): the message and its rule and
|
||
# catalog entry are pinned together, or an edit to the wording orphans the copy.
|
||
msg = adapters.POST_WITHOUT_VIDEO_MESSAGE
|
||
assert classify(msg) == "format_unavailable"
|
||
assert dlerrors.diagnose(msg, url=_GROUP_POST, cookie_attached=True) == (
|
||
"format_unavailable",
|
||
"facebook.group_post_video",
|
||
)
|
||
assert dlerrors.diagnose(msg, url=_REEL, cookie_attached=True) == ("format_unavailable", None)
|
||
|
||
|
||
def test_a_catalog_entry_never_leaves_its_host():
|
||
# "Cannot parse data" is yt-dlp's wording for a broken extractor on every site.
|
||
other = _FB_PARSE.replace("[facebook]", "[vimeo]")
|
||
assert dlerrors.diagnose(other, url="https://vimeo.com/1") == ("unknown", None)
|
||
# The Facebook text for a link that is not Facebook's (a look-alike host) is not the Facebook wall.
|
||
assert dlerrors.diagnose(_FB_PARSE, url="https://facebook.com.evil/reel/1") == ("unknown", None)
|
||
# No link known: the category only.
|
||
assert dlerrors.diagnose(_FB_PARSE) == ("unknown", None)
|
||
|
||
|
||
def test_diagnose_falls_back_to_the_category():
|
||
msg = "ERROR: [facebook] 123: You must be logged in to see this post"
|
||
assert dlerrors.diagnose(msg, url=_REEL) == ("login_required", None)
|
||
assert dlerrors.diagnose(None, url=_REEL) == ("unknown", None)
|
||
|
||
|
||
def test_a_url_jobs_network_failure_is_redacted_but_youtube_keeps_its_text():
|
||
# SEC-12: the raw transport text for a user-chosen host:port is a port-scanner readout, so it is
|
||
# replaced for `url` jobs. Everything else must survive verbatim, or a real diagnosis is lost.
|
||
raw = "ERROR: Unable to download webpage: <urlopen error [Errno 111] Connection refused>"
|
||
assert dlerrors.user_error_text("url", raw, "network") == dlerrors.NETWORK_REDACTED_MESSAGE
|
||
# Same failure, but the destination was never user-chosen — nothing to leak, keep the detail.
|
||
assert dlerrors.user_error_text("youtube", raw, "network") == raw
|
||
# A user-chosen host, but a category that says something real about the CONTENT, not the network.
|
||
login = "ERROR: [facebook] 123: You must be logged in to see this post"
|
||
assert dlerrors.user_error_text("url", login, "login_required") == login
|