fix(download): tier-gated = warning, race subprocess timeout, install yt-dlp
Three coupled operator-reported pains from the 2026-05-31 download event audit: 1. `[patreon][warning] Not allowed to view post N` was bubbling up as an error event, bumping consecutive_failures and parking the source in "needs attention." The classifier's tier-gated branch was gated on `return_code in (1, 4)`. Gallery-dl returns a different exit code for mixed-failure runs (e.g. paywall warnings + a missing yt-dlp dep flipping the exit bits), so the branch never fired and the path fell through to UNKNOWN_ERROR. Widen the gate: when no source-level error fired AND tier-gated warnings are present, classify as TIER_LIMITED regardless of return code. 2. Knuxy event #38275 (2026-05-31) ran 30 min and finalized with "stranded by recovery sweep (no terminal status after time_limit)" + empty stdout/stderr. Root cause: subprocess.run timeout (900s) and Celery soft_time_limit (900s) raced; when Celery won, SIGKILL wiped the in-memory captured output and the DownloadEvent ended up empty-logged 18 minutes later when the sweep finalized it. Drop gallery-dl's default subprocess timeout to 870s — a 30s margin shy of Celery's soft limit — so subprocess.TimeoutExpired always wins the race and captures the partial stdout/stderr via the existing handler. 3. `[downloader.ytdl][error] Cannot import yt-dlp or youtube-dl` was firing on every video attachment, causing per-item download failures that masked legitimate tier-gated classification. Add yt-dlp>=2025.1 to requirements.txt. Once it's in the image, video posts download normally and the per-item failure noise disappears. Tests added: - pure tier-gated stderr with exit code 128 → TIER_LIMITED + success - mixed tier-gated + yt-dlp + per-item failures → still TIER_LIMITED
This commit is contained in:
@@ -42,13 +42,16 @@ class ErrorType(StrEnum):
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
|
||||
|
||||
# Pinned to download_source's Celery soft_time_limit (900s, see
|
||||
# tasks/download.py:32). Anything larger and Celery kills the task before
|
||||
# subprocess.run can raise TimeoutExpired — leaving the DownloadEvent
|
||||
# stranded for the recovery sweep instead of capturing a clean timeout
|
||||
# error. Per-source bumps live in source.config_overrides for legitimately
|
||||
# long syncs. Operator-confirmed 2026-05-30 (~40-min hang investigation).
|
||||
_DEFAULT_GDL_TIMEOUT_SECONDS = 900
|
||||
# 30 seconds shy of download_source's Celery soft_time_limit (900s, see
|
||||
# tasks/download.py:32). subprocess.run MUST raise TimeoutExpired before
|
||||
# Celery raises SoftTimeLimitExceeded — otherwise Celery wins the race,
|
||||
# SIGKILLs the worker, in-memory stdout/stderr is lost, and the
|
||||
# DownloadEvent ends up empty-logged with "stranded by recovery sweep"
|
||||
# 18 minutes later (operator-flagged 2026-05-31, Knuxy event #38275).
|
||||
# The 30s buffer absorbs scheduler jitter / GC pauses without making
|
||||
# legitimately-long-running syncs timeout-friendlier. Per-source bumps
|
||||
# still live in source.config_overrides for legitimately long syncs.
|
||||
_DEFAULT_GDL_TIMEOUT_SECONDS = 870
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -369,7 +372,17 @@ class GalleryDLService:
|
||||
if return_code in (1, 4) and (skip_line_count > 0 or has_skip_text) and not has_actual_error:
|
||||
return ErrorType.NO_NEW_CONTENT, "No new content to download"
|
||||
|
||||
if return_code in (1, 4) and not has_actual_error:
|
||||
# Tier-gated classification used to require `return_code in (1, 4)`,
|
||||
# which silently fell through to UNKNOWN_ERROR when gallery-dl
|
||||
# returned a different exit code for mixed-failure runs (e.g.
|
||||
# paywall warnings + a missing yt-dlp dep flipping the exit bits).
|
||||
# The artist then surfaced as "needs attention" purely because a
|
||||
# paywall blocked posts the operator wasn't paying to see —
|
||||
# operator-flagged 2026-05-31. Now: if no source-level error
|
||||
# category fired AND tier-gated warnings are present, classify
|
||||
# as TIER_LIMITED regardless of return code. Same priority order
|
||||
# as before (auth/rate/access/not_found/network/http still win).
|
||||
if not has_actual_error:
|
||||
tier_gated_lines = [
|
||||
line for line in combined.split("\n")
|
||||
if "][warning]" in line and "not allowed to view post" in line
|
||||
|
||||
@@ -22,6 +22,11 @@ imagehash>=4.3,<4.4
|
||||
|
||||
# Gallery-dl wrapper (lands in FC-3)
|
||||
gallery-dl>=1.32,<1.33
|
||||
# Video extractor backend for gallery-dl. Without it, every video post
|
||||
# attachment fails with `[downloader.ytdl][error] Cannot import yt-dlp`
|
||||
# → `[download][error] Failed to download NN_video.mp4`. Operator-flagged
|
||||
# 2026-05-31 after Patreon video posts produced empty downloads.
|
||||
yt-dlp>=2025.1
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.2,<2.0
|
||||
|
||||
@@ -164,6 +164,58 @@ async def test_validation_quarantines_truncated_files(tmp_path, monkeypatch):
|
||||
assert result.error_type == ErrorType.VALIDATION_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_tier_limited_classified_as_success(gdl, monkeypatch):
|
||||
"""Pure tier-gated stderr (paywall warnings only, no source-level
|
||||
errors). Should be TIER_LIMITED with success=True so the
|
||||
DownloadEvent doesn't surface as 'needs attention' for an operator
|
||||
who's just not paying the top tier. Pre-fix: this fell through to
|
||||
UNKNOWN_ERROR when the return code wasn't 1 or 4."""
|
||||
stderr = (
|
||||
"[patreon][warning] Not allowed to view post 159421258\n"
|
||||
"[patreon][warning] Not allowed to view post 159247879\n"
|
||||
"[patreon][warning] Not allowed to view post 158111214\n"
|
||||
)
|
||||
# Use a non-(1,4) return code to prove the gate widened.
|
||||
monkeypatch.setattr(
|
||||
"backend.app.services.gallery_dl.subprocess.run",
|
||||
lambda *a, **k: _proc(stdout="", stderr=stderr, returncode=128),
|
||||
)
|
||||
result = await gdl.download(
|
||||
url="https://patreon.com/alice", artist_slug="alice", platform="patreon",
|
||||
source_config=SourceConfig(),
|
||||
)
|
||||
assert result.error_type == ErrorType.TIER_LIMITED
|
||||
assert result.success is True
|
||||
assert "3 posts" in (result.error_message or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_tier_limited_with_per_item_video_failures(gdl, monkeypatch):
|
||||
"""Real operator log shape (2026-05-31): hundreds of paywall warnings
|
||||
+ a handful of per-item video errors caused by missing yt-dlp. The
|
||||
video errors are classified as per-item (not source-level), so the
|
||||
classifier should still pick TIER_LIMITED. Once yt-dlp is installed
|
||||
the video errors stop firing; until then, the source-health doesn't
|
||||
flap to 'needs attention' over paywalled content."""
|
||||
stderr = (
|
||||
"[patreon][warning] Not allowed to view post 159421258\n"
|
||||
"[patreon][warning] Not allowed to view post 159247879\n"
|
||||
"[downloader.ytdl][error] Cannot import yt-dlp or youtube-dl\n"
|
||||
"[download][error] Failed to download 02_video.mp4\n"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.app.services.gallery_dl.subprocess.run",
|
||||
lambda *a, **k: _proc(stdout="", stderr=stderr, returncode=1),
|
||||
)
|
||||
result = await gdl.download(
|
||||
url="https://patreon.com/alice", artist_slug="alice", platform="patreon",
|
||||
source_config=SourceConfig(),
|
||||
)
|
||||
assert result.error_type == ErrorType.TIER_LIMITED
|
||||
assert result.success is True
|
||||
|
||||
|
||||
def test_compute_run_stats(gdl):
|
||||
stats = gdl._compute_run_stats(
|
||||
return_code=0,
|
||||
|
||||
Reference in New Issue
Block a user