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"
|
UNKNOWN_ERROR = "unknown_error"
|
||||||
|
|
||||||
|
|
||||||
# Pinned to download_source's Celery soft_time_limit (900s, see
|
# 30 seconds shy of download_source's Celery soft_time_limit (900s, see
|
||||||
# tasks/download.py:32). Anything larger and Celery kills the task before
|
# tasks/download.py:32). subprocess.run MUST raise TimeoutExpired before
|
||||||
# subprocess.run can raise TimeoutExpired — leaving the DownloadEvent
|
# Celery raises SoftTimeLimitExceeded — otherwise Celery wins the race,
|
||||||
# stranded for the recovery sweep instead of capturing a clean timeout
|
# SIGKILLs the worker, in-memory stdout/stderr is lost, and the
|
||||||
# error. Per-source bumps live in source.config_overrides for legitimately
|
# DownloadEvent ends up empty-logged with "stranded by recovery sweep"
|
||||||
# long syncs. Operator-confirmed 2026-05-30 (~40-min hang investigation).
|
# 18 minutes later (operator-flagged 2026-05-31, Knuxy event #38275).
|
||||||
_DEFAULT_GDL_TIMEOUT_SECONDS = 900
|
# 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
|
@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:
|
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"
|
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 = [
|
tier_gated_lines = [
|
||||||
line for line in combined.split("\n")
|
line for line in combined.split("\n")
|
||||||
if "][warning]" in line and "not allowed to view post" in line
|
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 wrapper (lands in FC-3)
|
||||||
gallery-dl>=1.32,<1.33
|
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
|
# Utilities
|
||||||
python-dotenv>=1.2,<2.0
|
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
|
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):
|
def test_compute_run_stats(gdl):
|
||||||
stats = gdl._compute_run_stats(
|
stats = gdl._compute_run_stats(
|
||||||
return_code=0,
|
return_code=0,
|
||||||
|
|||||||
Reference in New Issue
Block a user