"""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: ": "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: " 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