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
|
2026-02-10 16:56:17 +08:00
|
|
|
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
|
2026-02-13 13:36:39 +08:00
|
|
|
from .scanner import categorize_file
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
"""
|
2026-02-10 16:56:17 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
# 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
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
# 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
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-13 09:50:29 +08:00
|
|
|
# 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)
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
try:
|
2026-02-13 09:50:29 +08:00
|
|
|
self._save_manifest(category, manifest)
|
2026-02-09 17:43:35 +08:00
|
|
|
log_operation(
|
|
|
|
|
self.logger,
|
2026-02-13 09:50:29 +08:00
|
|
|
logging.DEBUG,
|
|
|
|
|
f"Phase 1: Wrote pending manifest entry for {file_path}",
|
|
|
|
|
operation_type="quarantine"
|
2026-02-09 17:43:35 +08:00
|
|
|
)
|
|
|
|
|
except Exception as e:
|
2026-02-13 09:50:29 +08:00
|
|
|
error_msg = f"Failed to write pending manifest entry: {str(e)}"
|
2026-02-09 17:43:35 +08:00
|
|
|
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
|
|
|
|
|
)
|
2026-02-13 09:50:29 +08:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
def _determine_category(self, file_path: Path) -> str:
|
|
|
|
|
"""Determine the category of a file based on its path.
|
2026-02-13 13:36:39 +08:00
|
|
|
|
|
|
|
|
Uses the configured category mappings to support custom directory names.
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
Args:
|
|
|
|
|
file_path: Path to the file
|
2026-02-13 13:36:39 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
Returns:
|
|
|
|
|
Category name ("movie", "series", "anime", "other")
|
|
|
|
|
"""
|
2026-02-13 13:36:39 +08:00
|
|
|
# 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
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
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.
|
2026-02-13 13:36:39 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
Args:
|
|
|
|
|
category: Category name ("movie" or "series")
|
2026-02-13 13:36:39 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
Returns:
|
|
|
|
|
Path to the manifest.json file
|
|
|
|
|
"""
|
2026-02-13 13:36:39 +08:00
|
|
|
# Find the actual directory name for this category
|
|
|
|
|
actual_dir = self._find_category_dir(category)
|
|
|
|
|
category_root = self.config.library_root / actual_dir
|
2026-02-09 17:43:35 +08:00
|
|
|
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'],
|
2026-02-13 09:50:29 +08:00
|
|
|
category=entry_data['category'],
|
|
|
|
|
status=entry_data.get('status', 'committed') # Default for backward compatibility
|
2026-02-09 17:43:35 +08:00
|
|
|
)
|
|
|
|
|
entries.append(entry)
|
2026-02-13 09:50:29 +08:00
|
|
|
|
|
|
|
|
# Auto-recovery: Clean up pending entries
|
|
|
|
|
manifest = QuarantineManifest(entries=entries)
|
|
|
|
|
self._recover_pending_entries(category, manifest)
|
|
|
|
|
|
|
|
|
|
return manifest
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
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,
|
2026-02-13 09:50:29 +08:00
|
|
|
'category': entry.category,
|
|
|
|
|
'status': entry.status
|
2026-02-09 17:43:35 +08:00
|
|
|
}
|
|
|
|
|
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"
|
|
|
|
|
)
|
2026-02-13 09:50:29 +08:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
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
|
|
|
|
|
|
2026-02-10 18:07:38 +08:00
|
|
|
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
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
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
|
|
|
|
|
"""
|
2026-02-10 16:56:17 +08:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-13 09:50:29 +08:00
|
|
|
# Two-phase commit for atomic restore operation
|
|
|
|
|
|
|
|
|
|
# PHASE 1: Mark entry as pending restoration
|
|
|
|
|
entry.status = "pending"
|
2026-02-09 17:43:35 +08:00
|
|
|
try:
|
2026-02-13 09:50:29 +08:00
|
|
|
self._save_manifest(category, manifest)
|
2026-02-09 17:43:35 +08:00
|
|
|
log_operation(
|
|
|
|
|
self.logger,
|
2026-02-13 09:50:29 +08:00
|
|
|
logging.DEBUG,
|
|
|
|
|
f"Phase 1: Marked entry as pending restoration for {quarantine_path}",
|
|
|
|
|
operation_type="restore"
|
2026-02-09 17:43:35 +08:00
|
|
|
)
|
|
|
|
|
except Exception as e:
|
2026-02-13 09:50:29 +08:00
|
|
|
error_msg = f"Failed to mark entry as pending: {str(e)}"
|
2026-02-09 17:43:35 +08:00
|
|
|
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
|
|
|
|
|
)
|
2026-02-13 09:50:29 +08:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
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
|