Measured on the isolated test stack the same night, on the very same video ~90 minutes after it was answering with the bot message: yt-dlp got the player response and picked its formats, and the CDN then refused the stream itself — "unable to download video data: HTTP Error 403: Forbidden". Signed out it did that on every attempt; with the owner's cookie attached the video downloaded in full. So the wall moves between the player response and the stream, and the text it wears there was costing the download twice. retriable: the format URLs belong to the client that minted them, so the next client set is a real second chance — but this text classifies as `network` and nothing in the YouTube marker list matched it, so the ladder ended the job on attempt 1 and never tried the fallback. catalog: `youtube.media_forbidden` for the cookie-less case, so the UI stops calling it a network error when nothing was wrong with the network, and names what actually gets past it. Scoped exactly as narrowly as the measurement: another host keeps the generic `network` category, and so does a download that already carried a cookie — telling a signed-in user to sign in is the failure the catalog exists to avoid. EN + HU copy.
236 lines
12 KiB
Python
236 lines
12 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",
|
||
)
|
||
|
||
|
||
# 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(
|
||
"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
|