Files
FabledCurator/tests/test_gallery_dl_service.py
T
bvandeusen 99b66aa85f fix(download): preserve partial output + classify timeouts richer
Operator-flagged 2026-05-30: "the fail state of timeouts doesn't show
anything other than that the task timedout and was cleaned up. I can't
tell why it ran over or if it was stuck failed or there was just that
much to get."

The TimeoutExpired branch was returning a DownloadResult with no stdout,
no stderr, no files_downloaded, and a generic "Download timed out after
N seconds" message — even though subprocess.TimeoutExpired carries the
partial output gallery-dl emitted before being killed.

Now:
- Capture e.stdout / e.stderr (coerced str if bytes; "" if None).
- Count files_downloaded from partial stdout via _count_downloaded_files.
- Surface a tail-of-stderr hint in error_message so the UI summary tells
  the operator at a glance whether it was "lots of content" (high count,
  clean stderr), "stuck retrying" (any count, 429-spam stderr), or "hung
  silent" (zero count, "no stderr output").
- Promote error_type to RATE_LIMITED when the partial stderr matches
  RATE_LIMIT_PATTERNS — gallery-dl spinning on retries through the whole
  900s window is the timeout-shaped tail of a real rate limit, and the
  platform cooldown should kick in for the same reason.

Existing test_download_timeout strengthened to also assert empty-partial
case stays correctly TIMEOUT-classified with no preserved output.
New test_download_timeout_preserves_partial_output_and_classifies covers
the rich-partial-output → RATE_LIMITED promotion path.

DownloadEvent.metadata already flows stdout/stderr/run_stats from
DownloadResult via _phase3_persist — no UI change needed; the existing
DownloadDetailModal will surface the captured output automatically once
the build redeploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:16:54 -04:00

200 lines
6.8 KiB
Python

"""GalleryDLService tests — subprocess is mocked so no real gallery-dl runs."""
from types import SimpleNamespace
import pytest
from backend.app.services.gallery_dl import (
ErrorType,
GalleryDLService,
SourceConfig,
)
@pytest.fixture
def gdl(tmp_path):
return GalleryDLService(
images_root=tmp_path / "images",
rate_limit=3.0,
validate_files=False,
)
def _proc(stdout="", stderr="", returncode=0):
return SimpleNamespace(stdout=stdout, stderr=stderr, returncode=returncode)
@pytest.mark.asyncio
async def test_download_happy_path(gdl, monkeypatch):
out_path = gdl.images_root / "alice" / "patreon" / "post1" / "img.jpg"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 32 + b"\xff\xd9")
monkeypatch.setattr(
"backend.app.services.gallery_dl.subprocess.run",
lambda *a, **k: _proc(stdout=str(out_path) + "\n", returncode=0),
)
result = await gdl.download(
url="https://patreon.com/alice",
artist_slug="alice",
platform="patreon",
source_config=SourceConfig(),
)
assert result.success is True
assert result.files_downloaded == 1
assert result.error_type is None
assert str(out_path) in result.written_paths
@pytest.mark.asyncio
async def test_download_no_new_content(gdl, monkeypatch):
monkeypatch.setattr(
"backend.app.services.gallery_dl.subprocess.run",
lambda *a, **k: _proc(stdout="# /images/alice/patreon/old.jpg\n", returncode=0),
)
result = await gdl.download(
url="https://patreon.com/alice", artist_slug="alice", platform="patreon",
source_config=SourceConfig(),
)
assert result.success is True
assert result.files_downloaded == 0
@pytest.mark.asyncio
async def test_download_auth_error_categorization(gdl, monkeypatch):
monkeypatch.setattr(
"backend.app.services.gallery_dl.subprocess.run",
lambda *a, **k: _proc(
stdout="", stderr='[patreon][error] "GET /api/foo HTTP/1.1" 401 None\n',
returncode=1,
),
)
result = await gdl.download(
url="https://patreon.com/alice", artist_slug="alice", platform="patreon",
source_config=SourceConfig(),
)
assert result.success is False
assert result.error_type == ErrorType.AUTH_ERROR
@pytest.mark.asyncio
async def test_download_timeout(gdl, monkeypatch):
import subprocess as sp
def _raise(*a, **k):
raise sp.TimeoutExpired(cmd="gallery-dl", timeout=10)
monkeypatch.setattr("backend.app.services.gallery_dl.subprocess.run", _raise)
result = await gdl.download(
url="https://patreon.com/alice", artist_slug="alice", platform="patreon",
source_config=SourceConfig(timeout=10),
)
assert result.success is False
assert result.error_type == ErrorType.TIMEOUT
# Empty-partial timeout: no preserved output and zero files.
assert result.stdout == ""
assert result.stderr == ""
assert result.files_downloaded == 0
@pytest.mark.asyncio
async def test_download_timeout_preserves_partial_output_and_classifies(gdl, monkeypatch):
"""When gallery-dl is killed by timeout but had emitted partial output,
the DownloadResult preserves stdout/stderr, counts files written so
far, and promotes to RATE_LIMITED if the partial stderr shows a rate-
limit pattern (so the platform cooldown kicks in). Without this, the
operator only sees 'timed out' with no clue whether it was 'lots of
content', 'stuck retrying', or 'hung silent'."""
import subprocess as sp
# Simulate gallery-dl writing two files, then spinning on 429s.
partial_stdout = (
"/tmp/images/alice/file_001.jpg\n"
"/tmp/images/alice/file_002.jpg\n"
)
partial_stderr = (
"[urllib3] 429 Too Many Requests; sleeping 60s\n"
"[urllib3] 429 Too Many Requests; sleeping 60s\n"
)
def _raise(*a, **k):
raise sp.TimeoutExpired(
cmd="gallery-dl", timeout=900,
output=partial_stdout, stderr=partial_stderr,
)
monkeypatch.setattr("backend.app.services.gallery_dl.subprocess.run", _raise)
result = await gdl.download(
url="https://patreon.com/alice", artist_slug="alice", platform="patreon",
source_config=SourceConfig(timeout=900),
)
assert result.success is False
# Partial output preserved on the DownloadResult — _phase3_persist
# writes them into download_event.metadata so the UI can render them.
assert result.stdout == partial_stdout
assert result.stderr == partial_stderr
# Files counted from partial stdout (lines starting with '/').
assert result.files_downloaded == 2
# Rate-limit pattern in partial stderr → promoted to RATE_LIMITED so
# _update_source_health stamps the platform cooldown.
assert result.error_type == ErrorType.RATE_LIMITED
assert "Rate-limited" in result.error_message
@pytest.mark.asyncio
async def test_validation_quarantines_truncated_files(tmp_path, monkeypatch):
gdl = GalleryDLService(
images_root=tmp_path / "images", rate_limit=3.0, validate_files=True,
)
bad = gdl.images_root / "alice" / "patreon" / "post" / "bad.jpg"
bad.parent.mkdir(parents=True, exist_ok=True)
bad.write_bytes(b"\xff\xd8\xff" + b"\x00" * 100) # SOI but no EOI
monkeypatch.setattr(
"backend.app.services.gallery_dl.subprocess.run",
lambda *a, **k: _proc(stdout=str(bad) + "\n", returncode=0),
)
result = await gdl.download(
url="https://patreon.com/alice", artist_slug="alice", platform="patreon",
source_config=SourceConfig(),
)
assert result.files_quarantined == 1
assert len(result.quarantined_paths) == 1
assert not bad.exists()
assert result.error_type == ErrorType.VALIDATION_FAILED
def test_compute_run_stats(gdl):
stats = gdl._compute_run_stats(
return_code=0,
stdout="/path/a.jpg\n/path/b.jpg\n# /path/c.jpg\n",
stderr="[patreon][warning] foo\n[patreon][download][error] failed to download /x\n",
)
assert stats["downloaded_count"] == 2
assert stats["skipped_count"] == 1
assert stats["warning_count"] >= 1
assert stats["per_item_failures"] == 1
def test_extract_errors_warnings(gdl):
stderr = (
"[patreon][debug] foo\n[patreon][error] bar\n"
"[urllib3][debug] baz\n[patreon][warning] qux\n"
)
result = gdl._extract_errors_warnings(stderr)
assert "[error]" in result
assert "[warning]" in result
assert "[debug]" not in result
def test_truncate_log_caps_large_text(gdl):
huge = "line\n" * 200_000
result = gdl._truncate_log(huge, max_bytes=10_000)
assert len(result.encode()) < 11_000
assert "lines elided" in result
def test_truncate_log_passes_short_text(gdl):
text = "small\n"
assert gdl._truncate_log(text) == text