fix(downloads): E11.5 epic-end review round 1

A refused Facebook cookie on a group post is no longer "no video in this post":
measured on dev, Facebook answers with its login page (no post id at all), so the
adapter now raises its own POST_NOT_SHOWN verdict and the catalog explains it as
the refused cookie. The adapter owns both post verdicts and the "cannot parse
data" wall wording, so the Chrome retry and the catalog read one constant. The
group-post "no formats" entry is cookie-scoped (with a cookie yt-dlp only gets the
post's video link) and uses the adapter's own group-post test instead of a
"/groups/" substring.

Job transitions that move an asset's ref count (resume, cancel, delete,
redownload) re-read the job under a row lock, asset first, so two overlapping
requests can no longer release the same hold. get_or_create_asset loses an insert
race inside a SAVEPOINT instead of rolling back the caller's pending writes (Retry
lost its cleared error). Every worker-side job failure goes through
finalize_if_running and writes error_detail, so a cancel that landed first is kept
and a failure never shows an earlier one's copy. The worker asks the download
child's own cookie gate (credentials.usable_cookie) when a job inherits a failed
asset.

UI: the Facebook copy no longer points at a Retry button the failure window does
not have, says "paste" like Settings, and the HU wording is fixed
("Újraletöltés" on the re-download confirm). A category this client has no copy
for falls back to the generic sentence instead of a raw key.

Tests: route serialization of error_detail, the probed duration through a real
_download, the page-read cap and a failed read, a Retry off a pending asset, the
confirm-button labels, the lock and savepoint races.
This commit is contained in:
2026-09-14 00:50:05 +02:00
parent b17d92d7c2
commit e887233210
20 changed files with 610 additions and 112 deletions
+31 -11
View File
@@ -19,6 +19,14 @@ from yt_dlp.networking.impersonate import ImpersonateTarget
from app.config import settings
# Facebook's "Cannot parse data": the page yt-dlp got was a wall, not the content. Both the Chrome retry
# below and the failure catalog (errors.py) decide on it, so the wording exists once.
FACEBOOK_PARSE_WALL = "cannot parse data"
# A source's own verdicts on a link, raised from `resolve_url`. errors.py classifies them; it imports
# them from here because this module must not import errors (errors → credentials → adapters).
POST_WITHOUT_VIDEO_MESSAGE = "No single video was found in this post."
POST_NOT_SHOWN_MESSAGE = "Facebook did not show this post to the signed-in session."
class SourceAdapter:
"""Generic source: yt-dlp handles everything and the ref is already a fetchable URL."""
@@ -26,10 +34,11 @@ class SourceAdapter:
key = "generic"
# --- extraction (worker._extract_with_fallback / backfill) ---------------------------------
def resolve_url(self, url: str, ydl_opts: dict) -> str | None:
"""The link to hand yt-dlp for `url`, resolved BEFORE the first attempt, or None when the source
itself shows there is no single video to download there. `ydl_opts` are the download's own
options (cookie, timeouts, address family) for any page this needs to read. Generic: `url`."""
def resolve_url(self, url: str, ydl_opts: dict) -> str:
"""The link to hand yt-dlp for `url`, resolved BEFORE the first attempt. Raises a RuntimeError
carrying one of this module's verdicts when the source itself shows there is no single video to
download there. `ydl_opts` are the download's own options (cookie, timeouts, address family) for
any page this needs to read. Generic: `url`."""
return url
def client_sets(self, authed: bool = False) -> list:
@@ -110,11 +119,10 @@ class FacebookAdapter(SourceAdapter):
# 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"
# A post page is ~4–5 MB of inline JSON; anything far past that is not a post page.
_POST_PAGE_MAX_BYTES = 16 * 1024 * 1024
def resolve_url(self, url: str, ydl_opts: dict) -> str | None:
def resolve_url(self, url: str, ydl_opts: dict) -> str:
# Measured (E11.5 S4): given a group post's link, yt-dlp either finds no formats or — worse —
# downloads ANOTHER video from the page's feed data. The post's own video is the one Video node
# in the page scripts that carry the post id; its watch link downloads (plain first, then the
@@ -124,9 +132,16 @@ class FacebookAdapter(SourceAdapter):
return url
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
raw = ydl.urlopen(url).read(self._POST_PAGE_MAX_BYTES)
video_id = facebook_post_video_id(raw.decode("utf-8", "replace"), post_id)
# Never fall back to the post link: that is exactly where the wrong video comes from.
return f"https://www.facebook.com/watch/?v={video_id}" if video_id else None
page = raw.decode("utf-8", "replace")
# Measured (E11.5 review): a refused or expired cookie gets the login page (~450 KB, a login
# form, no post id at all), which is not a post without a video — the user's cookie is the cause.
if _quoted(post_id) not in page:
raise RuntimeError(POST_NOT_SHOWN_MESSAGE)
video_id = facebook_post_video_id(page, post_id)
if video_id is None:
# Never fall back to the post link: that is exactly where the wrong video comes from.
raise RuntimeError(POST_WITHOUT_VIDEO_MESSAGE)
return f"https://www.facebook.com/watch/?v={video_id}"
def client_sets(self, authed: bool = False) -> list:
return [None, self._IMPERSONATE] if authed else [None]
@@ -137,7 +152,7 @@ class FacebookAdapter(SourceAdapter):
return {}
def retriable(self, error_text: str) -> bool:
return self._WALL in error_text.lower()
return FACEBOOK_PARSE_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`
@@ -157,6 +172,11 @@ _SCRIPT_BLOCK = re.compile(r"<script[^>]*>(.*?)</script>", re.S)
_VIDEO_NODE = re.compile(r'"__typename":"Video","id":"(\d+)"')
def _quoted(post_id: str) -> str:
# How the page JSON carries the id: quoted, so a longer id that merely starts with it never counts.
return f'"{post_id}"'
def facebook_group_post_id(url: str) -> str | None:
"""The post id of a Facebook group post link (`/groups/<group>/permalink|posts/<id>/`), else None."""
parsed = urlparse(url)
@@ -170,7 +190,7 @@ def facebook_post_video_id(page: str, post_id: str) -> str | None:
"""The id of the one video attached to `post_id`, read from its page: the Video nodes of the script
blocks that carry the post id (the page's other ~10 videos — feed, reels — sit in other blocks).
None for no video AND for more than one: guessing between two would be the wrong-video bug again."""
quoted = f'"{post_id}"'
quoted = _quoted(post_id)
ids = {
vid
for block in _SCRIPT_BLOCK.findall(page)
+20 -10
View File
@@ -224,15 +224,12 @@ def status_list(db: Session, user_id: int) -> list[dict]:
# --- worker: resolve + write the cookie file --------------------------------------------------
def resolve_cookiefile(
db: Session,
user_id: int,
url: str | None,
asset: MediaAsset,
spec: dict,
staging: Path,
) -> Path | None:
"""In the worker child: the cookies.txt to hand yt-dlp for this download, or None.
def usable_cookie(
db: Session, user_id: int, url: str | None, asset: MediaAsset, spec: dict
) -> str | None:
"""The user's cookie text a download of `url` into `asset` goes with, or None — the ONE decision
whether a download is signed in. The worker child writes it out for yt-dlp (`resolve_cookiefile`);
the worker asks it again for a job that inherits a failed asset, so both jobs are told the same.
The double gate that prevents cross-user leakage:
1. a credential must still exist for `url`'s host (host-scoped via `host_key_for_url`), AND
@@ -260,7 +257,20 @@ def resolve_cookiefile(
host_key,
)
return None
return _write_cookiefile(raw, staging)
return raw
def resolve_cookiefile(
db: Session,
user_id: int,
url: str | None,
asset: MediaAsset,
spec: dict,
staging: Path,
) -> Path | None:
"""In the worker child: the cookies.txt to hand yt-dlp for this download (`usable_cookie`), or None."""
raw = usable_cookie(db, user_id, url, asset, spec)
return _write_cookiefile(raw, staging) if raw else None
def _write_cookiefile(raw: str, staging: Path) -> Path:
+37 -16
View File
@@ -16,16 +16,23 @@ its own sentence for — why it failed on that site, and what the user can do. `
catalog first and falls back to the category. How an entry is added, every time a failure is reported:
1. reproduce it on dev (with and without the user's cookie) and keep the verbatim yt-dlp text;
2. add the entry below, scoped as narrowly as the measurement allows (a marker that is yt-dlp's
generic wording for every site must never match without its host);
generic wording for every site must never match without its host; a marker another module also
decides on — the Facebook Chrome retry's FACEBOOK_PARSE_WALL — is imported, never retyped);
3. pin the real text in `tests/test_download_errors.py`;
4. add `downloads.errors.fail.catalog.<detail>.{short,body}` in EN and HU (a contract test checks it;
the group is not called `detail`, which `actionErrors.contract` reads as a raw `HttpError.detail`).
"""
from collections.abc import Callable
from dataclasses import dataclass
from typing import NamedTuple
from urllib.parse import urlparse
from app.downloads.adapters import (
FACEBOOK_PARSE_WALL,
POST_NOT_SHOWN_MESSAGE,
POST_WITHOUT_VIDEO_MESSAGE,
facebook_group_post_id,
)
from app.downloads.credentials import host_key_for_url
# Worker-authored strings that this module owns because each is coupled to `_RULES`.
@@ -41,12 +48,12 @@ from app.downloads.credentials import host_key_for_url
# Other worker-raised texts are still inline literals at their raise sites. The ffmpeg ones and
# "produced no output file" have markers here; the urlguard refusal deliberately has none and
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
#
# A source adapter's verdicts on a link (`SourceAdapter.resolve_url`, E11.5 S4) are adapters.py's
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
# that gives the site's sentence.
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
# POST_WITHOUT_VIDEO_MESSAGE is raised by the worker when a source adapter read a post's own page and
# found no single video in it (`SourceAdapter.resolve_url` → None; a Facebook group post, E11.5 S4).
# Its rule gives the category back without a link; the catalog entry gives the site's sentence.
POST_WITHOUT_VIDEO_MESSAGE = "No single video was found in this post."
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
# case-insensitive on a substring, against the raw yt-dlp/ffmpeg message.
@@ -56,8 +63,10 @@ _RULES: tuple[tuple[str, str], ...] = (
("download storage is full", "quota_exceeded"),
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
# Our own verdict on a post link whose page holds no single video — our wording too.
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
# or the site answered the signed-in read with something that is not the post at all.
(POST_WITHOUT_VIDEO_MESSAGE.lower(), "format_unavailable"),
(POST_NOT_SHOWN_MESSAGE.lower(), "login_required"),
# Login / age / members / bot-check — the user must be signed in (we deliberately are not).
("sign in to confirm your age", "login_required"),
("confirm your age", "login_required"),
@@ -141,14 +150,15 @@ class Diagnosis(NamedTuple):
class _Entry:
"""One measured failure of one site. It matches when the download URL belongs to `host`
(`credentials.SUPPORTED_HOSTS`), every one of `markers` is in the message, the cookie state is
`cookie` (None: either), and — when `link` is given — one of those fragments is in the URL path."""
`cookie` (None: either), and — when `link` is given — it recognises the URL (a truthy result). A
link predicate is the adapter's own, so the catalog and the download agree on what a link is."""
host: str
markers: tuple[str, ...]
code: str
detail: str
cookie: bool | None = None
link: tuple[str, ...] = ()
link: Callable[[str], object] | None = None
_CATALOG: tuple[_Entry, ...] = (
@@ -158,7 +168,7 @@ _CATALOG: tuple[_Entry, ...] = (
# which is why the entry is scoped to Facebook and never becomes a general rule.
_Entry(
host="facebook",
markers=("[facebook]", "cannot parse data"),
markers=("[facebook]", FACEBOOK_PARSE_WALL),
code="login_required",
detail="facebook.login_wall",
cookie=False,
@@ -167,7 +177,16 @@ _CATALOG: tuple[_Entry, ...] = (
# impersonation (prod job 257). Telling this user to add cookies would be wrong — they have.
_Entry(
host="facebook",
markers=("[facebook]", "cannot parse data"),
markers=("[facebook]", FACEBOOK_PARSE_WALL),
code="login_required",
detail="facebook.cookie_rejected",
cookie=True,
),
# A group post read with a cookie that Facebook answered with its login page (measured with a
# refused cookie: no post id anywhere on the page) — the same refused cookie, found by the adapter.
_Entry(
host="facebook",
markers=(POST_NOT_SHOWN_MESSAGE.lower(),),
code="login_required",
detail="facebook.cookie_rejected",
cookie=True,
@@ -175,20 +194,23 @@ _CATALOG: tuple[_Entry, ...] = (
# A group post with no single video of its own. With a cookie the adapter reads the post's page
# and says so itself (POST_WITHOUT_VIDEO_MESSAGE); without one yt-dlp is given the post link and
# answers "No video formats found" (measured on prod jobs 66 and 145, whose videos are reshared
# clips). Only for group links: a reel with no formats is a different failure.
# clips). With a cookie yt-dlp only ever gets the post's VIDEO link, so the same text there is about
# that video, not the post — the category says it. Only for group post links (the adapter's own
# test of one): a reel with no formats is a different failure.
_Entry(
host="facebook",
markers=(POST_WITHOUT_VIDEO_MESSAGE.lower(),),
code="format_unavailable",
detail="facebook.group_post_video",
link=("/groups/",),
link=facebook_group_post_id,
),
_Entry(
host="facebook",
markers=("[facebook]", "no video formats found"),
code="format_unavailable",
detail="facebook.group_post_video",
link=("/groups/",),
cookie=False,
link=facebook_group_post_id,
),
)
@@ -214,13 +236,12 @@ def diagnose(
host = host_key_for_url(url) if msg and url else None
if host is not None:
low = msg.lower() # type: ignore[union-attr] — `host` is only set when `msg` is truthy
path = urlparse(url).path.lower()
for e in _CATALOG:
if (
e.host == host
and all(m in low for m in e.markers)
and (e.cookie is None or e.cookie == cookie_attached)
and (not e.link or any(fragment in path for fragment in e.link))
and (e.link is None or e.link(url)) # type: ignore[arg-type] — as `msg` above
):
return Diagnosis(e.code, e.detail)
return Diagnosis(classify(msg))
+2 -4
View File
@@ -326,8 +326,8 @@ def _extract_with_fallback(
sources make a single attempt. Returns the info dict of the successful attempt.
Before the first attempt the adapter may resolve `url` to the link that really holds the video (a
Facebook group post → its video's watch link); when it finds no single video there the job fails
with POST_WITHOUT_VIDEO_MESSAGE instead of letting yt-dlp guess.
Facebook group post → its video's watch link); when the source shows no single video there, the
adapter raises its own verdict (adapters.POST_*_MESSAGE) instead of letting yt-dlp guess.
`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.
@@ -347,8 +347,6 @@ def _extract_with_fallback(
max_bytes=max_bytes,
),
)
if target is None:
raise RuntimeError(dlerrors.POST_WITHOUT_VIDEO_MESSAGE)
last_exc: Exception | None = None
for i, clients in enumerate(client_sets):
opts = formats.build_ydl_opts(
+5 -3
View File
@@ -81,11 +81,13 @@ def get_or_create_asset(
status="pending",
ref_count=0,
)
db.add(asset)
try:
db.flush()
# A SAVEPOINT: losing the race undoes only this insert, never the caller's own pending writes
# (Retry clears the job's error before it gets here; a whole-session rollback threw that away).
with db.begin_nested():
db.add(asset)
db.flush()
except IntegrityError:
db.rollback()
asset = db.execute(stmt).scalar_one()
return asset