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
+15 -2
View File
@@ -264,9 +264,22 @@ def _held_job(db, u, *, sig, asset_status="error", job_status="error"):
class TestRequeueTakesTheCookie:
spec = formats.normalize({})
def test_retry_after_adding_a_cookie_moves_the_job_to_the_private_asset(self, db, tmp_path):
@pytest.mark.parametrize(
("asset_status", "job_status"),
# A failed download; and a job paused before it ever started (its asset never downloaded).
[("error", "error"), ("pending", "paused")],
)
def test_retry_after_adding_a_cookie_moves_the_job_to_the_private_asset(
self, db, tmp_path, asset_status, job_status
):
u = _user(db)
shared, j = _held_job(db, u, sig=formats.format_sig(self.spec))
shared, j = _held_job(
db,
u,
sig=formats.format_sig(self.spec),
asset_status=asset_status,
job_status=job_status,
)
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
db.commit()
+21 -5
View File
@@ -1,5 +1,6 @@
"""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
@@ -102,21 +103,36 @@ def test_facebook_cannot_parse_data_with_a_cookie_is_the_cookie_refused():
def test_a_facebook_group_post_without_formats_names_the_link():
assert dlerrors.diagnose(_FB_NO_FORMATS, url=_GROUP_POST, cookie_attached=True) == (
# 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",
)
# A reel with no formats is another failure: only the category.
assert dlerrors.diagnose(_FB_NO_FORMATS, url=_REEL, cookie_attached=True) == (
# 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 worker's own text when the post page holds no single video (S4): the message and its rule and
# 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 = dlerrors.POST_WITHOUT_VIDEO_MESSAGE
msg = adapters.POST_WITHOUT_VIDEO_MESSAGE
assert classify(msg) == "format_unavailable"
assert dlerrors.diagnose(msg, url=_GROUP_POST, cookie_attached=True) == (
"format_unavailable",
+112 -2
View File
@@ -6,13 +6,21 @@ 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 `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
@@ -25,6 +33,13 @@ _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):
@@ -129,8 +144,9 @@ def test_a_job_meeting_a_failed_shared_asset_gets_the_same_explanation(db):
)
def test_a_job_meeting_its_users_failed_private_asset_knows_the_cookie_was_used(db):
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
@@ -143,6 +159,100 @@ def test_a_job_meeting_its_users_failed_private_asset_knows_the_cookie_was_used(
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):
+119
View File
@@ -0,0 +1,119 @@
"""Overlapping requests on ONE job (E11.5 review) — DB-backed, two real sessions.
The routes that move a job's asset ref count (resume, cancel, delete, redownload) used to decide on an
unlocked read: two overlapping requests for one job (a double-click, a second tab, a replay after a
gateway timeout) both saw it holding its asset and both released the hold. The count then reaches 0
while another job still holds the asset, and GC or the ready-file delete removes it under that job.
`_own_job_for_update` makes the second request wait and decide on the first one's committed result.
"""
import threading
import pytest
from fastapi import HTTPException
from app.db import SessionLocal
from app.downloads import service
from app.models import DownloadJob, MediaAsset, User
from app.routes.downloads import _own_job_for_update
_REF = "https://example.com/video"
def _asset_held_by_two_jobs(db):
mine, theirs = User(email="lock-mine@example.com"), User(email="lock-theirs@example.com")
db.add_all([mine, theirs])
db.flush()
asset = MediaAsset(
source_kind="url", source_ref=_REF, format_sig="shared", status="error", ref_count=2
)
db.add(asset)
db.flush()
jobs = [
DownloadJob(
user_id=owner.id,
asset_id=asset.id,
source_kind="url",
source_ref=_REF,
status="error",
queue_pos=0,
profile_snapshot={},
)
for owner in (mine, theirs)
]
db.add_all(jobs)
db.commit()
return mine, asset, jobs[0]
@pytest.mark.parametrize("transition", [service.cancel, service.delete])
def test_overlapping_requests_for_one_job_release_its_hold_once(db, transition):
user, asset, job = _asset_held_by_two_jobs(db)
first, second = SessionLocal(), SessionLocal()
finished = threading.Event()
def the_other_request():
try:
transition(second, _own_job_for_update(second, user, job.id))
except HTTPException:
pass # a delete after the delete: the job is gone, a 404
finally:
second.close()
finished.set()
try:
locked = _own_job_for_update(first, user, job.id)
other = threading.Thread(target=the_other_request)
other.start()
# The second request waits for the first one's locks instead of deciding on the same row.
assert not finished.wait(0.5), "the second request did not wait for the first"
transition(first, locked)
other.join(10)
assert finished.is_set()
finally:
first.close()
db.expire_all()
assert db.get(MediaAsset, asset.id).ref_count == 1 # the other user's job still holds it
def test_losing_the_asset_insert_race_keeps_the_callers_own_writes(db, monkeypatch):
# Retry clears the job's error, THEN may create the cookie asset. When another request inserted that
# asset first, the loser used to roll the whole session back — and requeue the job with its stale
# error, which a stall or crash later leaves showing under the new failure.
user = User(email="race@example.com")
db.add(user)
db.flush()
job = DownloadJob(
user_id=user.id,
source_kind="url",
source_ref=_REF,
status="error",
error="the old failure",
queue_pos=0,
profile_snapshot={},
)
db.add(job)
db.commit()
job.error = None # the caller's pending write
real_flush, raced = db.flush, []
def flush_after_the_other_request_inserted(*args, **kwargs):
if not raced: # the other request commits the same asset between our lookup and our insert
raced.append(True)
with SessionLocal() as other:
other.add(
MediaAsset(source_kind="url", source_ref=_REF, format_sig="raced", ref_count=0)
)
other.commit()
return real_flush(*args, **kwargs)
monkeypatch.setattr(db, "flush", flush_after_the_other_request_inserted)
asset = service.get_or_create_asset(db, "url", _REF, "raced")
db.commit()
assert raced and asset.format_sig == "raced"
db.expire_all()
assert db.get(DownloadJob, job.id).error is None
+40 -2
View File
@@ -15,6 +15,7 @@ page holds ~10 other videos), and its watch link downloads.
import pytest
import yt_dlp
from yt_dlp.networking.exceptions import TransportError
from yt_dlp.networking.impersonate import ImpersonateTarget
from app.downloads import adapters, pipeline
@@ -59,10 +60,11 @@ def test_the_locked_curl_cffi_can_impersonate_chrome():
@pytest.fixture
def ydl(monkeypatch):
"""Fake `yt_dlp.YoutubeDL`: records each attempt's options and plays back `outcomes` in order;
`web["page"]` is what a page read returns, and `web` records the pages opened and links extracted."""
`web["page"]` is what a page read returns (`web["fail"]`, when set, is raised instead), and `web`
records the pages opened, the byte cap each read asked for, and the links extracted."""
seen: list[dict] = []
outcomes: list = []
web: dict = {"page": "", "opened": [], "extracted": []}
web: dict = {"page": "", "fail": None, "opened": [], "reads": [], "extracted": []}
class FakeYDL:
def __init__(self, opts):
@@ -76,10 +78,13 @@ def ydl(monkeypatch):
def urlopen(self, url):
web["opened"].append(url)
if web["fail"] is not None:
raise web["fail"]
body = web["page"].encode()
class Response:
def read(self, amt=None):
web["reads"].append(amt)
return body
return Response()
@@ -145,6 +150,8 @@ def test_a_cookie_group_post_downloads_the_posts_own_video(ydl, tmp_path):
assert _run(tmp_path, "/cookies.txt", _GROUP_LINK) == {"id": _POST_VIDEO}
assert web["opened"] == [_GROUP_LINK]
# Never an unbounded read.
assert web["reads"] == [adapters.FacebookAdapter._POST_PAGE_MAX_BYTES]
assert seen[0]["cookiefile"] == "/cookies.txt" # the post page is read signed in
watch = f"https://www.facebook.com/watch/?v={_POST_VIDEO}"
# Never the post link: that is where the feed video comes from.
@@ -166,6 +173,37 @@ def test_a_group_post_without_a_single_video_fails_saying_so(ydl, tmp_path):
)
def test_a_group_post_page_without_the_post_is_the_refused_cookie(ydl, tmp_path):
# Measured with a refused cookie (E11.5 review): Facebook answers the post link with its login page —
# a login form, no post id anywhere. Saying "this post has no video" would blame the post.
_, _, web = ydl
web["page"] = '<html><title>ChatGPT magyarul</title><form id="login_form"></form></html>'
with pytest.raises(RuntimeError) as exc:
_run(tmp_path, "/cookies.txt", _GROUP_LINK)
assert web["extracted"] == []
assert dlerrors.diagnose(str(exc.value), url=_GROUP_LINK, cookie_attached=True) == (
"login_required",
"facebook.cookie_rejected",
)
def test_a_failed_post_page_read_fails_the_download_as_itself(ydl, tmp_path):
# A read that failed says nothing about the post: it must not turn into "no video in this post".
_, _, web = ydl
web["fail"] = TransportError("Connection reset by peer")
with pytest.raises(TransportError) as exc:
_run(tmp_path, "/cookies.txt", _GROUP_LINK)
assert web["extracted"] == []
assert dlerrors.diagnose(str(exc.value), url=_GROUP_LINK, cookie_attached=True) == (
"network",
None,
)
def test_without_a_cookie_the_group_post_link_goes_to_ytdlp_unchanged(ydl, tmp_path):
_, outcomes, web = ydl
outcomes += [_NO_FORMATS]
+14 -15
View File
@@ -51,7 +51,7 @@ def rec(monkeypatch):
monkeypatch.setattr(worker, "_reset_asset_pending", record("reset_asset"))
monkeypatch.setattr(worker, "_requeue_if_running", record("requeue"))
monkeypatch.setattr(worker, "_set_job", record("set_job"))
monkeypatch.setattr(worker, "_fail_running", record("fail"))
monkeypatch.setattr(worker, "_cleanup_staging", record("cleanup"))
monkeypatch.setattr(worker, "_stop_child", record("stop_child"))
monkeypatch.setattr(worker._stop, "is_set", lambda: False)
@@ -84,9 +84,9 @@ def test_no_progress_stall_kills_and_fails_the_job(monkeypatch, rec):
worker._run_supervised(1, 99)
names = _names(rec)
assert "stop_child" in names and "reset_asset" in names
set_job = next(c for c in rec if c[0] == "set_job")
assert set_job[2]["status"] == "error" and set_job[2]["error_code"] == "network"
assert "stalled" in set_job[2]["error"].lower()
fail = next(c for c in rec if c[0] == "fail")
assert fail[2]["error_code"] == "network"
assert "stalled" in fail[2]["error"].lower()
def test_route_pause_kills_but_keeps_the_route_status(monkeypatch, rec):
@@ -97,7 +97,7 @@ def test_route_pause_kills_but_keeps_the_route_status(monkeypatch, rec):
worker._run_supervised(1, 99)
names = _names(rec)
assert "stop_child" in names and "reset_asset" in names
assert "set_job" not in names and "requeue" not in names
assert "fail" not in names and "requeue" not in names
def test_route_cancel_kills_but_keeps_the_route_status(monkeypatch, rec):
@@ -110,7 +110,7 @@ def test_route_cancel_kills_but_keeps_the_route_status(monkeypatch, rec):
worker._run_supervised(1, 99)
names = _names(rec)
assert "stop_child" in names and "reset_asset" in names
assert "set_job" not in names and "requeue" not in names
assert "fail" not in names and "requeue" not in names
def test_wall_clock_cap_kills_even_while_progressing(monkeypatch, rec):
@@ -118,9 +118,8 @@ def test_wall_clock_cap_kills_even_while_progressing(monkeypatch, rec):
_spawn(monkeypatch, _FakeProc(["timeout"]))
_liveness(monkeypatch, ("running", 1.0)) # fresh progress, but over the absolute cap
worker._run_supervised(1, 99)
set_job = next(c for c in rec if c[0] == "set_job")
assert set_job[2]["status"] == "error"
assert "time limit" in set_job[2]["error"].lower()
fail = next(c for c in rec if c[0] == "fail")
assert "time limit" in fail[2]["error"].lower()
def test_child_crash_without_finishing_is_failed(monkeypatch, rec):
@@ -130,8 +129,8 @@ def test_child_crash_without_finishing_is_failed(monkeypatch, rec):
worker._run_supervised(1, 99)
names = _names(rec)
assert "reset_asset" in names
set_job = next(c for c in rec if c[0] == "set_job")
assert set_job[2]["status"] == "error" and "unexpectedly" in set_job[2]["error"].lower()
fail = next(c for c in rec if c[0] == "fail")
assert "unexpectedly" in fail[2]["error"].lower()
def test_child_nonzero_exit_that_already_set_state_is_left_alone(monkeypatch, rec):
@@ -139,7 +138,7 @@ def test_child_nonzero_exit_that_already_set_state_is_left_alone(monkeypatch, re
_spawn(monkeypatch, _FakeProc([("exit", 1)]))
_liveness(monkeypatch, ("error", None))
worker._run_supervised(1, 99)
assert "set_job" not in _names(rec)
assert "fail" not in _names(rec)
def test_spawn_failure_frees_the_asset_and_requeues(monkeypatch, rec):
@@ -158,7 +157,7 @@ def test_shutdown_kills_and_requeues_for_next_boot(monkeypatch, rec):
_liveness(monkeypatch, ("running", 1.0))
worker._run_supervised(1, 99)
names = _names(rec)
assert "stop_child" in names and "requeue" in names and "set_job" not in names
assert "stop_child" in names and "requeue" in names and "fail" not in names
def test_a_long_download_survives_while_progress_stays_fresh(monkeypatch, rec):
@@ -191,7 +190,7 @@ def test_a_wedged_finished_child_is_killed_by_the_wall_clock(monkeypatch, rec):
_liveness(monkeypatch, ("done", 1.0)) # finished, but the process lingers
worker._run_supervised(1, 99)
names = _names(rec)
assert "stop_child" in names and "set_job" not in names # killed to free the slot, status kept
assert "stop_child" in names and "fail" not in names # killed to free the slot, status kept
def test_a_watchdog_error_still_stops_the_child(monkeypatch, rec):
@@ -206,7 +205,7 @@ def test_a_watchdog_error_still_stops_the_child(monkeypatch, rec):
worker._run_supervised(1, 99)
names = _names(rec)
assert "stop_child" in names and "reset_asset" in names
assert next(c for c in rec if c[0] == "set_job")[2]["status"] == "error"
assert "fail" in _names(rec)
# --- _stop_child escalation -----------------------------------------------------------------