This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
bvandeusen 2765c464bd feat(downloader): validate and quarantine truncated files post-download
After gallery-dl returns, parse stdout for written file paths and run
the magic-byte validator on each. Files that fail are moved to
{download_path}/_quarantine/{subscription}/{platform}/ with a sidecar
JSON capturing the source URL, validation reason, and original path —
enough for an operator to redownload-from-source or delete.

Adds ErrorType.VALIDATION_FAILED. A successful gallery-dl run that
produced quarantined files is now a soft failure (success=False,
error_message names the dominant failure reason and count) so the
source's error_count ticks up and the dashboard surfaces it. Gated
by download.validate_files setting (default True).

files_quarantined and quarantined_paths are persisted into download
metadata for the UI/API to consume.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:55:52 -04:00

1115 lines
46 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Gallery-dl wrapper service.
This service provides a clean interface for executing gallery-dl downloads
with support for per-source configuration overrides.
"""
import json
import logging
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
from app.config import get_settings
from app.models.base import utcnow
logger = logging.getLogger(__name__)
class ErrorType(str, Enum):
"""Error types for categorizing download failures."""
AUTH_ERROR = "auth_error"
RATE_LIMITED = "rate_limited"
NOT_FOUND = "not_found"
ACCESS_DENIED = "access_denied"
NETWORK_ERROR = "network_error"
NO_NEW_CONTENT = "no_new_content"
TIER_LIMITED = "tier_limited"
TIMEOUT = "timeout"
HTTP_ERROR = "http_error"
UNSUPPORTED_URL = "unsupported_url"
VALIDATION_FAILED = "validation_failed"
UNKNOWN_ERROR = "unknown_error"
class ContentType(str, Enum):
"""Content types that can be downloaded from platforms."""
# Patreon content types
IMAGES = "images"
IMAGE_LARGE = "image_large"
ATTACHMENTS = "attachments"
POSTFILE = "postfile"
CONTENT = "content" # Embedded content in post body
# Generic types
ALL = "all"
@dataclass
class SourceConfig:
"""Per-source configuration that can override global settings.
These settings are stored in the source's metadata field and merged
with global defaults when executing downloads.
"""
# Content types to download (platform-specific)
# For Patreon: ["images", "image_large", "attachments", "postfile", "content"]
# For others: typically just ["all"] or specific types
content_types: list[str] = field(default_factory=lambda: ["all"])
# Rate limiting overrides
sleep: Optional[float] = None # Delay between downloads
sleep_request: Optional[float] = None # Delay between requests
# Directory/filename pattern overrides
directory_pattern: Optional[str] = None
filename_pattern: Optional[str] = None
# Other options
skip_existing: bool = True # Skip files already in archive
save_metadata: bool = True # Save JSON metadata alongside files
timeout: int = 3600 # Download timeout in seconds
@classmethod
def from_dict(cls, data: dict) -> "SourceConfig":
"""Create SourceConfig from a dictionary (e.g., from source.metadata)."""
return cls(
content_types=data.get("content_types", ["all"]),
sleep=data.get("sleep"),
sleep_request=data.get("sleep_request"),
directory_pattern=data.get("directory_pattern"),
filename_pattern=data.get("filename_pattern"),
skip_existing=data.get("skip_existing", True),
save_metadata=data.get("save_metadata", True),
timeout=data.get("timeout", 3600),
)
def to_dict(self) -> dict:
"""Convert to dictionary for storage."""
return {
"content_types": self.content_types,
"sleep": self.sleep,
"sleep_request": self.sleep_request,
"directory_pattern": self.directory_pattern,
"filename_pattern": self.filename_pattern,
"skip_existing": self.skip_existing,
"save_metadata": self.save_metadata,
"timeout": self.timeout,
}
def _summarize_validation_failures(failures: list[dict]) -> str:
"""Human-readable error_message for a run that quarantined files.
Picks the dominant failure reason (most common) and counts so the
dashboard's error column says something useful — "3 files truncated
(missing JPEG EOI marker)" rather than a generic VALIDATION_FAILED.
"""
if not failures:
return "Validation failed"
reasons: dict[str, int] = {}
for f in failures:
key = f.get("reason") or "unknown"
reasons[key] = reasons.get(key, 0) + 1
top_reason, top_count = max(reasons.items(), key=lambda kv: kv[1])
n = len(failures)
if top_count == n:
return f"{n} file{'s' if n != 1 else ''} quarantined: {top_reason}"
return f"{n} files quarantined ({top_count}× {top_reason}, mixed)"
@dataclass
class DownloadResult:
"""Result of a gallery-dl download execution."""
success: bool
url: str
subscription_name: str
platform: str
# Output details
files_downloaded: int = 0
files_quarantined: int = 0
quarantined_paths: list[str] = field(default_factory=list)
stdout: str = ""
stderr: str = ""
return_code: int = 0
# Error details (if failed)
error_type: Optional[ErrorType] = None
error_message: Optional[str] = None
# Timing
duration_seconds: float = 0.0
started_at: Optional[str] = None
completed_at: Optional[str] = None
class GalleryDLService:
"""Service for executing gallery-dl downloads.
Handles:
- Building gallery-dl configuration with per-source overrides
- Executing the subprocess with proper error handling
- Parsing output and categorizing errors
- Managing temporary config files
"""
# Error classification patterns — used by _categorize_error()
AUTH_ERROR_PATTERNS = [
"unauthorized",
"login required",
"please login",
"must be logged in",
"authentication required",
"authenticationerror",
"refresh-token",
"session expired",
"invalid cookie",
"cookies are expired",
"cookies have expired",
"not logged in",
]
RATE_LIMIT_PATTERNS = [
'" 429 ',
"rate limit",
"too many requests",
"ratelimit",
"throttl",
]
NOT_FOUND_PATTERNS = [
'" 404 ',
"error 404",
"status: 404",
"status code: 404",
"404 not found",
"page not found",
"user not found",
"creator not found",
"artist not found",
"content no longer available",
"has been deleted",
"account deleted",
"profile does not exist",
]
GENERIC_NOT_FOUND_PATTERNS = ["does not exist", "no longer available"]
NETWORK_ERROR_PATTERNS = [
"timed out",
"read timed out",
"connect timed out",
"connection timed out",
"connection refused",
"connection reset",
"network unreachable",
"name resolution failed",
"name or service not known",
"nodename nor servname provided",
"ssl: certificate_verify_failed",
"ssl: wrong_version_number",
"certificate verify failed",
"[errno",
]
ACCESS_DENIED_PATTERNS = [
'" 403 ',
"error 403",
"forbidden",
"access denied",
"permission denied",
"tier required",
"pledge required",
]
# Platform-specific default content types and options
# Directory patterns are relative - subscription_name/platform is prepended automatically
PLATFORM_DEFAULTS = {
"patreon": {
"content_types": ["images", "image_large", "attachments", "postfile", "content"],
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
"filename": "{num:>02}_{filename}.{extension}",
# Additional options
"videos": True, # Download video content
"embeds": True, # Download embedded URLs (YouTube, etc.)
# Deep crawling options
"cursor": True, # Enable cursor-based pagination for complete history
},
"subscribestar": {
"content_types": ["all"],
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
"filename": "{num:>02}_{filename}.{extension}",
# Automatic infinite scroll pagination is built-in
},
"hentaifoundry": {
"content_types": ["all"], # Download all content types
"directory": [], # Flat structure within platform folder
"filename": "{category}_{index:>03}_{title[:50]}.{extension}",
"include": "all", # Include pictures, scraps, stories - complete content
# Automatic pagination through all pages (25 items/page)
},
"discord": {
"content_types": ["all"],
"directory": ["{channel[name]}"],
"filename": "{date:%Y%m%d}_{id}_{filename}.{extension}",
# Additional options. gallery-dl's discord extractor treats `embeds`
# as an iterable of embed types (strings) or the string "all" — a
# bool causes "argument of type 'bool' is not iterable" at runtime.
"embeds": "all",
"stickers": True, # Download stickers
"reactions": False, # Skip reaction images
# Deep crawling options
"threads": True, # Include thread content for complete history
},
"pixiv": {
"content_types": ["all"],
"directory": ["{category}"],
"filename": "{id}_{title[:50]}_{num:>02}.{extension}",
# Content options
"ugoira": True, # Download ugoira (animated) as WebM
},
"deviantart": {
"content_types": ["all"],
"directory": [], # Flat structure within platform folder
"filename": "{index:>03}_{title[:50]}.{extension}",
# Content options
"flat": True, # Flatten folder structure
"original": True, # Download original quality
"mature": True, # Include mature content
"metadata": True, # Include metadata
},
}
def __init__(
self,
rate_limit: Optional[float] = None,
validate_files: bool = True,
):
"""Initialize the GalleryDL service.
Args:
rate_limit: Override for download rate limit (seconds between requests).
If None, uses environment default.
validate_files: When True, run magic-byte validation on each file
gallery-dl writes and quarantine truncated files. Caller
(tasks.downloads) typically reads this from the
`download.validate_files` DB setting.
"""
self.settings = get_settings()
self._rate_limit = rate_limit if rate_limit is not None else self.settings.download_rate_limit
self._validate_files = validate_files
self._base_config = self._load_base_config()
def _load_base_config(self) -> dict:
"""Load the base gallery-dl configuration file."""
config_path = Path(self.settings.config_path) / "gallery-dl.conf"
if config_path.exists():
try:
with open(config_path) as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.warning(f"Invalid gallery-dl.conf, using defaults: {e}")
# Return sensible defaults if no config exists
return self._get_default_config()
def _get_default_config(self) -> dict:
"""Get default gallery-dl configuration.
Platform-specific defaults from PLATFORM_DEFAULTS are folded into
extractor.<platform> so the returned dict is a complete, self-contained
gallery-dl config — editable via the Settings view and writable to
gallery-dl.conf for full user control. The internal `content_types`
meta-key is stripped (it's our abstraction, not a gallery-dl option —
it gets translated per-platform to `files`/`include` at run time).
"""
config = {
"extractor": {
"base-directory": self.settings.download_path,
"archive": str(Path(self.settings.config_path) / "archive.sqlite3"),
"skip": True,
"sleep": self._rate_limit,
# Faster request rate for pagination/checking (1/4 of rate_limit, min 0.5s)
"sleep-request": max(0.5, self._rate_limit / 4),
"retries": 3,
"timeout": 30.0,
"verify": True,
"postprocessors": [
{
"name": "metadata",
"mode": "json",
"directory": ".",
"filename": "{filename}.json",
}
],
},
"downloader": {
"part": True,
"part-directory": str(Path(self.settings.config_path) / "temp"),
"retries": 3,
"timeout": 120.0,
},
"output": {
"progress": True,
},
}
for platform_name, defaults in self.PLATFORM_DEFAULTS.items():
config["extractor"][platform_name] = {
key: value for key, value in defaults.items() if key != "content_types"
}
return config
def _build_config_for_source(
self,
platform: str,
source_config: SourceConfig,
destination: str,
) -> dict:
"""Build a merged configuration for a specific source.
Precedence: _get_default_config() (built-in) → gallery-dl.conf on disk
→ per-source overrides. The platform's extractor section is preserved
from the base config (deep copy) and source overrides are applied on
top — so user tweaks in gallery-dl.conf survive. If the user's conf
dropped the platform section entirely, re-seed from defaults so
platform behavior stays sticky.
"""
config = json.loads(json.dumps(self._base_config)) # Deep copy
if "extractor" not in config:
config["extractor"] = {}
# Re-seed platform defaults if the user's conf is missing the section.
# Prevents "I deleted extractor.discord from my conf and silently lost
# embeds/stickers/threads." from becoming a footgun.
if platform not in config["extractor"]:
seed = self._get_default_config()
config["extractor"][platform] = seed.get("extractor", {}).get(platform, {})
# extractor.* global overrides from source config
config["extractor"]["base-directory"] = destination
if source_config.sleep is not None:
config["extractor"]["sleep"] = source_config.sleep
if source_config.sleep_request is not None:
config["extractor"]["sleep-request"] = source_config.sleep_request
config["extractor"]["skip"] = source_config.skip_existing
if source_config.save_metadata:
config["extractor"]["postprocessors"] = [
{
"name": "metadata",
"mode": "json",
"directory": ".",
"filename": "{filename}.json",
}
]
else:
config["extractor"].pop("postprocessors", None)
# Source overrides on the platform-specific section. We preserve
# whatever is in the base config (platform defaults or user's conf)
# and only set keys that the source explicitly controls.
platform_section = config["extractor"][platform]
# Content types translate to platform-specific gallery-dl keys.
if platform == "patreon":
if "all" in source_config.content_types:
platform_section["files"] = ["images", "image_large", "attachments", "postfile", "content"]
else:
platform_section["files"] = source_config.content_types
elif platform == "hentaifoundry":
if "pictures" in source_config.content_types or "all" in source_config.content_types:
platform_section["include"] = "all"
# Directory/filename: source override wins; otherwise inherit whatever
# the base config already has (platform default or user-set value).
if source_config.directory_pattern:
platform_section["directory"] = source_config.directory_pattern
if source_config.filename_pattern:
platform_section["filename"] = source_config.filename_pattern
platform_section["metadata"] = source_config.save_metadata
return config
def _categorize_error(self, return_code: int, stdout: str, stderr: str) -> tuple[ErrorType, str]:
"""Categorize the error based on output and return code.
Priority order:
1. Check for skip messages (# prefixed lines or text patterns) → NO_NEW_CONTENT
2. Check for actual errors (auth, rate limit, 404, network, etc.)
3. Fall back to UNKNOWN_ERROR
This ordering prevents false positives where debug output matches error patterns.
"""
combined = f"{stdout} {stderr}".lower()
# === PHASE 1: Detect skip/no-new-content scenarios ===
# Gallery-dl indicates skipped files two ways:
# 1. Lines starting with '#' in stdout (e.g., "# /data/downloads/Artist/file.png")
# 2. Text patterns like "skipping", "already exists"
# Count lines starting with '#' (skip messages)
skip_line_count = len([
line for line in stdout.split('\n')
if line.strip().startswith('#')
])
# Text-based skip indicators
skip_text_indicators = ["skipping", "already exists", "archive"]
has_skip_text = any(ind in combined for ind in skip_text_indicators)
# Actual error indicators that override skip detection
actual_error_indicators = [
"][error]", # gallery-dl error log level: [patreon][error]
"exception", # Python exceptions
"traceback", # Python tracebacks
"download failed",
"extraction failed",
]
# Non-fatal error lines gallery-dl logs but recovers from. These are
# per-item failures the run already moved past — they shouldn't cause
# source-level misclassification:
# - "not allowed to view" / "unable to get post": per-item paywall
# warnings on Patreon URLs with mixed tier access.
# - "failed to extract campaign id": vanity-URL HTML path fails,
# gallery-dl falls back to /cw/<vanity> and recovers.
# - "failed to download": per-item media failure (expired URL,
# yt-dlp HLS 403 on tier-gated video, deleted attachment).
# Downloader logs [download][error] and continues.
# - "cannot import yt-dlp"/"youtube-dl": video backend missing;
# surfaces via run_stats as a setup gap, not a source failure.
per_item_patterns = [
"not allowed to view",
"unable to get post",
"failed to extract campaign id",
"failed to download",
"cannot import yt-dlp",
"cannot import youtube-dl",
]
all_lines = combined.split('\n')
error_lines = [
line for line in all_lines
if any(ind in line for ind in actual_error_indicators)
]
per_item_error_lines = [
line for line in error_lines
if any(p in line for p in per_item_patterns)
]
source_level_error_lines = [
line for line in error_lines
if line not in set(per_item_error_lines)
]
# Only source-level errors block skip/tier-limited classification.
# Per-item failures are tracked separately in run_stats.
has_actual_error = bool(source_level_error_lines)
# Strip per-item error lines from the text used for downstream pattern
# matching. Otherwise noise like "HTTP Error 403: Forbidden" from a
# yt-dlp HLS failure latches onto ACCESS_DENIED and masks the real
# signal (tier gating, no new content, etc.).
if per_item_error_lines:
per_item_set = set(per_item_error_lines)
combined = '\n'.join(line for line in all_lines if line not in per_item_set)
# If we have skip evidence and no real errors, it's no_new_content
if (skip_line_count > 0 or has_skip_text) and not has_actual_error:
trigger = f"{skip_line_count} skip lines" if skip_line_count > 0 else "skip text indicator"
logger.debug(f"Error classified as no_new_content via: {trigger}")
return ErrorType.NO_NEW_CONTENT, "No new content to download"
# Empty output with low return codes often means nothing to download
if return_code == 0 and not stdout.strip():
logger.debug("Error classified as no_new_content via: empty stdout with return code 0")
return ErrorType.NO_NEW_CONTENT, "No new content to download"
# === PHASE 2: Check for specific error types ===
# Auth errors - look for error context, not just HTTP codes that appear in debug logs
# HTTP debug lines look like: '"GET /path HTTP/1.1" 401 None' - we need to detect actual errors
# Check for 401 specifically as an HTTP error response (not in URLs)
# Look for patterns like "401" followed by error context or at end of HTTP log line
has_401_error = (
'" 401 ' in combined or # HTTP response: "GET /path HTTP/1.1" 401 None
"401 unauthorized" in combined or
"error 401" in combined or
"status: 401" in combined or
"status code: 401" in combined
)
if has_401_error or any(pattern in combined for pattern in self.AUTH_ERROR_PATTERNS):
matched = "401 HTTP response" if has_401_error else next(p for p in self.AUTH_ERROR_PATTERNS if p in combined)
logger.debug(f"Error classified as auth_error via pattern: '{matched}'")
return ErrorType.AUTH_ERROR, "Authentication failed - cookies may be expired or invalid"
# Rate limiting - look for actual rate limit errors
if any(pattern in combined for pattern in self.RATE_LIMIT_PATTERNS):
matched = next(p for p in self.RATE_LIMIT_PATTERNS if p in combined)
logger.debug(f"Error classified as rate_limited via pattern: '{matched}'")
return ErrorType.RATE_LIMITED, "Rate limited by server - try increasing sleep time"
# 404 Not Found - check for HTTP 404 responses with error context
# Be specific to avoid matching "not found" in debug messages like "archive entry not found"
# Also require error-level context for generic patterns to avoid false positives
has_specific_404 = any(pattern in combined for pattern in self.NOT_FOUND_PATTERNS)
has_generic_with_error = any(
pattern in combined and "][error]" in combined
for pattern in self.GENERIC_NOT_FOUND_PATTERNS
)
if has_specific_404 or has_generic_with_error:
if has_specific_404:
matched = next(p for p in self.NOT_FOUND_PATTERNS if p in combined)
logger.debug(f"Error classified as not_found via specific 404 pattern: '{matched}'")
else:
matched = next(p for p in self.GENERIC_NOT_FOUND_PATTERNS if p in combined)
logger.debug(f"Error classified as not_found via generic pattern + error context: '{matched}'")
return ErrorType.NOT_FOUND, "URL not found - artist may have changed username or deleted content"
# Network errors - use specific patterns to avoid matching config values
# (e.g., "timeout": 30.0 in debug output should not trigger network_error)
if any(pattern in combined for pattern in self.NETWORK_ERROR_PATTERNS):
matched = next(p for p in self.NETWORK_ERROR_PATTERNS if p in combined)
logger.debug(f"Error classified as network_error via pattern: '{matched}'")
return ErrorType.NETWORK_ERROR, "Network error - check internet connection"
# 403 Forbidden / Access denied
if any(pattern in combined for pattern in self.ACCESS_DENIED_PATTERNS):
matched = next(p for p in self.ACCESS_DENIED_PATTERNS if p in combined)
logger.debug(f"Error classified as access_denied via pattern: '{matched}'")
return ErrorType.ACCESS_DENIED, "Access denied - may need higher subscription tier"
# Generic HTTP errors
if "http error" in combined or "httperror" in combined:
trigger = "http error" if "http error" in combined else "httperror"
logger.debug(f"Error classified as http_error via trigger: '{trigger}'")
return ErrorType.HTTP_ERROR, "HTTP error occurred"
# Unsupported URL
if "no suitable" in combined or "unsupported" in combined or "no extractor" in combined:
trigger = next(t for t in ("no suitable", "unsupported", "no extractor") if t in combined)
logger.debug(f"Error classified as unsupported_url via trigger: '{trigger}'")
return ErrorType.UNSUPPORTED_URL, "URL format not supported by gallery-dl"
# If we got here with a non-catastrophic return code, saw skip activity, and no
# specific error patterns matched, it's likely just no new content.
# gallery-dl exit codes: 1 = some extractions skipped, 4 = no URLs extracted
# (common with /c/user/posts Patreon URLs where all content is already archived)
if return_code in (1, 4) and (skip_line_count > 0 or has_skip_text) and not has_actual_error:
trigger = f"{skip_line_count} skip lines" if skip_line_count > 0 else "skip text indicator"
logger.debug(f"Error classified as no_new_content via: rc{return_code} + {trigger}")
return ErrorType.NO_NEW_CONTENT, "No new content to download"
# Subscription-tier gating: gallery-dl emits [patreon][warning] "Not allowed to
# view post N" for posts the current auth token can't access. A run that exits
# 1/4 with only these warnings and no downloads means the account's tier doesn't
# cover the requested posts — it's a legitimate state, not a failure.
if return_code in (1, 4) and not has_actual_error:
tier_gated_lines = [
line for line in combined.split('\n')
if "][warning]" in line and "not allowed to view post" in line
]
if tier_gated_lines:
count = len(tier_gated_lines)
logger.debug(f"Error classified as tier_limited via: rc{return_code} + {count} tier-gated warnings")
return (
ErrorType.TIER_LIMITED,
f"Subscription tier does not grant access to {count} post{'s' if count != 1 else ''}",
)
# Final fallback - genuine unknown error
return ErrorType.UNKNOWN_ERROR, f"Unknown error (return code: {return_code})"
def _count_downloaded_files(self, stdout: str) -> int:
"""Count the number of files downloaded from stdout.
Gallery-dl writes each downloaded file path to stdout as an absolute path
(starting with '/'). Log lines contain '[' prefixes; verbose HTTP lines
go to stderr. Counting only lines starting with '/' avoids over-counting
log and progress output.
"""
if not stdout:
return 0
return sum(1 for line in stdout.splitlines() if line.strip().startswith("/"))
def _written_paths(self, stdout: str) -> list[Path]:
"""Extract absolute file paths gallery-dl wrote during this run.
Mirrors `_count_downloaded_files` but returns the paths themselves so
post-download validation only inspects files we actually just wrote
(skips on '#' lines and log noise are excluded). Caller is responsible
for filtering further by extension if needed.
"""
if not stdout:
return []
return [
Path(stripped)
for line in stdout.splitlines()
if (stripped := line.strip()).startswith("/")
]
def _validate_and_quarantine(
self,
written_paths: list[Path],
subscription_name: str,
platform: str,
url: str,
) -> tuple[list[str], list[dict]]:
"""Validate freshly-written files; quarantine any that fail.
Returns (quarantined_relpaths, failure_details). Each failure detail
is a dict suitable for logging/persisting: {path, format, reason, size}.
Files that pass validation or aren't a validatable format are left in
place. Failures are moved to {download_path}/_quarantine/<subscription>/
<platform>/ along with a sidecar JSON capturing the source URL, the
validation reason, and the original on-disk path so an operator can
decide to redownload-from-source or delete.
"""
# Local import keeps the module import-cycle clean and lets tests
# patch validation behavior.
from app.services.file_validator import is_validatable, validate_file
quarantined_relpaths: list[str] = []
failures: list[dict] = []
if not written_paths:
return quarantined_relpaths, failures
quarantine_root = (
Path(self.settings.download_path)
/ "_quarantine"
/ subscription_name
/ platform
)
for path in written_paths:
if not is_validatable(path):
continue
try:
result = validate_file(path)
except Exception as e:
# Validator must never crash the download flow.
logger.warning(f"Validator raised on {path}: {e}")
continue
if result.ok:
continue
# Move the bad file out of the library so downstream consumers
# (thumbnailer, ML, sync) don't trip on it.
try:
quarantine_root.mkdir(parents=True, exist_ok=True)
dest = quarantine_root / path.name
# Avoid overwriting prior quarantine entries with the same
# filename — append a numeric suffix until we find a free slot.
counter = 1
while dest.exists():
dest = quarantine_root / f"{path.stem}.{counter}{path.suffix}"
counter += 1
path.rename(dest)
sidecar = dest.with_suffix(dest.suffix + ".quarantine.json")
sidecar.write_text(
json.dumps(
{
"original_path": str(path),
"source_url": url,
"subscription": subscription_name,
"platform": platform,
"format": result.format,
"reason": result.reason,
"size": result.size,
"quarantined_at": utcnow().isoformat(),
},
indent=2,
)
)
except OSError as e:
# If we can't move it, log loudly but keep going. A failed
# quarantine isn't worse than the pre-fix status quo.
logger.error(
f"Failed to quarantine {path}: {e}. File left in place."
)
continue
logger.warning(
f"Quarantined corrupt file: {path}{dest} "
f"({result.format}: {result.reason})"
)
quarantined_relpaths.append(str(dest))
failures.append(
{
"path": str(path),
"format": result.format,
"reason": result.reason,
"size": result.size,
}
)
return quarantined_relpaths, failures
def _compute_run_stats(self, return_code: int, stdout: str, stderr: str) -> dict:
"""Summarize a gallery-dl run as structured counts for UI/analytics.
Returns a flat dict:
- exit_code: gallery-dl's process return code
- downloaded_count: files actually written (stdout lines starting with '/')
- skipped_count: archive-hit skips (stdout '#' lines + stderr 'skipping ' lines)
- per_item_failures: [download][error] lines (single-item failures the
downloader logs but recovers from)
- warning_count: [*][warning] lines
- tier_gated_count: 'not allowed to view post' warnings (Patreon
subscription-tier gating — informational, not a failure)
"""
stdout = stdout or ""
stderr = stderr or ""
skipped_stdout = sum(
1 for line in stdout.splitlines() if line.strip().startswith("#")
)
skipped_stderr = sum(
1 for line in stderr.splitlines() if "] skipping " in line.lower()
)
per_item_failures = sum(
1 for line in stderr.splitlines()
if "[download][error]" in line.lower() and "failed to download" in line.lower()
)
warning_count = sum(
1 for line in stderr.splitlines() if "][warning]" in line.lower()
)
tier_gated_count = sum(
1 for line in stderr.splitlines()
if "][warning]" in line.lower() and "not allowed to view post" in line.lower()
)
return {
"exit_code": return_code,
"downloaded_count": self._count_downloaded_files(stdout),
"skipped_count": skipped_stdout + skipped_stderr,
"per_item_failures": per_item_failures,
"warning_count": warning_count,
"tier_gated_count": tier_gated_count,
# quarantined_count is overlaid by the caller (tasks.downloads)
# when persisting metadata; surfaced via DownloadResult.files_quarantined
# because run_stats has no access to the live filesystem.
}
@staticmethod
def _extract_errors_warnings(stderr: str) -> str:
"""Filter stderr down to just [error] / [warning] lines.
Gallery-dl's verbose mode sends all logging to stderr including [debug]
and urllib3 connection-pool noise. This helper produces the short
summary a user actually wants when opening a failed download.
"""
if not stderr:
return ""
kept = [
line for line in stderr.splitlines()
if "][error]" in line.lower() or "][warning]" in line.lower()
]
return "\n".join(kept)
@staticmethod
def _truncate_log(text: str, max_bytes: int = 500_000) -> str:
"""Cap a log field at max_bytes by keeping the head and tail.
Verbose 10+ minute runs can produce multi-MB stderr blobs. For display
and storage, we keep the first and last halves and drop the middle with
a marker indicating how many lines were elided. Beginning is useful for
context (config, URL); end is useful for the actual failure.
"""
if not text:
return text
encoded = text.encode("utf-8")
if len(encoded) <= max_bytes:
return text
half = max_bytes // 2
head = encoded[:half].decode("utf-8", errors="ignore")
tail = encoded[-half:].decode("utf-8", errors="ignore")
head_lines = head.count("\n")
tail_lines = tail.count("\n")
total_lines = text.count("\n")
elided = max(0, total_lines - head_lines - tail_lines)
marker = f"\n\n... [{elided} lines elided, {len(encoded) - max_bytes} bytes] ...\n\n"
return head + marker + tail
async def download(
self,
url: str,
subscription_name: str,
platform: str,
source_config: Optional[SourceConfig] = None,
cookies_path: Optional[str] = None,
auth_token: Optional[str] = None,
) -> DownloadResult:
"""Execute a gallery-dl download.
Args:
url: The URL to download from
subscription_name: Name of the subscription/artist (used for destination folder)
platform: Platform name (patreon, subscribestar, etc.)
source_config: Optional per-source configuration overrides
cookies_path: Optional path to cookies file
auth_token: Optional auth token (for Discord and other token-based platforms)
Returns:
DownloadResult with success status and details
Downloads are saved to: {download_path}/{subscription_name}/{platform}/{post_folders}
"""
import asyncio
start_time = time.time()
started_at = utcnow().isoformat()
# Use default config if none provided
if source_config is None:
source_config = SourceConfig()
# Build destination path: subscription_name/platform
destination = str(Path(self.settings.download_path) / subscription_name / platform)
# Build merged configuration
config = self._build_config_for_source(platform, source_config, destination)
# Add cookies if provided
if cookies_path:
config["extractor"]["cookies"] = cookies_path
# Add auth token for Discord
if auth_token and platform == "discord":
if "discord" not in config["extractor"]:
config["extractor"]["discord"] = {}
config["extractor"]["discord"]["token"] = auth_token
# Add refresh token for Pixiv
if auth_token and platform == "pixiv":
if "pixiv" not in config["extractor"]:
config["extractor"]["pixiv"] = {}
config["extractor"]["pixiv"]["refresh-token"] = auth_token
# Write temporary config file
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".json",
delete=False,
dir=self.settings.config_path,
) as f:
json.dump(config, f, indent=2)
temp_config_path = f.name
try:
# Build command
cmd = [
sys.executable,
"-m",
"gallery_dl",
"--config",
temp_config_path,
"--verbose",
url,
]
logger.info(f"Executing gallery-dl for {subscription_name}/{platform}: {url}")
logger.debug(f"Command: {' '.join(cmd)}")
# Execute subprocess
# Run in thread pool to not block async event loop
loop = asyncio.get_running_loop()
proc = await loop.run_in_executor(
None,
lambda: subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=source_config.timeout,
),
)
duration = time.time() - start_time
completed_at = utcnow().isoformat()
# Log output
if proc.stdout:
logger.debug(f"STDOUT for {subscription_name}/{platform}:\n{proc.stdout}")
if proc.stderr:
logger.debug(f"STDERR for {subscription_name}/{platform}:\n{proc.stderr}")
# Run validation on every fresh write before classifying. Files
# gallery-dl wrote and we then quarantined no longer count as
# "downloaded" — counting them would understate how many files
# need re-downloading and overstate the run's success.
quarantined_paths: list[str] = []
quarantine_failures: list[dict] = []
if self._validate_files:
quarantined_paths, quarantine_failures = self._validate_and_quarantine(
written_paths=self._written_paths(proc.stdout),
subscription_name=subscription_name,
platform=platform,
url=url,
)
files_written = self._count_downloaded_files(proc.stdout)
files_quarantined = len(quarantined_paths)
files_kept = max(0, files_written - files_quarantined)
# Determine success
if proc.returncode == 0:
# Exit 0 with quarantines: the run technically succeeded but
# delivered partially-corrupt content. Surface as a soft
# failure so the source's error_count ticks up and the user
# sees it on the dashboard, but keep the per-item details
# (quarantined_paths) for the maintenance UI.
if files_quarantined > 0:
return DownloadResult(
success=False,
url=url,
subscription_name=subscription_name,
platform=platform,
files_downloaded=files_kept,
files_quarantined=files_quarantined,
quarantined_paths=quarantined_paths,
stdout=proc.stdout,
stderr=proc.stderr,
return_code=proc.returncode,
error_type=ErrorType.VALIDATION_FAILED,
error_message=_summarize_validation_failures(quarantine_failures),
duration_seconds=duration,
started_at=started_at,
completed_at=completed_at,
)
return DownloadResult(
success=True,
url=url,
subscription_name=subscription_name,
platform=platform,
files_downloaded=files_kept,
stdout=proc.stdout,
stderr=proc.stderr,
return_code=proc.returncode,
duration_seconds=duration,
started_at=started_at,
completed_at=completed_at,
)
# Handle errors
error_type, error_message = self._categorize_error(
proc.returncode, proc.stdout, proc.stderr
)
# NO_NEW_CONTENT and TIER_LIMITED are non-failure states — the run completed
# as well as it could given external constraints (archive up-to-date, or
# current subscription tier can't see the requested posts).
success = error_type in (ErrorType.NO_NEW_CONTENT, ErrorType.TIER_LIMITED)
# If a non-zero exit also produced quarantined files, prefer the
# validation-failed signal — it's actionable (the file needs to
# be redownloaded) and tells the operator the partial-write
# problem is happening, not just the categorized exit-code error.
if files_quarantined > 0:
error_type = ErrorType.VALIDATION_FAILED
error_message = _summarize_validation_failures(quarantine_failures)
success = False
return DownloadResult(
success=success,
url=url,
subscription_name=subscription_name,
platform=platform,
files_downloaded=files_kept if success else 0,
files_quarantined=files_quarantined,
quarantined_paths=quarantined_paths,
stdout=proc.stdout,
stderr=proc.stderr,
return_code=proc.returncode,
error_type=error_type,
error_message=error_message,
duration_seconds=duration,
started_at=started_at,
completed_at=completed_at,
)
except subprocess.TimeoutExpired:
duration = time.time() - start_time
logger.error(f"Download timeout for {subscription_name}/{platform} after {duration:.1f}s")
return DownloadResult(
success=False,
url=url,
subscription_name=subscription_name,
platform=platform,
error_type=ErrorType.TIMEOUT,
error_message=f"Download timed out after {source_config.timeout} seconds",
duration_seconds=duration,
started_at=started_at,
completed_at=utcnow().isoformat(),
)
except Exception as e:
duration = time.time() - start_time
logger.exception(f"Unexpected error downloading {subscription_name}/{platform}: {e}")
return DownloadResult(
success=False,
url=url,
subscription_name=subscription_name,
platform=platform,
error_type=ErrorType.UNKNOWN_ERROR,
error_message=str(e),
duration_seconds=duration,
started_at=started_at,
completed_at=utcnow().isoformat(),
)
finally:
# Clean up temporary config file
try:
Path(temp_config_path).unlink()
except Exception:
pass
def get_platform_content_types(self, platform: str) -> list[str]:
"""Get available content types for a platform.
Useful for UI to show what options are available.
"""
if platform == "patreon":
return ["images", "image_large", "attachments", "postfile", "content"]
elif platform == "hentaifoundry":
return ["all", "pictures", "scraps", "stories"]
elif platform == "subscribestar":
return ["all"] # No granular control
elif platform == "discord":
return ["all"] # No granular control
elif platform == "pixiv":
return ["all", "illusts", "manga", "bookmarks"]
elif platform == "deviantart":
return ["all", "gallery", "scraps", "favorites"]
else:
return ["all"]
def get_default_config_for_platform(self, platform: str) -> dict:
"""Get the default configuration for a platform.
Useful for showing defaults in UI.
"""
defaults = self.PLATFORM_DEFAULTS.get(platform, {})
return {
"content_types": defaults.get("content_types", ["all"]),
"directory_pattern": defaults.get("directory"),
"filename_pattern": defaults.get("filename"),
"sleep": self._rate_limit,
"sleep_request": max(0.5, self._rate_limit / 4),
}