feat(downloads): a per-site failure catalog — Facebook's walls say why and what to do
A failed Facebook download read "Download failed": yt-dlp words the login wall (18+ reel, closed group) as "Cannot parse data", which no category matched. Measured on dev with the owner's cookie before writing a rule — the same text is the login wall without a cookie and a refused cookie with one. - errors.diagnose(msg, url, cookie_attached) tries a host-scoped catalog first (all-of markers, cookie state, link fragment) and falls back to the category. First entries: facebook.login_wall, facebook.cookie_rejected, facebook.group_post_video. The module doc is the recipe for the next entry. - download_jobs.error_detail (0084) + backfill of the stored Facebook parse failures that were unknown. - The pipeline's failure branch passes the link and whether the cookie went with it; resume and redownload clear code and detail (redownload never cleared error_code). - A job meeting an already-failed asset got the text but no code, so it read "Download failed" where the first job said why. It is diagnosed again, with the cookie state from credentials.is_authed_asset (now shared with the cookie gate); the redacted network text classifies back to network. - FE: failCopyKeys gives the row and the failure window one lookup — catalog copy, else the category. Copy in EN and HU; a contract test pins every backend entry to both locales.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""DB-backed tests for the download failure catalog (E11.5 S1) at the places a failure is WRITTEN.
|
||||
|
||||
`test_download_errors` pins which entry a message matches; these pin that every writer hands the
|
||||
matcher what it needs — the link and whether the user's cookie went with it — and that a stored entry
|
||||
never outlives the failure it describes:
|
||||
- the download child's failure branch (`pipeline._download`);
|
||||
- a second job meeting an already-failed asset (`worker._process_job`), which only has the text;
|
||||
- Retry / Re-download, which requeue the job;
|
||||
- the `0084` backfill of the rows that failed before the catalog existed.
|
||||
"""
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.downloads import credentials, formats, pipeline, service
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||
from app.worker import _process_job
|
||||
|
||||
_REEL = "https://www.facebook.com/reel/1736360027626346/?fs=e&fs=e"
|
||||
# Verbatim yt-dlp 2026.07.04, prod job 256.
|
||||
_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."
|
||||
)
|
||||
|
||||
|
||||
def _user(db, email):
|
||||
u = User(email=email)
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(
|
||||
user_id=u.id, max_bytes=10**12, max_concurrent=5, max_jobs=10**6, unlimited=True
|
||||
)
|
||||
)
|
||||
return u
|
||||
|
||||
|
||||
def _asset_and_job(db, user, *, sig, asset_status, job_status, error=None):
|
||||
a = MediaAsset(
|
||||
source_kind="url",
|
||||
source_ref=_REEL,
|
||||
format_sig=sig,
|
||||
status=asset_status,
|
||||
error=error,
|
||||
ref_count=1,
|
||||
)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
j = DownloadJob(
|
||||
user_id=user.id,
|
||||
asset_id=a.id,
|
||||
source_kind="url",
|
||||
source_ref=_REEL,
|
||||
status=job_status,
|
||||
queue_pos=0,
|
||||
profile_snapshot={},
|
||||
)
|
||||
db.add(j)
|
||||
db.commit()
|
||||
return a, j
|
||||
|
||||
|
||||
def _fail_the_child(monkeypatch, tmp_path, *, cookie: bool):
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||
cookiefile = tmp_path / "cookies.txt"
|
||||
cookiefile.write_text("# Netscape HTTP Cookie File\n")
|
||||
monkeypatch.setattr(
|
||||
pipeline.credentials,
|
||||
"resolve_cookiefile",
|
||||
lambda *a, **k: cookiefile if cookie else None,
|
||||
)
|
||||
|
||||
def extract(*a, **k):
|
||||
raise RuntimeError(_FB_PARSE)
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||
|
||||
|
||||
def test_the_download_child_diagnoses_with_the_link_and_no_cookie(db, monkeypatch, tmp_path):
|
||||
u = _user(db, "fb-nocookie@example.com")
|
||||
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
||||
_fail_the_child(monkeypatch, tmp_path, cookie=False)
|
||||
|
||||
pipeline._download(j.id, a.id, "url", _REEL, {}, u.id)
|
||||
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, j.id)
|
||||
assert (job.status, job.error_code, job.error_detail) == (
|
||||
"error",
|
||||
"login_required",
|
||||
"facebook.login_wall",
|
||||
)
|
||||
|
||||
|
||||
def test_the_download_child_knows_the_cookie_went_with_it(db, monkeypatch, tmp_path):
|
||||
# The same text with the user's cookie attached: telling them to add cookies would be wrong.
|
||||
u = _user(db, "fb-cookie@example.com")
|
||||
a, j = _asset_and_job(db, u, sig="private", asset_status="downloading", job_status="running")
|
||||
_fail_the_child(monkeypatch, tmp_path, cookie=True)
|
||||
|
||||
pipeline._download(j.id, a.id, "url", _REEL, {}, u.id)
|
||||
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, j.id)
|
||||
assert (job.error_code, job.error_detail) == ("login_required", "facebook.cookie_rejected")
|
||||
|
||||
|
||||
def test_a_job_meeting_a_failed_shared_asset_gets_the_same_explanation(db):
|
||||
# Before the catalog this path wrote the text and NO code: "Download failed" for the second
|
||||
# job, "Facebook sign-in required" for the first.
|
||||
u = _user(db, "fb-second@example.com")
|
||||
_, j = _asset_and_job(
|
||||
db, u, sig="shared", asset_status="error", job_status="running", error=_FB_PARSE
|
||||
)
|
||||
|
||||
_process_job(j.id)
|
||||
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, j.id)
|
||||
assert (job.status, job.error_code, job.error_detail) == (
|
||||
"error",
|
||||
"login_required",
|
||||
"facebook.login_wall",
|
||||
)
|
||||
|
||||
|
||||
def test_a_job_meeting_its_users_failed_private_asset_knows_the_cookie_was_used(db):
|
||||
u = _user(db, "fb-private@example.com")
|
||||
sig = credentials.authed_format_sig(formats.normalize({}), u.id, "facebook")
|
||||
_, j = _asset_and_job(
|
||||
db, u, sig=sig, asset_status="error", job_status="running", error=_FB_PARSE
|
||||
)
|
||||
|
||||
_process_job(j.id)
|
||||
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, j.id)
|
||||
assert (job.error_code, job.error_detail) == ("login_required", "facebook.cookie_rejected")
|
||||
|
||||
|
||||
def test_retry_and_redownload_drop_the_old_explanation(db):
|
||||
u = _user(db, "fb-requeue@example.com")
|
||||
for requeue in (service.resume, service.redownload):
|
||||
_, j = _asset_and_job(
|
||||
db, u, sig=f"s-{requeue.__name__}", asset_status="error", job_status="error"
|
||||
)
|
||||
j.error, j.error_code, j.error_detail = _FB_PARSE, "login_required", "facebook.login_wall"
|
||||
db.commit()
|
||||
|
||||
requeue(db, j)
|
||||
|
||||
db.refresh(j)
|
||||
assert (j.status, j.error_code, j.error_detail) == ("queued", None, None), requeue.__name__
|
||||
|
||||
|
||||
def test_0084_backfills_only_the_facebook_parse_failures_it_measured(db):
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0083_comfort_pins")
|
||||
upgraded = False
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
uid = conn.execute(
|
||||
text("INSERT INTO users (email) VALUES ('backfill@example.com') RETURNING id")
|
||||
).scalar_one()
|
||||
rows = {
|
||||
"fb_unknown": ("error", "unknown", _FB_PARSE),
|
||||
"fb_null": ("canceled", None, _FB_PARSE),
|
||||
"other_site": ("error", "unknown", _FB_PARSE.replace("[facebook]", "[vimeo]")),
|
||||
"fb_other_code": ("error", "network", _FB_PARSE),
|
||||
}
|
||||
ids = {}
|
||||
for name, (status, code, error) in rows.items():
|
||||
ids[name] = conn.execute(
|
||||
text(
|
||||
"INSERT INTO download_jobs (user_id, source_kind, source_ref, "
|
||||
"profile_snapshot, status, error_code, error) "
|
||||
"VALUES (:u, 'url', :ref, '{}', :s, :c, :e) RETURNING id"
|
||||
),
|
||||
{"u": uid, "ref": _REEL, "s": status, "c": code, "e": error},
|
||||
).scalar_one()
|
||||
command.upgrade(cfg, "head")
|
||||
upgraded = True
|
||||
with engine.connect() as conn:
|
||||
got = {
|
||||
name: tuple(
|
||||
conn.execute(
|
||||
text("SELECT error_code, error_detail FROM download_jobs WHERE id = :i"),
|
||||
{"i": i},
|
||||
).one()
|
||||
)
|
||||
for name, i in ids.items()
|
||||
}
|
||||
assert got == {
|
||||
"fb_unknown": ("login_required", "facebook.login_wall"),
|
||||
"fb_null": ("login_required", "facebook.login_wall"),
|
||||
"other_site": ("unknown", None),
|
||||
"fb_other_code": ("network", None),
|
||||
}
|
||||
finally:
|
||||
if not upgraded:
|
||||
command.upgrade(cfg, "head")
|
||||
Reference in New Issue
Block a user