feat(downloader): tier-limited classification, config defaults, yt-dlp support

- Classify Patreon "Not allowed to view post" warnings as TIER_LIMITED so
  subscription-gated runs are distinguished from genuine failures, and persist
  error_type/message on completed runs so the distinction survives to the UI.
- Fold PLATFORM_DEFAULTS into _get_default_config so gallery-dl.conf is a
  complete, editable document; _build_config_for_source now preserves the
  user's conf and only re-seeds missing platform sections.
- Add Reset-to-Defaults action in Settings (vs. Revert Changes which just
  reloads disk); show info banner when no gallery-dl.conf exists yet.
- Fix Discord embeds option: must be "all" string, not bool (gallery-dl
  iterates the value).
- Install yt-dlp + ffmpeg in Docker images so Patreon/Mux HLS video posts
  download instead of logging "Cannot import yt-dlp" and skipping.
- Recognize yt-dlp import failures as per-item errors so they don't mask
  TIER_LIMITED/NO_NEW_CONTENT classification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 19:32:44 -04:00
parent f747750f97
commit 86bf43c80a
10 changed files with 435 additions and 48 deletions
@@ -93,3 +93,94 @@ def test_patreon_per_item_download_failure_without_skips_still_fails():
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_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