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>
This commit is contained in:
2026-04-25 22:55:52 -04:00
parent ba75d1ffdc
commit 2765c464bd
4 changed files with 401 additions and 4 deletions
+4
View File
@@ -41,6 +41,7 @@ DEFAULT_SETTINGS = {
"download.rate_limit": 3.0,
"download.retry_count": 3,
"download.schedule_interval": 28800, # 8 hours
"download.validate_files": True,
"notification.enabled": False,
"notification.webhook_url": None,
"dashboard.failure_threshold": 5,
@@ -51,3 +52,6 @@ EXTENSION_API_KEY_SETTING = "security.extension_api_key"
# Key for cached storage stats
STORAGE_STATS_SETTING = "cache.storage_stats"
# Key for cached library-validation report (last sweep)
LIBRARY_VALIDATION_REPORT_SETTING = "cache.library_validation_report"
+205 -3
View File
@@ -33,6 +33,7 @@ class ErrorType(str, Enum):
TIMEOUT = "timeout"
HTTP_ERROR = "http_error"
UNSUPPORTED_URL = "unsupported_url"
VALIDATION_FAILED = "validation_failed"
UNKNOWN_ERROR = "unknown_error"
@@ -102,6 +103,26 @@ class SourceConfig:
}
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."""
@@ -112,6 +133,8 @@ class DownloadResult:
# 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
@@ -258,15 +281,24 @@ class GalleryDLService:
},
}
def __init__(self, rate_limit: Optional[float] = None):
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:
@@ -603,6 +635,121 @@ class GalleryDLService:
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.
@@ -647,6 +794,9 @@ class GalleryDLService:
"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
@@ -796,14 +946,55 @@ class GalleryDLService:
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=self._count_downloaded_files(proc.stdout),
files_downloaded=files_kept,
stdout=proc.stdout,
stderr=proc.stderr,
return_code=proc.returncode,
@@ -822,12 +1013,23 @@ class GalleryDLService:
# 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=self._count_downloaded_files(proc.stdout) if success else 0,
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,
+9 -1
View File
@@ -319,6 +319,9 @@ async def _download_source_async(source_id: int, download_id: int = None) -> dic
# Get rate limit from database settings
db_rate_limit = await _get_db_setting(session, "download.rate_limit", settings.download_rate_limit)
db_validate_files = bool(
await _get_db_setting(session, "download.validate_files", True)
)
# Session is now closed - DB connection released
finally:
@@ -331,7 +334,10 @@ async def _download_source_async(source_id: int, download_id: int = None) -> dic
resolved_campaign_id: Optional[str] = None
try:
gdl_service = GalleryDLService(rate_limit=db_rate_limit)
gdl_service = GalleryDLService(
rate_limit=db_rate_limit,
validate_files=db_validate_files,
)
attempt = await _execute_download_with_retry(
gdl_service=gdl_service,
platform=source_platform,
@@ -409,6 +415,7 @@ async def _download_source_async(source_id: int, download_id: int = None) -> dic
run_stats = gdl_service._compute_run_stats(
dl_result.return_code, dl_result.stdout, dl_result.stderr
)
run_stats["quarantined_count"] = dl_result.files_quarantined
stderr_summary = gdl_service._extract_errors_warnings(dl_result.stderr)
download.metadata_ = {
"stdout": gdl_service._truncate_log(dl_result.stdout) or None,
@@ -416,6 +423,7 @@ async def _download_source_async(source_id: int, download_id: int = None) -> dic
"stderr_errors_warnings": stderr_summary or None,
"run_stats": run_stats,
"duration_seconds": dl_result.duration_seconds,
"quarantined_paths": dl_result.quarantined_paths or None,
}
if dl_result.success: