Files
dl-organizer/src/vlm/quarantine.py
T

761 lines
26 KiB
Python
Raw Normal View History

2026-02-09 17:43:35 +08:00
"""Quarantine manager for Video Library Manager.
This module provides functionality to safely isolate unwanted files in
category-specific quarantine directories for review before deletion.
v1 Constraints:
- Only movie and series categories support quarantine
- Anime and other categories: quarantine operations rejected with error
- Each category has its own .quarantine/ subdirectory and manifest.json
"""
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
from .config import Config
from .utils import utc_now
2026-02-09 17:43:35 +08:00
from .logging_config import get_logger, log_operation
from .models import QuarantineEntry, QuarantineManifest, OperationResult, FileOperation
class QuarantineManager:
"""Manager for quarantine operations on video files."""
def __init__(self, config: Config, logger: Optional[logging.Logger] = None):
"""Initialize the quarantine manager.
Args:
config: Configuration object with library settings
logger: Optional logger instance (uses default if not provided)
"""
self.config = config
self.logger = logger or get_logger()
def quarantine_file(
self,
file_path: Path,
reason: Optional[str] = None
) -> OperationResult:
"""Move a file to category-specific quarantine directory.
This method:
1. Verifies file is in movie or series category (rejects anime/other)
2. Determines relative path from category root
3. Constructs quarantine path: <category_root>/.quarantine/<relative_path>
4. Handles destination conflicts by appending numeric suffix
5. Moves file to quarantine directory
6. Updates category-specific manifest.json
Args:
file_path: Path to the file to quarantine
reason: Optional reason for quarantining the file
Returns:
OperationResult indicating success or failure
Raises:
ValueError: If file is in anime or other category (not supported in v1)
"""
executed_at = utc_now()
2026-02-09 17:43:35 +08:00
# Verify file exists
if not file_path.exists():
error_msg = f"File does not exist: {file_path}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="quarantine",
file_path=file_path
)
return OperationResult(
operation=FileOperation(
operation_type="quarantine",
source_path=file_path,
destination_path=None,
reason=reason or "File not found",
has_conflict=False
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
# Get file size before moving
try:
file_size = file_path.stat().st_size
except Exception as e:
error_msg = f"Failed to get file size: {str(e)}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="quarantine",
file_path=file_path
)
return OperationResult(
operation=FileOperation(
operation_type="quarantine",
source_path=file_path,
destination_path=None,
reason=reason or "Error getting file size",
has_conflict=False
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
# Determine category from file path
category = self._determine_category(file_path)
# Reject anime and other categories (v1 constraint)
if category not in ("movie", "series"):
error_msg = (
f"Quarantine not supported for category '{category}'. "
f"Only 'movie' and 'series' categories are supported in v1."
)
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="quarantine",
file_path=file_path
)
raise ValueError(error_msg)
# Get category root directory
category_root = self.config.library_root / category
# Determine relative path from category root
try:
relative_path = file_path.relative_to(category_root)
except ValueError:
error_msg = f"File is not within category root {category_root}: {file_path}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="quarantine",
file_path=file_path
)
return OperationResult(
operation=FileOperation(
operation_type="quarantine",
source_path=file_path,
destination_path=None,
reason=reason or "Invalid path",
has_conflict=False
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
# Construct quarantine path
quarantine_root = category_root / self.config.quarantine_dir
quarantine_path = quarantine_root / relative_path
# Handle destination conflicts by appending numeric suffix
quarantine_path = self._resolve_conflict(quarantine_path)
# Create quarantine directory structure
try:
quarantine_path.parent.mkdir(parents=True, exist_ok=True)
except Exception as e:
error_msg = f"Failed to create quarantine directory: {str(e)}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="quarantine",
file_path=file_path
)
return OperationResult(
operation=FileOperation(
operation_type="quarantine",
source_path=file_path,
destination_path=quarantine_path,
reason=reason or "Quarantine",
has_conflict=False
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
# Move file to quarantine
try:
file_path.rename(quarantine_path)
log_operation(
self.logger,
logging.INFO,
f"Successfully quarantined file: {file_path} -> {quarantine_path}",
operation_type="quarantine",
file_path=file_path
)
# Update manifest
try:
self._update_manifest(
category=category,
original_path=file_path,
quarantine_path=quarantine_path,
quarantined_at=executed_at,
reason=reason,
size_bytes=file_size
)
except Exception as e:
# Log manifest update failure but don't fail the operation
# since the file was already moved successfully
log_operation(
self.logger,
logging.WARNING,
f"Failed to update manifest: {str(e)}",
operation_type="quarantine",
file_path=file_path
)
return OperationResult(
operation=FileOperation(
operation_type="quarantine",
source_path=file_path,
destination_path=quarantine_path,
reason=reason or "Quarantine",
has_conflict=False
),
success=True,
error_message=None,
executed_at=executed_at
)
except Exception as e:
error_msg = f"Failed to move file to quarantine: {str(e)}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="quarantine",
file_path=file_path
)
return OperationResult(
operation=FileOperation(
operation_type="quarantine",
source_path=file_path,
destination_path=quarantine_path,
reason=reason or "Quarantine",
has_conflict=False
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
def _determine_category(self, file_path: Path) -> str:
"""Determine the category of a file based on its path.
Args:
file_path: Path to the file
Returns:
Category name ("movie", "series", "anime", "other")
"""
# Get path relative to library root
try:
relative_path = file_path.relative_to(self.config.library_root)
except ValueError:
return "other"
# First component of relative path is the category
parts = relative_path.parts
if not parts:
return "other"
category = parts[0].lower()
# Validate category
if category in ("movie", "series", "anime", "other"):
return category
else:
return "other"
def _resolve_conflict(self, quarantine_path: Path) -> Path:
"""Resolve destination conflicts by appending numeric suffix.
If the quarantine destination already exists, append _1, _2, etc.
until a non-existent path is found.
Args:
quarantine_path: Proposed quarantine path
Returns:
Resolved quarantine path that doesn't exist
"""
if not quarantine_path.exists():
return quarantine_path
# Extract stem and suffix
stem = quarantine_path.stem
suffix = quarantine_path.suffix
parent = quarantine_path.parent
# Try appending numeric suffixes
counter = 1
while True:
new_path = parent / f"{stem}_{counter}{suffix}"
if not new_path.exists():
log_operation(
self.logger,
logging.INFO,
f"Resolved quarantine conflict: {quarantine_path} -> {new_path}",
operation_type="quarantine"
)
return new_path
counter += 1
# Safety check to prevent infinite loop
if counter > 1000:
raise RuntimeError(
f"Could not resolve quarantine conflict after 1000 attempts: {quarantine_path}"
)
def _get_manifest_path(self, category: str) -> Path:
"""Get the path to the manifest file for a category.
Args:
category: Category name ("movie" or "series")
Returns:
Path to the manifest.json file
"""
category_root = self.config.library_root / category
quarantine_root = category_root / self.config.quarantine_dir
return quarantine_root / "manifest.json"
def _load_manifest(self, category: str) -> QuarantineManifest:
"""Load the quarantine manifest for a category.
Args:
category: Category name ("movie" or "series")
Returns:
QuarantineManifest object (empty if manifest doesn't exist)
"""
manifest_path = self._get_manifest_path(category)
if not manifest_path.exists():
return QuarantineManifest(entries=[])
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Parse entries
entries = []
for entry_data in data.get('entries', []):
entry = QuarantineEntry(
original_path=Path(entry_data['original_path']),
quarantine_path=Path(entry_data['quarantine_path']),
quarantined_at=datetime.fromisoformat(entry_data['quarantined_at']),
reason=entry_data.get('reason'),
size_bytes=entry_data['size_bytes'],
category=entry_data['category']
)
entries.append(entry)
return QuarantineManifest(entries=entries)
except Exception as e:
log_operation(
self.logger,
logging.WARNING,
f"Failed to load manifest for category '{category}': {str(e)}. Using empty manifest.",
operation_type="quarantine"
)
return QuarantineManifest(entries=[])
def _save_manifest(self, category: str, manifest: QuarantineManifest) -> None:
"""Save the quarantine manifest for a category.
Args:
category: Category name ("movie" or "series")
manifest: QuarantineManifest object to save
Raises:
Exception: If manifest cannot be saved
"""
manifest_path = self._get_manifest_path(category)
# Ensure quarantine directory exists
manifest_path.parent.mkdir(parents=True, exist_ok=True)
# Convert manifest to JSON-serializable format
data = {
'entries': [
{
'original_path': str(entry.original_path),
'quarantine_path': str(entry.quarantine_path),
'quarantined_at': entry.quarantined_at.isoformat(),
'reason': entry.reason,
'size_bytes': entry.size_bytes,
'category': entry.category
}
for entry in manifest.entries
]
}
# Save to file
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
log_operation(
self.logger,
logging.DEBUG,
f"Saved manifest for category '{category}' with {len(manifest.entries)} entries",
operation_type="quarantine"
)
def _update_manifest(
self,
category: str,
original_path: Path,
quarantine_path: Path,
quarantined_at: datetime,
reason: Optional[str],
size_bytes: int
) -> None:
"""Update the quarantine manifest with a new entry.
Args:
category: Category name ("movie" or "series")
original_path: Original path of the file before quarantine
quarantine_path: Path to the file in quarantine
quarantined_at: Timestamp when the file was quarantined
reason: Optional reason for quarantining
size_bytes: File size in bytes
Raises:
Exception: If manifest cannot be updated
"""
# Load existing manifest
manifest = self._load_manifest(category)
# Create new entry
entry = QuarantineEntry(
original_path=original_path,
quarantine_path=quarantine_path,
quarantined_at=quarantined_at,
reason=reason,
size_bytes=size_bytes,
category=category
)
# Add entry to manifest
manifest.entries.append(entry)
# Save updated manifest
self._save_manifest(category, manifest)
log_operation(
self.logger,
logging.INFO,
f"Updated manifest for category '{category}': added entry for {original_path}",
operation_type="quarantine"
)
def list_quarantined(self, category: Optional[str] = None) -> list[QuarantineEntry]:
"""List quarantined files from category manifests.
Args:
category: Optional category filter ("movie" or "series").
If None, lists from all categories.
Returns:
List of QuarantineEntry objects
"""
entries = []
# Determine which categories to query
if category is not None:
# Validate category
if category not in ("movie", "series"):
log_operation(
self.logger,
logging.WARNING,
f"Invalid category '{category}' for listing. Only 'movie' and 'series' are supported.",
operation_type="quarantine"
)
return []
categories = [category]
else:
# List from all supported categories
categories = ["movie", "series"]
# Load manifests from each category
for cat in categories:
manifest = self._load_manifest(cat)
entries.extend(manifest.entries)
log_operation(
self.logger,
logging.INFO,
f"Listed {len(entries)} quarantined files" +
(f" from category '{category}'" if category else " from all categories"),
operation_type="quarantine"
)
return entries
def restore_from_quarantine(
self,
quarantine_path: Path
) -> OperationResult:
"""Restore a file from quarantine to its original location.
This is a best-effort operation. It attempts to:
1. Find the entry in the appropriate category manifest
2. Move the file from quarantine back to original location
3. Remove the entry from the manifest
Args:
quarantine_path: Path to the file in quarantine
Returns:
OperationResult indicating success or failure
"""
executed_at = utc_now()
2026-02-09 17:43:35 +08:00
# Verify quarantine file exists
if not quarantine_path.exists():
error_msg = f"Quarantine file does not exist: {quarantine_path}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="restore",
file_path=quarantine_path
)
return OperationResult(
operation=FileOperation(
operation_type="restore",
source_path=quarantine_path,
destination_path=None,
reason="File not found",
has_conflict=False
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
# Determine category from quarantine path
category = self._determine_category_from_quarantine(quarantine_path)
if category is None:
error_msg = f"Could not determine category for quarantine file: {quarantine_path}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="restore",
file_path=quarantine_path
)
return OperationResult(
operation=FileOperation(
operation_type="restore",
source_path=quarantine_path,
destination_path=None,
reason="Invalid quarantine path",
has_conflict=False
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
# Load manifest to find original path
manifest = self._load_manifest(category)
# Find entry matching quarantine path
entry = None
for e in manifest.entries:
if e.quarantine_path == quarantine_path:
entry = e
break
if entry is None:
error_msg = f"No manifest entry found for quarantine file: {quarantine_path}"
log_operation(
self.logger,
logging.WARNING,
error_msg,
operation_type="restore",
file_path=quarantine_path
)
return OperationResult(
operation=FileOperation(
operation_type="restore",
source_path=quarantine_path,
destination_path=None,
reason="No manifest entry",
has_conflict=False
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
original_path = entry.original_path
# Check if original location already has a file (conflict)
if original_path.exists():
error_msg = f"Cannot restore: original location already exists: {original_path}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="restore",
file_path=quarantine_path
)
return OperationResult(
operation=FileOperation(
operation_type="restore",
source_path=quarantine_path,
destination_path=original_path,
reason="Restore",
has_conflict=True,
conflict_reason="Destination already exists"
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
# Create parent directory if needed
try:
original_path.parent.mkdir(parents=True, exist_ok=True)
except Exception as e:
error_msg = f"Failed to create parent directory: {str(e)}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="restore",
file_path=quarantine_path
)
return OperationResult(
operation=FileOperation(
operation_type="restore",
source_path=quarantine_path,
destination_path=original_path,
reason="Restore",
has_conflict=False
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
# Move file back to original location
try:
quarantine_path.rename(original_path)
log_operation(
self.logger,
logging.INFO,
f"Successfully restored file: {quarantine_path} -> {original_path}",
operation_type="restore",
file_path=quarantine_path
)
# Remove entry from manifest
try:
manifest.entries.remove(entry)
self._save_manifest(category, manifest)
log_operation(
self.logger,
logging.INFO,
f"Removed entry from manifest for category '{category}'",
operation_type="restore"
)
except Exception as e:
# Log manifest update failure but don't fail the operation
# since the file was already moved successfully
log_operation(
self.logger,
logging.WARNING,
f"Failed to update manifest after restore: {str(e)}",
operation_type="restore",
file_path=quarantine_path
)
return OperationResult(
operation=FileOperation(
operation_type="restore",
source_path=quarantine_path,
destination_path=original_path,
reason="Restore",
has_conflict=False
),
success=True,
error_message=None,
executed_at=executed_at
)
except Exception as e:
error_msg = f"Failed to restore file: {str(e)}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="restore",
file_path=quarantine_path
)
return OperationResult(
operation=FileOperation(
operation_type="restore",
source_path=quarantine_path,
destination_path=original_path,
reason="Restore",
has_conflict=False
),
success=False,
error_message=error_msg,
executed_at=executed_at
)
def _determine_category_from_quarantine(self, quarantine_path: Path) -> Optional[str]:
"""Determine the category from a quarantine path.
Args:
quarantine_path: Path to a file in quarantine
Returns:
Category name ("movie" or "series") or None if cannot be determined
"""
try:
relative_path = quarantine_path.relative_to(self.config.library_root)
except ValueError:
return None
parts = relative_path.parts
if len(parts) < 2:
return None
# First part should be category, second should be .quarantine
category = parts[0].lower()
quarantine_dir = parts[1]
if quarantine_dir != self.config.quarantine_dir:
return None
if category in ("movie", "series"):
return category
return None