Initial commit: Video Library Manager
- Add core VLM modules (scanner, parser, planner, executor, analysis) - Add CLI with quarantine, reports, rollback, and state management - Add comprehensive test suite - Add project configuration and documentation - Add .gitignore for Python project
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Video Library Manager - A Python-based CLI tool for managing personal video collections."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Analysis engine for detecting completeness issues and duplicates.
|
||||
|
||||
This module provides functionality to analyze video collections for:
|
||||
- Series completeness (detecting episode gaps)
|
||||
- Duplicate detection (finding duplicate content)
|
||||
- Quality comparison (comparing video quality metrics)
|
||||
"""
|
||||
|
||||
from vlm.models import SeriesIdentity, SeasonCompleteness, DuplicateGroup, VideoFile, MovieIdentity
|
||||
|
||||
|
||||
def analyze_series_completeness(episodes: list[SeriesIdentity]) -> list[SeasonCompleteness]:
|
||||
"""Analyze series completeness and detect episode gaps using heuristic detection.
|
||||
|
||||
This function groups episodes by series title and season, then detects gaps
|
||||
in the episode sequence using heuristic detection. For each season, it finds
|
||||
the minimum and maximum episode numbers and identifies missing episodes in
|
||||
that range [min, max].
|
||||
|
||||
Note: This is heuristic gap detection only. It does NOT calculate percentages
|
||||
or determine if seasons are "complete" (v1 constraint: no external metadata).
|
||||
|
||||
Args:
|
||||
episodes: List of parsed series identities
|
||||
|
||||
Returns:
|
||||
List of SeasonCompleteness objects for seasons with detected gaps
|
||||
"""
|
||||
from vlm.parser import group_episodes
|
||||
|
||||
# Group episodes by (title, season)
|
||||
grouped = group_episodes(episodes)
|
||||
|
||||
completeness_results = []
|
||||
|
||||
# Analyze each season
|
||||
for (series_title, season), episode_list in grouped.items():
|
||||
# Collect all episode numbers from this season
|
||||
all_episode_numbers = set()
|
||||
for episode in episode_list:
|
||||
all_episode_numbers.update(episode.episodes)
|
||||
|
||||
# Convert to sorted list
|
||||
episodes_found = sorted(all_episode_numbers)
|
||||
|
||||
# Find min and max episode numbers
|
||||
if not episodes_found:
|
||||
continue
|
||||
|
||||
min_episode = min(episodes_found)
|
||||
max_episode = max(episodes_found)
|
||||
|
||||
# Detect gaps in the range [min, max]
|
||||
expected_episodes = set(range(min_episode, max_episode + 1))
|
||||
found_episodes_set = set(episodes_found)
|
||||
missing_episodes = sorted(expected_episodes - found_episodes_set)
|
||||
|
||||
# Only include seasons with gaps
|
||||
if missing_episodes:
|
||||
completeness_results.append(SeasonCompleteness(
|
||||
series_title=series_title,
|
||||
season=season,
|
||||
episodes_found=episodes_found,
|
||||
episodes_missing=missing_episodes
|
||||
))
|
||||
|
||||
return completeness_results
|
||||
|
||||
|
||||
def detect_duplicates(
|
||||
identities: list[MovieIdentity | SeriesIdentity],
|
||||
files: list[VideoFile]
|
||||
) -> list[DuplicateGroup]:
|
||||
"""Detect duplicate video files and provide quality comparison data.
|
||||
|
||||
Groups files by normalized identity (title+year for movies, title+season+episode
|
||||
for series) and identifies groups with multiple files as potential duplicates.
|
||||
|
||||
Args:
|
||||
identities: List of parsed identities (movies or series)
|
||||
files: List of video files corresponding to the identities
|
||||
|
||||
Returns:
|
||||
List of DuplicateGroup objects for files with duplicates
|
||||
"""
|
||||
# Create a mapping from original filename to VideoFile for quick lookup
|
||||
file_map = {file.filename: file for file in files}
|
||||
|
||||
# Group identities by normalized identity
|
||||
groups: dict[tuple, list[tuple[MovieIdentity | SeriesIdentity, VideoFile]]] = {}
|
||||
|
||||
for identity in identities:
|
||||
# Create grouping key based on identity type
|
||||
if isinstance(identity, MovieIdentity):
|
||||
# For movies: group by (title, year)
|
||||
# Skip if year is None (needs review)
|
||||
if identity.year is None:
|
||||
continue
|
||||
key = ('movie', identity.title, identity.year)
|
||||
else: # SeriesIdentity
|
||||
# For series: group by (title, season, episode)
|
||||
# Skip if season is None or episodes is empty (needs review)
|
||||
if identity.season is None or not identity.episodes:
|
||||
continue
|
||||
# For multi-episode files, use the first episode for grouping
|
||||
# Each episode in the list should be treated separately
|
||||
for episode in identity.episodes:
|
||||
key = ('series', identity.title, identity.season, episode)
|
||||
|
||||
# Get the corresponding VideoFile
|
||||
video_file = file_map.get(identity.original_filename)
|
||||
if video_file is None:
|
||||
continue
|
||||
|
||||
# Add to group
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
groups[key].append((identity, video_file))
|
||||
continue
|
||||
|
||||
# Get the corresponding VideoFile for movies
|
||||
video_file = file_map.get(identity.original_filename)
|
||||
if video_file is None:
|
||||
continue
|
||||
|
||||
# Add to group
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
groups[key].append((identity, video_file))
|
||||
|
||||
# Filter groups to only those with multiple files (duplicates)
|
||||
duplicate_groups = []
|
||||
for key, items in groups.items():
|
||||
if len(items) > 1:
|
||||
# Extract identities and files
|
||||
# Use the first identity as the representative
|
||||
representative_identity = items[0][0]
|
||||
duplicate_files = [item[1] for item in items]
|
||||
|
||||
# Generate quality comparison data
|
||||
quality_comparison = compare_quality(duplicate_files)
|
||||
|
||||
duplicate_groups.append(DuplicateGroup(
|
||||
identity=representative_identity,
|
||||
files=duplicate_files,
|
||||
quality_comparison=quality_comparison
|
||||
))
|
||||
|
||||
return duplicate_groups
|
||||
|
||||
|
||||
def compare_quality(files: list[VideoFile]) -> list[dict]:
|
||||
"""Compare video quality metrics for a set of files.
|
||||
|
||||
Extracts and compares resolution, codec, file size, and other quality
|
||||
indicators to help users decide which files to keep.
|
||||
|
||||
Args:
|
||||
files: List of video files to compare
|
||||
|
||||
Returns:
|
||||
List of dictionaries with quality comparison data for each file
|
||||
"""
|
||||
comparison_data = []
|
||||
|
||||
for file in files:
|
||||
quality_info = {
|
||||
'filename': file.filename,
|
||||
'path': str(file.path),
|
||||
'size_bytes': file.size_bytes,
|
||||
}
|
||||
|
||||
# Add optional metadata if available
|
||||
if file.resolution is not None:
|
||||
quality_info['resolution'] = file.resolution
|
||||
|
||||
if file.codec is not None:
|
||||
quality_info['codec'] = file.codec
|
||||
|
||||
if file.duration_seconds is not None:
|
||||
quality_info['duration_seconds'] = file.duration_seconds
|
||||
|
||||
if file.bitrate_kbps is not None:
|
||||
quality_info['bitrate_kbps'] = file.bitrate_kbps
|
||||
|
||||
comparison_data.append(quality_info)
|
||||
|
||||
return comparison_data
|
||||
+2019
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
"""Configuration management for Video Library Manager."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Configuration for Video Library Manager.
|
||||
|
||||
Attributes:
|
||||
library_root: Root directory of the video library
|
||||
video_extensions: List of video file extensions to recognize
|
||||
movie_template: Directory template for movies (e.g., "movie/{title} ({year})/")
|
||||
series_template: Directory template for series (e.g., "series/{title}/Season {season:02d}/")
|
||||
movie_filename_template: Filename template for movies (e.g., "{title} ({year}){ext}")
|
||||
series_filename_template: Filename template for series (e.g., "S{season:02d}E{episode:02d}{ext}")
|
||||
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
quarantine_dir: Quarantine directory name relative to category root (e.g., ".quarantine")
|
||||
"""
|
||||
library_root: Path
|
||||
video_extensions: list[str] = field(default_factory=lambda: [
|
||||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||||
])
|
||||
movie_template: str = "movie/{title} ({year})/"
|
||||
series_template: str = "series/{title}/Season {season:02d}/"
|
||||
movie_filename_template: str = "{title} ({year}){ext}"
|
||||
series_filename_template: str = "S{season:02d}E{episode:02d}{ext}"
|
||||
log_level: str = "INFO"
|
||||
quarantine_dir: str = ".quarantine"
|
||||
|
||||
|
||||
def load_config(path: Path) -> Config:
|
||||
"""Load configuration from YAML file.
|
||||
|
||||
Args:
|
||||
path: Path to configuration file
|
||||
|
||||
Returns:
|
||||
Config object with loaded settings
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file doesn't exist (caller should handle by creating default)
|
||||
yaml.YAMLError: If YAML syntax is invalid (caller should handle by using defaults)
|
||||
"""
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Configuration file not found: {path}")
|
||||
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
raise yaml.YAMLError(f"Invalid YAML syntax in configuration file: {e}")
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
# Extract library_root (required field)
|
||||
library_root_str = data.get('library_root')
|
||||
if not library_root_str:
|
||||
raise ValueError("Configuration must specify 'library_root'")
|
||||
|
||||
library_root = Path(library_root_str).expanduser()
|
||||
|
||||
# Extract optional fields with defaults
|
||||
video_extensions = data.get('video_extensions', [
|
||||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||||
])
|
||||
|
||||
# Extract templates
|
||||
templates = data.get('templates', {})
|
||||
movie_template = templates.get('movie_dir', "movie/{title} ({year})/")
|
||||
series_template = templates.get('series_dir', "series/{title}/Season {season:02d}/")
|
||||
movie_filename_template = templates.get('movie_filename', "{title} ({year}){ext}")
|
||||
series_filename_template = templates.get('series_filename', "S{season:02d}E{episode:02d}{ext}")
|
||||
|
||||
# Extract other settings
|
||||
quarantine_dir = data.get('quarantine_dir', '.quarantine')
|
||||
log_level = data.get('log_level', 'INFO')
|
||||
|
||||
return Config(
|
||||
library_root=library_root,
|
||||
video_extensions=video_extensions,
|
||||
movie_template=movie_template,
|
||||
series_template=series_template,
|
||||
movie_filename_template=movie_filename_template,
|
||||
series_filename_template=series_filename_template,
|
||||
log_level=log_level,
|
||||
quarantine_dir=quarantine_dir
|
||||
)
|
||||
|
||||
|
||||
def create_default_config(path: Path) -> Config:
|
||||
"""Create a default configuration file and return the Config object.
|
||||
|
||||
Args:
|
||||
path: Path where configuration file should be created
|
||||
|
||||
Returns:
|
||||
Config object with default settings
|
||||
"""
|
||||
# Create default config object
|
||||
default_config = Config(
|
||||
library_root=Path.home() / "Videos",
|
||||
video_extensions=[".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"],
|
||||
movie_template="movie/{title} ({year})/",
|
||||
series_template="series/{title}/Season {season:02d}/",
|
||||
movie_filename_template="{title} ({year}){ext}",
|
||||
series_filename_template="S{season:02d}E{episode:02d}{ext}",
|
||||
log_level="INFO",
|
||||
quarantine_dir=".quarantine"
|
||||
)
|
||||
|
||||
# Create YAML content
|
||||
yaml_content = {
|
||||
'library_root': str(default_config.library_root),
|
||||
'video_extensions': default_config.video_extensions,
|
||||
'templates': {
|
||||
'movie_dir': default_config.movie_template,
|
||||
'series_dir': default_config.series_template,
|
||||
'movie_filename': default_config.movie_filename_template,
|
||||
'series_filename': default_config.series_filename_template
|
||||
},
|
||||
'quarantine_dir': default_config.quarantine_dir,
|
||||
'log_level': default_config.log_level
|
||||
}
|
||||
|
||||
# Ensure parent directory exists
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write configuration file
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(yaml_content, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
return default_config
|
||||
|
||||
|
||||
def validate_config(config: Config) -> list[str]:
|
||||
"""Validate configuration and return list of error messages.
|
||||
|
||||
Args:
|
||||
config: Configuration object to validate
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Validate library_root
|
||||
if not isinstance(config.library_root, Path):
|
||||
errors.append("library_root must be a Path object")
|
||||
elif not str(config.library_root) or str(config.library_root) == ".":
|
||||
errors.append("library_root cannot be empty")
|
||||
|
||||
# Validate video_extensions
|
||||
if not config.video_extensions:
|
||||
errors.append("video_extensions cannot be empty")
|
||||
elif not isinstance(config.video_extensions, list):
|
||||
errors.append("video_extensions must be a list")
|
||||
else:
|
||||
for ext in config.video_extensions:
|
||||
if not isinstance(ext, str):
|
||||
errors.append(f"video_extensions must contain strings, found: {type(ext)}")
|
||||
break
|
||||
if not ext.startswith('.'):
|
||||
errors.append(f"video extension must start with '.': {ext}")
|
||||
|
||||
# Validate templates
|
||||
if not config.movie_template:
|
||||
errors.append("movie_template cannot be empty")
|
||||
elif not isinstance(config.movie_template, str):
|
||||
errors.append("movie_template must be a string")
|
||||
|
||||
if not config.series_template:
|
||||
errors.append("series_template cannot be empty")
|
||||
elif not isinstance(config.series_template, str):
|
||||
errors.append("series_template must be a string")
|
||||
|
||||
if not config.movie_filename_template:
|
||||
errors.append("movie_filename_template cannot be empty")
|
||||
elif not isinstance(config.movie_filename_template, str):
|
||||
errors.append("movie_filename_template must be a string")
|
||||
|
||||
if not config.series_filename_template:
|
||||
errors.append("series_filename_template cannot be empty")
|
||||
elif not isinstance(config.series_filename_template, str):
|
||||
errors.append("series_filename_template must be a string")
|
||||
|
||||
# Validate log_level
|
||||
valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if not config.log_level:
|
||||
errors.append("log_level cannot be empty")
|
||||
elif not isinstance(config.log_level, str):
|
||||
errors.append("log_level must be a string")
|
||||
elif config.log_level.upper() not in valid_log_levels:
|
||||
errors.append(f"log_level must be one of {valid_log_levels}, got: {config.log_level}")
|
||||
|
||||
# Validate quarantine_dir
|
||||
if not config.quarantine_dir:
|
||||
errors.append("quarantine_dir cannot be empty")
|
||||
elif not isinstance(config.quarantine_dir, str):
|
||||
errors.append("quarantine_dir must be a string")
|
||||
elif config.quarantine_dir.startswith('/') or config.quarantine_dir.startswith('\\'):
|
||||
errors.append("quarantine_dir must be relative to category root, not absolute")
|
||||
|
||||
return errors
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Execution engine for Video Library Manager.
|
||||
|
||||
This module provides safe execution of file operations with:
|
||||
- Dry-run mode (default): simulates operations without making changes
|
||||
- Execute mode: performs actual file operations (requires explicit confirmation)
|
||||
- Comprehensive logging of all operations
|
||||
- Error handling and resilience
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from .logging_config import get_logger, log_operation
|
||||
from .models import ExecutionPlan, FileOperation, OperationResult, RollbackLog
|
||||
|
||||
|
||||
class ExecutionEngine:
|
||||
"""Engine for executing file operations safely with dry-run support."""
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None):
|
||||
"""Initialize the execution engine.
|
||||
|
||||
Args:
|
||||
logger: Optional logger instance (uses default if not provided)
|
||||
"""
|
||||
self.logger = logger or get_logger()
|
||||
|
||||
def execute_plan(
|
||||
self,
|
||||
plan: ExecutionPlan,
|
||||
mode: str = "dry-run",
|
||||
confirmed: bool = False
|
||||
) -> tuple[list[OperationResult], dict, Optional[RollbackLog]]:
|
||||
"""Execute an execution plan with the specified mode.
|
||||
|
||||
Args:
|
||||
plan: The execution plan to execute
|
||||
mode: Execution mode - "dry-run" (default) or "execute"
|
||||
confirmed: Whether execution has been explicitly confirmed (required for execute mode)
|
||||
|
||||
Returns:
|
||||
Tuple of (operation results, execution summary, rollback log)
|
||||
- operation results: List of results for each operation
|
||||
- execution summary: Dict with counts of successful, failed, and skipped operations
|
||||
- rollback log: RollbackLog if mode is "execute", None for dry-run
|
||||
|
||||
Raises:
|
||||
ValueError: If mode is invalid or execute mode used without confirmation
|
||||
"""
|
||||
# Validate mode
|
||||
if mode not in ("dry-run", "execute"):
|
||||
raise ValueError(f"Invalid mode: {mode}. Must be 'dry-run' or 'execute'")
|
||||
|
||||
# Require confirmation for execute mode
|
||||
if mode == "execute" and not confirmed:
|
||||
raise ValueError(
|
||||
"Execute mode requires explicit confirmation. "
|
||||
"Set confirmed=True or use --confirm flag in CLI"
|
||||
)
|
||||
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.INFO,
|
||||
f"Starting execution in {mode} mode with {len(plan.operations)} operations",
|
||||
operation_type="execute"
|
||||
)
|
||||
|
||||
# Execute all operations
|
||||
results = []
|
||||
for operation in plan.operations:
|
||||
result = self.execute_operation(operation, mode)
|
||||
results.append(result)
|
||||
|
||||
# Generate execution summary
|
||||
summary = self._generate_execution_summary(results)
|
||||
|
||||
# Create rollback log only in execute mode
|
||||
rollback_log = None
|
||||
if mode == "execute":
|
||||
# Only include successful operations in rollback log
|
||||
successful_operations = [r for r in results if r.success]
|
||||
rollback_log = RollbackLog(
|
||||
log_id=str(uuid4()),
|
||||
execution_plan_id=plan.plan_id,
|
||||
executed_at=datetime.now(),
|
||||
operations=successful_operations
|
||||
)
|
||||
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.INFO,
|
||||
f"Created rollback log with {len(successful_operations)} successful operations",
|
||||
operation_type="execute"
|
||||
)
|
||||
|
||||
# Log execution summary
|
||||
self._log_execution_summary(summary, mode)
|
||||
|
||||
return results, summary, rollback_log
|
||||
|
||||
def execute_operation(
|
||||
self,
|
||||
operation: FileOperation,
|
||||
mode: str
|
||||
) -> OperationResult:
|
||||
"""Execute a single file operation.
|
||||
|
||||
Args:
|
||||
operation: The file operation to execute
|
||||
mode: Execution mode - "dry-run" or "execute"
|
||||
|
||||
Returns:
|
||||
OperationResult with success status and any error message
|
||||
"""
|
||||
executed_at = datetime.now()
|
||||
|
||||
# Handle no-op operations
|
||||
if operation.operation_type == "no-op":
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.DEBUG,
|
||||
f"Skipping no-op operation: {operation.reason}",
|
||||
operation_type="execute",
|
||||
file_path=operation.source_path
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=True,
|
||||
error_message=None,
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
# Handle conflicted operations
|
||||
if operation.has_conflict:
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.WARNING,
|
||||
f"Skipping conflicted operation: {operation.conflict_reason}",
|
||||
operation_type="execute",
|
||||
file_path=operation.source_path
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=False,
|
||||
error_message=f"Conflict: {operation.conflict_reason}",
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
# Execute based on mode
|
||||
if mode == "dry-run":
|
||||
return self._simulate_operation(operation, executed_at)
|
||||
else:
|
||||
return self._perform_operation(operation, executed_at)
|
||||
|
||||
def _simulate_operation(
|
||||
self,
|
||||
operation: FileOperation,
|
||||
executed_at: datetime
|
||||
) -> OperationResult:
|
||||
"""Simulate an operation in dry-run mode without making changes.
|
||||
|
||||
Args:
|
||||
operation: The file operation to simulate
|
||||
executed_at: Timestamp of execution
|
||||
|
||||
Returns:
|
||||
OperationResult indicating what would happen
|
||||
"""
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.INFO,
|
||||
f"[DRY-RUN] Would {operation.operation_type}: "
|
||||
f"{operation.source_path} -> {operation.destination_path}",
|
||||
operation_type="execute",
|
||||
file_path=operation.source_path
|
||||
)
|
||||
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=True,
|
||||
error_message=None,
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
def _perform_operation(
|
||||
self,
|
||||
operation: FileOperation,
|
||||
executed_at: datetime
|
||||
) -> OperationResult:
|
||||
"""Perform an actual file operation.
|
||||
|
||||
Args:
|
||||
operation: The file operation to perform
|
||||
executed_at: Timestamp of execution
|
||||
|
||||
Returns:
|
||||
OperationResult with success status and any error message
|
||||
"""
|
||||
try:
|
||||
# Validate source file exists
|
||||
if not operation.source_path.exists():
|
||||
error_msg = f"Source file does not exist: {operation.source_path}"
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.ERROR,
|
||||
error_msg,
|
||||
operation_type="execute",
|
||||
file_path=operation.source_path
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
# Create destination directory if needed
|
||||
if operation.destination_path:
|
||||
operation.destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Perform the move/rename operation
|
||||
operation.source_path.rename(operation.destination_path)
|
||||
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.INFO,
|
||||
f"Successfully {operation.operation_type}: "
|
||||
f"{operation.source_path} -> {operation.destination_path}",
|
||||
operation_type="execute",
|
||||
file_path=operation.source_path
|
||||
)
|
||||
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=True,
|
||||
error_message=None,
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to {operation.operation_type}: {str(e)}"
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.ERROR,
|
||||
error_msg,
|
||||
operation_type="execute",
|
||||
file_path=operation.source_path
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
def _generate_execution_summary(
|
||||
self,
|
||||
results: list[OperationResult]
|
||||
) -> dict:
|
||||
"""Generate a structured execution summary.
|
||||
|
||||
Args:
|
||||
results: List of operation results
|
||||
|
||||
Returns:
|
||||
Dictionary with counts of successful, failed, and skipped operations
|
||||
"""
|
||||
successful = sum(1 for r in results if r.success)
|
||||
failed = sum(1 for r in results if not r.success)
|
||||
skipped = sum(
|
||||
1 for r in results
|
||||
if r.operation.operation_type == "no-op" or r.operation.has_conflict
|
||||
)
|
||||
|
||||
return {
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"total": len(results)
|
||||
}
|
||||
|
||||
def _log_execution_summary(
|
||||
self,
|
||||
summary: dict,
|
||||
mode: str
|
||||
) -> None:
|
||||
"""Log a summary of execution results.
|
||||
|
||||
Args:
|
||||
summary: Execution summary dictionary
|
||||
mode: Execution mode that was used
|
||||
"""
|
||||
summary_msg = (
|
||||
f"Execution summary ({mode} mode): "
|
||||
f"{summary['successful']} successful, {summary['failed']} failed, {summary['skipped']} skipped"
|
||||
)
|
||||
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.INFO,
|
||||
summary_msg,
|
||||
operation_type="execute"
|
||||
)
|
||||
|
||||
def save_rollback_log(
|
||||
self,
|
||||
rollback_log: RollbackLog,
|
||||
output_path: Path
|
||||
) -> None:
|
||||
"""Save rollback log to disk in JSON format.
|
||||
|
||||
Args:
|
||||
rollback_log: The rollback log to save
|
||||
output_path: Path where the rollback log should be saved
|
||||
"""
|
||||
# Convert rollback log to JSON-serializable format
|
||||
log_data = {
|
||||
"log_id": rollback_log.log_id,
|
||||
"execution_plan_id": rollback_log.execution_plan_id,
|
||||
"executed_at": rollback_log.executed_at.isoformat(),
|
||||
"operations": [
|
||||
{
|
||||
"operation_type": op.operation.operation_type,
|
||||
"source_path": str(op.operation.source_path),
|
||||
"destination_path": str(op.operation.destination_path) if op.operation.destination_path else None,
|
||||
"reason": op.operation.reason,
|
||||
"success": op.success,
|
||||
"error_message": op.error_message,
|
||||
"executed_at": op.executed_at.isoformat()
|
||||
}
|
||||
for op in rollback_log.operations
|
||||
]
|
||||
}
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to file
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(log_data, f, indent=2)
|
||||
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.INFO,
|
||||
f"Saved rollback log to {output_path}",
|
||||
operation_type="execute"
|
||||
)
|
||||
def load_rollback_log(
|
||||
self,
|
||||
log_path: Path
|
||||
) -> RollbackLog:
|
||||
"""Load rollback log from disk.
|
||||
|
||||
Args:
|
||||
log_path: Path to the rollback log JSON file
|
||||
|
||||
Returns:
|
||||
RollbackLog object loaded from the file
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the log file does not exist
|
||||
ValueError: If the log file is invalid JSON or missing required fields
|
||||
"""
|
||||
if not log_path.exists():
|
||||
raise FileNotFoundError(f"Rollback log not found: {log_path}")
|
||||
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
log_data = json.load(f)
|
||||
|
||||
# Reconstruct RollbackLog from JSON data
|
||||
operations = []
|
||||
for op_data in log_data["operations"]:
|
||||
# Reconstruct FileOperation
|
||||
file_op = FileOperation(
|
||||
operation_type=op_data["operation_type"],
|
||||
source_path=Path(op_data["source_path"]),
|
||||
destination_path=Path(op_data["destination_path"]) if op_data["destination_path"] else None,
|
||||
reason=op_data["reason"],
|
||||
has_conflict=False, # Conflicts don't matter for rollback
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Reconstruct OperationResult
|
||||
op_result = OperationResult(
|
||||
operation=file_op,
|
||||
success=op_data["success"],
|
||||
error_message=op_data["error_message"],
|
||||
executed_at=datetime.fromisoformat(op_data["executed_at"])
|
||||
)
|
||||
operations.append(op_result)
|
||||
|
||||
rollback_log = RollbackLog(
|
||||
log_id=log_data["log_id"],
|
||||
execution_plan_id=log_data["execution_plan_id"],
|
||||
executed_at=datetime.fromisoformat(log_data["executed_at"]),
|
||||
operations=operations
|
||||
)
|
||||
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.INFO,
|
||||
f"Loaded rollback log from {log_path} with {len(operations)} operations",
|
||||
operation_type="rollback"
|
||||
)
|
||||
|
||||
return rollback_log
|
||||
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
raise ValueError(f"Invalid rollback log format: {str(e)}")
|
||||
|
||||
def rollback(
|
||||
self,
|
||||
rollback_log: RollbackLog
|
||||
) -> tuple[list[OperationResult], dict]:
|
||||
"""Rollback operations from a rollback log (best-effort).
|
||||
|
||||
This method attempts to reverse all operations in the rollback log by moving
|
||||
files from their destination back to their source. Operations are processed
|
||||
in LIFO (Last In, First Out) order for best-effort restoration.
|
||||
|
||||
Args:
|
||||
rollback_log: The rollback log containing operations to reverse
|
||||
|
||||
Returns:
|
||||
Tuple of (rollback results, rollback summary)
|
||||
- rollback results: List of OperationResult for each rollback attempt
|
||||
- rollback summary: Dict with counts of successful, failed, and skipped operations
|
||||
"""
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.INFO,
|
||||
f"Starting rollback of {len(rollback_log.operations)} operations (LIFO order)",
|
||||
operation_type="rollback"
|
||||
)
|
||||
|
||||
# Reverse the operation list (LIFO order)
|
||||
reversed_operations = list(reversed(rollback_log.operations))
|
||||
|
||||
# Attempt to rollback each operation
|
||||
results = []
|
||||
for original_result in reversed_operations:
|
||||
result = self._rollback_operation(original_result)
|
||||
results.append(result)
|
||||
|
||||
# Generate rollback summary
|
||||
summary = self._generate_rollback_summary(results)
|
||||
|
||||
# Log rollback summary
|
||||
self._log_rollback_summary(summary)
|
||||
|
||||
return results, summary
|
||||
|
||||
def _rollback_operation(
|
||||
self,
|
||||
original_result: OperationResult
|
||||
) -> OperationResult:
|
||||
"""Rollback a single operation (best-effort).
|
||||
|
||||
Args:
|
||||
original_result: The original operation result to rollback
|
||||
|
||||
Returns:
|
||||
OperationResult indicating success or failure of the rollback
|
||||
"""
|
||||
operation = original_result.operation
|
||||
executed_at = datetime.now()
|
||||
|
||||
# Skip no-op operations
|
||||
if operation.operation_type == "no-op":
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.DEBUG,
|
||||
f"Skipping rollback of no-op operation",
|
||||
operation_type="rollback",
|
||||
file_path=operation.source_path
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=True,
|
||||
error_message=None,
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
try:
|
||||
# For move/rename operations, reverse the direction
|
||||
# Original: source -> destination
|
||||
# Rollback: destination -> source
|
||||
if operation.destination_path and operation.destination_path.exists():
|
||||
# Move file back from destination to source
|
||||
operation.destination_path.rename(operation.source_path)
|
||||
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.INFO,
|
||||
f"Successfully rolled back: {operation.destination_path} -> {operation.source_path}",
|
||||
operation_type="rollback",
|
||||
file_path=operation.destination_path
|
||||
)
|
||||
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=True,
|
||||
error_message=None,
|
||||
executed_at=executed_at
|
||||
)
|
||||
else:
|
||||
# Destination file doesn't exist - cannot rollback
|
||||
error_msg = f"Cannot rollback: destination file not found at {operation.destination_path}"
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.WARNING,
|
||||
error_msg,
|
||||
operation_type="rollback",
|
||||
file_path=operation.destination_path
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Handle rollback failures gracefully - log and continue
|
||||
error_msg = f"Failed to rollback operation: {str(e)}"
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.ERROR,
|
||||
error_msg,
|
||||
operation_type="rollback",
|
||||
file_path=operation.destination_path
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
def _generate_rollback_summary(
|
||||
self,
|
||||
results: list[OperationResult]
|
||||
) -> dict:
|
||||
"""Generate a structured rollback summary.
|
||||
|
||||
Args:
|
||||
results: List of rollback operation results
|
||||
|
||||
Returns:
|
||||
Dictionary with counts of successful, failed, and skipped operations
|
||||
"""
|
||||
successful = sum(1 for r in results if r.success)
|
||||
failed = sum(1 for r in results if not r.success)
|
||||
skipped = sum(
|
||||
1 for r in results
|
||||
if r.operation.operation_type == "no-op"
|
||||
)
|
||||
|
||||
return {
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"total": len(results)
|
||||
}
|
||||
|
||||
def _log_rollback_summary(
|
||||
self,
|
||||
summary: dict
|
||||
) -> None:
|
||||
"""Log a summary of rollback results.
|
||||
|
||||
Args:
|
||||
summary: Rollback summary dictionary
|
||||
"""
|
||||
summary_msg = (
|
||||
f"Rollback summary: "
|
||||
f"{summary['successful']} successful, {summary['failed']} failed, {summary['skipped']} skipped"
|
||||
)
|
||||
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.INFO,
|
||||
summary_msg,
|
||||
operation_type="rollback"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Logging configuration for Video Library Manager.
|
||||
|
||||
This module provides centralized logging configuration with:
|
||||
- Configurable log levels (DEBUG, INFO, WARNING, ERROR)
|
||||
- Dual output: console (INFO+) and file (DEBUG+)
|
||||
- Timestamps, operation type, and file paths in log entries
|
||||
- Log rotation at 10MB threshold
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Default log directory
|
||||
DEFAULT_LOG_DIR = Path.home() / ".vlm" / "logs"
|
||||
DEFAULT_LOG_FILE = "vlm.log"
|
||||
MAX_LOG_SIZE = 10 * 1024 * 1024 # 10MB in bytes
|
||||
BACKUP_COUNT = 5 # Keep 5 backup log files
|
||||
|
||||
|
||||
class OperationContextFilter(logging.Filter):
|
||||
"""Filter to add operation context to log records."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Add operation_type and file_path attributes if not present."""
|
||||
if not hasattr(record, 'operation_type'):
|
||||
record.operation_type = 'general'
|
||||
if not hasattr(record, 'file_path'):
|
||||
record.file_path = ''
|
||||
return True
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_level: str = "INFO",
|
||||
log_dir: Optional[Path] = None,
|
||||
log_file: str = DEFAULT_LOG_FILE
|
||||
) -> logging.Logger:
|
||||
"""Configure logging with dual output (console and file) and rotation.
|
||||
|
||||
Args:
|
||||
log_level: Minimum log level for console output (DEBUG, INFO, WARNING, ERROR)
|
||||
log_dir: Directory for log files (defaults to ~/.vlm/logs)
|
||||
log_file: Name of the log file (defaults to vlm.log)
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
|
||||
Raises:
|
||||
ValueError: If log_level is invalid
|
||||
"""
|
||||
# Validate log level
|
||||
numeric_level = getattr(logging, log_level.upper(), None)
|
||||
if not isinstance(numeric_level, int):
|
||||
raise ValueError(f"Invalid log level: {log_level}")
|
||||
|
||||
# Use default log directory if not specified
|
||||
if log_dir is None:
|
||||
log_dir = DEFAULT_LOG_DIR
|
||||
|
||||
# Create log directory if it doesn't exist
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get root logger
|
||||
logger = logging.getLogger("vlm")
|
||||
logger.setLevel(logging.DEBUG) # Capture all levels, handlers will filter
|
||||
|
||||
# Remove existing handlers to avoid duplicates
|
||||
logger.handlers.clear()
|
||||
|
||||
# Create formatter with timestamps, operation type, and file paths
|
||||
formatter = logging.Formatter(
|
||||
fmt='%(asctime)s - %(levelname)s - [%(operation_type)s] - %(message)s%(file_path)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# Console handler (INFO+)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(numeric_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.addFilter(OperationContextFilter())
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# File handler with rotation (DEBUG+)
|
||||
log_file_path = log_dir / log_file
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
filename=log_file_path,
|
||||
maxBytes=MAX_LOG_SIZE,
|
||||
backupCount=BACKUP_COUNT,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.addFilter(OperationContextFilter())
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# Prevent propagation to root logger
|
||||
logger.propagate = False
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def get_logger() -> logging.Logger:
|
||||
"""Get the configured VLM logger instance.
|
||||
|
||||
Returns:
|
||||
Logger instance (creates default configuration if not already set up)
|
||||
"""
|
||||
logger = logging.getLogger("vlm")
|
||||
|
||||
# If logger has no handlers, set up default configuration
|
||||
if not logger.handlers:
|
||||
setup_logging()
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def log_operation(
|
||||
logger: logging.Logger,
|
||||
level: int,
|
||||
message: str,
|
||||
operation_type: str = "general",
|
||||
file_path: Optional[Path] = None
|
||||
) -> None:
|
||||
"""Log a message with operation context.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
level: Log level (logging.DEBUG, logging.INFO, etc.)
|
||||
message: Log message
|
||||
operation_type: Type of operation (scan, parse, execute, etc.)
|
||||
file_path: Optional file path related to the operation
|
||||
"""
|
||||
extra = {
|
||||
'operation_type': operation_type,
|
||||
'file_path': f' - {file_path}' if file_path else ''
|
||||
}
|
||||
logger.log(level, message, extra=extra)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Data structures for Video Library Manager.
|
||||
|
||||
This module defines the core data structures used throughout the application
|
||||
for representing video files and their parsed identities.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoFile:
|
||||
"""Represents a video file discovered during inventory scanning.
|
||||
|
||||
Attributes:
|
||||
path: Full path to the video file
|
||||
filename: Name of the file (without directory path)
|
||||
size_bytes: File size in bytes
|
||||
modified_timestamp: Last modification timestamp
|
||||
category: Category of the video ("movie", "series", "anime", "other")
|
||||
resolution: Optional video resolution (e.g., "1920x1080")
|
||||
codec: Optional video codec (e.g., "h264")
|
||||
duration_seconds: Optional video duration in seconds
|
||||
bitrate_kbps: Optional video bitrate in kilobits per second
|
||||
"""
|
||||
path: Path
|
||||
filename: str
|
||||
size_bytes: int
|
||||
modified_timestamp: datetime
|
||||
category: str
|
||||
|
||||
# Optional metadata (if ffprobe available)
|
||||
resolution: Optional[str] = None
|
||||
codec: Optional[str] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
bitrate_kbps: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MovieIdentity:
|
||||
"""Represents the parsed identity of a movie file.
|
||||
|
||||
Attributes:
|
||||
title: Extracted movie title (normalized)
|
||||
year: Extracted release year (None if not found)
|
||||
confidence: Confidence score of the parsing (0.0 to 1.0)
|
||||
needs_review: Flag indicating if manual review is needed
|
||||
original_filename: Original filename before parsing
|
||||
"""
|
||||
title: str
|
||||
year: Optional[int]
|
||||
confidence: float
|
||||
needs_review: bool
|
||||
original_filename: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeriesIdentity:
|
||||
"""Represents the parsed identity of a TV series episode file.
|
||||
|
||||
Attributes:
|
||||
title: Extracted series title (normalized)
|
||||
season: Extracted season number (None if not found)
|
||||
episodes: List of episode numbers (supports multi-episode files)
|
||||
confidence: Confidence score of the parsing (0.0 to 1.0)
|
||||
needs_review: Flag indicating if manual review is needed
|
||||
original_filename: Original filename before parsing
|
||||
"""
|
||||
title: str
|
||||
season: Optional[int]
|
||||
episodes: list[int]
|
||||
confidence: float
|
||||
needs_review: bool
|
||||
original_filename: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileOperation:
|
||||
"""Represents a single file operation in an execution plan.
|
||||
|
||||
Attributes:
|
||||
operation_type: Type of operation ("move", "rename", "quarantine", "no-op")
|
||||
source_path: Source file path
|
||||
destination_path: Destination file path (None for no-op operations)
|
||||
reason: Human-readable reason for the operation
|
||||
has_conflict: Flag indicating if destination already exists
|
||||
conflict_reason: Description of the conflict (None if no conflict)
|
||||
"""
|
||||
operation_type: str
|
||||
source_path: Path
|
||||
destination_path: Optional[Path]
|
||||
reason: str
|
||||
has_conflict: bool
|
||||
conflict_reason: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionPlan:
|
||||
"""Represents a complete execution plan with all file operations.
|
||||
|
||||
Attributes:
|
||||
plan_id: Unique identifier for the plan (UUID)
|
||||
created_at: Timestamp when the plan was created
|
||||
operations: List of file operations to execute
|
||||
summary: Dictionary with operation counts by type
|
||||
"""
|
||||
plan_id: str
|
||||
created_at: datetime
|
||||
operations: list[FileOperation]
|
||||
summary: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperationResult:
|
||||
"""Represents the result of executing a single file operation.
|
||||
|
||||
Attributes:
|
||||
operation: The file operation that was executed
|
||||
success: Flag indicating if the operation succeeded
|
||||
error_message: Error message if operation failed (None if successful)
|
||||
executed_at: Timestamp when the operation was executed
|
||||
"""
|
||||
operation: FileOperation
|
||||
success: bool
|
||||
error_message: Optional[str]
|
||||
executed_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class RollbackLog:
|
||||
"""Represents a log of executed operations for rollback purposes.
|
||||
|
||||
Attributes:
|
||||
log_id: Unique identifier for the rollback log (UUID)
|
||||
execution_plan_id: ID of the execution plan that was executed
|
||||
executed_at: Timestamp when the operations were executed
|
||||
operations: List of operation results that were executed
|
||||
"""
|
||||
log_id: str
|
||||
execution_plan_id: str
|
||||
executed_at: datetime
|
||||
operations: list[OperationResult]
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuarantineEntry:
|
||||
"""Represents a single file in quarantine.
|
||||
|
||||
Attributes:
|
||||
original_path: Original path of the file before quarantine
|
||||
quarantine_path: Path to the file in quarantine directory
|
||||
quarantined_at: Timestamp when the file was quarantined
|
||||
reason: Optional reason for quarantining the file
|
||||
size_bytes: File size in bytes
|
||||
category: Category of the video ("movie" or "series")
|
||||
"""
|
||||
original_path: Path
|
||||
quarantine_path: Path
|
||||
quarantined_at: datetime
|
||||
reason: Optional[str]
|
||||
size_bytes: int
|
||||
category: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuarantineManifest:
|
||||
"""Represents a manifest of all quarantined files in a category.
|
||||
|
||||
Attributes:
|
||||
entries: List of quarantine entries
|
||||
"""
|
||||
entries: list[QuarantineEntry]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileState:
|
||||
"""Represents the state of a file in the workflow.
|
||||
|
||||
Attributes:
|
||||
file_path: Path to the file
|
||||
status: Current status ("reviewed", "ignored", "planned", "executed", "quarantined")
|
||||
reason: Optional reason for the status
|
||||
updated_at: Timestamp when the state was last updated
|
||||
"""
|
||||
file_path: Path
|
||||
status: str
|
||||
reason: Optional[str]
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class StateStore:
|
||||
"""Represents the persistent state store for all files.
|
||||
|
||||
Attributes:
|
||||
states: Dictionary mapping file path strings to FileState objects
|
||||
version: Version of the state store format
|
||||
last_updated: Timestamp when the state store was last updated
|
||||
"""
|
||||
states: dict[str, FileState]
|
||||
version: str
|
||||
last_updated: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeasonCompleteness:
|
||||
"""Represents completeness analysis for a single season of a series.
|
||||
|
||||
Attributes:
|
||||
series_title: Normalized series title
|
||||
season: Season number
|
||||
episodes_found: List of episode numbers that were found
|
||||
episodes_missing: List of episode numbers missing in the range [min, max]
|
||||
"""
|
||||
series_title: str
|
||||
season: int
|
||||
episodes_found: list[int]
|
||||
episodes_missing: list[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplicateGroup:
|
||||
"""Represents a group of duplicate video files.
|
||||
|
||||
Attributes:
|
||||
identity: The shared identity (MovieIdentity or SeriesIdentity)
|
||||
files: List of VideoFile objects that are duplicates
|
||||
quality_comparison: List of dictionaries with quality metrics for each file
|
||||
"""
|
||||
identity: MovieIdentity | SeriesIdentity
|
||||
files: list[VideoFile]
|
||||
quality_comparison: list[dict]
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Identity parser for extracting movie and series information from filenames.
|
||||
|
||||
This module provides functionality to parse video filenames and extract
|
||||
logical identities such as movie titles/years and series titles/seasons/episodes.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from vlm.models import MovieIdentity, SeriesIdentity
|
||||
|
||||
|
||||
# Quality tags to remove from titles
|
||||
QUALITY_TAGS = [
|
||||
r'\b1080p\b', r'\b720p\b', r'\b480p\b', r'\b2160p\b',
|
||||
r'\b4K\b', r'\bUHD\b', r'\bHD\b',
|
||||
r'\bBluRay\b', r'\bBlu-Ray\b', r'\bBRRip\b', r'\bBDRip\b',
|
||||
r'\bWEB-DL\b', r'\bWEBRip\b', r'\bWEB\b',
|
||||
r'\bHDTV\b', r'\bHDRip\b',
|
||||
r'\bDVDRip\b', r'\bDVD\b',
|
||||
r'\bx264\b', r'\bx265\b', r'\bh264\b', r'\bh265\b', r'\bHEVC\b',
|
||||
r'\bAAC\b', r'\bAC3\b', r'\bDTS\b',
|
||||
r'\b10bit\b', r'\b8bit\b',
|
||||
]
|
||||
|
||||
# Release group patterns (in brackets, but NOT years in parentheses)
|
||||
RELEASE_GROUP_PATTERNS = [
|
||||
r'\[[\w\s\-\.]+\]', # [RARBG], [YTS], etc.
|
||||
]
|
||||
|
||||
|
||||
def remove_quality_tags(text: str) -> str:
|
||||
"""Remove quality indicators from text.
|
||||
|
||||
Args:
|
||||
text: Input text containing potential quality tags
|
||||
|
||||
Returns:
|
||||
Text with quality tags removed
|
||||
"""
|
||||
result = text
|
||||
for pattern in QUALITY_TAGS:
|
||||
result = re.sub(pattern, '', result, flags=re.IGNORECASE)
|
||||
return result
|
||||
|
||||
|
||||
def remove_release_groups(text: str) -> str:
|
||||
"""Remove release group tags from text.
|
||||
|
||||
Args:
|
||||
text: Input text containing potential release group tags
|
||||
|
||||
Returns:
|
||||
Text with release group tags removed
|
||||
"""
|
||||
result = text
|
||||
for pattern in RELEASE_GROUP_PATTERNS:
|
||||
result = re.sub(pattern, '', result)
|
||||
return result
|
||||
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
"""Normalize a title by cleaning whitespace and standardizing capitalization.
|
||||
|
||||
Args:
|
||||
title: Raw title string
|
||||
|
||||
Returns:
|
||||
Normalized title with proper capitalization and spacing
|
||||
"""
|
||||
# Replace dots and underscores with spaces
|
||||
title = title.replace('.', ' ').replace('_', ' ')
|
||||
|
||||
# Remove extra whitespace
|
||||
title = ' '.join(title.split())
|
||||
|
||||
# Apply title case
|
||||
title = title.title()
|
||||
|
||||
return title.strip()
|
||||
|
||||
|
||||
def parse_movie(filename: str) -> MovieIdentity:
|
||||
"""Parse a movie filename to extract title and year.
|
||||
|
||||
Supports patterns:
|
||||
- Title (Year)
|
||||
- Title.Year
|
||||
- Title - Year
|
||||
- Title Year
|
||||
|
||||
Args:
|
||||
filename: Movie filename to parse
|
||||
|
||||
Returns:
|
||||
MovieIdentity with extracted information
|
||||
"""
|
||||
# Remove file extension
|
||||
name_without_ext = filename
|
||||
for ext in ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v']:
|
||||
if name_without_ext.lower().endswith(ext):
|
||||
name_without_ext = name_without_ext[:-len(ext)]
|
||||
break
|
||||
|
||||
# Try different patterns in order of confidence BEFORE cleaning
|
||||
# This preserves the year in parentheses
|
||||
patterns = [
|
||||
# Pattern: Title (Year) - High confidence
|
||||
(r'^(.+?)\s*\((\d{4})\)', 0.9),
|
||||
# Pattern: Title.Year or Title-Year - High confidence
|
||||
(r'^(.+?)[\.\-](\d{4})', 0.9),
|
||||
# Pattern: Title - Year - Medium confidence
|
||||
(r'^(.+?)\s+-\s+(\d{4})', 0.7),
|
||||
# Pattern: Title Year (4 digits at end) - Medium confidence
|
||||
(r'^(.+?)\s+(\d{4})(?:\s|$)', 0.7),
|
||||
]
|
||||
|
||||
for pattern, confidence in patterns:
|
||||
match = re.search(pattern, name_without_ext)
|
||||
if match:
|
||||
title = match.group(1)
|
||||
year = int(match.group(2))
|
||||
|
||||
# Now clean the title
|
||||
title = remove_quality_tags(title)
|
||||
title = remove_release_groups(title)
|
||||
title = normalize_title(title)
|
||||
|
||||
return MovieIdentity(
|
||||
title=title,
|
||||
year=year,
|
||||
confidence=confidence,
|
||||
needs_review=False,
|
||||
original_filename=filename
|
||||
)
|
||||
|
||||
# No year found - clean and extract title, flag for review
|
||||
cleaned = remove_quality_tags(name_without_ext)
|
||||
cleaned = remove_release_groups(cleaned)
|
||||
title = normalize_title(cleaned)
|
||||
|
||||
return MovieIdentity(
|
||||
title=title,
|
||||
year=None,
|
||||
confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename=filename
|
||||
)
|
||||
|
||||
|
||||
def parse_series(filename: str) -> SeriesIdentity:
|
||||
"""Parse a series filename to extract title, season, and episode numbers.
|
||||
|
||||
Supports patterns:
|
||||
- SXXEYY (e.g., S01E01)
|
||||
- SXXeYY (e.g., S01e01)
|
||||
- SeasonXEpisodeY (e.g., Season1Episode1)
|
||||
- XXxYY (e.g., 1x01)
|
||||
- Multi-episode: S01E01-E02, S01E01E02, etc.
|
||||
|
||||
Args:
|
||||
filename: Series filename to parse
|
||||
|
||||
Returns:
|
||||
SeriesIdentity with extracted information
|
||||
"""
|
||||
# Remove file extension
|
||||
name_without_ext = filename
|
||||
for ext in ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v']:
|
||||
if name_without_ext.lower().endswith(ext):
|
||||
name_without_ext = name_without_ext[:-len(ext)]
|
||||
break
|
||||
|
||||
# Try different patterns in order of confidence
|
||||
patterns = [
|
||||
# Pattern: SXXEYY or SXXeYY - High confidence
|
||||
# Also handles multi-episode: S01E01-E02, S01E01E02E03, etc.
|
||||
(r'[Ss](\d{1,2})[Ee](\d{1,2})', 0.9),
|
||||
# Pattern: XXxYY - High confidence
|
||||
(r'(\d{1,2})x(\d{1,2})', 0.9),
|
||||
# Pattern: Season X Episode Y - Medium confidence
|
||||
(r'[Ss]eason\s*(\d{1,2})\s*[Ee]pisode\s*(\d{1,2})', 0.7),
|
||||
]
|
||||
|
||||
season = None
|
||||
episodes = []
|
||||
confidence = 0.0
|
||||
title_part = name_without_ext
|
||||
|
||||
for pattern, conf in patterns:
|
||||
match = re.search(pattern, name_without_ext, re.IGNORECASE)
|
||||
if match:
|
||||
season = int(match.group(1))
|
||||
episodes = [int(match.group(2))]
|
||||
confidence = conf
|
||||
|
||||
# Extract title (everything before the match)
|
||||
title_part = name_without_ext[:match.start()]
|
||||
|
||||
# Handle multi-episode files for SXXEYY pattern
|
||||
if pattern.startswith(r'[Ss]'):
|
||||
# Find the full episode section (from S01E01 onwards)
|
||||
remaining = name_without_ext[match.start():]
|
||||
|
||||
# Look for all episode numbers: E01, -E02, E03, etc.
|
||||
all_episode_matches = re.findall(r'[Ee](\d{1,2})', remaining)
|
||||
if all_episode_matches:
|
||||
episodes = [int(ep) for ep in all_episode_matches]
|
||||
|
||||
break
|
||||
|
||||
# Clean the title
|
||||
if title_part:
|
||||
title_part = remove_quality_tags(title_part)
|
||||
title_part = remove_release_groups(title_part)
|
||||
title_part = normalize_title(title_part)
|
||||
else:
|
||||
# If no title part found, use the whole filename cleaned
|
||||
title_part = remove_quality_tags(name_without_ext)
|
||||
title_part = remove_release_groups(title_part)
|
||||
title_part = normalize_title(title_part)
|
||||
|
||||
# Determine if review is needed
|
||||
needs_review = season is None or len(episodes) == 0
|
||||
|
||||
# If no pattern matched, set low confidence
|
||||
if season is None:
|
||||
confidence = 0.3
|
||||
|
||||
return SeriesIdentity(
|
||||
title=title_part,
|
||||
season=season,
|
||||
episodes=episodes,
|
||||
confidence=confidence,
|
||||
needs_review=needs_review,
|
||||
original_filename=filename
|
||||
)
|
||||
|
||||
|
||||
def group_episodes(episodes: list[SeriesIdentity]) -> dict[tuple[str, int], list[SeriesIdentity]]:
|
||||
"""Group parsed episodes by normalized series title and season number.
|
||||
|
||||
Episodes are grouped by (normalized_title, season) tuple. Episodes with
|
||||
season=None are excluded from grouping as they need manual review.
|
||||
|
||||
Args:
|
||||
episodes: List of parsed series identities
|
||||
|
||||
Returns:
|
||||
Dictionary mapping (title, season) tuples to lists of SeriesIdentity objects
|
||||
"""
|
||||
groups: dict[tuple[str, int], list[SeriesIdentity]] = {}
|
||||
|
||||
for episode in episodes:
|
||||
# Skip episodes without season (they need manual review)
|
||||
if episode.season is None:
|
||||
continue
|
||||
|
||||
# Create grouping key from normalized title and season
|
||||
key = (episode.title, episode.season)
|
||||
|
||||
# Add episode to the appropriate group
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
groups[key].append(episode)
|
||||
|
||||
return groups
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Plan generator for creating execution plans from parsed identities.
|
||||
|
||||
This module generates structured execution plans that specify how video files
|
||||
should be organized based on their parsed identities and configuration templates.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.models import (
|
||||
ExecutionPlan,
|
||||
FileOperation,
|
||||
MovieIdentity,
|
||||
SeriesIdentity,
|
||||
VideoFile,
|
||||
)
|
||||
|
||||
|
||||
def generate_plan(
|
||||
identities: list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]],
|
||||
config: Config
|
||||
) -> ExecutionPlan:
|
||||
"""Generate an execution plan from parsed identities.
|
||||
|
||||
Creates file operations for organizing video files based on their parsed
|
||||
identities and configuration templates. Handles movies, series, anime,
|
||||
and other categories according to v1 constraints.
|
||||
|
||||
Args:
|
||||
identities: List of tuples containing (VideoFile, parsed_identity)
|
||||
config: Configuration with templates and settings
|
||||
|
||||
Returns:
|
||||
ExecutionPlan with all file operations and summary
|
||||
"""
|
||||
operations = []
|
||||
|
||||
for video_file, identity in identities:
|
||||
operation = _create_operation(video_file, identity, config)
|
||||
operations.append(operation)
|
||||
|
||||
# Generate summary counts
|
||||
summary = _generate_summary(operations)
|
||||
|
||||
return ExecutionPlan(
|
||||
plan_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=operations,
|
||||
summary=summary
|
||||
)
|
||||
|
||||
|
||||
def _create_operation(
|
||||
video_file: VideoFile,
|
||||
identity: Union[MovieIdentity, SeriesIdentity, None],
|
||||
config: Config
|
||||
) -> FileOperation:
|
||||
"""Create a file operation for a single video file.
|
||||
|
||||
Args:
|
||||
video_file: The video file to create an operation for
|
||||
identity: Parsed identity (MovieIdentity, SeriesIdentity, or None)
|
||||
config: Configuration with templates
|
||||
|
||||
Returns:
|
||||
FileOperation specifying what to do with the file
|
||||
"""
|
||||
# Handle anime category - generate no-op (v1 constraint)
|
||||
if video_file.category == "anime":
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Anime files not organized in v1",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Handle other category - generate no-op (v1 constraint)
|
||||
if video_file.category == "other":
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Other files not organized in v1",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Handle files without identity - generate no-op
|
||||
if identity is None:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="No identity parsed",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Handle movie identity
|
||||
if isinstance(identity, MovieIdentity):
|
||||
return _create_movie_operation(video_file, identity, config)
|
||||
|
||||
# Handle series identity
|
||||
if isinstance(identity, SeriesIdentity):
|
||||
return _create_series_operation(video_file, identity, config)
|
||||
|
||||
# Fallback - should not reach here
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Unknown identity type",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
|
||||
def _create_movie_operation(
|
||||
video_file: VideoFile,
|
||||
identity: MovieIdentity,
|
||||
config: Config
|
||||
) -> FileOperation:
|
||||
"""Create operation for a movie file.
|
||||
|
||||
Args:
|
||||
video_file: The movie file
|
||||
identity: Parsed movie identity
|
||||
config: Configuration with templates
|
||||
|
||||
Returns:
|
||||
FileOperation for organizing the movie
|
||||
"""
|
||||
# If movie needs review (no year), generate no-op
|
||||
if identity.needs_review or identity.year is None:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Movie needs manual review (no year found)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Apply movie directory template
|
||||
target_dir = config.movie_template.format(
|
||||
title=identity.title,
|
||||
year=identity.year
|
||||
)
|
||||
|
||||
# Get file extension
|
||||
ext = video_file.path.suffix
|
||||
|
||||
# Apply movie filename template
|
||||
target_filename = config.movie_filename_template.format(
|
||||
title=identity.title,
|
||||
year=identity.year,
|
||||
ext=ext
|
||||
)
|
||||
|
||||
# Construct full destination path
|
||||
destination = config.library_root / target_dir / target_filename
|
||||
|
||||
# Check if source and destination are the same
|
||||
if video_file.path.resolve() == destination.resolve():
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="File already at target location",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Determine operation type (move or rename)
|
||||
if video_file.path.parent == destination.parent:
|
||||
operation_type = "rename"
|
||||
else:
|
||||
operation_type = "move"
|
||||
|
||||
# Check for conflicts - destination file already exists
|
||||
has_conflict = destination.exists()
|
||||
conflict_reason = None
|
||||
if has_conflict:
|
||||
conflict_reason = f"Destination file already exists: {destination}"
|
||||
|
||||
return FileOperation(
|
||||
operation_type=operation_type,
|
||||
source_path=video_file.path,
|
||||
destination_path=destination,
|
||||
reason=f"Organize movie: {identity.title} ({identity.year})",
|
||||
has_conflict=has_conflict,
|
||||
conflict_reason=conflict_reason
|
||||
)
|
||||
|
||||
|
||||
def _create_series_operation(
|
||||
video_file: VideoFile,
|
||||
identity: SeriesIdentity,
|
||||
config: Config
|
||||
) -> FileOperation:
|
||||
"""Create operation for a series file.
|
||||
|
||||
Args:
|
||||
video_file: The series file
|
||||
identity: Parsed series identity
|
||||
config: Configuration with templates
|
||||
|
||||
Returns:
|
||||
FileOperation for organizing the series episode
|
||||
"""
|
||||
# If series needs review (no season or no episodes), generate no-op (v1 constraint)
|
||||
if identity.needs_review or identity.season is None or len(identity.episodes) == 0:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Series needs manual review (no season/episode found)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Apply series directory template
|
||||
target_dir = config.series_template.format(
|
||||
title=identity.title,
|
||||
season=identity.season
|
||||
)
|
||||
|
||||
# Get file extension
|
||||
ext = video_file.path.suffix
|
||||
|
||||
# Apply series filename template
|
||||
# For multi-episode files, use the first episode number
|
||||
target_filename = config.series_filename_template.format(
|
||||
season=identity.season,
|
||||
episode=identity.episodes[0],
|
||||
ext=ext
|
||||
)
|
||||
|
||||
# Construct full destination path
|
||||
destination = config.library_root / target_dir / target_filename
|
||||
|
||||
# Check if source and destination are the same
|
||||
if video_file.path.resolve() == destination.resolve():
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="File already at target location",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Determine operation type (move or rename)
|
||||
if video_file.path.parent == destination.parent:
|
||||
operation_type = "rename"
|
||||
else:
|
||||
operation_type = "move"
|
||||
|
||||
# Check for conflicts - destination file already exists
|
||||
has_conflict = destination.exists()
|
||||
conflict_reason = None
|
||||
if has_conflict:
|
||||
conflict_reason = f"Destination file already exists: {destination}"
|
||||
|
||||
return FileOperation(
|
||||
operation_type=operation_type,
|
||||
source_path=video_file.path,
|
||||
destination_path=destination,
|
||||
reason=f"Organize series: {identity.title} S{identity.season:02d}E{identity.episodes[0]:02d}",
|
||||
has_conflict=has_conflict,
|
||||
conflict_reason=conflict_reason
|
||||
)
|
||||
|
||||
|
||||
def _generate_summary(operations: list[FileOperation]) -> dict:
|
||||
"""Generate summary statistics for operations.
|
||||
|
||||
Args:
|
||||
operations: List of file operations
|
||||
|
||||
Returns:
|
||||
Dictionary with operation counts by type
|
||||
"""
|
||||
summary = {
|
||||
"total": len(operations),
|
||||
"move": 0,
|
||||
"rename": 0,
|
||||
"quarantine": 0,
|
||||
"no-op": 0
|
||||
}
|
||||
|
||||
for operation in operations:
|
||||
op_type = operation.operation_type
|
||||
if op_type in summary:
|
||||
summary[op_type] += 1
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def save_plan(plan: ExecutionPlan, output_path: Path) -> None:
|
||||
"""Save execution plan to JSON file.
|
||||
|
||||
Serializes the execution plan to a human-readable and editable JSON format.
|
||||
Includes plan_id, created_at timestamp, operations list, and summary.
|
||||
|
||||
Args:
|
||||
plan: ExecutionPlan to save
|
||||
output_path: Path where the JSON file should be saved
|
||||
"""
|
||||
# Convert ExecutionPlan to dictionary
|
||||
plan_dict = {
|
||||
"plan_id": plan.plan_id,
|
||||
"created_at": plan.created_at.isoformat(),
|
||||
"operations": [
|
||||
{
|
||||
"operation_type": op.operation_type,
|
||||
"source_path": str(op.source_path),
|
||||
"destination_path": str(op.destination_path) if op.destination_path else None,
|
||||
"reason": op.reason,
|
||||
"has_conflict": op.has_conflict,
|
||||
"conflict_reason": op.conflict_reason
|
||||
}
|
||||
for op in plan.operations
|
||||
],
|
||||
"summary": plan.summary
|
||||
}
|
||||
|
||||
# Write to JSON file with indentation for human readability
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(plan_dict, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def load_plan(input_path: Path) -> ExecutionPlan:
|
||||
"""Load execution plan from JSON file.
|
||||
|
||||
Deserializes an execution plan from JSON format, reconstructing all
|
||||
data structures including Path and datetime objects.
|
||||
|
||||
Args:
|
||||
input_path: Path to the JSON file to load
|
||||
|
||||
Returns:
|
||||
ExecutionPlan reconstructed from JSON
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the input file does not exist
|
||||
json.JSONDecodeError: If the file contains invalid JSON
|
||||
KeyError: If required fields are missing from the JSON
|
||||
"""
|
||||
with open(input_path, 'r', encoding='utf-8') as f:
|
||||
plan_dict = json.load(f)
|
||||
|
||||
# Reconstruct FileOperation objects
|
||||
operations = [
|
||||
FileOperation(
|
||||
operation_type=op["operation_type"],
|
||||
source_path=Path(op["source_path"]),
|
||||
destination_path=Path(op["destination_path"]) if op["destination_path"] else None,
|
||||
reason=op["reason"],
|
||||
has_conflict=op["has_conflict"],
|
||||
conflict_reason=op.get("conflict_reason")
|
||||
)
|
||||
for op in plan_dict["operations"]
|
||||
]
|
||||
|
||||
# Reconstruct ExecutionPlan
|
||||
return ExecutionPlan(
|
||||
plan_id=plan_dict["plan_id"],
|
||||
created_at=datetime.fromisoformat(plan_dict["created_at"]),
|
||||
operations=operations,
|
||||
summary=plan_dict["summary"]
|
||||
)
|
||||
@@ -0,0 +1,759 @@
|
||||
"""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 .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 = datetime.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 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 = datetime.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
|
||||
)
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,532 @@
|
||||
"""Report generation for Video Library Manager.
|
||||
|
||||
This module provides functionality to generate various reports about the video library:
|
||||
- Inventory reports (all discovered files with metadata)
|
||||
- Completeness reports (series with episode gaps)
|
||||
- Duplicate reports (duplicate files with quality comparisons)
|
||||
- Summary reports (library statistics)
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
from vlm.models import SeasonCompleteness, DuplicateGroup, VideoFile, MovieIdentity, SeriesIdentity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_inventory_report(
|
||||
files: list[VideoFile],
|
||||
format: str,
|
||||
library_root: Path
|
||||
) -> str:
|
||||
"""Generate inventory report listing all discovered video files with metadata.
|
||||
|
||||
Args:
|
||||
files: List of VideoFile objects to include in the report
|
||||
format: Output format ("csv" or "json")
|
||||
library_root: Root of the library (included in report metadata)
|
||||
|
||||
Returns:
|
||||
Formatted report as string
|
||||
|
||||
Raises:
|
||||
ValueError: If format is not "csv" or "json"
|
||||
"""
|
||||
if format not in ["csv", "json"]:
|
||||
raise ValueError(f"Invalid format: {format}. Must be 'csv' or 'json'")
|
||||
|
||||
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
if format == "json":
|
||||
return _generate_inventory_json(files, generation_timestamp, library_root)
|
||||
else: # csv
|
||||
return _generate_inventory_csv(files, generation_timestamp, library_root)
|
||||
|
||||
|
||||
def _generate_inventory_csv(
|
||||
files: list[VideoFile],
|
||||
timestamp: str,
|
||||
library_root: Path
|
||||
) -> str:
|
||||
"""Generate inventory report in CSV format.
|
||||
|
||||
CSV Schema:
|
||||
- path, filename, size_bytes, modified_timestamp, category, resolution,
|
||||
codec, duration_seconds, bitrate_kbps
|
||||
- Timestamps in ISO 8601 format (YYYY-MM-DDTHH:MM:SS) in UTC
|
||||
- Missing optional values represented as empty strings
|
||||
- Header row always present
|
||||
"""
|
||||
output = StringIO()
|
||||
|
||||
# Write metadata as comments
|
||||
output.write(f"# Generated: {timestamp}\n")
|
||||
output.write(f"# Library Root: {library_root}\n")
|
||||
|
||||
# Define CSV schema
|
||||
fieldnames = [
|
||||
'path',
|
||||
'filename',
|
||||
'size_bytes',
|
||||
'modified_timestamp',
|
||||
'category',
|
||||
'resolution',
|
||||
'codec',
|
||||
'duration_seconds',
|
||||
'bitrate_kbps'
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
# Write each file
|
||||
for video_file in files:
|
||||
# Format timestamp as ISO 8601 in UTC
|
||||
if video_file.modified_timestamp.tzinfo is None:
|
||||
# Assume local time, convert to UTC
|
||||
modified_utc = video_file.modified_timestamp.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
modified_utc = video_file.modified_timestamp.astimezone(timezone.utc)
|
||||
|
||||
row = {
|
||||
'path': str(video_file.path),
|
||||
'filename': video_file.filename,
|
||||
'size_bytes': video_file.size_bytes,
|
||||
'modified_timestamp': modified_utc.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
'category': video_file.category,
|
||||
'resolution': video_file.resolution or '',
|
||||
'codec': video_file.codec or '',
|
||||
'duration_seconds': video_file.duration_seconds if video_file.duration_seconds is not None else '',
|
||||
'bitrate_kbps': video_file.bitrate_kbps if video_file.bitrate_kbps is not None else ''
|
||||
}
|
||||
writer.writerow(row)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _generate_inventory_json(
|
||||
files: list[VideoFile],
|
||||
timestamp: str,
|
||||
library_root: Path
|
||||
) -> str:
|
||||
"""Generate inventory report in JSON format."""
|
||||
inventory_data = {
|
||||
'metadata': {
|
||||
'generated': timestamp,
|
||||
'library_root': str(library_root),
|
||||
'file_count': len(files)
|
||||
},
|
||||
'files': []
|
||||
}
|
||||
|
||||
# Add each file
|
||||
for video_file in files:
|
||||
# Format timestamp as ISO 8601 in UTC
|
||||
if video_file.modified_timestamp.tzinfo is None:
|
||||
# Assume local time, convert to UTC
|
||||
modified_utc = video_file.modified_timestamp.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
modified_utc = video_file.modified_timestamp.astimezone(timezone.utc)
|
||||
|
||||
file_data = {
|
||||
'path': str(video_file.path),
|
||||
'filename': video_file.filename,
|
||||
'size_bytes': video_file.size_bytes,
|
||||
'modified_timestamp': modified_utc.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
'category': video_file.category,
|
||||
'resolution': video_file.resolution,
|
||||
'codec': video_file.codec,
|
||||
'duration_seconds': video_file.duration_seconds,
|
||||
'bitrate_kbps': video_file.bitrate_kbps
|
||||
}
|
||||
inventory_data['files'].append(file_data)
|
||||
|
||||
return json.dumps(inventory_data, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def generate_completeness_report(
|
||||
analysis: list[SeasonCompleteness],
|
||||
format: str,
|
||||
library_root: Path
|
||||
) -> str:
|
||||
"""Generate completeness report showing series with episode gaps.
|
||||
|
||||
Args:
|
||||
analysis: List of SeasonCompleteness objects with detected gaps
|
||||
format: Output format ("text" or "json")
|
||||
library_root: Root of the library (included in report metadata)
|
||||
|
||||
Returns:
|
||||
Formatted report as string
|
||||
|
||||
Raises:
|
||||
ValueError: If format is not "text" or "json"
|
||||
"""
|
||||
if format not in ["text", "json"]:
|
||||
raise ValueError(f"Invalid format: {format}. Must be 'text' or 'json'")
|
||||
|
||||
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
if format == "json":
|
||||
return _generate_completeness_json(analysis, generation_timestamp, library_root)
|
||||
else: # text
|
||||
return _generate_completeness_text(analysis, generation_timestamp, library_root)
|
||||
|
||||
|
||||
def _generate_completeness_text(
|
||||
analysis: list[SeasonCompleteness],
|
||||
timestamp: str,
|
||||
library_root: Path
|
||||
) -> str:
|
||||
"""Generate completeness report in text format."""
|
||||
lines = []
|
||||
lines.append("=" * 80)
|
||||
lines.append("SERIES COMPLETENESS REPORT")
|
||||
lines.append("=" * 80)
|
||||
lines.append(f"Generated: {timestamp}")
|
||||
lines.append(f"Library Root: {library_root}")
|
||||
lines.append(f"Series with gaps: {len(analysis)}")
|
||||
lines.append("")
|
||||
|
||||
if not analysis:
|
||||
lines.append("No series with episode gaps detected.")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Group by series title
|
||||
series_groups = {}
|
||||
for season_data in analysis:
|
||||
if season_data.series_title not in series_groups:
|
||||
series_groups[season_data.series_title] = []
|
||||
series_groups[season_data.series_title].append(season_data)
|
||||
|
||||
# Sort series alphabetically
|
||||
for series_title in sorted(series_groups.keys()):
|
||||
lines.append("-" * 80)
|
||||
lines.append(f"Series: {series_title}")
|
||||
lines.append("-" * 80)
|
||||
|
||||
# Sort seasons by season number
|
||||
seasons = sorted(series_groups[series_title], key=lambda x: x.season)
|
||||
|
||||
for season_data in seasons:
|
||||
lines.append(f" Season {season_data.season:02d}:")
|
||||
lines.append(f" Episodes found: {_format_episode_list(season_data.episodes_found)}")
|
||||
lines.append(f" Episodes missing: {_format_episode_list(season_data.episodes_missing)}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_completeness_json(
|
||||
analysis: list[SeasonCompleteness],
|
||||
timestamp: str,
|
||||
library_root: Path
|
||||
) -> str:
|
||||
"""Generate completeness report in JSON format."""
|
||||
report_data = {
|
||||
"metadata": {
|
||||
"generated": timestamp,
|
||||
"library_root": str(library_root),
|
||||
"series_count": len(set(s.series_title for s in analysis))
|
||||
},
|
||||
"series": []
|
||||
}
|
||||
|
||||
# Group by series title
|
||||
series_groups = {}
|
||||
for season_data in analysis:
|
||||
if season_data.series_title not in series_groups:
|
||||
series_groups[season_data.series_title] = []
|
||||
series_groups[season_data.series_title].append(season_data)
|
||||
|
||||
# Build series data
|
||||
for series_title in sorted(series_groups.keys()):
|
||||
seasons_data = []
|
||||
for season_data in sorted(series_groups[series_title], key=lambda x: x.season):
|
||||
seasons_data.append({
|
||||
"season": season_data.season,
|
||||
"episodes_found": season_data.episodes_found,
|
||||
"episodes_missing": season_data.episodes_missing
|
||||
})
|
||||
|
||||
report_data["series"].append({
|
||||
"title": series_title,
|
||||
"seasons": seasons_data
|
||||
})
|
||||
|
||||
return json.dumps(report_data, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def generate_duplicate_report(
|
||||
duplicates: list[DuplicateGroup],
|
||||
format: str,
|
||||
library_root: Path
|
||||
) -> str:
|
||||
"""Generate duplicate report showing duplicate files with quality comparisons.
|
||||
|
||||
Args:
|
||||
duplicates: List of DuplicateGroup objects with duplicate files
|
||||
format: Output format ("text" or "json")
|
||||
library_root: Root of the library (included in report metadata)
|
||||
|
||||
Returns:
|
||||
Formatted report as string
|
||||
|
||||
Raises:
|
||||
ValueError: If format is not "text" or "json"
|
||||
"""
|
||||
if format not in ["text", "json"]:
|
||||
raise ValueError(f"Invalid format: {format}. Must be 'text' or 'json'")
|
||||
|
||||
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
if format == "json":
|
||||
return _generate_duplicate_json(duplicates, generation_timestamp, library_root)
|
||||
else: # text
|
||||
return _generate_duplicate_text(duplicates, generation_timestamp, library_root)
|
||||
|
||||
|
||||
def _generate_duplicate_text(
|
||||
duplicates: list[DuplicateGroup],
|
||||
timestamp: str,
|
||||
library_root: Path
|
||||
) -> str:
|
||||
"""Generate duplicate report in text format."""
|
||||
lines = []
|
||||
lines.append("=" * 80)
|
||||
lines.append("DUPLICATE FILES REPORT")
|
||||
lines.append("=" * 80)
|
||||
lines.append(f"Generated: {timestamp}")
|
||||
lines.append(f"Library Root: {library_root}")
|
||||
lines.append(f"Duplicate groups: {len(duplicates)}")
|
||||
lines.append("")
|
||||
|
||||
if not duplicates:
|
||||
lines.append("No duplicate files detected.")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Sort by largest file size first
|
||||
sorted_duplicates = sorted(
|
||||
duplicates,
|
||||
key=lambda g: max(f.size_bytes for f in g.files),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for idx, group in enumerate(sorted_duplicates, 1):
|
||||
lines.append("-" * 80)
|
||||
|
||||
# Format identity
|
||||
identity = group.identity
|
||||
if isinstance(identity, MovieIdentity):
|
||||
lines.append(f"Group {idx}: {identity.title} ({identity.year})")
|
||||
else: # SeriesIdentity
|
||||
episodes_str = ", ".join(str(e) for e in identity.episodes)
|
||||
lines.append(f"Group {idx}: {identity.title} - S{identity.season:02d}E{episodes_str}")
|
||||
|
||||
lines.append("-" * 80)
|
||||
lines.append(f" Files: {len(group.files)}")
|
||||
lines.append("")
|
||||
|
||||
# Show quality comparison for each file
|
||||
for file_idx, quality_data in enumerate(group.quality_comparison, 1):
|
||||
lines.append(f" File {file_idx}:")
|
||||
lines.append(f" Filename: {quality_data['filename']}")
|
||||
lines.append(f" Path: {quality_data['path']}")
|
||||
lines.append(f" Size: {_format_size(quality_data['size_bytes'])}")
|
||||
|
||||
if 'resolution' in quality_data:
|
||||
lines.append(f" Resolution: {quality_data['resolution']}")
|
||||
|
||||
if 'codec' in quality_data:
|
||||
lines.append(f" Codec: {quality_data['codec']}")
|
||||
|
||||
if 'duration_seconds' in quality_data:
|
||||
lines.append(f" Duration: {_format_duration(quality_data['duration_seconds'])}")
|
||||
|
||||
if 'bitrate_kbps' in quality_data:
|
||||
lines.append(f" Bitrate: {quality_data['bitrate_kbps']} kbps")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_duplicate_json(
|
||||
duplicates: list[DuplicateGroup],
|
||||
timestamp: str,
|
||||
library_root: Path
|
||||
) -> str:
|
||||
"""Generate duplicate report in JSON format."""
|
||||
report_data = {
|
||||
"metadata": {
|
||||
"generated": timestamp,
|
||||
"library_root": str(library_root),
|
||||
"duplicate_groups": len(duplicates)
|
||||
},
|
||||
"duplicates": []
|
||||
}
|
||||
|
||||
for group in duplicates:
|
||||
identity = group.identity
|
||||
|
||||
# Format identity
|
||||
if isinstance(identity, MovieIdentity):
|
||||
identity_data = {
|
||||
"type": "movie",
|
||||
"title": identity.title,
|
||||
"year": identity.year
|
||||
}
|
||||
else: # SeriesIdentity
|
||||
identity_data = {
|
||||
"type": "series",
|
||||
"title": identity.title,
|
||||
"season": identity.season,
|
||||
"episodes": identity.episodes
|
||||
}
|
||||
|
||||
group_data = {
|
||||
"identity": identity_data,
|
||||
"file_count": len(group.files),
|
||||
"files": group.quality_comparison
|
||||
}
|
||||
|
||||
report_data["duplicates"].append(group_data)
|
||||
|
||||
return json.dumps(report_data, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def generate_summary_report(
|
||||
files: list[VideoFile],
|
||||
library_root: Path
|
||||
) -> str:
|
||||
"""Generate summary report with library statistics.
|
||||
|
||||
Args:
|
||||
files: List of all VideoFile objects in the library
|
||||
library_root: Root of the library (included in report metadata)
|
||||
|
||||
Returns:
|
||||
Formatted summary report as text string
|
||||
"""
|
||||
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
lines = []
|
||||
lines.append("=" * 80)
|
||||
lines.append("LIBRARY SUMMARY REPORT")
|
||||
lines.append("=" * 80)
|
||||
lines.append(f"Generated: {generation_timestamp}")
|
||||
lines.append(f"Library Root: {library_root}")
|
||||
lines.append("")
|
||||
|
||||
# Calculate total statistics
|
||||
total_files = len(files)
|
||||
total_size = sum(f.size_bytes for f in files)
|
||||
|
||||
lines.append(f"Total Files: {total_files}")
|
||||
lines.append(f"Total Size: {_format_size(total_size)}")
|
||||
lines.append("")
|
||||
|
||||
# Category breakdown
|
||||
lines.append("Category Breakdown:")
|
||||
lines.append("-" * 40)
|
||||
|
||||
category_stats = {}
|
||||
for file in files:
|
||||
category = file.category
|
||||
if category not in category_stats:
|
||||
category_stats[category] = {"count": 0, "size": 0}
|
||||
category_stats[category]["count"] += 1
|
||||
category_stats[category]["size"] += file.size_bytes
|
||||
|
||||
# Sort categories alphabetically
|
||||
for category in sorted(category_stats.keys()):
|
||||
stats = category_stats[category]
|
||||
lines.append(f" {category.capitalize()}:")
|
||||
lines.append(f" Files: {stats['count']}")
|
||||
lines.append(f" Size: {_format_size(stats['size'])}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_episode_list(episodes: list[int]) -> str:
|
||||
"""Format episode list as compact string with ranges.
|
||||
|
||||
Examples:
|
||||
[1, 2, 3, 5, 6, 8] -> "1-3, 5-6, 8"
|
||||
[1, 3, 5] -> "1, 3, 5"
|
||||
"""
|
||||
if not episodes:
|
||||
return "none"
|
||||
|
||||
# Sort episodes
|
||||
sorted_episodes = sorted(episodes)
|
||||
|
||||
# Build ranges
|
||||
ranges = []
|
||||
start = sorted_episodes[0]
|
||||
end = sorted_episodes[0]
|
||||
|
||||
for episode in sorted_episodes[1:]:
|
||||
if episode == end + 1:
|
||||
# Continue current range
|
||||
end = episode
|
||||
else:
|
||||
# End current range and start new one
|
||||
if start == end:
|
||||
ranges.append(str(start))
|
||||
else:
|
||||
ranges.append(f"{start}-{end}")
|
||||
start = episode
|
||||
end = episode
|
||||
|
||||
# Add final range
|
||||
if start == end:
|
||||
ranges.append(str(start))
|
||||
else:
|
||||
ranges.append(f"{start}-{end}")
|
||||
|
||||
return ", ".join(ranges)
|
||||
|
||||
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""Format file size in human-readable format.
|
||||
|
||||
Examples:
|
||||
1024 -> "1.00 KB"
|
||||
1048576 -> "1.00 MB"
|
||||
1073741824 -> "1.00 GB"
|
||||
"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.2f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.2f} PB"
|
||||
|
||||
|
||||
def _format_duration(duration_seconds: float) -> str:
|
||||
"""Format duration in human-readable format.
|
||||
|
||||
Examples:
|
||||
90 -> "1m 30s"
|
||||
3665 -> "1h 1m 5s"
|
||||
"""
|
||||
hours = int(duration_seconds // 3600)
|
||||
minutes = int((duration_seconds % 3600) // 60)
|
||||
seconds = int(duration_seconds % 60)
|
||||
|
||||
parts = []
|
||||
if hours > 0:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes > 0:
|
||||
parts.append(f"{minutes}m")
|
||||
if seconds > 0 or not parts:
|
||||
parts.append(f"{seconds}s")
|
||||
|
||||
return " ".join(parts)
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Inventory scanner for discovering and cataloging video files.
|
||||
|
||||
This module implements the core scanning functionality for the Video Library Manager,
|
||||
including recursive directory traversal, file filtering, metadata extraction, and
|
||||
categorization based on directory structure.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.models import VideoFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def scan_library(root: Path, config: Config) -> list[VideoFile]:
|
||||
"""Recursively scan library for video files.
|
||||
|
||||
Discovers all video files matching configured extensions within the library root,
|
||||
records their metadata, and categorizes them based on directory structure.
|
||||
|
||||
Args:
|
||||
root: Root directory to scan
|
||||
config: Configuration object with video extensions and settings
|
||||
|
||||
Returns:
|
||||
List of VideoFile objects representing discovered files
|
||||
|
||||
Note:
|
||||
- Handles inaccessible files gracefully by logging errors and continuing
|
||||
- Performs read-only operations without modifying any files or directories
|
||||
- Categorizes files based on parent directory structure (movie/series/anime/other)
|
||||
"""
|
||||
logger.info(f"Starting library scan at: {root}")
|
||||
|
||||
if not root.exists():
|
||||
logger.error(f"Library root does not exist: {root}")
|
||||
return []
|
||||
|
||||
if not root.is_dir():
|
||||
logger.error(f"Library root is not a directory: {root}")
|
||||
return []
|
||||
|
||||
video_files = []
|
||||
file_count = 0
|
||||
error_count = 0
|
||||
|
||||
# Recursively scan directory tree
|
||||
for video_file in _scan_directory_recursive(root, config, root):
|
||||
video_files.append(video_file)
|
||||
file_count += 1
|
||||
|
||||
if file_count % 100 == 0:
|
||||
logger.debug(f"Scanned {file_count} files so far...")
|
||||
|
||||
logger.info(f"Scan complete. Found {file_count} video files")
|
||||
if error_count > 0:
|
||||
logger.warning(f"Encountered {error_count} errors during scan (see log for details)")
|
||||
|
||||
return video_files
|
||||
|
||||
|
||||
def _scan_directory_recursive(
|
||||
directory: Path,
|
||||
config: Config,
|
||||
library_root: Path
|
||||
) -> list[VideoFile]:
|
||||
"""Recursively scan a directory for video files.
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
config: Configuration object
|
||||
library_root: Root of the library (for categorization)
|
||||
|
||||
Yields:
|
||||
VideoFile objects for each discovered video file
|
||||
"""
|
||||
try:
|
||||
# Use os.scandir for efficient directory traversal
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
try:
|
||||
# Skip hidden files and directories (starting with .)
|
||||
if entry.name.startswith('.'):
|
||||
continue
|
||||
|
||||
if entry.is_file(follow_symlinks=False):
|
||||
# Check if file has video extension
|
||||
file_path = Path(entry.path)
|
||||
if _is_video_file(file_path, config.video_extensions):
|
||||
video_file = _create_video_file(file_path, library_root)
|
||||
if video_file:
|
||||
yield video_file
|
||||
|
||||
elif entry.is_dir(follow_symlinks=False):
|
||||
# Recursively scan subdirectory
|
||||
subdir_path = Path(entry.path)
|
||||
yield from _scan_directory_recursive(subdir_path, config, library_root)
|
||||
|
||||
except (OSError, PermissionError) as e:
|
||||
# Handle inaccessible files/directories gracefully
|
||||
logger.error(f"Cannot access {entry.path}: {e}")
|
||||
continue
|
||||
|
||||
except (OSError, PermissionError) as e:
|
||||
# Handle inaccessible directory
|
||||
logger.error(f"Cannot access directory {directory}: {e}")
|
||||
|
||||
|
||||
def _is_video_file(file_path: Path, video_extensions: list[str]) -> bool:
|
||||
"""Check if file has a video extension.
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
video_extensions: List of valid video extensions (e.g., [".mp4", ".mkv"])
|
||||
|
||||
Returns:
|
||||
True if file has a video extension, False otherwise
|
||||
"""
|
||||
file_extension = file_path.suffix.lower()
|
||||
return file_extension in [ext.lower() for ext in video_extensions]
|
||||
|
||||
|
||||
def _create_video_file(file_path: Path, library_root: Path) -> Optional[VideoFile]:
|
||||
"""Create VideoFile object from file path.
|
||||
|
||||
Extracts file metadata and categorizes based on directory structure.
|
||||
Optionally extracts video metadata using ffprobe if available.
|
||||
|
||||
Args:
|
||||
file_path: Path to video file
|
||||
library_root: Root of the library (for categorization)
|
||||
|
||||
Returns:
|
||||
VideoFile object or None if file cannot be accessed
|
||||
"""
|
||||
try:
|
||||
# Get file stats
|
||||
stat = file_path.stat()
|
||||
size_bytes = stat.st_size
|
||||
modified_timestamp = datetime.fromtimestamp(stat.st_mtime)
|
||||
|
||||
# Categorize based on directory structure
|
||||
category = categorize_file(file_path, library_root)
|
||||
|
||||
# Extract video metadata using ffprobe (optional, non-blocking)
|
||||
video_metadata = extract_metadata(file_path)
|
||||
|
||||
# Create VideoFile object with optional metadata
|
||||
return VideoFile(
|
||||
path=file_path,
|
||||
filename=file_path.name,
|
||||
size_bytes=size_bytes,
|
||||
modified_timestamp=modified_timestamp,
|
||||
category=category,
|
||||
resolution=video_metadata.get('resolution'),
|
||||
codec=video_metadata.get('codec'),
|
||||
duration_seconds=video_metadata.get('duration_seconds'),
|
||||
bitrate_kbps=video_metadata.get('bitrate_kbps')
|
||||
)
|
||||
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.error(f"Cannot read file metadata for {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def categorize_file(file_path: Path, library_root: Path) -> str:
|
||||
"""Determine category based on directory structure.
|
||||
|
||||
Categories are determined by the top-level directory within the library root:
|
||||
- movie/ -> "movie"
|
||||
- series/ -> "series"
|
||||
- anime/ -> "anime"
|
||||
- other/ or anything else -> "other"
|
||||
|
||||
Args:
|
||||
file_path: Path to video file
|
||||
library_root: Root of the library
|
||||
|
||||
Returns:
|
||||
Category string: "movie", "series", "anime", or "other"
|
||||
"""
|
||||
try:
|
||||
# Get relative path from library root
|
||||
relative_path = file_path.relative_to(library_root)
|
||||
|
||||
# Get the first component of the relative path (top-level directory)
|
||||
parts = relative_path.parts
|
||||
if len(parts) > 0:
|
||||
top_level_dir = parts[0].lower()
|
||||
|
||||
if top_level_dir == "movie":
|
||||
return "movie"
|
||||
elif top_level_dir == "series":
|
||||
return "series"
|
||||
elif top_level_dir == "anime":
|
||||
return "anime"
|
||||
else:
|
||||
return "other"
|
||||
else:
|
||||
# File is directly in library root
|
||||
return "other"
|
||||
|
||||
except ValueError:
|
||||
# File is not within library root
|
||||
logger.warning(f"File {file_path} is not within library root {library_root}")
|
||||
return "other"
|
||||
|
||||
|
||||
def extract_metadata(file_path: Path) -> dict:
|
||||
"""Extract video metadata using ffprobe.
|
||||
|
||||
Attempts to extract resolution, codec, duration, and bitrate from video file
|
||||
using ffprobe. If ffprobe is not available or fails, returns empty dict.
|
||||
This is a non-blocking operation that gracefully handles failures.
|
||||
|
||||
Args:
|
||||
file_path: Path to video file
|
||||
|
||||
Returns:
|
||||
Dictionary with optional keys:
|
||||
- resolution: str (e.g., "1920x1080")
|
||||
- codec: str (e.g., "h264")
|
||||
- duration_seconds: float
|
||||
- bitrate_kbps: int
|
||||
|
||||
Note:
|
||||
- Returns empty dict if ffprobe is not available
|
||||
- Returns empty dict if ffprobe fails to extract metadata
|
||||
- Logs warnings for failures but does not raise exceptions
|
||||
"""
|
||||
try:
|
||||
# Run ffprobe to get video stream information in JSON format
|
||||
result = subprocess.run(
|
||||
[
|
||||
'ffprobe',
|
||||
'-v', 'quiet', # Suppress ffprobe output
|
||||
'-print_format', 'json', # Output as JSON
|
||||
'-show_streams', # Show stream information
|
||||
'-show_format', # Show format information
|
||||
str(file_path)
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10 # 10 second timeout to prevent hanging
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.debug(f"ffprobe failed for {file_path.name}: {result.stderr}")
|
||||
return {}
|
||||
|
||||
# Parse JSON output
|
||||
probe_data = json.loads(result.stdout)
|
||||
|
||||
# Extract metadata from the first video stream
|
||||
metadata = {}
|
||||
|
||||
# Find the first video stream
|
||||
video_stream = None
|
||||
for stream in probe_data.get('streams', []):
|
||||
if stream.get('codec_type') == 'video':
|
||||
video_stream = stream
|
||||
break
|
||||
|
||||
if video_stream:
|
||||
# Extract resolution
|
||||
width = video_stream.get('width')
|
||||
height = video_stream.get('height')
|
||||
if width and height:
|
||||
metadata['resolution'] = f"{width}x{height}"
|
||||
|
||||
# Extract codec
|
||||
codec_name = video_stream.get('codec_name')
|
||||
if codec_name:
|
||||
metadata['codec'] = codec_name
|
||||
|
||||
# Extract duration and bitrate from format section
|
||||
format_info = probe_data.get('format', {})
|
||||
|
||||
# Extract duration
|
||||
duration = format_info.get('duration')
|
||||
if duration:
|
||||
try:
|
||||
metadata['duration_seconds'] = float(duration)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Extract bitrate
|
||||
bitrate = format_info.get('bit_rate')
|
||||
if bitrate:
|
||||
try:
|
||||
# Convert from bits/sec to kbits/sec
|
||||
metadata['bitrate_kbps'] = int(float(bitrate) / 1000)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if metadata:
|
||||
logger.debug(f"Extracted metadata for {file_path.name}: {metadata}")
|
||||
|
||||
return metadata
|
||||
|
||||
except FileNotFoundError:
|
||||
# ffprobe not installed or not in PATH
|
||||
logger.debug("ffprobe not available - skipping metadata extraction")
|
||||
return {}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(f"ffprobe timeout for {file_path.name} - skipping metadata")
|
||||
return {}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse ffprobe output for {file_path.name}: {e}")
|
||||
return {}
|
||||
|
||||
except Exception as e:
|
||||
# Catch any other unexpected errors
|
||||
logger.warning(f"Unexpected error extracting metadata for {file_path.name}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
def save_inventory_csv(files: list[VideoFile], output: Path, library_root: Path) -> None:
|
||||
"""Save inventory to CSV format (primary format).
|
||||
|
||||
Generates a CSV file with all file metadata following the defined schema:
|
||||
path, filename, size_bytes, modified_timestamp, category, resolution, codec,
|
||||
duration_seconds, bitrate_kbps
|
||||
|
||||
Args:
|
||||
files: List of VideoFile objects to export
|
||||
output: Path to output CSV file
|
||||
library_root: Root of the library (included in report metadata)
|
||||
|
||||
Note:
|
||||
- Timestamps are formatted as ISO 8601 (YYYY-MM-DDTHH:MM:SS) in UTC
|
||||
- Missing optional values are represented as empty strings
|
||||
- Header row is always present with column names
|
||||
- Generation timestamp and library root are included as comment lines
|
||||
"""
|
||||
logger.info(f"Saving inventory to CSV: {output}")
|
||||
|
||||
# Ensure output directory exists
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get generation timestamp in UTC
|
||||
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
with open(output, 'w', newline='', encoding='utf-8') as csvfile:
|
||||
# Write metadata as comments
|
||||
csvfile.write(f"# Generated: {generation_timestamp}\n")
|
||||
csvfile.write(f"# Library Root: {library_root}\n")
|
||||
|
||||
# Define CSV schema
|
||||
fieldnames = [
|
||||
'path',
|
||||
'filename',
|
||||
'size_bytes',
|
||||
'modified_timestamp',
|
||||
'category',
|
||||
'resolution',
|
||||
'codec',
|
||||
'duration_seconds',
|
||||
'bitrate_kbps'
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
# Write each file
|
||||
for video_file in files:
|
||||
# Format timestamp as ISO 8601 in UTC
|
||||
if video_file.modified_timestamp.tzinfo is None:
|
||||
# Assume local time, convert to UTC
|
||||
modified_utc = video_file.modified_timestamp.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
modified_utc = video_file.modified_timestamp.astimezone(timezone.utc)
|
||||
|
||||
row = {
|
||||
'path': str(video_file.path),
|
||||
'filename': video_file.filename,
|
||||
'size_bytes': video_file.size_bytes,
|
||||
'modified_timestamp': modified_utc.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
'category': video_file.category,
|
||||
'resolution': video_file.resolution or '',
|
||||
'codec': video_file.codec or '',
|
||||
'duration_seconds': video_file.duration_seconds if video_file.duration_seconds is not None else '',
|
||||
'bitrate_kbps': video_file.bitrate_kbps if video_file.bitrate_kbps is not None else ''
|
||||
}
|
||||
writer.writerow(row)
|
||||
|
||||
logger.info(f"Saved {len(files)} files to CSV inventory")
|
||||
|
||||
|
||||
def save_inventory_json(files: list[VideoFile], output: Path, library_root: Path) -> None:
|
||||
"""Save inventory to JSON format (optional export format).
|
||||
|
||||
Generates a JSON file with all file metadata and report metadata.
|
||||
|
||||
Args:
|
||||
files: List of VideoFile objects to export
|
||||
output: Path to output JSON file
|
||||
library_root: Root of the library (included in report metadata)
|
||||
|
||||
Note:
|
||||
- Timestamps are formatted as ISO 8601 strings
|
||||
- Missing optional values are represented as null
|
||||
- Generation timestamp and library root are included in metadata section
|
||||
"""
|
||||
logger.info(f"Saving inventory to JSON: {output}")
|
||||
|
||||
# Ensure output directory exists
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get generation timestamp in UTC
|
||||
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
# Build JSON structure
|
||||
inventory_data = {
|
||||
'metadata': {
|
||||
'generated': generation_timestamp,
|
||||
'library_root': str(library_root),
|
||||
'file_count': len(files)
|
||||
},
|
||||
'files': []
|
||||
}
|
||||
|
||||
# Add each file
|
||||
for video_file in files:
|
||||
# Format timestamp as ISO 8601 in UTC
|
||||
if video_file.modified_timestamp.tzinfo is None:
|
||||
# Assume local time, convert to UTC
|
||||
modified_utc = video_file.modified_timestamp.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
modified_utc = video_file.modified_timestamp.astimezone(timezone.utc)
|
||||
|
||||
file_data = {
|
||||
'path': str(video_file.path),
|
||||
'filename': video_file.filename,
|
||||
'size_bytes': video_file.size_bytes,
|
||||
'modified_timestamp': modified_utc.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
'category': video_file.category,
|
||||
'resolution': video_file.resolution,
|
||||
'codec': video_file.codec,
|
||||
'duration_seconds': video_file.duration_seconds,
|
||||
'bitrate_kbps': video_file.bitrate_kbps
|
||||
}
|
||||
inventory_data['files'].append(file_data)
|
||||
|
||||
# Write JSON file with pretty formatting
|
||||
with open(output, 'w', encoding='utf-8') as jsonfile:
|
||||
json.dump(inventory_data, jsonfile, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Saved {len(files)} files to JSON inventory")
|
||||
@@ -0,0 +1,177 @@
|
||||
"""State Store for Video Library Manager.
|
||||
|
||||
This module provides persistent state management for tracking file statuses
|
||||
and user decisions throughout the workflow.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from vlm.models import FileState, StateStore
|
||||
|
||||
|
||||
# Valid status values
|
||||
VALID_STATUSES = {"reviewed", "ignored", "planned", "executed", "quarantined"}
|
||||
|
||||
|
||||
def load_state(path: Path) -> StateStore:
|
||||
"""Load state store from JSON file.
|
||||
|
||||
Args:
|
||||
path: Path to the state store JSON file
|
||||
|
||||
Returns:
|
||||
StateStore object with all file states
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the state file doesn't exist
|
||||
json.JSONDecodeError: If the file contains invalid JSON
|
||||
"""
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Parse states dictionary
|
||||
states = {}
|
||||
for file_path_str, state_data in data.get('states', {}).items():
|
||||
states[file_path_str] = FileState(
|
||||
file_path=Path(state_data['file_path']),
|
||||
status=state_data['status'],
|
||||
reason=state_data.get('reason'),
|
||||
updated_at=datetime.fromisoformat(state_data['updated_at'])
|
||||
)
|
||||
|
||||
return StateStore(
|
||||
states=states,
|
||||
version=data.get('version', '1.0'),
|
||||
last_updated=datetime.fromisoformat(data['last_updated'])
|
||||
)
|
||||
|
||||
|
||||
def save_state(store: StateStore, path: Path) -> None:
|
||||
"""Save state store to JSON file.
|
||||
|
||||
Args:
|
||||
store: StateStore object to save
|
||||
path: Path where the state store should be saved
|
||||
"""
|
||||
# Create parent directory if it doesn't exist
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Convert states to serializable format
|
||||
states_data = {}
|
||||
for file_path_str, state in store.states.items():
|
||||
states_data[file_path_str] = {
|
||||
'file_path': str(state.file_path),
|
||||
'status': state.status,
|
||||
'reason': state.reason,
|
||||
'updated_at': state.updated_at.isoformat()
|
||||
}
|
||||
|
||||
data = {
|
||||
'states': states_data,
|
||||
'version': store.version,
|
||||
'last_updated': store.last_updated.isoformat()
|
||||
}
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Manager for state store operations.
|
||||
|
||||
This class provides a convenient interface for managing file states
|
||||
with automatic persistence.
|
||||
"""
|
||||
|
||||
def __init__(self, state_path: Path):
|
||||
"""Initialize the state manager.
|
||||
|
||||
Args:
|
||||
state_path: Path to the state store JSON file
|
||||
"""
|
||||
self.state_path = state_path
|
||||
|
||||
# Load existing state or create new one
|
||||
if state_path.exists():
|
||||
self.store = load_state(state_path)
|
||||
else:
|
||||
self.store = StateStore(
|
||||
states={},
|
||||
version='1.0',
|
||||
last_updated=datetime.now()
|
||||
)
|
||||
|
||||
def get_file_state(self, file_path: Path) -> Optional[FileState]:
|
||||
"""Get state for a specific file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
FileState object if found, None otherwise
|
||||
"""
|
||||
file_path_str = str(file_path)
|
||||
return self.store.states.get(file_path_str)
|
||||
|
||||
def set_file_state(self, file_path: Path, status: str, reason: Optional[str] = None) -> None:
|
||||
"""Set or update state for a file.
|
||||
|
||||
This operation is idempotent - setting the same status multiple times
|
||||
will update the timestamp and reason.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
status: Status value (must be one of VALID_STATUSES)
|
||||
reason: Optional reason for the status
|
||||
|
||||
Raises:
|
||||
ValueError: If status is not valid
|
||||
"""
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(
|
||||
f"Invalid status '{status}'. Must be one of: {', '.join(sorted(VALID_STATUSES))}"
|
||||
)
|
||||
|
||||
file_path_str = str(file_path)
|
||||
now = datetime.now()
|
||||
|
||||
self.store.states[file_path_str] = FileState(
|
||||
file_path=file_path,
|
||||
status=status,
|
||||
reason=reason,
|
||||
updated_at=now
|
||||
)
|
||||
|
||||
self.store.last_updated = now
|
||||
|
||||
def query_by_status(self, status: str) -> list[FileState]:
|
||||
"""Get all files with a specific status.
|
||||
|
||||
Args:
|
||||
status: Status value to filter by
|
||||
|
||||
Returns:
|
||||
List of FileState objects with the specified status
|
||||
"""
|
||||
return [
|
||||
state for state in self.store.states.values()
|
||||
if state.status == status
|
||||
]
|
||||
|
||||
def clear_state(self, file_path: Path) -> None:
|
||||
"""Remove state for a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
"""
|
||||
file_path_str = str(file_path)
|
||||
if file_path_str in self.store.states:
|
||||
del self.store.states[file_path_str]
|
||||
self.store.last_updated = datetime.now()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save the current state store to disk."""
|
||||
save_state(self.store, self.state_path)
|
||||
Reference in New Issue
Block a user