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.
317 lines
12 KiB
Python
317 lines
12 KiB
Python
"""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;
|
|
- the downloads list, which is how the entry reaches the UI.
|
|
It also holds the one success-path check of the same child: the duration it stores (E11.5 S3).
|
|
"""
|
|
|
|
import shutil
|
|
import subprocess
|
|
|
|
import pytest
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from cryptography.fernet import Fernet
|
|
from sqlalchemy import text
|
|
|
|
from app import security
|
|
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."
|
|
)
|
|
_COOKIES = "# Netscape HTTP Cookie File\n.facebook.com\tTRUE\t/\tTRUE\t0\tc_user\t123\n"
|
|
|
|
|
|
@pytest.fixture
|
|
def _fernet(monkeypatch):
|
|
# A throwaway key, so a stored credential encrypts and decrypts (mirrors test_download_credentials).
|
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
|
|
|
|
|
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, _fernet):
|
|
u = _user(db, "fb-private@example.com")
|
|
credentials.upsert_credential(db, u.id, "facebook", _COOKIES)
|
|
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_a_private_asset_the_cookie_could_not_go_with_is_explained_as_the_download_had_it(db):
|
|
# The cookie was deleted (or can no longer be decrypted) after the jobs were queued: the download
|
|
# child had nothing to attach and said "add your cookies". The job meeting that failed asset must say
|
|
# the same — the asset's salt alone would have told it "your cookies were refused".
|
|
u = _user(db, "fb-gone@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.login_wall")
|
|
|
|
|
|
def test_a_job_canceled_before_it_meets_a_failed_asset_stays_canceled(db):
|
|
# The cancel landed between the claim and this write. `error` would hold the asset again after the
|
|
# route already released it — a later delete releases twice.
|
|
u = _user(db, "fb-canceled@example.com")
|
|
_, j = _asset_and_job(
|
|
db, u, sig="shared", asset_status="error", job_status="canceled", 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) == ("canceled", None, None)
|
|
|
|
|
|
def test_the_downloads_list_carries_the_catalog_entry(db, as_user):
|
|
# The whole explanation rides on this one field: without it every row falls back to the category.
|
|
u = _user(db, "fb-list@example.com")
|
|
_, j = _asset_and_job(
|
|
db, u, sig="shared", asset_status="error", job_status="error", error=_FB_PARSE
|
|
)
|
|
j.error_code, j.error_detail = "login_required", "facebook.login_wall"
|
|
db.commit()
|
|
|
|
[row] = as_user(u).get("/api/downloads").json()
|
|
|
|
assert (row["error_code"], row["error_detail"]) == ("login_required", "facebook.login_wall")
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
|
def test_a_download_yt_dlp_gave_no_duration_stores_the_files_duration(db, monkeypatch, tmp_path):
|
|
# Facebook reel 257 (E11.5): yt-dlp's info had no duration, the asset stored NULL, and the Library
|
|
# row lost its trim action. Through the real finalize, with a real 3-second file.
|
|
u = _user(db, "fb-duration@example.com")
|
|
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
|
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
|
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
|
|
|
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
|
media = staging / "1736360027626346.mp4"
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-y",
|
|
"-loglevel",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"testsrc=size=64x64:rate=10",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"sine=frequency=440",
|
|
"-t",
|
|
"3",
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-c:a",
|
|
"aac",
|
|
str(media),
|
|
],
|
|
check=True,
|
|
)
|
|
return {"id": "1736360027626346", "ext": "mp4", "title": "Reel", "filepath": str(media)}
|
|
|
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
|
|
|
pipeline._download(j.id, a.id, "url", _REEL, {}, u.id)
|
|
|
|
db.expire_all()
|
|
assert db.get(DownloadJob, j.id).status == "done"
|
|
assert db.get(MediaAsset, a.id).duration_s == 3
|
|
|
|
|
|
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")
|