"""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 from .logging_config import get_logger, log_operation from .models import QuarantineEntry, QuarantineManifest, OperationResult, FileOperation from .scanner import categorize_file 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: /.quarantine/ 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() # 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 the actual category directory name from the file path # (not the category name, which may differ due to category mappings) try: relative_from_lib = file_path.relative_to(self.config.library_root) actual_category_dir = relative_from_lib.parts[0] if relative_from_lib.parts else None except ValueError: actual_category_dir = None if not actual_category_dir: error_msg = f"File is not within library root {self.config.library_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 ) # Get category root directory using actual directory name category_root = self.config.library_root / actual_category_dir # 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 ) # Two-phase commit for atomic quarantine operation # PHASE 1: Write pending manifest entry BEFORE moving file manifest = self._load_manifest(category) pending_entry = QuarantineEntry( original_path=file_path, quarantine_path=quarantine_path, quarantined_at=executed_at, reason=reason, size_bytes=file_size, category=category, status="pending" ) manifest.entries.append(pending_entry) try: self._save_manifest(category, manifest) log_operation( self.logger, logging.DEBUG, f"Phase 1: Wrote pending manifest entry for {file_path}", operation_type="quarantine" ) except Exception as e: error_msg = f"Failed to write pending manifest entry: {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 ) # PHASE 2: Move file try: file_path.rename(quarantine_path) log_operation( self.logger, logging.INFO, f"Phase 2: Successfully moved file: {file_path} -> {quarantine_path}", operation_type="quarantine", file_path=file_path ) except Exception as e: # Rollback: Remove pending entry from manifest 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 ) try: manifest.entries.remove(pending_entry) self._save_manifest(category, manifest) log_operation( self.logger, logging.INFO, f"Rollback: Removed pending manifest entry for {file_path}", operation_type="quarantine" ) except Exception as rollback_error: log_operation( self.logger, logging.ERROR, f"Rollback failed: {str(rollback_error)}", operation_type="quarantine" ) 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 ) # PHASE 3: Mark as committed try: pending_entry.status = "committed" self._save_manifest(category, manifest) log_operation( self.logger, logging.DEBUG, f"Phase 3: Marked manifest entry as committed for {file_path}", operation_type="quarantine" ) except Exception as e: # File was moved but manifest update failed # Auto-recovery will handle this on next load log_operation( self.logger, logging.WARNING, f"Failed to mark entry as committed (auto-recovery will fix): {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 ) def _determine_category(self, file_path: Path) -> str: """Determine the category of a file based on its path. Uses the configured category mappings to support custom directory names. Args: file_path: Path to the file Returns: Category name ("movie", "series", "anime", "other") """ # Use the same categorization logic as the scanner categories_config = self.config.categories or { "movie": ["movie"], "series": ["series"], "anime": ["anime"] } return categorize_file(file_path, self.config.library_root, categories_config) def _find_category_dir(self, category: str) -> Optional[str]: """Find the actual directory name for a given category. Scans the library root for directories that match the category mapping. Args: category: Category name ("movie" or "series") Returns: Actual directory name if found, or category name as fallback """ categories_config = self.config.categories or { "movie": ["movie"], "series": ["series"], "anime": ["anime"] } # Get the list of possible directory names for this category dir_names = categories_config.get(category, [category]) # Check which one actually exists in the library root for dir_name in dir_names: candidate = self.config.library_root / dir_name if candidate.exists() and candidate.is_dir(): return dir_name # Fallback to category name itself return category 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 """ # Find the actual directory name for this category actual_dir = self._find_category_dir(category) category_root = self.config.library_root / actual_dir 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'], status=entry_data.get('status', 'committed') # Default for backward compatibility ) entries.append(entry) # Auto-recovery: Clean up pending entries manifest = QuarantineManifest(entries=entries) self._recover_pending_entries(category, manifest) return manifest 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, 'status': entry.status } 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 _recover_pending_entries(self, category: str, manifest: QuarantineManifest) -> None: """Auto-recovery: Clean up pending entries from incomplete operations. Checks each pending entry: - If file exists in quarantine → mark as committed - If file doesn't exist → remove orphaned entry Args: category: Category name manifest: Manifest to recover (modified in place) """ pending_entries = [e for e in manifest.entries if e.status == "pending"] if not pending_entries: return log_operation( self.logger, logging.INFO, f"Auto-recovery: Found {len(pending_entries)} pending entries for category '{category}'", operation_type="quarantine" ) recovered = 0 removed = 0 for entry in pending_entries: if entry.quarantine_path.exists(): # File exists → mark as committed entry.status = "committed" recovered += 1 log_operation( self.logger, logging.INFO, f"Auto-recovery: Marked as committed: {entry.quarantine_path}", operation_type="quarantine" ) else: # File doesn't exist → remove orphaned entry manifest.entries.remove(entry) removed += 1 log_operation( self.logger, logging.WARNING, f"Auto-recovery: Removed orphaned entry: {entry.original_path}", operation_type="quarantine" ) if recovered > 0 or removed > 0: # Save recovered manifest try: self._save_manifest(category, manifest) log_operation( self.logger, logging.INFO, f"Auto-recovery: Completed for '{category}' - {recovered} committed, {removed} removed", operation_type="quarantine" ) except Exception as e: log_operation( self.logger, logging.ERROR, f"Auto-recovery: Failed to save manifest: {str(e)}", 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 find_quarantine_path_by_original(self, original_path: Path) -> Optional[Path]: """Find the quarantine path for a file that was quarantined from original_path. Used when rolling back a quarantine operation when the rollback log only has the original (source) path and not the actual quarantine destination. Args: original_path: The original path of the file before quarantine Returns: The path where the file was moved in quarantine, or None if not found """ for category in ("movie", "series"): manifest = self._load_manifest(category) for entry in manifest.entries: if entry.original_path == original_path: return entry.quarantine_path return None 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() # 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 ) # Two-phase commit for atomic restore operation # PHASE 1: Mark entry as pending restoration entry.status = "pending" try: self._save_manifest(category, manifest) log_operation( self.logger, logging.DEBUG, f"Phase 1: Marked entry as pending restoration for {quarantine_path}", operation_type="restore" ) except Exception as e: error_msg = f"Failed to mark entry as pending: {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 ) # PHASE 2: Move file back to original location try: quarantine_path.rename(original_path) log_operation( self.logger, logging.INFO, f"Phase 2: Successfully restored file: {quarantine_path} -> {original_path}", operation_type="restore", file_path=quarantine_path ) except Exception as e: # Rollback: Mark entry back as committed error_msg = f"Failed to restore file: {str(e)}" log_operation( self.logger, logging.ERROR, error_msg, operation_type="restore", file_path=quarantine_path ) try: entry.status = "committed" self._save_manifest(category, manifest) log_operation( self.logger, logging.INFO, f"Rollback: Marked entry back as committed for {quarantine_path}", operation_type="restore" ) except Exception as rollback_error: log_operation( self.logger, logging.ERROR, f"Rollback failed: {str(rollback_error)}", operation_type="restore" ) 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 ) # PHASE 3: Remove entry from manifest try: manifest.entries.remove(entry) self._save_manifest(category, manifest) log_operation( self.logger, logging.DEBUG, f"Phase 3: Removed entry from manifest for category '{category}'", operation_type="restore" ) except Exception as e: # File was restored but manifest update failed # This is not critical since the file is in the right place log_operation( self.logger, logging.WARNING, f"Failed to remove entry from manifest (file was restored): {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 ) 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