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:
windyboy
2026-02-09 17:43:35 +08:00
commit 1705275e99
38 changed files with 16694 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Video Library Manager - A Python-based CLI tool for managing personal video collections."""
__version__ = "0.1.0"
+188
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+208
View File
@@ -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
+591
View File
@@ -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"
)
+139
View File
@@ -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)
+234
View File
@@ -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]
+267
View File
@@ -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
+378
View File
@@ -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"]
)
+759
View File
@@ -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
+532
View File
@@ -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)
+460
View File
@@ -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")
+177
View File
@@ -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)