From 97864efacb1afb5752787024495e7a979272c458 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 18 Apr 2026 15:23:41 -0400 Subject: [PATCH] fix(downloads): treat recovered Patreon campaign-ID errors as non-fatal When gallery-dl logs "[patreon][error] Failed to extract campaign ID" but then recovers via its /cw/ fallback and skips every file (all already archived), _categorize_error was flagging the [error] line as fatal and returning UNKNOWN_ERROR. Add "failed to extract campaign id" to the per-item error allowlist alongside "not allowed to view" and "unable to get post" so skip detection proceeds and the run is classified NO_NEW_CONTENT. Co-Authored-By: Claude Opus 4.7 --- backend/app/services/gallery_dl.py | 17 ++++-- .../services/test_gallery_dl_categorize.py | 58 +++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 backend/tests/services/test_gallery_dl_categorize.py diff --git a/backend/app/services/gallery_dl.py b/backend/app/services/gallery_dl.py index cb4dce0..8b70954 100644 --- a/backend/app/services/gallery_dl.py +++ b/backend/app/services/gallery_dl.py @@ -434,12 +434,19 @@ class GalleryDLService: ] has_actual_error = any(err in combined for err in actual_error_indicators) - # Per-item access warnings (e.g., "Not allowed to view post" on Patreon - # /c/user/posts URLs) are normal in multi-post downloads where some posts - # are paywalled. These shouldn't block skip detection when the overall - # download proceeded successfully with skips. + # Non-fatal error lines gallery-dl logs but recovers from. These shouldn't + # block skip detection when the overall download proceeded successfully: + # - "not allowed to view" / "unable to get post": per-item paywall warnings + # on Patreon /c/user/posts URLs where some posts are paywalled. + # - "failed to extract campaign id": logged when the vanity-URL HTML path + # fails, but gallery-dl falls back to the /cw/ redirect and + # recovers the campaign_id on its own. if has_actual_error and (skip_line_count > 0 or has_skip_text): - per_item_patterns = ["not allowed to view", "unable to get post"] + per_item_patterns = [ + "not allowed to view", + "unable to get post", + "failed to extract campaign id", + ] error_lines = [ line for line in combined.split('\n') if any(ind in line for ind in actual_error_indicators) diff --git a/backend/tests/services/test_gallery_dl_categorize.py b/backend/tests/services/test_gallery_dl_categorize.py new file mode 100644 index 0000000..6740dbe --- /dev/null +++ b/backend/tests/services/test_gallery_dl_categorize.py @@ -0,0 +1,58 @@ +"""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/) 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