Files
dl-organizer/src/vlm/cli.py
T

2020 lines
70 KiB
Python
Raw Normal View History

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
# Default configuration path
DEFAULT_CONFIG_PATH = Path.home() / ".vlm" / "config.yaml"
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),
default=DEFAULT_CONFIG_PATH,
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),
default=DEFAULT_CONFIG_PATH,
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()