feat(downloads): a Facebook cookie download stuck at "Cannot parse data" is retried as Chrome

Measured on dev: with the user's cookie some reels still answer "Cannot parse
data" and only extract when the request impersonates Chrome. Impersonation is
a second attempt behind that one error, only when a cookie is attached — links
that already download with the cookie keep their plain path, and without a
cookie the same error is the login wall.

curl_cffi is pinned inside yt-dlp's supported range (0.10-0.15; outside it
every target silently reports "unavailable"); the lock only gains the new
packages. A test fails the gate if the locked pair can no longer impersonate.
This commit is contained in:
2026-09-13 22:48:21 +02:00
parent 98ec1ffcab
commit 1354f5b503
6 changed files with 141 additions and 7 deletions
+25 -4
View File
@@ -13,6 +13,8 @@ Resolution works both before extraction (from source_kind / URL host) and after
from urllib.parse import urlparse
from yt_dlp.networking.impersonate import ImpersonateTarget
from app.config import settings
@@ -22,9 +24,10 @@ class SourceAdapter:
key = "generic"
# --- extraction (worker._extract_with_fallback / backfill) ---------------------------------
def client_sets(self) -> list:
"""Player-client sets to attempt, in order. A generic source needs no client override, so a
single default attempt (`[None]`) — the per-client fallback is a YouTube-only concept."""
def client_sets(self, authed: bool = False) -> list:
"""Attempts to make, in order — each entry is handed back to `ydl_opts_extra`, and the next one
is tried when `retriable` says so. A generic source makes a single default attempt (`[None]`).
`authed` is whether the acting user's cookie file goes with the download."""
return [None]
def ydl_opts_extra(self, clients) -> dict:
@@ -67,7 +70,7 @@ class YouTubeAdapter(SourceAdapter):
"Sign in to confirm",
)
def client_sets(self) -> list:
def client_sets(self, authed: bool = False) -> list:
return [
c for c in (settings.player_client_list, settings.player_client_fallback_list) if c
] or [None]
@@ -93,6 +96,24 @@ class YouTubeAdapter(SourceAdapter):
class FacebookAdapter(SourceAdapter):
key = "facebook"
# Measured (E11.5, yt-dlp 2026.07.04): with the user's cookie, some reels answer a plain request
# with "Cannot parse data" and parse only when the request looks like Chrome. Impersonation is a
# SECOND attempt behind that one error, not the default: the links that already download with the
# cookie keep their proven path, and only the ones stuck at the wall go through curl_cffi. Without
# a cookie the same error is the login wall, which no disguise gets past.
_IMPERSONATE = "impersonate"
_WALL = "cannot parse data"
def client_sets(self, authed: bool = False) -> list:
return [None, self._IMPERSONATE] if authed else [None]
def ydl_opts_extra(self, clients) -> dict:
if clients == self._IMPERSONATE:
return {"impersonate": ImpersonateTarget.from_str("chrome")}
return {}
def retriable(self, error_text: str) -> bool:
return self._WALL in error_text.lower()
def uploader_url(self, info: dict) -> str | None:
# Facebook gives neither channel_url nor uploader_url, but exposes a numeric `uploader_id`
+3 -2
View File
@@ -322,13 +322,14 @@ def _extract_with_fallback(
):
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
error); other sources make a single attempt. Returns the info dict of the successful attempt.
error); a Facebook download WITH a cookie is retried as Chrome behind "Cannot parse data"; other
sources make a single attempt. Returns the info dict of the successful attempt.
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
client_sets = adapter.client_sets()
client_sets = adapter.client_sets(authed=cookiefile is not None)
outtmpl = str(staging / "%(id)s.%(ext)s")
last_exc: Exception | None = None
for i, clients in enumerate(client_sets):