c2a2162c86
When a Patreon source loses tier access, gallery-dl emits "Not allowed to view post N" warnings for every post AND yt-dlp tries to fetch HLS manifests which also 403. The ytdl error text contained "HTTP Error 403: Forbidden", which the classifier's ACCESS_DENIED_PATTERNS matched against the full combined stdout+stderr — so the run was labeled ACCESS_DENIED instead of TIER_LIMITED. Two fixes in one edit, because they're interlocked: - Compute per-item vs source-level error lines upfront. has_actual_error now reflects only source-level errors (previously the per-item exclusion was gated on skip evidence, which tier-limited runs don't produce since no content was accessed). - Strip per-item error lines from `combined` before downstream pattern matching so noise from recovered per-item failures doesn't latch onto ACCESS_DENIED / HTTP_ERROR / NOT_FOUND classifiers. Regression test: tier-gated Patreon run with yt-dlp HLS 403s → TIER_LIMITED, not ACCESS_DENIED. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
217 lines
8.6 KiB
Python
217 lines
8.6 KiB
Python
"""Unit tests for GalleryDLService._categorize_error.
|
|
|
|
Focus: making sure errors gallery-dl logs but recovers from (e.g., Patreon's
|
|
"Failed to extract campaign ID" that falls back to /cw/<vanity>) don't get
|
|
misclassified as real failures when the overall run was just archive skips.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
from app.services.gallery_dl import ErrorType, GalleryDLService
|
|
|
|
|
|
def _make_service():
|
|
"""Build a service without touching the filesystem."""
|
|
with patch.object(GalleryDLService, "_load_base_config", return_value={}):
|
|
return GalleryDLService(rate_limit=1.0)
|
|
|
|
|
|
def test_patreon_campaign_id_error_with_skips_is_no_new_content():
|
|
"""gallery-dl logged the extractor error but recovered + skipped all → no_new_content."""
|
|
service = _make_service()
|
|
stderr = (
|
|
"[patreon][error] Failed to extract campaign ID\n"
|
|
"[patreon][info] campaign_id: 548223\n"
|
|
"[patreon][api] HEAD https://c10.patreonusercontent.com/4/.../img.jpg\n"
|
|
)
|
|
stdout = (
|
|
"# /data/downloads/ElArteDeVero/patreon/2024-01-01_1_title/01_img.jpg\n"
|
|
"# /data/downloads/ElArteDeVero/patreon/2024-01-02_2_title/01_img.jpg\n"
|
|
)
|
|
|
|
error_type, _message = service._categorize_error(1, stdout, stderr)
|
|
|
|
assert error_type == ErrorType.NO_NEW_CONTENT
|
|
|
|
|
|
def test_patreon_campaign_id_error_without_skips_still_fails():
|
|
"""Same error line but no skip activity → genuine failure, not masked."""
|
|
service = _make_service()
|
|
stderr = "[patreon][error] Failed to extract campaign ID\n"
|
|
stdout = ""
|
|
|
|
error_type, _message = service._categorize_error(1, stdout, stderr)
|
|
|
|
assert error_type != ErrorType.NO_NEW_CONTENT
|
|
|
|
|
|
def test_patreon_campaign_id_error_alongside_real_error_still_fails():
|
|
"""Campaign-ID error + a genuine auth error → auth error wins, not masked."""
|
|
service = _make_service()
|
|
stderr = (
|
|
"[patreon][error] Failed to extract campaign ID\n"
|
|
'[patreon][error] "GET /api/posts HTTP/1.1" 401 None\n'
|
|
)
|
|
stdout = "# /data/downloads/Foo/patreon/post1/img.jpg\n"
|
|
|
|
error_type, _message = service._categorize_error(1, stdout, stderr)
|
|
|
|
assert error_type == ErrorType.AUTH_ERROR
|
|
|
|
|
|
def test_patreon_per_item_download_404_with_skips_is_no_new_content():
|
|
"""Per-item 'Failed to download' + urllib3 404 HEAD + skips → no_new_content.
|
|
|
|
Real-world trigger: a Patreon post's media URL (e.g., /media-u/v3/<id>)
|
|
expired or was deleted. gallery-dl logs [download][error] and moves on;
|
|
other items skip cleanly. We shouldn't classify the whole source NOT_FOUND
|
|
just because one attachment 404'd.
|
|
"""
|
|
service = _make_service()
|
|
stderr = (
|
|
"[download][error] Failed to download 02_46004845.part\n"
|
|
'[urllib3.connectionpool][debug] "HEAD /media-u/v3/46004845 HTTP/1.1" 404 0\n'
|
|
"[downloader.http][warning] '404 OK' for 'https://www.patreon.com/media-u/v3/46004845'\n"
|
|
"[download][error] Failed to download 03_46004845.part\n"
|
|
"[patreon][debug] skipping https://c10.patreonusercontent.com/4/patreon-media/p/post/30953341/.../1.png (abc image_large)\n"
|
|
)
|
|
stdout = ""
|
|
|
|
error_type, _message = service._categorize_error(1, stdout, stderr)
|
|
|
|
assert error_type == ErrorType.NO_NEW_CONTENT
|
|
|
|
|
|
def test_patreon_per_item_download_failure_without_skips_still_fails():
|
|
"""Same 'Failed to download' error but no skip activity → real failure."""
|
|
service = _make_service()
|
|
stderr = (
|
|
"[download][error] Failed to download 02_46004845.part\n"
|
|
'[urllib3.connectionpool][debug] "HEAD /media-u/v3/46004845 HTTP/1.1" 404 0\n'
|
|
)
|
|
stdout = ""
|
|
|
|
error_type, _message = service._categorize_error(1, stdout, stderr)
|
|
|
|
assert error_type != ErrorType.NO_NEW_CONTENT
|
|
|
|
|
|
def test_patreon_tier_gated_warnings_classify_as_tier_limited():
|
|
"""Many 'Not allowed to view post N' warnings + exit 4 + no downloads →
|
|
TIER_LIMITED. Reflects a Patreon tier downgrade where older posts are now
|
|
paywalled but we don't want to call this a failure."""
|
|
service = _make_service()
|
|
stderr = "\n".join(
|
|
f"[patreon][warning] Not allowed to view post {100000 + i}"
|
|
for i in range(15)
|
|
)
|
|
stdout = ""
|
|
|
|
error_type, message = service._categorize_error(4, stdout, stderr)
|
|
|
|
assert error_type == ErrorType.TIER_LIMITED
|
|
assert "15" in message
|
|
|
|
|
|
def test_tier_limited_with_exit_code_1_also_classifies():
|
|
"""Mixed run: exit 1 with tier-gated warnings and no real errors."""
|
|
service = _make_service()
|
|
stderr = "[patreon][warning] Not allowed to view post 999\n"
|
|
stdout = ""
|
|
|
|
error_type, _message = service._categorize_error(1, stdout, stderr)
|
|
|
|
assert error_type == ErrorType.TIER_LIMITED
|
|
|
|
|
|
def test_tier_limited_not_applied_when_real_error_present():
|
|
"""Tier warnings + a real auth error → auth error wins (TIER_LIMITED is
|
|
only for 'everything else was fine' runs)."""
|
|
service = _make_service()
|
|
stderr = (
|
|
"[patreon][warning] Not allowed to view post 999\n"
|
|
'[patreon][error] "GET /api/posts HTTP/1.1" 401 None\n'
|
|
)
|
|
stdout = ""
|
|
|
|
error_type, _message = service._categorize_error(1, stdout, stderr)
|
|
|
|
assert error_type == ErrorType.AUTH_ERROR
|
|
|
|
|
|
def test_tier_limited_does_not_fire_on_exit_0():
|
|
"""Successful run with some paywalled posts as warnings isn't an error at
|
|
all — don't reach the TIER_LIMITED branch (exit 0 → success path earlier)."""
|
|
service = _make_service()
|
|
stderr = "[patreon][warning] Not allowed to view post 999\n"
|
|
stdout = "/data/downloads/Artist/patreon/post1/img.jpg\n"
|
|
|
|
error_type, _message = service._categorize_error(0, stdout, stderr)
|
|
|
|
# Exit 0 + stdout activity should not hit the TIER_LIMITED branch
|
|
assert error_type != ErrorType.TIER_LIMITED
|
|
|
|
|
|
def test_skip_activity_takes_precedence_over_tier_limited():
|
|
"""If there's also skip activity, NO_NEW_CONTENT wins — precedence matters
|
|
because the tier_limited branch is the last informational fallback."""
|
|
service = _make_service()
|
|
stderr = "[patreon][warning] Not allowed to view post 999\n"
|
|
stdout = "# /data/downloads/Artist/patreon/post1/img.jpg\n"
|
|
|
|
error_type, _message = service._categorize_error(1, stdout, stderr)
|
|
|
|
assert error_type == ErrorType.NO_NEW_CONTENT
|
|
|
|
|
|
def test_tier_limited_with_ytdl_403s_not_access_denied():
|
|
"""Real-world Patreon scenario: account lost tier access. Posts show up as
|
|
'[patreon][warning] Not allowed to view post N' (no downloads attempted)
|
|
AND yt-dlp tries to fetch video HLS manifests that are also tier-gated,
|
|
producing '[downloader.ytdl][error] HTTP Error 403: Forbidden' per item.
|
|
|
|
Before the fix, the 'Forbidden' text in per-item ytdl errors latched onto
|
|
ACCESS_DENIED_PATTERNS and misclassified the whole source as ACCESS_DENIED.
|
|
Correct behavior: TIER_LIMITED (the warnings are the dominant signal; the
|
|
per-item 403s are just tier gating manifesting at the HLS layer)."""
|
|
service = _make_service()
|
|
tier_warnings = "\n".join(
|
|
f"[patreon][warning] Not allowed to view post {100000 + i}"
|
|
for i in range(20)
|
|
)
|
|
ytdl_errors = "\n".join(
|
|
"[downloader.ytdl][error] ExtractorError: Failed to download m3u8 "
|
|
"information: HTTP Error 403: Forbidden "
|
|
f"(caused by <HTTPError 403: Forbidden>) ({i}/4)"
|
|
for i in range(1, 5)
|
|
) + "\n[download][error] Failed to download 02_video.mp4\n"
|
|
stderr = tier_warnings + "\n" + ytdl_errors
|
|
stdout = ""
|
|
|
|
error_type, message = service._categorize_error(1, stdout, stderr)
|
|
|
|
assert error_type == ErrorType.TIER_LIMITED, f"got {error_type}: {message}"
|
|
assert "20" in message
|
|
|
|
|
|
def test_ytdl_missing_with_skips_classifies_as_no_new_content():
|
|
"""Real-world scenario from a Patreon source: container is missing yt-dlp
|
|
so every video logs '[downloader.ytdl][error] Cannot import yt-dlp' plus
|
|
one [download][error] per video. With overwhelming skip activity, this
|
|
should classify as NO_NEW_CONTENT (the setup gap is per-item — it
|
|
shouldn't mask the dominant signal that the archive is up-to-date).
|
|
|
|
Regression: previously the yt-dlp import error didn't match any per-item
|
|
pattern, so has_actual_error stayed True and the run was misclassified as
|
|
UNKNOWN_ERROR even though 1300+ files were skipped."""
|
|
service = _make_service()
|
|
stderr = (
|
|
"[downloader.ytdl][error] Cannot import yt-dlp or youtube-dl\n"
|
|
"[download][error] Failed to download 02_video.mp4\n"
|
|
"[download][error] Failed to download 02_video.mp4\n"
|
|
)
|
|
stdout = "# /data/downloads/Artist/patreon/post/01_img.jpg\n" * 100
|
|
|
|
error_type, _message = service._categorize_error(4, stdout, stderr)
|
|
|
|
assert error_type == ErrorType.NO_NEW_CONTENT
|