535 lines
19 KiB
Python
535 lines
19 KiB
Python
"""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
|
|
|
|
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"
|
|
TIMEOUT = "timeout"
|
|
HTTP_ERROR = "http_error"
|
|
UNSUPPORTED_URL = "unsupported_url"
|
|
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,
|
|
}
|
|
|
|
|
|
@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
|
|
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
|
|
"""
|
|
|
|
# Platform-specific default content types
|
|
# 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}",
|
|
},
|
|
"subscribestar": {
|
|
"content_types": ["all"],
|
|
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
|
|
"filename": "{num:>02}_{filename}.{extension}",
|
|
},
|
|
"hentaifoundry": {
|
|
"content_types": ["pictures"],
|
|
"directory": [], # Flat structure within platform folder
|
|
"filename": "{category}_{index:>03}_{title[:50]}.{extension}",
|
|
},
|
|
"discord": {
|
|
"content_types": ["all"],
|
|
"directory": ["{channel}"],
|
|
"filename": "{date:%Y%m%d}_{message_id}_{filename}.{extension}",
|
|
},
|
|
}
|
|
|
|
def __init__(self):
|
|
self.settings = get_settings()
|
|
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."""
|
|
return {
|
|
"extractor": {
|
|
"base-directory": self.settings.download_path,
|
|
"archive": str(Path(self.settings.config_path) / "archive.sqlite3"),
|
|
"skip": True,
|
|
"sleep": self.settings.download_rate_limit,
|
|
"sleep-request": max(1.0, self.settings.download_rate_limit / 2),
|
|
"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,
|
|
},
|
|
}
|
|
|
|
def _build_config_for_source(
|
|
self,
|
|
platform: str,
|
|
source_config: SourceConfig,
|
|
destination: str,
|
|
) -> dict:
|
|
"""Build a merged configuration for a specific source.
|
|
|
|
Merges: base config → platform defaults → source overrides
|
|
"""
|
|
config = json.loads(json.dumps(self._base_config)) # Deep copy
|
|
|
|
# Get platform defaults
|
|
platform_defaults = self.PLATFORM_DEFAULTS.get(platform, {})
|
|
|
|
# Ensure extractor section exists
|
|
if "extractor" not in config:
|
|
config["extractor"] = {}
|
|
|
|
# Apply base directory
|
|
config["extractor"]["base-directory"] = destination
|
|
|
|
# Apply rate limiting overrides
|
|
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
|
|
|
|
# Apply skip setting
|
|
config["extractor"]["skip"] = source_config.skip_existing
|
|
|
|
# Configure metadata postprocessor
|
|
if source_config.save_metadata:
|
|
config["extractor"]["postprocessors"] = [
|
|
{
|
|
"name": "metadata",
|
|
"mode": "json",
|
|
"directory": ".",
|
|
"filename": "{filename}.json",
|
|
}
|
|
]
|
|
else:
|
|
config["extractor"].pop("postprocessors", None)
|
|
|
|
# Build platform-specific section
|
|
platform_config = {}
|
|
|
|
# Content types (platform-specific handling)
|
|
if platform == "patreon":
|
|
# Patreon uses "files" array for content types
|
|
if "all" in source_config.content_types:
|
|
platform_config["files"] = ["images", "image_large", "attachments", "postfile", "content"]
|
|
else:
|
|
platform_config["files"] = source_config.content_types
|
|
elif platform == "hentaifoundry":
|
|
# HentaiFoundry uses "include" for content filtering
|
|
if "pictures" in source_config.content_types or "all" in source_config.content_types:
|
|
platform_config["include"] = "pictures"
|
|
|
|
# Directory pattern
|
|
if source_config.directory_pattern:
|
|
platform_config["directory"] = source_config.directory_pattern
|
|
elif "directory" in platform_defaults:
|
|
platform_config["directory"] = platform_defaults["directory"]
|
|
|
|
# Filename pattern
|
|
if source_config.filename_pattern:
|
|
platform_config["filename"] = source_config.filename_pattern
|
|
elif "filename" in platform_defaults:
|
|
platform_config["filename"] = platform_defaults["filename"]
|
|
|
|
# Always save metadata at platform level too
|
|
platform_config["metadata"] = source_config.save_metadata
|
|
|
|
# Add platform config to extractor
|
|
config["extractor"][platform] = platform_config
|
|
|
|
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."""
|
|
combined = f"{stdout} {stderr}".lower()
|
|
|
|
if "401" in combined or "unauthorized" in combined or "login" in combined:
|
|
return ErrorType.AUTH_ERROR, "Authentication failed - cookies may be expired or invalid"
|
|
|
|
if "429" in combined or "rate limit" in combined or "too many requests" in combined:
|
|
return ErrorType.RATE_LIMITED, "Rate limited by server - try increasing sleep time"
|
|
|
|
if "404" in combined or "not found" in combined:
|
|
return ErrorType.NOT_FOUND, "URL not found - artist may have changed username or deleted content"
|
|
|
|
if "timeout" in combined or "connection" in combined or "network" in combined:
|
|
return ErrorType.NETWORK_ERROR, "Network error - check internet connection"
|
|
|
|
if "403" in combined or "forbidden" in combined or "access denied" in combined:
|
|
return ErrorType.ACCESS_DENIED, "Access denied - may need higher subscription tier"
|
|
|
|
if return_code == 1 and ("no" in combined or "skip" in combined or not stdout.strip()):
|
|
return ErrorType.NO_NEW_CONTENT, "No new content to download"
|
|
|
|
if "http error" in combined:
|
|
return ErrorType.HTTP_ERROR, "HTTP error occurred"
|
|
|
|
if "no suitable" in combined or "unsupported" in combined:
|
|
return ErrorType.UNSUPPORTED_URL, "URL format not supported by gallery-dl"
|
|
|
|
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 outputs one line per downloaded file.
|
|
Lines starting with '#' or containing 'skip' are not downloads.
|
|
"""
|
|
if not stdout:
|
|
return 0
|
|
|
|
count = 0
|
|
for line in stdout.strip().split("\n"):
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and "skip" not in line.lower():
|
|
count += 1
|
|
|
|
return count
|
|
|
|
async def download(
|
|
self,
|
|
url: str,
|
|
subscription_name: str,
|
|
platform: str,
|
|
source_config: Optional[SourceConfig] = None,
|
|
cookies_path: 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
|
|
|
|
Returns:
|
|
DownloadResult with success status and details
|
|
|
|
Downloads are saved to: {download_path}/{subscription_name}/{platform}/{post_folders}
|
|
"""
|
|
import asyncio
|
|
from datetime import datetime
|
|
|
|
start_time = time.time()
|
|
started_at = datetime.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
|
|
|
|
# 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_event_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 = datetime.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}")
|
|
|
|
# Determine success
|
|
if proc.returncode == 0:
|
|
return DownloadResult(
|
|
success=True,
|
|
url=url,
|
|
subscription_name=subscription_name,
|
|
platform=platform,
|
|
files_downloaded=self._count_downloaded_files(proc.stdout),
|
|
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 is considered successful
|
|
success = error_type == ErrorType.NO_NEW_CONTENT
|
|
|
|
return DownloadResult(
|
|
success=success,
|
|
url=url,
|
|
subscription_name=subscription_name,
|
|
platform=platform,
|
|
files_downloaded=self._count_downloaded_files(proc.stdout) if success else 0,
|
|
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=datetime.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=datetime.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 ["pictures", "stories"]
|
|
elif platform == "subscribestar":
|
|
return ["all"] # No granular control
|
|
elif platform == "discord":
|
|
return ["all"] # No granular control
|
|
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.settings.download_rate_limit,
|
|
"sleep_request": max(1.0, self.settings.download_rate_limit / 2),
|
|
}
|