2026-02-09 17:43:35 +08:00
|
|
|
"""Command-line interface for Video Library Manager.
|
|
|
|
|
|
|
|
|
|
This module provides the main CLI entry point using Click framework.
|
|
|
|
|
It implements global options (--config, --log-level) and error handling.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
import click
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
|
from vlm.config import Config, load_config, create_default_config, validate_config
|
|
|
|
|
from vlm.logging_config import setup_logging, get_logger
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 20:16:39 +08:00
|
|
|
def default_config_path() -> Path:
|
|
|
|
|
"""Return the default config path resolved at runtime."""
|
|
|
|
|
return Path.home() / ".vlm" / "config.yaml"
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class CLIContext:
|
|
|
|
|
"""Context object to pass configuration and logger between commands."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, config: Config, logger):
|
|
|
|
|
self.config = config
|
|
|
|
|
self.logger = logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pass_context = click.make_pass_decorator(CLIContext)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@click.group()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--config',
|
|
|
|
|
type=click.Path(path_type=Path),
|
2026-02-09 20:16:39 +08:00
|
|
|
default=default_config_path,
|
2026-02-09 17:43:35 +08:00
|
|
|
help='Path to configuration file (default: ~/.vlm/config.yaml)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--log-level',
|
|
|
|
|
type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR'], case_sensitive=False),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Set logging level (overrides config file)'
|
|
|
|
|
)
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def main(ctx, config: Path, log_level: Optional[str]):
|
|
|
|
|
"""Video Library Manager - A tool for managing personal video collections.
|
|
|
|
|
|
|
|
|
|
VLM helps you organize, analyze, and maintain your video library with a
|
|
|
|
|
safety-first approach. All operations are reversible and require explicit
|
|
|
|
|
confirmation before making changes.
|
|
|
|
|
|
|
|
|
|
Common workflow:
|
|
|
|
|
|
|
|
|
|
1. vlm scan - Discover all video files
|
|
|
|
|
2. vlm parse - Extract titles, years, seasons, episodes
|
|
|
|
|
3. vlm analyze - Detect gaps and duplicates
|
|
|
|
|
4. vlm plan - Generate execution plan
|
|
|
|
|
5. vlm execute - Execute plan (dry-run by default)
|
|
|
|
|
6. vlm execute --confirm - Actually execute operations
|
|
|
|
|
|
|
|
|
|
Use 'vlm COMMAND --help' for more information on a specific command.
|
|
|
|
|
"""
|
|
|
|
|
# Ensure context object exists
|
|
|
|
|
ctx.ensure_object(dict)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Load or create configuration
|
|
|
|
|
if config.exists():
|
|
|
|
|
try:
|
|
|
|
|
cfg = load_config(config)
|
|
|
|
|
except yaml.YAMLError as e:
|
|
|
|
|
click.echo(f"Error: Invalid YAML syntax in configuration file: {e}", err=True)
|
|
|
|
|
click.echo("Using default configuration values.", err=True)
|
|
|
|
|
cfg = create_default_config(config)
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
click.echo(f"Error: {e}", err=True)
|
|
|
|
|
click.echo("Using default configuration values.", err=True)
|
|
|
|
|
cfg = create_default_config(config)
|
|
|
|
|
else:
|
|
|
|
|
click.echo(f"Configuration file not found at {config}", err=True)
|
|
|
|
|
click.echo("Creating default configuration...", err=True)
|
|
|
|
|
cfg = create_default_config(config)
|
|
|
|
|
click.echo(f"Default configuration created at {config}", err=True)
|
|
|
|
|
|
|
|
|
|
# Validate configuration
|
|
|
|
|
validation_errors = validate_config(cfg)
|
|
|
|
|
if validation_errors:
|
|
|
|
|
click.echo("Configuration validation errors:", err=True)
|
|
|
|
|
for error in validation_errors:
|
|
|
|
|
click.echo(f" - {error}", err=True)
|
|
|
|
|
click.echo("Please fix the configuration file and try again.", err=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
# Override log level if specified on command line
|
|
|
|
|
if log_level:
|
|
|
|
|
cfg.log_level = log_level.upper()
|
|
|
|
|
|
|
|
|
|
# Set up logging
|
|
|
|
|
logger = setup_logging(log_level=cfg.log_level)
|
|
|
|
|
|
|
|
|
|
# Store context for subcommands
|
|
|
|
|
ctx.obj = CLIContext(config=cfg, logger=logger)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error initializing VLM: {e}", err=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--output',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=Path('inventory.csv'),
|
|
|
|
|
help='Output file for inventory (default: inventory.csv)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def scan(ctx: CLIContext, output: Path):
|
|
|
|
|
"""Scan library and generate inventory.
|
|
|
|
|
|
|
|
|
|
Discovers all video files in the library and records their metadata.
|
|
|
|
|
This is a read-only operation that does not modify any files.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm scan # Save to inventory.csv
|
|
|
|
|
vlm scan --output my_library.csv # Save to custom file
|
|
|
|
|
"""
|
|
|
|
|
from vlm.scanner import scan_library, save_inventory_csv
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Display scan start message
|
|
|
|
|
click.echo(f"Scanning library at: {config.library_root}")
|
|
|
|
|
click.echo("This may take a while for large libraries...")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Perform the scan
|
|
|
|
|
video_files = scan_library(config.library_root, config)
|
|
|
|
|
|
|
|
|
|
# Display summary
|
|
|
|
|
click.echo(f"Scan complete!")
|
|
|
|
|
click.echo(f" Total files found: {len(video_files)}")
|
|
|
|
|
|
|
|
|
|
# Count by category
|
|
|
|
|
categories = {}
|
|
|
|
|
total_size = 0
|
|
|
|
|
for vf in video_files:
|
|
|
|
|
categories[vf.category] = categories.get(vf.category, 0) + 1
|
|
|
|
|
total_size += vf.size_bytes
|
|
|
|
|
|
|
|
|
|
click.echo(f" Total size: {_format_size(total_size)}")
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("Files by category:")
|
|
|
|
|
for category in sorted(categories.keys()):
|
|
|
|
|
click.echo(f" {category}: {categories[category]}")
|
|
|
|
|
|
|
|
|
|
# Save inventory to CSV
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(f"Saving inventory to: {output}")
|
|
|
|
|
save_inventory_csv(video_files, output, config.library_root)
|
|
|
|
|
click.echo(f"Inventory saved successfully!")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Scan completed: {len(video_files)} files found, saved to {output}")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error during scan: {e}", err=True)
|
|
|
|
|
logger.error(f"Scan failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _format_size(size_bytes: int) -> str:
|
|
|
|
|
"""Format file size in human-readable format.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
size_bytes: Size in bytes
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Formatted string (e.g., "1.5 GB", "234.2 MB")
|
|
|
|
|
"""
|
|
|
|
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
|
|
|
if size_bytes < 1024.0:
|
|
|
|
|
return f"{size_bytes:.1f} {unit}"
|
|
|
|
|
size_bytes /= 1024.0
|
|
|
|
|
return f"{size_bytes:.1f} PB"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
|
|
|
|
default=Path('inventory.csv'),
|
|
|
|
|
help='Input inventory CSV file (default: inventory.csv)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--output',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=Path('identities.json'),
|
|
|
|
|
help='Output file for parsed identities (default: identities.json)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def parse(ctx: CLIContext, input: Path, output: Path):
|
|
|
|
|
"""Parse identities from filenames.
|
|
|
|
|
|
|
|
|
|
Extracts movie titles, years, series titles, seasons, and episodes
|
|
|
|
|
from video filenames in the inventory.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm parse # Use default files
|
|
|
|
|
vlm parse --input my_inventory.csv # Custom input
|
|
|
|
|
vlm parse --output parsed_identities.json # Custom output
|
|
|
|
|
"""
|
|
|
|
|
import csv
|
|
|
|
|
import json
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from vlm.parser import parse_movie, parse_series
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Display parse start message
|
|
|
|
|
click.echo(f"Parsing identities from: {input}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Load inventory from CSV
|
|
|
|
|
video_files = []
|
|
|
|
|
with open(input, 'r', encoding='utf-8') as csvfile:
|
|
|
|
|
# Skip comment lines
|
|
|
|
|
lines = []
|
|
|
|
|
for line in csvfile:
|
|
|
|
|
if not line.startswith('#'):
|
|
|
|
|
lines.append(line)
|
|
|
|
|
|
|
|
|
|
# Parse CSV
|
|
|
|
|
reader = csv.DictReader(lines)
|
|
|
|
|
for row in reader:
|
|
|
|
|
video_files.append({
|
|
|
|
|
'path': row['path'],
|
|
|
|
|
'filename': row['filename'],
|
|
|
|
|
'category': row['category']
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
click.echo(f"Loaded {len(video_files)} files from inventory")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Parse identities based on category
|
|
|
|
|
movie_identities = []
|
|
|
|
|
series_identities = []
|
|
|
|
|
anime_files = []
|
|
|
|
|
other_files = []
|
|
|
|
|
|
|
|
|
|
for vf in video_files:
|
|
|
|
|
filename = vf['filename']
|
|
|
|
|
category = vf['category']
|
|
|
|
|
|
|
|
|
|
if category == 'movie':
|
|
|
|
|
identity = parse_movie(filename)
|
|
|
|
|
movie_identities.append({
|
|
|
|
|
'path': vf['path'],
|
|
|
|
|
'filename': filename,
|
|
|
|
|
'category': category,
|
|
|
|
|
'title': identity.title,
|
|
|
|
|
'year': identity.year,
|
|
|
|
|
'confidence': identity.confidence,
|
|
|
|
|
'needs_review': identity.needs_review
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
elif category == 'series':
|
|
|
|
|
identity = parse_series(filename)
|
|
|
|
|
series_identities.append({
|
|
|
|
|
'path': vf['path'],
|
|
|
|
|
'filename': filename,
|
|
|
|
|
'category': category,
|
|
|
|
|
'title': identity.title,
|
|
|
|
|
'season': identity.season,
|
|
|
|
|
'episodes': identity.episodes,
|
|
|
|
|
'confidence': identity.confidence,
|
|
|
|
|
'needs_review': identity.needs_review
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
elif category == 'anime':
|
|
|
|
|
# Anime files are not parsed in v1
|
|
|
|
|
anime_files.append({
|
|
|
|
|
'path': vf['path'],
|
|
|
|
|
'filename': filename,
|
|
|
|
|
'category': category,
|
|
|
|
|
'note': 'Anime parsing deferred in v1'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
# Other files are not parsed
|
|
|
|
|
other_files.append({
|
|
|
|
|
'path': vf['path'],
|
|
|
|
|
'filename': filename,
|
|
|
|
|
'category': category,
|
|
|
|
|
'note': 'Not categorized for parsing'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Display parsing statistics
|
|
|
|
|
click.echo("Parsing complete!")
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("Results by category:")
|
|
|
|
|
click.echo(f" Movies: {len(movie_identities)}")
|
|
|
|
|
|
|
|
|
|
# Count movies needing review
|
|
|
|
|
movies_need_review = sum(1 for m in movie_identities if m['needs_review'])
|
|
|
|
|
if movies_need_review > 0:
|
|
|
|
|
click.echo(f" - Need review: {movies_need_review}")
|
|
|
|
|
|
|
|
|
|
click.echo(f" Series: {len(series_identities)}")
|
|
|
|
|
|
|
|
|
|
# Count series needing review
|
|
|
|
|
series_need_review = sum(1 for s in series_identities if s['needs_review'])
|
|
|
|
|
if series_need_review > 0:
|
|
|
|
|
click.echo(f" - Need review: {series_need_review}")
|
|
|
|
|
|
|
|
|
|
click.echo(f" Anime: {len(anime_files)} (not parsed in v1)")
|
|
|
|
|
click.echo(f" Other: {len(other_files)} (not parsed)")
|
|
|
|
|
|
|
|
|
|
# Save parsed identities to JSON
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(f"Saving parsed identities to: {output}")
|
|
|
|
|
|
|
|
|
|
# Ensure output directory exists
|
|
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
# Build JSON structure
|
|
|
|
|
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
|
|
|
|
|
|
identities_data = {
|
|
|
|
|
'metadata': {
|
|
|
|
|
'generated': generation_timestamp,
|
|
|
|
|
'source_inventory': str(input),
|
|
|
|
|
'total_files': len(video_files)
|
|
|
|
|
},
|
|
|
|
|
'movies': movie_identities,
|
|
|
|
|
'series': series_identities,
|
|
|
|
|
'anime': anime_files,
|
|
|
|
|
'other': other_files
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Write JSON file with pretty formatting
|
|
|
|
|
with open(output, 'w', encoding='utf-8') as jsonfile:
|
|
|
|
|
json.dump(identities_data, jsonfile, indent=2, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
click.echo(f"Parsed identities saved successfully!")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Parse completed: {len(movie_identities)} movies, {len(series_identities)} series, saved to {output}")
|
|
|
|
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
click.echo(f"Error: Input file not found: {input}", err=True)
|
|
|
|
|
logger.error(f"Input file not found: {input}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except csv.Error as e:
|
|
|
|
|
click.echo(f"Error: Failed to parse CSV file: {e}", err=True)
|
|
|
|
|
logger.error(f"CSV parsing failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error during parsing: {e}", err=True)
|
|
|
|
|
logger.error(f"Parse failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
|
|
|
|
default=Path('identities.json'),
|
|
|
|
|
help='Path to parsed identities JSON file (default: identities.json)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--output',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=Path('analysis.json'),
|
|
|
|
|
help='Path to save analysis results (default: analysis.json)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def analyze(ctx: CLIContext, input: Path, output: Path):
|
|
|
|
|
"""Analyze completeness and duplicates.
|
|
|
|
|
|
|
|
|
|
Detects episode gaps in series and identifies potential duplicate files.
|
|
|
|
|
Provides quality comparison data for duplicates.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm analyze # Use default files
|
|
|
|
|
vlm analyze --input my_identities.json # Custom input
|
|
|
|
|
vlm analyze --output my_analysis.json # Custom output
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from vlm.analysis import analyze_series_completeness, detect_duplicates
|
|
|
|
|
from vlm.models import SeriesIdentity, MovieIdentity, VideoFile
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Display analyze start message
|
|
|
|
|
click.echo(f"Analyzing identities from: {input}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Load identities from JSON
|
|
|
|
|
with open(input, 'r', encoding='utf-8') as jsonfile:
|
|
|
|
|
identities_data = json.load(jsonfile)
|
|
|
|
|
|
|
|
|
|
# Extract movies and series
|
|
|
|
|
movies_data = identities_data.get('movies', [])
|
|
|
|
|
series_data = identities_data.get('series', [])
|
|
|
|
|
|
|
|
|
|
click.echo(f"Loaded {len(movies_data)} movies and {len(series_data)} series")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Convert to identity objects
|
|
|
|
|
movie_identities = []
|
|
|
|
|
for m in movies_data:
|
|
|
|
|
movie_identities.append(MovieIdentity(
|
|
|
|
|
title=m['title'],
|
|
|
|
|
year=m.get('year'),
|
|
|
|
|
confidence=m['confidence'],
|
|
|
|
|
needs_review=m['needs_review'],
|
|
|
|
|
original_filename=m['filename']
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
series_identities = []
|
|
|
|
|
for s in series_data:
|
|
|
|
|
series_identities.append(SeriesIdentity(
|
|
|
|
|
title=s['title'],
|
|
|
|
|
season=s.get('season'),
|
|
|
|
|
episodes=s.get('episodes', []),
|
|
|
|
|
confidence=s['confidence'],
|
|
|
|
|
needs_review=s['needs_review'],
|
|
|
|
|
original_filename=s['filename']
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# Create VideoFile objects for duplicate detection
|
|
|
|
|
# We need to reconstruct basic VideoFile info from the identities data
|
|
|
|
|
video_files = []
|
|
|
|
|
for m in movies_data:
|
|
|
|
|
video_files.append(VideoFile(
|
|
|
|
|
path=Path(m['path']),
|
|
|
|
|
filename=m['filename'],
|
|
|
|
|
size_bytes=0, # Not available from identities file
|
|
|
|
|
modified_timestamp=datetime.now(timezone.utc),
|
|
|
|
|
category=m['category'],
|
|
|
|
|
resolution=None,
|
|
|
|
|
codec=None,
|
|
|
|
|
duration_seconds=None,
|
|
|
|
|
bitrate_kbps=None
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
for s in series_data:
|
|
|
|
|
video_files.append(VideoFile(
|
|
|
|
|
path=Path(s['path']),
|
|
|
|
|
filename=s['filename'],
|
|
|
|
|
size_bytes=0, # Not available from identities file
|
|
|
|
|
modified_timestamp=datetime.now(timezone.utc),
|
|
|
|
|
category=s['category'],
|
|
|
|
|
resolution=None,
|
|
|
|
|
codec=None,
|
|
|
|
|
duration_seconds=None,
|
|
|
|
|
bitrate_kbps=None
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# Analyze series completeness
|
|
|
|
|
click.echo("Analyzing series completeness...")
|
|
|
|
|
completeness_results = analyze_series_completeness(series_identities)
|
|
|
|
|
|
|
|
|
|
# Detect duplicates
|
|
|
|
|
click.echo("Detecting duplicates...")
|
|
|
|
|
all_identities = movie_identities + series_identities
|
|
|
|
|
duplicate_groups = detect_duplicates(all_identities, video_files)
|
|
|
|
|
|
|
|
|
|
# Display analysis summary
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("Analysis complete!")
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("Results:")
|
|
|
|
|
click.echo(f" Series with episode gaps: {len(completeness_results)}")
|
|
|
|
|
|
|
|
|
|
if completeness_results:
|
|
|
|
|
total_missing = sum(len(c.episodes_missing) for c in completeness_results)
|
|
|
|
|
click.echo(f" - Total missing episodes: {total_missing}")
|
|
|
|
|
|
|
|
|
|
click.echo(f" Duplicate groups found: {len(duplicate_groups)}")
|
|
|
|
|
|
|
|
|
|
if duplicate_groups:
|
|
|
|
|
total_duplicates = sum(len(g.files) for g in duplicate_groups)
|
|
|
|
|
click.echo(f" - Total duplicate files: {total_duplicates}")
|
|
|
|
|
|
|
|
|
|
# Save analysis results to JSON
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(f"Saving analysis results to: {output}")
|
|
|
|
|
|
|
|
|
|
# Ensure output directory exists
|
|
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
# Build JSON structure
|
|
|
|
|
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
|
|
|
|
|
|
# Convert completeness results to dict
|
|
|
|
|
completeness_list = []
|
|
|
|
|
for c in completeness_results:
|
|
|
|
|
completeness_list.append({
|
|
|
|
|
'series_title': c.series_title,
|
|
|
|
|
'season': c.season,
|
|
|
|
|
'episodes_found': c.episodes_found,
|
|
|
|
|
'episodes_missing': c.episodes_missing
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Convert duplicate groups to dict
|
|
|
|
|
duplicates_list = []
|
|
|
|
|
for d in duplicate_groups:
|
|
|
|
|
# Get identity info
|
|
|
|
|
if isinstance(d.identity, MovieIdentity):
|
|
|
|
|
identity_info = {
|
|
|
|
|
'type': 'movie',
|
|
|
|
|
'title': d.identity.title,
|
|
|
|
|
'year': d.identity.year
|
|
|
|
|
}
|
|
|
|
|
else: # SeriesIdentity
|
|
|
|
|
identity_info = {
|
|
|
|
|
'type': 'series',
|
|
|
|
|
'title': d.identity.title,
|
|
|
|
|
'season': d.identity.season,
|
|
|
|
|
'episodes': d.identity.episodes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
duplicates_list.append({
|
|
|
|
|
'identity': identity_info,
|
|
|
|
|
'files': [str(f.path) for f in d.files],
|
|
|
|
|
'quality_comparison': d.quality_comparison
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
analysis_data = {
|
|
|
|
|
'metadata': {
|
|
|
|
|
'generated': generation_timestamp,
|
|
|
|
|
'source_identities': str(input),
|
|
|
|
|
'total_movies': len(movies_data),
|
|
|
|
|
'total_series': len(series_data)
|
|
|
|
|
},
|
|
|
|
|
'completeness': completeness_list,
|
|
|
|
|
'duplicates': duplicates_list
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Write JSON file with pretty formatting
|
|
|
|
|
with open(output, 'w', encoding='utf-8') as jsonfile:
|
|
|
|
|
json.dump(analysis_data, jsonfile, indent=2, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
click.echo(f"Analysis results saved successfully!")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Analysis completed: {len(completeness_results)} incomplete series, {len(duplicate_groups)} duplicate groups, saved to {output}")
|
|
|
|
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
click.echo(f"Error: Input file not found: {input}", err=True)
|
|
|
|
|
logger.error(f"Input file not found: {input}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
|
|
|
|
|
logger.error(f"JSON parsing failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error during analysis: {e}", err=True)
|
|
|
|
|
logger.error(f"Analysis failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
|
|
|
|
default=Path('identities.json'),
|
|
|
|
|
help='Path to parsed identities JSON file (default: identities.json)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--output',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=Path('plan.json'),
|
|
|
|
|
help='Path to save execution plan (default: plan.json)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def plan(ctx: CLIContext, input: Path, output: Path):
|
|
|
|
|
"""Generate execution plan.
|
|
|
|
|
|
|
|
|
|
Creates a structured, reviewable plan of all file operations to be performed.
|
|
|
|
|
The plan can be edited before execution.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm plan # Use default files
|
|
|
|
|
vlm plan --input my_identities.json # Custom input
|
|
|
|
|
vlm plan --output my_plan.json # Custom output
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
from vlm.planner import generate_plan, save_plan
|
|
|
|
|
from vlm.models import MovieIdentity, SeriesIdentity, VideoFile
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Display plan start message
|
|
|
|
|
click.echo(f"Generating execution plan from: {input}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Load identities from JSON
|
|
|
|
|
with open(input, 'r', encoding='utf-8') as jsonfile:
|
|
|
|
|
identities_data = json.load(jsonfile)
|
|
|
|
|
|
|
|
|
|
# Extract movies and series
|
|
|
|
|
movies_data = identities_data.get('movies', [])
|
|
|
|
|
series_data = identities_data.get('series', [])
|
|
|
|
|
anime_data = identities_data.get('anime', [])
|
|
|
|
|
other_data = identities_data.get('other', [])
|
|
|
|
|
|
|
|
|
|
click.echo(f"Loaded {len(movies_data)} movies, {len(series_data)} series, {len(anime_data)} anime, {len(other_data)} other")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Build list of (VideoFile, Identity) tuples for plan generator
|
|
|
|
|
identities_list = []
|
|
|
|
|
|
|
|
|
|
# Process movies
|
|
|
|
|
for m in movies_data:
|
|
|
|
|
video_file = VideoFile(
|
|
|
|
|
path=Path(m['path']),
|
|
|
|
|
filename=m['filename'],
|
|
|
|
|
size_bytes=0, # Not available from identities file
|
|
|
|
|
modified_timestamp=datetime.now(timezone.utc),
|
|
|
|
|
category=m['category'],
|
|
|
|
|
resolution=None,
|
|
|
|
|
codec=None,
|
|
|
|
|
duration_seconds=None,
|
|
|
|
|
bitrate_kbps=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
movie_identity = MovieIdentity(
|
|
|
|
|
title=m['title'],
|
|
|
|
|
year=m.get('year'),
|
|
|
|
|
confidence=m['confidence'],
|
|
|
|
|
needs_review=m['needs_review'],
|
|
|
|
|
original_filename=m['filename']
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
identities_list.append((video_file, movie_identity))
|
|
|
|
|
|
|
|
|
|
# Process series
|
|
|
|
|
for s in series_data:
|
|
|
|
|
video_file = VideoFile(
|
|
|
|
|
path=Path(s['path']),
|
|
|
|
|
filename=s['filename'],
|
|
|
|
|
size_bytes=0, # Not available from identities file
|
|
|
|
|
modified_timestamp=datetime.now(timezone.utc),
|
|
|
|
|
category=s['category'],
|
|
|
|
|
resolution=None,
|
|
|
|
|
codec=None,
|
|
|
|
|
duration_seconds=None,
|
|
|
|
|
bitrate_kbps=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
series_identity = SeriesIdentity(
|
|
|
|
|
title=s['title'],
|
|
|
|
|
season=s.get('season'),
|
|
|
|
|
episodes=s.get('episodes', []),
|
|
|
|
|
confidence=s['confidence'],
|
|
|
|
|
needs_review=s['needs_review'],
|
|
|
|
|
original_filename=s['filename']
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
identities_list.append((video_file, series_identity))
|
|
|
|
|
|
|
|
|
|
# Process anime (no identity in v1)
|
|
|
|
|
for a in anime_data:
|
|
|
|
|
video_file = VideoFile(
|
|
|
|
|
path=Path(a['path']),
|
|
|
|
|
filename=a['filename'],
|
|
|
|
|
size_bytes=0,
|
|
|
|
|
modified_timestamp=datetime.now(timezone.utc),
|
|
|
|
|
category=a['category'],
|
|
|
|
|
resolution=None,
|
|
|
|
|
codec=None,
|
|
|
|
|
duration_seconds=None,
|
|
|
|
|
bitrate_kbps=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
identities_list.append((video_file, None))
|
|
|
|
|
|
|
|
|
|
# Process other (no identity)
|
|
|
|
|
for o in other_data:
|
|
|
|
|
video_file = VideoFile(
|
|
|
|
|
path=Path(o['path']),
|
|
|
|
|
filename=o['filename'],
|
|
|
|
|
size_bytes=0,
|
|
|
|
|
modified_timestamp=datetime.now(timezone.utc),
|
|
|
|
|
category=o['category'],
|
|
|
|
|
resolution=None,
|
|
|
|
|
codec=None,
|
|
|
|
|
duration_seconds=None,
|
|
|
|
|
bitrate_kbps=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
identities_list.append((video_file, None))
|
|
|
|
|
|
|
|
|
|
# Generate execution plan
|
|
|
|
|
click.echo("Generating execution plan...")
|
|
|
|
|
execution_plan = generate_plan(identities_list, config)
|
|
|
|
|
|
|
|
|
|
# Display plan summary
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("Plan generation complete!")
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("Operation summary:")
|
|
|
|
|
click.echo(f" Total operations: {execution_plan.summary['total']}")
|
|
|
|
|
click.echo(f" Move operations: {execution_plan.summary['move']}")
|
|
|
|
|
click.echo(f" Rename operations: {execution_plan.summary['rename']}")
|
|
|
|
|
click.echo(f" Quarantine operations: {execution_plan.summary['quarantine']}")
|
|
|
|
|
click.echo(f" No-op (skipped): {execution_plan.summary['no-op']}")
|
|
|
|
|
|
|
|
|
|
# Count conflicts
|
|
|
|
|
conflicts = sum(1 for op in execution_plan.operations if op.has_conflict)
|
|
|
|
|
if conflicts > 0:
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(f" ⚠️ Conflicts detected: {conflicts}")
|
|
|
|
|
click.echo(" Review the plan file for details on conflicting operations.")
|
|
|
|
|
|
|
|
|
|
# Save execution plan to JSON
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(f"Saving execution plan to: {output}")
|
|
|
|
|
|
|
|
|
|
# Ensure output directory exists
|
|
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
save_plan(execution_plan, output)
|
|
|
|
|
|
|
|
|
|
click.echo(f"Execution plan saved successfully!")
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("Next steps:")
|
|
|
|
|
click.echo(f" 1. Review the plan: {output}")
|
|
|
|
|
click.echo(f" 2. Edit the plan if needed (it's JSON)")
|
|
|
|
|
click.echo(f" 3. Dry-run: vlm execute --plan {output}")
|
|
|
|
|
click.echo(f" 4. Execute: vlm execute --plan {output} --confirm")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Plan generated: {execution_plan.summary['total']} operations, {conflicts} conflicts, saved to {output}")
|
|
|
|
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
click.echo(f"Error: Input file not found: {input}", err=True)
|
|
|
|
|
logger.error(f"Input file not found: {input}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
|
|
|
|
|
logger.error(f"JSON parsing failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error during plan generation: {e}", err=True)
|
|
|
|
|
logger.error(f"Plan generation failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--plan',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
|
|
|
|
default=Path('plan.json'),
|
|
|
|
|
help='Path to execution plan JSON file (default: plan.json)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--confirm',
|
|
|
|
|
is_flag=True,
|
|
|
|
|
default=False,
|
|
|
|
|
help='Actually execute operations (default is dry-run)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def execute(ctx: CLIContext, plan: Path, confirm: bool):
|
|
|
|
|
"""Execute plan (defaults to dry-run, requires --confirm).
|
|
|
|
|
|
|
|
|
|
Executes file operations from a plan. Defaults to dry-run mode which
|
|
|
|
|
simulates operations without making changes. Use --confirm to actually
|
|
|
|
|
execute operations.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm execute # Dry-run with plan.json
|
|
|
|
|
vlm execute --plan my_plan.json # Dry-run with custom plan
|
|
|
|
|
vlm execute --confirm # Actually execute operations
|
|
|
|
|
"""
|
|
|
|
|
from vlm.planner import load_plan
|
|
|
|
|
from vlm.executor import ExecutionEngine
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Determine execution mode
|
|
|
|
|
mode = "execute" if confirm else "dry-run"
|
|
|
|
|
|
|
|
|
|
# Display execution start message
|
|
|
|
|
click.echo(f"Loading execution plan from: {plan}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Load execution plan
|
|
|
|
|
execution_plan = load_plan(plan)
|
|
|
|
|
|
|
|
|
|
# Display plan summary
|
|
|
|
|
click.echo(f"Execution plan loaded: {execution_plan.plan_id}")
|
|
|
|
|
click.echo(f"Created at: {execution_plan.created_at}")
|
|
|
|
|
click.echo(f"Total operations: {len(execution_plan.operations)}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Display mode warning
|
|
|
|
|
if mode == "dry-run":
|
|
|
|
|
click.echo("⚠️ DRY-RUN MODE - No files will be modified")
|
|
|
|
|
click.echo(" Use --confirm to actually execute operations")
|
|
|
|
|
else:
|
|
|
|
|
click.echo("⚠️ EXECUTE MODE - Files will be modified!")
|
|
|
|
|
click.echo(" This operation cannot be undone without rollback")
|
|
|
|
|
click.echo()
|
|
|
|
|
if not click.confirm("Are you sure you want to proceed?"):
|
|
|
|
|
click.echo("Execution cancelled.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(f"Executing {len(execution_plan.operations)} operations...")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Create execution engine and execute plan
|
|
|
|
|
engine = ExecutionEngine(logger=logger)
|
|
|
|
|
results, summary, rollback_log = engine.execute_plan(
|
|
|
|
|
execution_plan,
|
|
|
|
|
mode=mode,
|
|
|
|
|
confirmed=confirm
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Display execution progress (show some operations)
|
|
|
|
|
if mode == "dry-run":
|
|
|
|
|
click.echo("Sample operations (dry-run):")
|
|
|
|
|
# Show first 5 operations as examples
|
|
|
|
|
for i, result in enumerate(results[:5]):
|
|
|
|
|
op = result.operation
|
|
|
|
|
if op.operation_type != "no-op":
|
|
|
|
|
click.echo(f" [{i+1}] {op.operation_type}: {op.source_path.name}")
|
|
|
|
|
if op.destination_path:
|
|
|
|
|
click.echo(f" -> {op.destination_path}")
|
|
|
|
|
|
|
|
|
|
if len(results) > 5:
|
|
|
|
|
click.echo(f" ... and {len(results) - 5} more operations")
|
|
|
|
|
else:
|
|
|
|
|
# In execute mode, show progress for all operations
|
|
|
|
|
for i, result in enumerate(results):
|
|
|
|
|
op = result.operation
|
|
|
|
|
if op.operation_type != "no-op" and not op.has_conflict:
|
|
|
|
|
status = "✓" if result.success else "✗"
|
|
|
|
|
click.echo(f" [{i+1}/{len(results)}] {status} {op.operation_type}: {op.source_path.name}")
|
|
|
|
|
if result.error_message:
|
|
|
|
|
click.echo(f" Error: {result.error_message}")
|
|
|
|
|
|
|
|
|
|
# Display execution summary
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("=" * 60)
|
|
|
|
|
click.echo(f"Execution Summary ({mode} mode)")
|
|
|
|
|
click.echo("=" * 60)
|
|
|
|
|
click.echo(f" Total operations: {summary['total']}")
|
|
|
|
|
click.echo(f" Successful: {summary['successful']}")
|
|
|
|
|
click.echo(f" Failed: {summary['failed']}")
|
|
|
|
|
click.echo(f" Skipped: {summary['skipped']}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Save rollback log if in execute mode
|
|
|
|
|
if mode == "execute" and rollback_log:
|
|
|
|
|
# Save to ~/.vlm/rollback/ directory
|
|
|
|
|
rollback_dir = Path.home() / ".vlm" / "rollback"
|
|
|
|
|
rollback_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
rollback_path = rollback_dir / f"rollback_{rollback_log.log_id}.json"
|
|
|
|
|
|
|
|
|
|
engine.save_rollback_log(rollback_log, rollback_path)
|
|
|
|
|
|
|
|
|
|
click.echo(f"Rollback log saved to: {rollback_path}")
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("To undo these operations, run:")
|
|
|
|
|
click.echo(f" vlm rollback --log {rollback_path}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Log completion
|
|
|
|
|
if mode == "dry-run":
|
|
|
|
|
click.echo("Dry-run complete! No files were modified.")
|
|
|
|
|
click.echo("Review the operations above and use --confirm to execute.")
|
|
|
|
|
else:
|
|
|
|
|
if summary['failed'] > 0:
|
|
|
|
|
click.echo(f"⚠️ Execution completed with {summary['failed']} failures.")
|
|
|
|
|
click.echo(" Check the log file for details.")
|
|
|
|
|
else:
|
|
|
|
|
click.echo("✓ Execution completed successfully!")
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Execution completed in {mode} mode: "
|
|
|
|
|
f"{summary['successful']} successful, {summary['failed']} failed, {summary['skipped']} skipped"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
click.echo(f"Error: Plan file not found: {plan}", err=True)
|
|
|
|
|
logger.error(f"Plan file not found: {plan}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
click.echo(f"Error: {e}", err=True)
|
|
|
|
|
logger.error(f"Execution failed: {e}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error during execution: {e}", err=True)
|
|
|
|
|
logger.error(f"Execution failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.group()
|
|
|
|
|
@pass_context
|
|
|
|
|
def quarantine(ctx: CLIContext):
|
|
|
|
|
"""Manage quarantined files.
|
|
|
|
|
|
|
|
|
|
Quarantine operations allow you to safely isolate unwanted files
|
|
|
|
|
for review before deletion. Only movie and series files can be
|
|
|
|
|
quarantined in v1.
|
|
|
|
|
"""
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@quarantine.command('list')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--category',
|
|
|
|
|
type=click.Choice(['movie', 'series'], case_sensitive=False),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Filter by category (movie or series)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def quarantine_list(ctx: CLIContext, category: Optional[str]):
|
|
|
|
|
"""List quarantined files.
|
|
|
|
|
|
|
|
|
|
Shows all files currently in quarantine with their original locations,
|
|
|
|
|
quarantine timestamps, and reasons.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm quarantine list # List all quarantined files
|
|
|
|
|
vlm quarantine list --category movie # List only movie files
|
|
|
|
|
"""
|
|
|
|
|
from vlm.quarantine import QuarantineManager
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Create quarantine manager
|
|
|
|
|
manager = QuarantineManager(config, logger)
|
|
|
|
|
|
|
|
|
|
# List quarantined files
|
|
|
|
|
entries = manager.list_quarantined(category=category)
|
|
|
|
|
|
|
|
|
|
if not entries:
|
|
|
|
|
if category:
|
|
|
|
|
click.echo(f"No quarantined files found in category '{category}'.")
|
|
|
|
|
else:
|
|
|
|
|
click.echo("No quarantined files found.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Display quarantined files
|
|
|
|
|
click.echo()
|
|
|
|
|
if category:
|
|
|
|
|
click.echo(f"Quarantined files in category '{category}':")
|
|
|
|
|
else:
|
|
|
|
|
click.echo("Quarantined files:")
|
|
|
|
|
click.echo("=" * 80)
|
|
|
|
|
|
|
|
|
|
for i, entry in enumerate(entries, 1):
|
|
|
|
|
click.echo(f"\n[{i}] {entry.quarantine_path.name}")
|
|
|
|
|
click.echo(f" Category: {entry.category}")
|
|
|
|
|
click.echo(f" Original: {entry.original_path}")
|
|
|
|
|
click.echo(f" Quarantine: {entry.quarantine_path}")
|
|
|
|
|
click.echo(f" Size: {_format_size(entry.size_bytes)}")
|
|
|
|
|
click.echo(f" Quarantined: {entry.quarantined_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
|
|
|
if entry.reason:
|
|
|
|
|
click.echo(f" Reason: {entry.reason}")
|
|
|
|
|
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("=" * 80)
|
|
|
|
|
click.echo(f"Total: {len(entries)} quarantined file(s)")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
logger.info(f"Listed {len(entries)} quarantined files" +
|
|
|
|
|
(f" from category '{category}'" if category else ""))
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error listing quarantined files: {e}", err=True)
|
|
|
|
|
logger.error(f"Failed to list quarantined files: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@quarantine.command('add')
|
|
|
|
|
@click.argument('file', type=click.Path(exists=True, path_type=Path))
|
|
|
|
|
@click.option(
|
|
|
|
|
'--reason',
|
|
|
|
|
type=str,
|
|
|
|
|
default=None,
|
|
|
|
|
help='Reason for quarantining the file'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def quarantine_add(ctx: CLIContext, file: Path, reason: Optional[str]):
|
|
|
|
|
"""Add file to quarantine.
|
|
|
|
|
|
|
|
|
|
Moves a file to the category-specific quarantine directory. Only movie
|
|
|
|
|
and series files can be quarantined in v1. Anime and other files will
|
|
|
|
|
be rejected.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm quarantine add /path/to/movie.mkv
|
|
|
|
|
vlm quarantine add /path/to/series.mkv --reason "duplicate"
|
|
|
|
|
"""
|
|
|
|
|
from vlm.quarantine import QuarantineManager
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Create quarantine manager
|
|
|
|
|
manager = QuarantineManager(config, logger)
|
|
|
|
|
|
|
|
|
|
# Display confirmation
|
|
|
|
|
click.echo(f"Quarantining file: {file}")
|
|
|
|
|
if reason:
|
|
|
|
|
click.echo(f"Reason: {reason}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Quarantine the file
|
|
|
|
|
result = manager.quarantine_file(file, reason=reason)
|
|
|
|
|
|
|
|
|
|
if result.success:
|
|
|
|
|
click.echo(f"✓ File successfully quarantined!")
|
|
|
|
|
click.echo(f" Original: {result.operation.source_path}")
|
|
|
|
|
click.echo(f" Quarantine: {result.operation.destination_path}")
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("To restore this file, run:")
|
|
|
|
|
click.echo(f" vlm quarantine restore {result.operation.destination_path}")
|
|
|
|
|
else:
|
|
|
|
|
click.echo(f"✗ Failed to quarantine file: {result.error_message}", err=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Quarantined file: {file}")
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
# Category restriction error
|
|
|
|
|
click.echo(f"Error: {e}", err=True)
|
|
|
|
|
logger.error(f"Quarantine rejected: {e}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error quarantining file: {e}", err=True)
|
|
|
|
|
logger.error(f"Failed to quarantine file: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@quarantine.command('restore')
|
|
|
|
|
@click.argument('file', type=click.Path(exists=True, path_type=Path))
|
|
|
|
|
@pass_context
|
|
|
|
|
def quarantine_restore(ctx: CLIContext, file: Path):
|
|
|
|
|
"""Restore file from quarantine.
|
|
|
|
|
|
|
|
|
|
Moves a file from quarantine back to its original location. This is a
|
|
|
|
|
best-effort operation that may fail if the original location is occupied.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm quarantine restore /path/to/.quarantine/movie.mkv
|
|
|
|
|
"""
|
|
|
|
|
from vlm.quarantine import QuarantineManager
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Create quarantine manager
|
|
|
|
|
manager = QuarantineManager(config, logger)
|
|
|
|
|
|
|
|
|
|
# Display confirmation
|
|
|
|
|
click.echo(f"Restoring file from quarantine: {file}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Restore the file
|
|
|
|
|
result = manager.restore_from_quarantine(file)
|
|
|
|
|
|
|
|
|
|
if result.success:
|
|
|
|
|
click.echo(f"✓ File successfully restored!")
|
|
|
|
|
click.echo(f" Quarantine: {result.operation.source_path}")
|
|
|
|
|
click.echo(f" Restored to: {result.operation.destination_path}")
|
|
|
|
|
else:
|
|
|
|
|
if result.operation.has_conflict:
|
|
|
|
|
click.echo(f"✗ Cannot restore: {result.operation.conflict_reason}", err=True)
|
|
|
|
|
click.echo(f" Original location: {result.operation.destination_path}", err=True)
|
|
|
|
|
else:
|
|
|
|
|
click.echo(f"✗ Failed to restore file: {result.error_message}", err=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Restored file from quarantine: {file}")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error restoring file: {e}", err=True)
|
|
|
|
|
logger.error(f"Failed to restore file: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--log',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Path to rollback log JSON file'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def rollback(ctx: CLIContext, log: Optional[Path]):
|
|
|
|
|
"""Rollback previous execution (best-effort).
|
|
|
|
|
|
|
|
|
|
Attempts to reverse file operations from a previous execution by moving
|
|
|
|
|
files from their destination back to their source. This is a best-effort
|
|
|
|
|
operation that may not succeed if files have been modified or moved.
|
|
|
|
|
|
|
|
|
|
Operations are processed in LIFO (Last In, First Out) order for best-effort
|
|
|
|
|
restoration. All rollback attempts are logged with detailed results.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm rollback # Find latest rollback log
|
|
|
|
|
vlm rollback --log rollback_<uuid>.json # Use specific log
|
|
|
|
|
vlm rollback --log ~/.vlm/rollback/rollback_*.json
|
|
|
|
|
"""
|
|
|
|
|
from vlm.executor import ExecutionEngine
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# If no log specified, find the most recent rollback log
|
|
|
|
|
if log is None:
|
|
|
|
|
rollback_dir = Path.home() / ".vlm" / "rollback"
|
|
|
|
|
if not rollback_dir.exists():
|
|
|
|
|
click.echo("Error: No rollback logs found.", err=True)
|
|
|
|
|
click.echo(f"Rollback directory does not exist: {rollback_dir}", err=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
# Find all rollback log files
|
|
|
|
|
rollback_logs = sorted(rollback_dir.glob("rollback_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
|
|
|
|
|
|
|
|
if not rollback_logs:
|
|
|
|
|
click.echo("Error: No rollback logs found.", err=True)
|
|
|
|
|
click.echo(f"No rollback_*.json files in: {rollback_dir}", err=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
# Use the most recent log
|
|
|
|
|
log = rollback_logs[0]
|
|
|
|
|
click.echo(f"Using most recent rollback log: {log}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Display rollback start message
|
|
|
|
|
click.echo(f"Loading rollback log from: {log}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Create execution engine
|
|
|
|
|
engine = ExecutionEngine(logger=logger)
|
|
|
|
|
|
|
|
|
|
# Load rollback log
|
|
|
|
|
rollback_log = engine.load_rollback_log(log)
|
|
|
|
|
|
|
|
|
|
# Display rollback log info
|
|
|
|
|
click.echo(f"Rollback log loaded: {rollback_log.log_id}")
|
|
|
|
|
click.echo(f"Original execution: {rollback_log.execution_plan_id}")
|
|
|
|
|
click.echo(f"Executed at: {rollback_log.executed_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
|
|
|
click.echo(f"Operations to rollback: {len(rollback_log.operations)}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Display warning
|
|
|
|
|
click.echo("⚠️ ROLLBACK OPERATION - Best-effort restoration")
|
|
|
|
|
click.echo(" This will attempt to move files back to their original locations.")
|
|
|
|
|
click.echo(" Some operations may fail if files have been modified or moved.")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
if not click.confirm("Are you sure you want to proceed with rollback?"):
|
|
|
|
|
click.echo("Rollback cancelled.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(f"Rolling back {len(rollback_log.operations)} operations...")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Perform rollback
|
|
|
|
|
results, summary = engine.rollback(rollback_log)
|
|
|
|
|
|
|
|
|
|
# Display rollback progress
|
|
|
|
|
for i, result in enumerate(results):
|
|
|
|
|
op = result.operation
|
|
|
|
|
if op.operation_type != "no-op":
|
|
|
|
|
status = "✓" if result.success else "✗"
|
|
|
|
|
click.echo(f" [{i+1}/{len(results)}] {status} Rollback: {op.destination_path.name if op.destination_path else op.source_path.name}")
|
|
|
|
|
if result.error_message:
|
|
|
|
|
click.echo(f" Error: {result.error_message}")
|
|
|
|
|
|
|
|
|
|
# Display rollback summary
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo("=" * 60)
|
|
|
|
|
click.echo("Rollback Summary")
|
|
|
|
|
click.echo("=" * 60)
|
|
|
|
|
click.echo(f" Total operations: {summary['total']}")
|
|
|
|
|
click.echo(f" Successful: {summary['successful']}")
|
|
|
|
|
click.echo(f" Failed: {summary['failed']}")
|
|
|
|
|
click.echo(f" Skipped: {summary['skipped']}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Display completion message
|
|
|
|
|
if summary['failed'] > 0:
|
|
|
|
|
click.echo(f"⚠️ Rollback completed with {summary['failed']} failures.")
|
|
|
|
|
click.echo(" Check the log file for details.")
|
|
|
|
|
click.echo(" Some files may not have been restored to their original locations.")
|
|
|
|
|
else:
|
|
|
|
|
click.echo("✓ Rollback completed successfully!")
|
|
|
|
|
click.echo(" All files have been restored to their original locations.")
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Rollback completed: "
|
|
|
|
|
f"{summary['successful']} successful, {summary['failed']} failed, {summary['skipped']} skipped"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except FileNotFoundError as e:
|
|
|
|
|
click.echo(f"Error: {e}", err=True)
|
|
|
|
|
logger.error(f"Rollback log not found: {e}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
click.echo(f"Error: Invalid rollback log format: {e}", err=True)
|
|
|
|
|
logger.error(f"Invalid rollback log: {e}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error during rollback: {e}", err=True)
|
|
|
|
|
logger.error(f"Rollback failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.group()
|
|
|
|
|
@pass_context
|
|
|
|
|
def report(ctx: CLIContext):
|
|
|
|
|
"""Generate and export reports.
|
|
|
|
|
|
|
|
|
|
Generate various reports about your video library including inventory,
|
|
|
|
|
completeness analysis, duplicate detection, and summary statistics.
|
|
|
|
|
"""
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@report.command('inventory')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--format',
|
|
|
|
|
type=click.Choice(['csv', 'json', 'text'], case_sensitive=False),
|
|
|
|
|
default='text',
|
|
|
|
|
help='Output format (default: text)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
|
|
|
|
default=Path('inventory.csv'),
|
|
|
|
|
help='Input inventory CSV file (default: inventory.csv)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--output',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Output file (default: print to console)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
|
|
|
|
|
"""Generate inventory report.
|
|
|
|
|
|
|
|
|
|
Lists all discovered video files with metadata in the specified format.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm report inventory # Text format to console
|
|
|
|
|
vlm report inventory --format csv # CSV format to console
|
|
|
|
|
vlm report inventory --format json --output inventory_report.json
|
|
|
|
|
"""
|
|
|
|
|
import csv
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from vlm.reports import generate_inventory_report
|
|
|
|
|
from vlm.models import VideoFile
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Load inventory from CSV
|
|
|
|
|
click.echo(f"Loading inventory from: {input}")
|
|
|
|
|
|
|
|
|
|
video_files = []
|
|
|
|
|
with open(input, 'r', encoding='utf-8') as csvfile:
|
|
|
|
|
# Skip comment lines
|
|
|
|
|
lines = []
|
|
|
|
|
for line in csvfile:
|
|
|
|
|
if not line.startswith('#'):
|
|
|
|
|
lines.append(line)
|
|
|
|
|
|
|
|
|
|
# Parse CSV
|
|
|
|
|
reader = csv.DictReader(lines)
|
|
|
|
|
for row in reader:
|
|
|
|
|
# Parse timestamp
|
|
|
|
|
modified_timestamp = datetime.fromisoformat(row['modified_timestamp'])
|
|
|
|
|
if modified_timestamp.tzinfo is None:
|
|
|
|
|
modified_timestamp = modified_timestamp.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
# Parse optional fields
|
|
|
|
|
resolution = row.get('resolution') if row.get('resolution') else None
|
|
|
|
|
codec = row.get('codec') if row.get('codec') else None
|
|
|
|
|
duration_seconds = float(row['duration_seconds']) if row.get('duration_seconds') else None
|
|
|
|
|
bitrate_kbps = int(row['bitrate_kbps']) if row.get('bitrate_kbps') else None
|
|
|
|
|
|
|
|
|
|
video_file = VideoFile(
|
|
|
|
|
path=Path(row['path']),
|
|
|
|
|
filename=row['filename'],
|
|
|
|
|
size_bytes=int(row['size_bytes']),
|
|
|
|
|
modified_timestamp=modified_timestamp,
|
|
|
|
|
category=row['category'],
|
|
|
|
|
resolution=resolution,
|
|
|
|
|
codec=codec,
|
|
|
|
|
duration_seconds=duration_seconds,
|
|
|
|
|
bitrate_kbps=bitrate_kbps
|
|
|
|
|
)
|
|
|
|
|
video_files.append(video_file)
|
|
|
|
|
|
|
|
|
|
click.echo(f"Loaded {len(video_files)} files")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Generate report
|
|
|
|
|
click.echo(f"Generating inventory report in {format} format...")
|
|
|
|
|
|
|
|
|
|
# For text format, use CSV format as the text representation
|
|
|
|
|
report_format = 'csv' if format == 'text' else format
|
|
|
|
|
report_content = generate_inventory_report(video_files, report_format, config.library_root)
|
|
|
|
|
|
|
|
|
|
# Output report
|
|
|
|
|
if output:
|
|
|
|
|
# Save to file
|
|
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with open(output, 'w', encoding='utf-8') as f:
|
|
|
|
|
f.write(report_content)
|
|
|
|
|
click.echo(f"Report saved to: {output}")
|
|
|
|
|
else:
|
|
|
|
|
# Print to console
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(report_content)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Generated inventory report in {format} format with {len(video_files)} files")
|
|
|
|
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
click.echo(f"Error: Input file not found: {input}", err=True)
|
|
|
|
|
logger.error(f"Input file not found: {input}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except csv.Error as e:
|
|
|
|
|
click.echo(f"Error: Failed to parse CSV file: {e}", err=True)
|
|
|
|
|
logger.error(f"CSV parsing failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error generating inventory report: {e}", err=True)
|
|
|
|
|
logger.error(f"Inventory report generation failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@report.command('completeness')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--format',
|
|
|
|
|
type=click.Choice(['text', 'json'], case_sensitive=False),
|
|
|
|
|
default='text',
|
|
|
|
|
help='Output format (default: text)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
|
|
|
|
default=Path('analysis.json'),
|
|
|
|
|
help='Input analysis JSON file (default: analysis.json)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--output',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Output file (default: print to console)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
|
|
|
|
|
"""Generate completeness report.
|
|
|
|
|
|
|
|
|
|
Shows series with episode gaps detected through heuristic analysis.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm report completeness # Text format to console
|
|
|
|
|
vlm report completeness --format json # JSON format to console
|
|
|
|
|
vlm report completeness --format text --output completeness.txt
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
from vlm.reports import generate_completeness_report
|
|
|
|
|
from vlm.models import SeasonCompleteness
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Load analysis from JSON
|
|
|
|
|
click.echo(f"Loading analysis from: {input}")
|
|
|
|
|
|
|
|
|
|
with open(input, 'r', encoding='utf-8') as jsonfile:
|
|
|
|
|
analysis_data = json.load(jsonfile)
|
|
|
|
|
|
|
|
|
|
# Extract completeness data
|
|
|
|
|
completeness_list = analysis_data.get('completeness', [])
|
|
|
|
|
|
|
|
|
|
# Convert to SeasonCompleteness objects
|
|
|
|
|
season_completeness = []
|
|
|
|
|
for c in completeness_list:
|
|
|
|
|
season_completeness.append(SeasonCompleteness(
|
|
|
|
|
series_title=c['series_title'],
|
|
|
|
|
season=c['season'],
|
|
|
|
|
episodes_found=c['episodes_found'],
|
|
|
|
|
episodes_missing=c['episodes_missing']
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
click.echo(f"Loaded {len(season_completeness)} series with gaps")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Generate report
|
|
|
|
|
click.echo(f"Generating completeness report in {format} format...")
|
|
|
|
|
report_content = generate_completeness_report(season_completeness, format, config.library_root)
|
|
|
|
|
|
|
|
|
|
# Output report
|
|
|
|
|
if output:
|
|
|
|
|
# Save to file
|
|
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with open(output, 'w', encoding='utf-8') as f:
|
|
|
|
|
f.write(report_content)
|
|
|
|
|
click.echo(f"Report saved to: {output}")
|
|
|
|
|
else:
|
|
|
|
|
# Print to console
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(report_content)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Generated completeness report in {format} format with {len(season_completeness)} series")
|
|
|
|
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
click.echo(f"Error: Input file not found: {input}", err=True)
|
|
|
|
|
logger.error(f"Input file not found: {input}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
|
|
|
|
|
logger.error(f"JSON parsing failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error generating completeness report: {e}", err=True)
|
|
|
|
|
logger.error(f"Completeness report generation failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@report.command('duplicates')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--format',
|
|
|
|
|
type=click.Choice(['text', 'json'], case_sensitive=False),
|
|
|
|
|
default='text',
|
|
|
|
|
help='Output format (default: text)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
|
|
|
|
default=Path('analysis.json'),
|
|
|
|
|
help='Input analysis JSON file (default: analysis.json)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--output',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Output file (default: print to console)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
|
|
|
|
|
"""Generate duplicate report.
|
|
|
|
|
|
|
|
|
|
Shows duplicate files with quality comparison data to help decide which
|
|
|
|
|
files to keep.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm report duplicates # Text format to console
|
|
|
|
|
vlm report duplicates --format json # JSON format to console
|
|
|
|
|
vlm report duplicates --format text --output duplicates.txt
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
from vlm.reports import generate_duplicate_report
|
|
|
|
|
from vlm.models import DuplicateGroup, MovieIdentity, SeriesIdentity, VideoFile
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Load analysis from JSON
|
|
|
|
|
click.echo(f"Loading analysis from: {input}")
|
|
|
|
|
|
|
|
|
|
with open(input, 'r', encoding='utf-8') as jsonfile:
|
|
|
|
|
analysis_data = json.load(jsonfile)
|
|
|
|
|
|
|
|
|
|
# Extract duplicates data
|
|
|
|
|
duplicates_list = analysis_data.get('duplicates', [])
|
|
|
|
|
|
|
|
|
|
# Convert to DuplicateGroup objects
|
|
|
|
|
duplicate_groups = []
|
|
|
|
|
for d in duplicates_list:
|
|
|
|
|
identity_data = d['identity']
|
|
|
|
|
|
|
|
|
|
# Reconstruct identity
|
|
|
|
|
if identity_data['type'] == 'movie':
|
|
|
|
|
identity = MovieIdentity(
|
|
|
|
|
title=identity_data['title'],
|
|
|
|
|
year=identity_data.get('year'),
|
|
|
|
|
confidence=1.0,
|
|
|
|
|
needs_review=False,
|
|
|
|
|
original_filename=""
|
|
|
|
|
)
|
|
|
|
|
else: # series
|
|
|
|
|
identity = SeriesIdentity(
|
|
|
|
|
title=identity_data['title'],
|
|
|
|
|
season=identity_data.get('season'),
|
|
|
|
|
episodes=identity_data.get('episodes', []),
|
|
|
|
|
confidence=1.0,
|
|
|
|
|
needs_review=False,
|
|
|
|
|
original_filename=""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Reconstruct VideoFile objects from file paths
|
|
|
|
|
files = []
|
|
|
|
|
for file_path in d['files']:
|
|
|
|
|
files.append(VideoFile(
|
|
|
|
|
path=Path(file_path),
|
|
|
|
|
filename=Path(file_path).name,
|
|
|
|
|
size_bytes=0,
|
|
|
|
|
modified_timestamp=datetime.now(timezone.utc),
|
|
|
|
|
category="",
|
|
|
|
|
resolution=None,
|
|
|
|
|
codec=None,
|
|
|
|
|
duration_seconds=None,
|
|
|
|
|
bitrate_kbps=None
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
duplicate_groups.append(DuplicateGroup(
|
|
|
|
|
identity=identity,
|
|
|
|
|
files=files,
|
|
|
|
|
quality_comparison=d['quality_comparison']
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
click.echo(f"Loaded {len(duplicate_groups)} duplicate groups")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Generate report
|
|
|
|
|
click.echo(f"Generating duplicate report in {format} format...")
|
|
|
|
|
report_content = generate_duplicate_report(duplicate_groups, format, config.library_root)
|
|
|
|
|
|
|
|
|
|
# Output report
|
|
|
|
|
if output:
|
|
|
|
|
# Save to file
|
|
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with open(output, 'w', encoding='utf-8') as f:
|
|
|
|
|
f.write(report_content)
|
|
|
|
|
click.echo(f"Report saved to: {output}")
|
|
|
|
|
else:
|
|
|
|
|
# Print to console
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(report_content)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Generated duplicate report in {format} format with {len(duplicate_groups)} groups")
|
|
|
|
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
click.echo(f"Error: Input file not found: {input}", err=True)
|
|
|
|
|
logger.error(f"Input file not found: {input}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
|
|
|
|
|
logger.error(f"JSON parsing failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error generating duplicate report: {e}", err=True)
|
|
|
|
|
logger.error(f"Duplicate report generation failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@report.command('summary')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
|
|
|
|
default=Path('inventory.csv'),
|
|
|
|
|
help='Input inventory CSV file (default: inventory.csv)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--output',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Output file (default: print to console)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]):
|
|
|
|
|
"""Generate summary report.
|
|
|
|
|
|
|
|
|
|
Shows library statistics including total file count, size, and category
|
|
|
|
|
breakdown.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm report summary # Print to console
|
|
|
|
|
vlm report summary --output summary.txt # Save to file
|
|
|
|
|
"""
|
|
|
|
|
import csv
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from vlm.reports import generate_summary_report
|
|
|
|
|
from vlm.models import VideoFile
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Load inventory from CSV
|
|
|
|
|
click.echo(f"Loading inventory from: {input}")
|
|
|
|
|
|
|
|
|
|
video_files = []
|
|
|
|
|
with open(input, 'r', encoding='utf-8') as csvfile:
|
|
|
|
|
# Skip comment lines
|
|
|
|
|
lines = []
|
|
|
|
|
for line in csvfile:
|
|
|
|
|
if not line.startswith('#'):
|
|
|
|
|
lines.append(line)
|
|
|
|
|
|
|
|
|
|
# Parse CSV
|
|
|
|
|
reader = csv.DictReader(lines)
|
|
|
|
|
for row in reader:
|
|
|
|
|
# Parse timestamp
|
|
|
|
|
modified_timestamp = datetime.fromisoformat(row['modified_timestamp'])
|
|
|
|
|
if modified_timestamp.tzinfo is None:
|
|
|
|
|
modified_timestamp = modified_timestamp.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
# Parse optional fields
|
|
|
|
|
resolution = row.get('resolution') if row.get('resolution') else None
|
|
|
|
|
codec = row.get('codec') if row.get('codec') else None
|
|
|
|
|
duration_seconds = float(row['duration_seconds']) if row.get('duration_seconds') else None
|
|
|
|
|
bitrate_kbps = int(row['bitrate_kbps']) if row.get('bitrate_kbps') else None
|
|
|
|
|
|
|
|
|
|
video_file = VideoFile(
|
|
|
|
|
path=Path(row['path']),
|
|
|
|
|
filename=row['filename'],
|
|
|
|
|
size_bytes=int(row['size_bytes']),
|
|
|
|
|
modified_timestamp=modified_timestamp,
|
|
|
|
|
category=row['category'],
|
|
|
|
|
resolution=resolution,
|
|
|
|
|
codec=codec,
|
|
|
|
|
duration_seconds=duration_seconds,
|
|
|
|
|
bitrate_kbps=bitrate_kbps
|
|
|
|
|
)
|
|
|
|
|
video_files.append(video_file)
|
|
|
|
|
|
|
|
|
|
click.echo(f"Loaded {len(video_files)} files")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
# Generate report
|
|
|
|
|
click.echo("Generating summary report...")
|
|
|
|
|
report_content = generate_summary_report(video_files, config.library_root)
|
|
|
|
|
|
|
|
|
|
# Output report
|
|
|
|
|
if output:
|
|
|
|
|
# Save to file
|
|
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with open(output, 'w', encoding='utf-8') as f:
|
|
|
|
|
f.write(report_content)
|
|
|
|
|
click.echo(f"Report saved to: {output}")
|
|
|
|
|
else:
|
|
|
|
|
# Print to console
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(report_content)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Generated summary report with {len(video_files)} files")
|
|
|
|
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
click.echo(f"Error: Input file not found: {input}", err=True)
|
|
|
|
|
logger.error(f"Input file not found: {input}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except csv.Error as e:
|
|
|
|
|
click.echo(f"Error: Failed to parse CSV file: {e}", err=True)
|
|
|
|
|
logger.error(f"CSV parsing failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error generating summary report: {e}", err=True)
|
|
|
|
|
logger.error(f"Summary report generation failed: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.group()
|
|
|
|
|
@pass_context
|
|
|
|
|
def state(ctx: CLIContext):
|
|
|
|
|
"""Manage file states.
|
|
|
|
|
|
|
|
|
|
Track file statuses and user decisions throughout the workflow.
|
|
|
|
|
"""
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@state.command('show')
|
|
|
|
|
@click.argument('file', type=click.Path(path_type=Path))
|
|
|
|
|
@pass_context
|
|
|
|
|
def state_show(ctx: CLIContext, file: Path):
|
|
|
|
|
"""Show state for a file.
|
|
|
|
|
|
|
|
|
|
Displays the current status, reason, and last update timestamp for a file.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm state show /path/to/movie.mkv
|
|
|
|
|
"""
|
|
|
|
|
from vlm.state import StateManager
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Get state store path
|
|
|
|
|
state_path = Path.home() / ".vlm" / "state.json"
|
|
|
|
|
|
|
|
|
|
# Create state manager
|
|
|
|
|
manager = StateManager(state_path)
|
|
|
|
|
|
|
|
|
|
# Get file state
|
|
|
|
|
file_state = manager.get_file_state(file)
|
|
|
|
|
|
|
|
|
|
if file_state is None:
|
|
|
|
|
click.echo(f"No state found for file: {file}")
|
|
|
|
|
click.echo("This file has not been tracked yet.")
|
|
|
|
|
else:
|
|
|
|
|
click.echo(f"State for file: {file}")
|
|
|
|
|
click.echo()
|
|
|
|
|
click.echo(f" Status: {file_state.status}")
|
|
|
|
|
if file_state.reason:
|
|
|
|
|
click.echo(f" Reason: {file_state.reason}")
|
|
|
|
|
click.echo(f" Updated: {file_state.updated_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Showed state for file: {file}")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error showing file state: {e}", err=True)
|
|
|
|
|
logger.error(f"Failed to show file state: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@state.command('set')
|
|
|
|
|
@click.argument('file', type=click.Path(path_type=Path))
|
|
|
|
|
@click.option(
|
|
|
|
|
'--status',
|
|
|
|
|
type=click.Choice(['reviewed', 'ignored', 'planned', 'executed', 'quarantined'], case_sensitive=False),
|
|
|
|
|
required=True,
|
|
|
|
|
help='Status to set for the file'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--reason',
|
|
|
|
|
type=str,
|
|
|
|
|
default=None,
|
|
|
|
|
help='Optional reason for the status'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def state_set(ctx: CLIContext, file: Path, status: str, reason: Optional[str]):
|
|
|
|
|
"""Set state for a file.
|
|
|
|
|
|
|
|
|
|
Updates the status and optional reason for a file. This operation is
|
|
|
|
|
idempotent - setting the same status multiple times will update the
|
|
|
|
|
timestamp and reason.
|
|
|
|
|
|
|
|
|
|
Valid statuses: reviewed, ignored, planned, executed, quarantined
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm state set /path/to/movie.mkv --status reviewed
|
|
|
|
|
vlm state set /path/to/movie.mkv --status ignored --reason "duplicate"
|
|
|
|
|
"""
|
|
|
|
|
from vlm.state import StateManager
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Get state store path
|
|
|
|
|
state_path = Path.home() / ".vlm" / "state.json"
|
|
|
|
|
|
|
|
|
|
# Create state manager
|
|
|
|
|
manager = StateManager(state_path)
|
|
|
|
|
|
|
|
|
|
# Set file state
|
|
|
|
|
manager.set_file_state(file, status, reason)
|
|
|
|
|
|
|
|
|
|
# Save state
|
|
|
|
|
manager.save()
|
|
|
|
|
|
|
|
|
|
# Display confirmation
|
|
|
|
|
click.echo(f"✓ State updated for file: {file}")
|
|
|
|
|
click.echo(f" Status: {status}")
|
|
|
|
|
if reason:
|
|
|
|
|
click.echo(f" Reason: {reason}")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Set state for file {file}: status={status}, reason={reason}")
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
click.echo(f"Error: {e}", err=True)
|
|
|
|
|
logger.error(f"Invalid status: {e}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error setting file state: {e}", err=True)
|
|
|
|
|
logger.error(f"Failed to set file state: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@state.command('query')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--status',
|
|
|
|
|
type=click.Choice(['reviewed', 'ignored', 'planned', 'executed', 'quarantined'], case_sensitive=False),
|
|
|
|
|
required=True,
|
|
|
|
|
help='Status to query for'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def state_query(ctx: CLIContext, status: str):
|
|
|
|
|
"""Query files by status.
|
|
|
|
|
|
|
|
|
|
Lists all files with the specified status.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm state query --status ignored
|
|
|
|
|
vlm state query --status reviewed
|
|
|
|
|
"""
|
|
|
|
|
from vlm.state import StateManager
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Get state store path
|
|
|
|
|
state_path = Path.home() / ".vlm" / "state.json"
|
|
|
|
|
|
|
|
|
|
# Create state manager
|
|
|
|
|
manager = StateManager(state_path)
|
|
|
|
|
|
|
|
|
|
# Query files by status
|
|
|
|
|
file_states = manager.query_by_status(status)
|
|
|
|
|
|
|
|
|
|
if not file_states:
|
|
|
|
|
click.echo(f"No files found with status '{status}'.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Display results
|
|
|
|
|
click.echo(f"Files with status '{status}':")
|
|
|
|
|
click.echo("=" * 80)
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
for i, file_state in enumerate(file_states, 1):
|
|
|
|
|
click.echo(f"[{i}] {file_state.file_path}")
|
|
|
|
|
if file_state.reason:
|
|
|
|
|
click.echo(f" Reason: {file_state.reason}")
|
|
|
|
|
click.echo(f" Updated: {file_state.updated_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
|
|
|
click.echo()
|
|
|
|
|
|
|
|
|
|
click.echo("=" * 80)
|
|
|
|
|
click.echo(f"Total: {len(file_states)} file(s)")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Queried files with status '{status}': {len(file_states)} found")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error querying file states: {e}", err=True)
|
|
|
|
|
logger.error(f"Failed to query file states: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@state.command('clear')
|
|
|
|
|
@click.argument('file', type=click.Path(path_type=Path))
|
|
|
|
|
@pass_context
|
|
|
|
|
def state_clear(ctx: CLIContext, file: Path):
|
|
|
|
|
"""Clear state for a file.
|
|
|
|
|
|
|
|
|
|
Removes the state tracking for a file.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
vlm state clear /path/to/movie.mkv
|
|
|
|
|
"""
|
|
|
|
|
from vlm.state import StateManager
|
|
|
|
|
|
|
|
|
|
config = ctx.config
|
|
|
|
|
logger = ctx.logger
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Get state store path
|
|
|
|
|
state_path = Path.home() / ".vlm" / "state.json"
|
|
|
|
|
|
|
|
|
|
# Create state manager
|
|
|
|
|
manager = StateManager(state_path)
|
|
|
|
|
|
|
|
|
|
# Check if state exists
|
|
|
|
|
file_state = manager.get_file_state(file)
|
|
|
|
|
|
|
|
|
|
if file_state is None:
|
|
|
|
|
click.echo(f"No state found for file: {file}")
|
|
|
|
|
click.echo("Nothing to clear.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Clear file state
|
|
|
|
|
manager.clear_state(file)
|
|
|
|
|
|
|
|
|
|
# Save state
|
|
|
|
|
manager.save()
|
|
|
|
|
|
|
|
|
|
# Display confirmation
|
|
|
|
|
click.echo(f"✓ State cleared for file: {file}")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Cleared state for file: {file}")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error clearing file state: {e}", err=True)
|
|
|
|
|
logger.error(f"Failed to clear file state: {e}", exc_info=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.group(name='config')
|
|
|
|
|
@pass_context
|
|
|
|
|
def config_cmd(ctx: CLIContext):
|
|
|
|
|
"""Manage configuration.
|
|
|
|
|
|
|
|
|
|
Initialize, view, and validate configuration settings.
|
|
|
|
|
"""
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@config_cmd.command('init')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--path',
|
|
|
|
|
type=click.Path(path_type=Path),
|
2026-02-09 20:16:39 +08:00
|
|
|
default=default_config_path,
|
2026-02-09 17:43:35 +08:00
|
|
|
help='Path where configuration file should be created'
|
|
|
|
|
)
|
|
|
|
|
def config_init(path: Path):
|
|
|
|
|
"""Initialize configuration file with defaults."""
|
|
|
|
|
try:
|
|
|
|
|
if path.exists():
|
|
|
|
|
click.echo(f"Configuration file already exists at {path}", err=True)
|
|
|
|
|
if not click.confirm("Overwrite existing configuration?"):
|
|
|
|
|
click.echo("Configuration initialization cancelled.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
create_default_config(path)
|
|
|
|
|
click.echo(f"Configuration file created at {path}")
|
|
|
|
|
click.echo("Edit this file to customize your settings.")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
click.echo(f"Error creating configuration: {e}", err=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@config_cmd.command('show')
|
|
|
|
|
@pass_context
|
|
|
|
|
def config_show(ctx: CLIContext):
|
|
|
|
|
"""Show current configuration."""
|
|
|
|
|
cfg = ctx.config
|
|
|
|
|
click.echo("Current configuration:")
|
|
|
|
|
click.echo(f" Library root: {cfg.library_root}")
|
|
|
|
|
click.echo(f" Video extensions: {', '.join(cfg.video_extensions)}")
|
|
|
|
|
click.echo(f" Movie template: {cfg.movie_template}")
|
|
|
|
|
click.echo(f" Series template: {cfg.series_template}")
|
|
|
|
|
click.echo(f" Movie filename template: {cfg.movie_filename_template}")
|
|
|
|
|
click.echo(f" Series filename template: {cfg.series_filename_template}")
|
|
|
|
|
click.echo(f" Quarantine directory: {cfg.quarantine_dir}")
|
|
|
|
|
click.echo(f" Log level: {cfg.log_level}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@config_cmd.command('validate')
|
|
|
|
|
@pass_context
|
|
|
|
|
def config_validate(ctx: CLIContext):
|
|
|
|
|
"""Validate configuration."""
|
|
|
|
|
cfg = ctx.config
|
|
|
|
|
errors = validate_config(cfg)
|
|
|
|
|
|
|
|
|
|
if not errors:
|
|
|
|
|
click.echo("Configuration is valid.")
|
|
|
|
|
else:
|
|
|
|
|
click.echo("Configuration validation errors:", err=True)
|
|
|
|
|
for error in errors:
|
|
|
|
|
click.echo(f" - {error}", err=True)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
main()
|