fix(downloads): don't classify per-item download 404s as source-level Not Found

gallery-dl logs `[download][error] Failed to download <file>.part` when a
single post attachment's media URL expires or is deleted, then recovers
and proceeds with the rest of the run. Our error categorizer was letting
a urllib3 debug line like `"HEAD /media-u/v3/<id> HTTP/1.1" 404 0` match
the NOT_FOUND_PATTERNS list, flagging the whole source as deleted even
though gallery-dl kept downloading subsequent items successfully.

Adds "failed to download" to the per-item allowlist so the mask flips
when skip activity is present, matching the shape of the campaign-ID
fix. Total-failure runs still classify as failures.

Also renames the Download Details modal's "Error Log" panel to
"Verbose Log (stderr)" — gallery-dl's `-v` flag sends all logging
(including [debug] lines) to stderr, so the original label was
misleading. Drops the blanket red text color for the same reason.
This commit is contained in:
2026-04-19 14:07:50 -04:00
parent 24c3999f55
commit edbb349c58
3 changed files with 43 additions and 2 deletions
+4
View File
@@ -441,11 +441,15 @@ class GalleryDLService:
# - "failed to extract campaign id": logged when the vanity-URL HTML path
# fails, but gallery-dl falls back to the /cw/<vanity> redirect and
# recovers the campaign_id on its own.
# - "failed to download": per-item download failure (e.g., a single
# Patreon attachment whose media URL expired / was deleted). The
# downloader logs [download][error] and moves to the next item.
if has_actual_error and (skip_line_count > 0 or has_skip_text):
per_item_patterns = [
"not allowed to view",
"unable to get post",
"failed to extract campaign id",
"failed to download",
]
error_lines = [
line for line in combined.split('\n')
@@ -56,3 +56,40 @@ def test_patreon_campaign_id_error_alongside_real_error_still_fails():
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
+2 -2
View File
@@ -334,9 +334,9 @@
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel v-if="selectedDownload.metadata.stderr">
<v-expansion-panel-title>Error Log</v-expansion-panel-title>
<v-expansion-panel-title>Verbose Log (stderr)</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption text-error" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stderr }}</pre>
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stderr }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>