"""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 json import sys import traceback 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.context import CLIContext, pass_context from vlm.logging_config import setup_logging, get_logger from vlm.utils import format_size def default_config_path() -> Path: """Return the default config path resolved at runtime.""" return Path.home() / ".vlm" / "config.yaml" @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: traceback.print_exc(file=sys.stderr) 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)' ) @click.option( '--metadata/--no-metadata', default=True, help='Extract video metadata via ffprobe (default: enabled)' ) @click.option( '--reuse-from', type=click.Path(exists=True, path_type=Path), default=None, help='Reuse metadata cache from an existing inventory CSV (default: output file if it exists)' ) @click.option( '--force-refresh-metadata', is_flag=True, default=False, help='Ignore metadata cache and re-run ffprobe for all files' ) @pass_context def scan( ctx: CLIContext, output: Path, metadata: bool, reuse_from: Optional[Path], force_refresh_metadata: bool ): """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 vlm scan --no-metadata # Faster scan without ffprobe vlm scan --reuse-from old.csv # Reuse prior metadata cache vlm scan --force-refresh-metadata # Re-run ffprobe for all files """ try: from vlm.commands.scan import scan_cmd scan_cmd(ctx, output, metadata, reuse_from, force_refresh_metadata) except Exception as e: click.echo(f"Error during scan: {e}", err=True) ctx.logger.error(f"Scan failed: {e}", exc_info=True) sys.exit(1) @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)' ) @click.option( '--inventory', type=click.Path(exists=True, path_type=Path), default=None, help='Inventory CSV to embed video metadata (enables v2 schema with quality data)' ) @pass_context def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]): """Parse identities from filenames. Extracts movie titles, years, series titles, seasons, and episodes from video filenames in the inventory. Use --inventory to embed video metadata (size, resolution, codec) in the output, which enables accurate duplicate resolution by quality in the analyze stage. Example: vlm parse # Use default files (v1 schema) vlm parse --inventory inventory.csv # Embed metadata (v2 schema) vlm parse --input my_inventory.csv # Custom input vlm parse --output parsed_identities.json # Custom output """ from datetime import datetime, timezone from vlm.parser import parse_movie, parse_series from vlm.io import load_inventory_csv, save_identities_json config = ctx.config logger = ctx.logger try: # Display parse start message click.echo(f"Parsing identities from: {input}") # Load video metadata from inventory if provided path_to_metadata = {} if inventory: click.echo(f"Loading video metadata from: {inventory}") inventory_files = load_inventory_csv(inventory) path_to_metadata = {str(vf.path): vf for vf in inventory_files} click.echo(f"Loaded metadata for {len(path_to_metadata)} files") click.echo() # Load inventory via unified I/O layer inventory_files = load_inventory_csv(input) video_files = [ { 'path': str(vf.path), 'filename': vf.filename, 'category': vf.category, } for vf in inventory_files ] 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 = [] def get_video_metadata(file_path: str) -> dict: """Extract video metadata from inventory if available.""" if not path_to_metadata: return {} vf = path_to_metadata.get(file_path) if not vf: return {} return { 'size_bytes': vf.size_bytes, 'modified_timestamp': vf.modified_timestamp.isoformat(), 'resolution': vf.resolution, 'codec': vf.codec, 'duration_seconds': vf.duration_seconds, 'bitrate_kbps': vf.bitrate_kbps, } for vf in video_files: filename = vf['filename'] category = vf['category'] file_path = vf['path'] video_metadata = get_video_metadata(file_path) if category == 'movie': identity = parse_movie(filename, extensions=config.video_extensions) record = { 'path': file_path, 'filename': filename, 'category': category, 'title': identity.title, 'year': identity.year, 'confidence': identity.confidence, 'needs_review': identity.needs_review } if video_metadata: record['video_metadata'] = video_metadata movie_identities.append(record) elif category == 'series': identity = parse_series(filename, extensions=config.video_extensions) record = { 'path': file_path, 'filename': filename, 'category': category, 'title': identity.title, 'season': identity.season, 'episodes': identity.episodes, 'confidence': identity.confidence, 'needs_review': identity.needs_review } if video_metadata: record['video_metadata'] = video_metadata series_identities.append(record) 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") # Use v2 schema if video metadata was embedded schema_version = "2.0" if path_to_metadata else "1.0" identities_data = { 'vlm_schema_version': schema_version, '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 via unified I/O layer save_identities_json(identities_data, output) 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 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 identities JSON file (default: identities.json)' ) @click.option( '--output', type=click.Path(path_type=Path), default=None, help='Output path (default: overwrite input file)' ) @click.option( '--refresh-changed-only', is_flag=True, default=False, help='Refresh only records whose identity fingerprint changed (incremental behavior)' ) @click.option( '--refresh-all', is_flag=True, default=False, help='Bypass cache and re-enrich all records' ) @click.option( '--timeout', type=int, default=6, show_default=True, help='Per-request timeout in seconds' ) @click.option( '--retries', type=int, default=2, show_default=True, help='Retry attempts for provider/API requests' ) @pass_context def enrich( ctx: CLIContext, input: Path, output: Optional[Path], refresh_changed_only: bool, refresh_all: bool, timeout: int, retries: int, ): """Enrich identities with translation and reputation metadata. Applies incremental cache-backed enrichment to parsed identities and writes results back into identities JSON. """ import json from vlm.enrichment import enrich_identities_data config = ctx.config logger = ctx.logger if output is None: output = input try: click.echo(f"Enriching identities from: {input}") click.echo(f"Output file: {output}") click.echo() with open(input, 'r', encoding='utf-8') as jsonfile: identities_data = json.load(jsonfile) if refresh_all and refresh_changed_only: click.echo("Error: --refresh-all and --refresh-changed-only are mutually exclusive.", err=True) sys.exit(1) if timeout < 1: click.echo("Error: --timeout must be >= 1", err=True) sys.exit(1) if retries < 0: click.echo("Error: --retries must be >= 0", err=True) sys.exit(1) refresh_mode = "incremental" if refresh_all: refresh_mode = "refresh_all" elif refresh_changed_only: refresh_mode = "refresh_changed_only" total = ( len(identities_data.get('movies', [])) + len(identities_data.get('series', [])) + len(identities_data.get('anime', [])) ) progress_state: dict[str, Optional[click.ProgressBar]] = {"bar": None} progress_position = {"current": 0} progress_bucket = {"value": -1} is_tty = bool(getattr(sys.stderr, "isatty", lambda: False)()) def _enrich_progress(processed: int, total_count: int, metrics: dict[str, int]) -> None: if total_count <= 0: return if is_tty and progress_state["bar"] is None: bar = click.progressbar( length=total_count, label="Enriching records", show_pos=True, ) progress_state["bar"] = bar.__enter__() step = processed - progress_position["current"] if step > 0 and progress_state["bar"] is not None: progress_state["bar"].update(step) progress_position["current"] = processed if not is_tty: percent = int(processed * 100 / total_count) bucket = percent // 5 if bucket > progress_bucket["value"] or processed == total_count: progress_bucket["value"] = bucket click.echo( "Progress: " f"{processed}/{total_count} ({percent}%) " f"api_calls={metrics.get('api_calls', 0)} " f"cache_hits={metrics.get('cache_hits', 0)} " f"failed={metrics.get('failed', 0)}" ) try: if total == 0: click.echo("No movie/series/anime records found to enrich.") enriched_data, stats = enrich_identities_data( identities_data, config, refresh_mode=refresh_mode, request_timeout=timeout, retries=retries, logger=logger, progress_callback=_enrich_progress, ) finally: if progress_state["bar"] is not None: progress_state["bar"].__exit__(None, None, None) click.echo() output.parent.mkdir(parents=True, exist_ok=True) with open(output, 'w', encoding='utf-8') as jsonfile: json.dump(enriched_data, jsonfile, indent=2, ensure_ascii=False) total_records = int(stats['total']) if stats['total'] else 0 cache_hits = int(stats['cache_hits']) hit_rate = (cache_hits / total_records * 100.0) if total_records else 0.0 click.echo("Enrichment complete!") click.echo(f" Total records: {total_records}") click.echo(f" Refresh mode: {refresh_mode}") click.echo(f" Enriched now: {stats['enriched']}") click.echo(f" Cache hits: {stats['cache_hits']}") click.echo(f" Cache hit rate: {hit_rate:.1f}%") click.echo(f" API calls: {stats['api_calls']}") click.echo(f" Failed requests: {stats['failed']}") click.echo(f" Skipped: {stats['skipped']}") click.echo(f" Needs review: {stats['needs_review']}") skip_reasons = stats.get("skip_reasons", {}) if isinstance(skip_reasons, dict): non_zero = [f"{name}={count}" for name, count in sorted(skip_reasons.items()) if int(count) > 0] if non_zero: click.echo(f" Skip reasons: {' '.join(non_zero)}") failed_items = stats.get('failed_items', []) if isinstance(failed_items, list) and failed_items: click.echo(" Failure sample:") for item in failed_items[:3]: click.echo( f" - [{item.get('provider', 'unknown')}] {item.get('title', '')}: {item.get('reason', '')}" ) logger.info( "Enrich completed: total=%s enriched=%s cache_hits=%s failed=%s skipped=%s mode=%s", stats['total'], stats['enriched'], stats['cache_hits'], stats['failed'], stats['skipped'], refresh_mode, ) 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 during enrich: {e}", exc_info=True) sys.exit(1) except Exception as e: click.echo(f"Error during enrichment: {e}", err=True) logger.error(f"Enrich 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)' ) @click.option( '--inventory', type=click.Path(exists=True, path_type=Path), default=None, help='Optional inventory CSV to merge size/resolution/codec for duplicate quality comparison' ) @pass_context def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[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 --inventory inventory.csv # Merge metadata for quality comparison vlm analyze --output my_analysis.json # Custom output """ try: from vlm.commands.analyze import analyze_cmd analyze_cmd(ctx, input, output, inventory) except FileNotFoundError: click.echo(f"Error: Input file not found: {input}", err=True) ctx.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) ctx.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) ctx.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)' ) @click.option( '--analysis', type=click.Path(path_type=Path), default=None, help='Path to analysis JSON (optional); when provided, duplicate groups are applied to the plan' ) @pass_context def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None): """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 --analysis analysis.json # Use analysis for duplicate handling vlm plan --output my_plan.json # Custom output """ try: from vlm.commands.plan import plan_cmd plan_cmd(ctx, input, output, analysis) except FileNotFoundError: click.echo(f"Error: Input file not found: {input}", err=True) ctx.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) ctx.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) ctx.logger.error(f"Plan generation failed: {e}", exc_info=True) sys.exit(1) @main.command(name="review-plan") @click.option( '--input', type=click.Path(exists=True, path_type=Path), default=Path('plan.json'), help='Path to execution plan JSON file (default: plan.json)' ) @click.option( '--output', type=click.Path(path_type=Path), default=Path('plan_manual_review.csv'), help='Path to save manual review CSV (default: plan_manual_review.csv)' ) @click.option( '--season-threshold', type=int, default=20, show_default=True, help='Flag operations with season >= this value as high risk' ) @click.option( '--episode-threshold', type=int, default=40, show_default=True, help='Flag operations with episode >= this value as high risk' ) @pass_context def review_plan_cmd( ctx: CLIContext, input: Path, output: Path, season_threshold: int, episode_threshold: int, ): """Review a plan and export high-risk operations for manual confirmation.""" from vlm.planner import load_plan from vlm.plan_review import review_plan, save_review_csv logger = ctx.logger try: if season_threshold < 1 or episode_threshold < 1: click.echo("Error: thresholds must be >= 1", err=True) sys.exit(1) click.echo(f"Loading plan: {input}") execution_plan = load_plan(input) rows, counters = review_plan( execution_plan, season_threshold=season_threshold, episode_threshold=episode_threshold, ) save_review_csv(rows, output) click.echo() click.echo("Plan review summary:") click.echo(f" Total operations: {counters['total_operations']}") click.echo(f" High-risk operations: {counters['high_risk_operations']}") click.echo(f" manual_review: {counters['manual_review']}") click.echo(f" sample_source: {counters['sample_source']}") click.echo(f" high_season: {counters['high_season']}") click.echo(f" high_episode: {counters['high_episode']}") click.echo(f" conflicts: {counters['conflicts']}") click.echo() click.echo(f"Saved manual review CSV to: {output}") if rows: click.echo("Top review samples:") for row in rows[:5]: click.echo( f" - [{row['index']}] {row['operation_type']} {Path(row['source_path']).name} ({row['risk_flags']})" ) logger.info( "Plan review completed: total=%s high_risk=%s output=%s", counters["total_operations"], counters["high_risk_operations"], output, ) except FileNotFoundError: click.echo(f"Error: Plan file not found: {input}", err=True) logger.error(f"Plan file not found: {input}") sys.exit(1) except json.JSONDecodeError as e: click.echo(f"Error: Failed to parse plan JSON: {e}", err=True) logger.error(f"Plan review JSON parsing failed: {e}", exc_info=True) sys.exit(1) except Exception as e: click.echo(f"Error during plan review: {e}", err=True) logger.error(f"Plan review 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)' ) @click.option( '--yes', is_flag=True, default=False, help='Skip confirmation prompt (auto-approve)' ) @click.option( '--verbose-ops', is_flag=True, default=False, help='Print per-operation dry-run logs at INFO level' ) @pass_context def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops: 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 (with prompt) vlm execute --confirm --yes # Execute without confirmation prompt """ 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)}") if execution_plan.human_summary: click.echo() click.echo(execution_plan.human_summary) elif execution_plan.summary or execution_plan.summary_by_reason: s = execution_plan.summary or {} by_r = execution_plan.summary_by_reason or {} parts = [f"操作统计:共 {s.get('total', len(execution_plan.operations))} 条(move {s.get('move', 0)},rename {s.get('rename', 0)},quarantine {s.get('quarantine', 0)},no-op {s.get('no-op', 0)})"] if by_r: parts.append("原因分布:" + ";".join(f"{r}: {c}" for r, c in list(by_r.items())[:5])) click.echo() click.echo("\n".join(parts)) 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 yes: if not click.confirm("Are you sure you want to proceed?"): click.echo("Execution cancelled.") return else: click.echo("Auto-approved via --yes flag") click.echo() click.echo(f"Executing {len(execution_plan.operations)} operations...") click.echo() # Load state manager to update file statuses during execution from vlm.state import StateManager state_path = Path.home() / ".vlm" / "state.json" state_manager = StateManager(state_path) # Create execution engine and execute plan engine = ExecutionEngine( logger=logger, config=config, verbose_operations=verbose_ops, state_manager=state_manager ) 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_.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, config=config) # 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) def _fallback_plan_summary(execution_plan) -> str: """Build a short plan summary from summary and summary_by_reason when human_summary is empty.""" s = execution_plan.summary or {} by_r = execution_plan.summary_by_reason or {} total = s.get("total", len(execution_plan.operations)) parts = [ f"计划操作统计:共 {total} 条(move {s.get('move', 0)},rename {s.get('rename', 0)}," f"quarantine {s.get('quarantine', 0)},no-op {s.get('no-op', 0)})" ] if by_r: parts.append("原因分布:" + ";".join(f"{r}: {c}" for r, c in list(by_r.items())[:8])) return "\n".join(parts) @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 """ from vlm.reports import generate_inventory_report from vlm.io import load_inventory_csv config = ctx.config logger = ctx.logger try: # Load inventory from CSV click.echo(f"Loading inventory from: {input}") video_files = load_inventory_csv(input) 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 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)' ) @click.option( '--plan', type=click.Path(path_type=Path), default=None, help='Optional plan JSON; when provided, report includes plan content summary' ) @pass_context def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: 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 --plan plan.json # Include plan content summary vlm report completeness --format text --output completeness.txt """ from vlm.reports import generate_completeness_report from vlm.models import SeasonCompleteness from vlm.planner import load_plan from vlm.io import load_analysis_json config = ctx.config logger = ctx.logger plan_summary = None if plan: if not plan.exists(): click.echo(f"Error: Plan file not found: {plan}", err=True) sys.exit(1) execution_plan = load_plan(plan) plan_summary = execution_plan.human_summary or _fallback_plan_summary(execution_plan) try: # Load analysis from JSON click.echo(f"Loading analysis from: {input}") analysis_data = load_analysis_json(input) # 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, plan_summary=plan_summary ) # 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)' ) @click.option( '--plan', type=click.Path(path_type=Path), default=None, help='Optional plan JSON; when provided, report includes plan content summary' ) @pass_context def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: 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 --plan plan.json # Include plan content summary vlm report duplicates --format text --output duplicates.txt """ from vlm.reports import generate_duplicate_report from vlm.models import DuplicateGroup, MovieIdentity, SeriesIdentity, VideoFile from vlm.planner import load_plan from datetime import datetime, timezone from vlm.io import load_analysis_json config = ctx.config logger = ctx.logger plan_summary = None if plan: if not plan.exists(): click.echo(f"Error: Plan file not found: {plan}", err=True) sys.exit(1) execution_plan = load_plan(plan) plan_summary = execution_plan.human_summary or _fallback_plan_summary(execution_plan) try: # Load analysis from JSON click.echo(f"Loading analysis from: {input}") analysis_data = load_analysis_json(input) # 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="" ) quality_by_path = { str(item.get("path", "")): item for item in d.get("quality_comparison", []) } # Reconstruct VideoFile objects from file paths and preserve size metadata files = [] for file_path in d['files']: quality = quality_by_path.get(str(file_path), {}) files.append(VideoFile( path=Path(file_path), filename=Path(file_path).name, size_bytes=int(quality.get("size_bytes", 0) or 0), modified_timestamp=datetime.now(timezone.utc), category="", resolution=quality.get("resolution"), codec=quality.get("codec"), duration_seconds=quality.get("duration_seconds"), bitrate_kbps=quality.get("bitrate_kbps"), )) 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, plan_summary=plan_summary ) # 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 """ from vlm.reports import generate_summary_report from vlm.io import load_inventory_csv config = ctx.config logger = ctx.logger try: # Load inventory from CSV click.echo(f"Loading inventory from: {input}") video_files = load_inventory_csv(input) 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 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' ) @pass_context def config_init(ctx: CLIContext, 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()