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

1598 lines
53 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.
"""
2026-02-13 13:36:39 +08:00
import json
2026-02-09 17:43:35 +08:00
import sys
import traceback
2026-02-09 17:43:35 +08:00
from pathlib import Path
from typing import Optional
import click
import yaml
from click.core import ParameterSource
2026-02-09 17:43:35 +08:00
from vlm.config import Config, load_config, create_default_config, validate_config
from vlm.context import CLIContext, pass_context
2026-02-09 17:43:35 +08:00
from vlm.logging_config import setup_logging, get_logger
from vlm.utils import format_size
2026-02-09 17:43:35 +08:00
def default_config_path() -> Path:
"""Return the default config path resolved at runtime."""
return Path.home() / ".vlm" / "config.yaml"
2026-02-09 17:43:35 +08:00
def _workspace_dir_from_context() -> Path:
"""Resolve workspace directory from CLI context at runtime."""
click_ctx = click.get_current_context(silent=True)
if click_ctx and isinstance(click_ctx.obj, CLIContext):
return click_ctx.obj.config.workspace_dir
return Path("artifacts")
def default_artifact_path(filename: str) -> Path:
"""Build default artifact path for a filename at runtime."""
return _workspace_dir_from_context() / filename
def _is_default_parameter(parameter_name: str) -> bool:
"""Check whether a parameter value came from Click default."""
click_ctx = click.get_current_context(silent=True)
if click_ctx is None:
return False
return click_ctx.get_parameter_source(parameter_name) == ParameterSource.DEFAULT
def resolve_legacy_default_input_path(
current_path: Path,
parameter_name: str,
legacy_filename: str,
option_name: str,
) -> Path:
"""Fallback to legacy root path when default workspace file is missing.
This keeps stage-A compatibility for users who still have legacy artifacts
in repository root while printing a migration warning.
"""
if not _is_default_parameter(parameter_name):
return current_path
workspace_default = default_artifact_path(legacy_filename)
legacy_default = Path(legacy_filename)
if current_path != workspace_default:
return current_path
if current_path.exists() or not legacy_default.exists():
return current_path
click.echo(
"Warning: detected legacy default input at "
f"{legacy_default.resolve()}. Please migrate to "
f"{workspace_default.resolve()} (example: {option_name} {workspace_default.resolve()}).",
err=True,
)
return legacy_default
2026-02-09 17:43:35 +08:00
@click.group()
@click.option(
'--config',
type=click.Path(path_type=Path),
default=default_config_path,
2026-02-09 17:43:35 +08:00
help='Path to configuration file (default: ~/.vlm/config.yaml)'
)
@click.option(
'--log-level',
type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR'], case_sensitive=False),
default=None,
help='Set logging level (overrides config file)'
)
@click.pass_context
def main(ctx, config: Path, log_level: Optional[str]):
"""Video Library Manager - A tool for managing personal video collections.
VLM helps you organize, analyze, and maintain your video library with a
safety-first approach. All operations are reversible and require explicit
confirmation before making changes.
Common workflow:
1. vlm scan - Discover all video files
2. vlm parse - Extract titles, years, seasons, episodes
3. vlm analyze - Detect gaps and duplicates
4. vlm plan - Generate execution plan
5. vlm execute - Execute plan (dry-run by default)
6. vlm execute --confirm - Actually execute operations
Use 'vlm COMMAND --help' for more information on a specific command.
"""
# Ensure context object exists
ctx.ensure_object(dict)
try:
# Load or create configuration
if config.exists():
try:
cfg = load_config(config)
except yaml.YAMLError as e:
click.echo(f"Error: Invalid YAML syntax in configuration file: {e}", err=True)
click.echo("Using default configuration values.", err=True)
cfg = create_default_config(config)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
click.echo("Using default configuration values.", err=True)
cfg = create_default_config(config)
else:
click.echo(f"Configuration file not found at {config}", err=True)
click.echo("Creating default configuration...", err=True)
cfg = create_default_config(config)
click.echo(f"Default configuration created at {config}", err=True)
# Validate configuration
validation_errors = validate_config(cfg)
if validation_errors:
click.echo("Configuration validation errors:", err=True)
for error in validation_errors:
click.echo(f" - {error}", err=True)
click.echo("Please fix the configuration file and try again.", err=True)
sys.exit(1)
# Override log level if specified on command line
if log_level:
cfg.log_level = log_level.upper()
# Set up logging
logger = setup_logging(log_level=cfg.log_level)
# Store context for subcommands
ctx.obj = CLIContext(config=cfg, logger=logger)
except Exception as e:
traceback.print_exc(file=sys.stderr)
2026-02-09 17:43:35 +08:00
click.echo(f"Error initializing VLM: {e}", err=True)
sys.exit(1)
@main.command()
@click.option(
'--output',
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('inventory.csv'),
help='Output file for inventory (default: artifacts/inventory.csv)'
2026-02-09 17:43:35 +08:00
)
@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'
)
2026-02-09 17:43:35 +08:00
@pass_context
def scan(
ctx: CLIContext,
output: Path,
metadata: bool,
reuse_from: Optional[Path],
force_refresh_metadata: bool
):
2026-02-09 17:43:35 +08:00
"""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
2026-02-09 17:43:35 +08:00
"""
try:
from vlm.commands.scan import scan_cmd
scan_cmd(ctx, output, metadata, reuse_from, force_refresh_metadata)
2026-02-09 17:43:35 +08:00
except Exception as e:
click.echo(f"Error during scan: {e}", err=True)
ctx.logger.error(f"Scan failed: {e}", exc_info=True)
2026-02-09 17:43:35 +08:00
sys.exit(1)
@main.command()
@click.option(
'--input',
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('inventory.csv'),
help='Input inventory CSV file (default: artifacts/inventory.csv)'
2026-02-09 17:43:35 +08:00
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('identities.json'),
help='Output file for parsed identities (default: artifacts/identities.json)'
2026-02-09 17:43:35 +08:00
)
@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)'
)
2026-02-09 17:43:35 +08:00
@pass_context
def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]):
2026-02-09 17:43:35 +08:00
"""Parse identities from filenames.
2026-02-09 17:43:35 +08:00
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.
2026-02-09 17:43:35 +08:00
Example:
vlm parse # Use default files (v1 schema)
vlm parse --inventory inventory.csv # Embed metadata (v2 schema)
2026-02-09 17:43:35 +08:00
vlm parse --input my_inventory.csv # Custom input
vlm parse --output parsed_identities.json # Custom output
"""
try:
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
from vlm.commands.parse import parse_cmd
parse_cmd(ctx, input, output, inventory)
2026-02-09 17:43:35 +08:00
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
ctx.logger.error(f"Input file not found: {input}")
2026-02-09 17:43:35 +08:00
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
ctx.logger.error(f"Parse failed: {e}")
sys.exit(1)
except OSError as e:
click.echo(f"Error reading/writing files: {e}", err=True)
ctx.logger.error(f"Parse file I/O failed: {e}", exc_info=True)
2026-02-09 17:43:35 +08:00
sys.exit(1)
@main.command()
@click.option(
'--input',
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('identities.json'),
help='Path to identities JSON file (default: artifacts/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.
"""
try:
input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
from vlm.commands.enrich import enrich_cmd
enrich_cmd(
ctx,
input,
output,
refresh_changed_only,
refresh_all,
timeout,
retries,
)
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 during enrich: {e}", exc_info=True)
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
ctx.logger.error(f"Enrich validation failed: {e}")
sys.exit(1)
except OSError as e:
click.echo(f"Error reading/writing files: {e}", err=True)
ctx.logger.error(f"Enrich file I/O failed: {e}", exc_info=True)
sys.exit(1)
2026-02-09 17:43:35 +08:00
@main.command()
@click.option(
'--input',
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('identities.json'),
help='Path to parsed identities JSON file (default: artifacts/identities.json)'
2026-02-09 17:43:35 +08:00
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('analysis.json'),
help='Path to save analysis results (default: artifacts/analysis.json)'
2026-02-09 17:43:35 +08:00
)
@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'
)
2026-02-09 17:43:35 +08:00
@pass_context
def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]):
2026-02-09 17:43:35 +08:00
"""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
2026-02-09 17:43:35 +08:00
vlm analyze --output my_analysis.json # Custom output
"""
try:
input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
from vlm.commands.analyze import analyze_cmd
analyze_cmd(ctx, input, output, inventory)
2026-02-09 17:43:35 +08:00
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
ctx.logger.error(f"Input file not found: {input}")
2026-02-09 17:43:35 +08:00
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)
2026-02-09 17:43:35 +08:00
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)
2026-02-09 17:43:35 +08:00
sys.exit(1)
@main.command()
@click.option(
'--input',
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('identities.json'),
help='Path to parsed identities JSON file (default: artifacts/identities.json)'
2026-02-09 17:43:35 +08:00
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('plan.json'),
help='Path to save execution plan (default: artifacts/plan.json)'
2026-02-09 17:43:35 +08:00
)
@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'
)
2026-02-09 17:43:35 +08:00
@pass_context
def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None):
2026-02-09 17:43:35 +08:00
"""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
2026-02-09 17:43:35 +08:00
vlm plan --output my_plan.json # Custom output
"""
try:
input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
from vlm.commands.plan import plan_cmd
plan_cmd(ctx, input, output, analysis)
2026-02-09 17:43:35 +08:00
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
ctx.logger.error(f"Input file not found: {input}")
2026-02-09 17:43:35 +08:00
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)
2026-02-09 17:43:35 +08:00
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)
2026-02-09 17:43:35 +08:00
sys.exit(1)
2026-02-13 13:36:39 +08:00
@main.command(name="review-plan")
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=lambda: default_artifact_path('plan.json'),
help='Path to execution plan JSON file (default: artifacts/plan.json)'
2026-02-13 13:36:39 +08:00
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('plan_manual_review.csv'),
help='Path to save manual review CSV (default: artifacts/plan_manual_review.csv)'
2026-02-13 13:36:39 +08:00
)
@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:
input = resolve_legacy_default_input_path(input, "input", "plan.json", "--input")
2026-02-13 13:36:39 +08:00
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)
2026-02-09 17:43:35 +08:00
@main.command()
@click.option(
'--plan',
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('plan.json'),
help='Path to execution plan JSON file (default: artifacts/plan.json)'
2026-02-09 17:43:35 +08:00
)
@click.option(
'--confirm',
is_flag=True,
default=False,
help='Actually execute operations (default is dry-run)'
)
2026-02-13 13:36:39 +08:00
@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'
)
@click.option(
'--preserve-directories',
is_flag=True,
default=False,
help='Preserve empty source directories instead of allowing them to be destroyed'
)
@click.option(
'--safe-mode',
is_flag=True,
default=False,
help='Enable safe mode: prevent any operations that would destroy directories'
)
2026-02-09 17:43:35 +08:00
@pass_context
def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops: bool, preserve_directories: bool, safe_mode: bool):
2026-02-09 17:43:35 +08:00
"""Execute plan (defaults to dry-run, requires --confirm).
2026-02-13 13:36:39 +08:00
2026-02-09 17:43:35 +08:00
Executes file operations from a plan. Defaults to dry-run mode which
simulates operations without making changes. Use --confirm to actually
execute operations.
2026-02-13 13:36:39 +08:00
2026-02-09 17:43:35 +08:00
Example:
2026-02-13 13:36:39 +08:00
2026-02-09 17:43:35 +08:00
vlm execute # Dry-run with plan.json
vlm execute --plan my_plan.json # Dry-run with custom plan
2026-02-13 13:36:39 +08:00
vlm execute --confirm # Actually execute operations (with prompt)
vlm execute --confirm --yes # Execute without confirmation prompt
2026-02-09 17:43:35 +08:00
"""
try:
plan = resolve_legacy_default_input_path(plan, "plan", "plan.json", "--plan")
from vlm.commands.execute import execute_cmd
execute_cmd(ctx, plan, confirm, yes, verbose_ops, preserve_directories, safe_mode)
2026-02-09 17:43:35 +08:00
except FileNotFoundError:
click.echo(f"Error: File not found: {plan}", err=True)
ctx.logger.error(f"Execution file not found: {plan}")
2026-02-09 17:43:35 +08:00
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
ctx.logger.error(f"Execution validation failed: {e}")
2026-02-09 17:43:35 +08:00
sys.exit(1)
except OSError as e:
2026-02-09 17:43:35 +08:00
click.echo(f"Error during execution: {e}", err=True)
ctx.logger.error(f"Execution I/O failed: {e}", exc_info=True)
2026-02-09 17:43:35 +08:00
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)}")
2026-02-09 17:43:35 +08:00
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
"""
try:
from vlm.commands.execute import rollback_cmd
rollback_cmd(ctx, log)
2026-02-09 17:43:35 +08:00
except FileNotFoundError as e:
click.echo(f"Error: {e}", err=True)
ctx.logger.error(f"Rollback log not found: {e}")
2026-02-09 17:43:35 +08:00
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
ctx.logger.error(f"Rollback failed: {e}")
2026-02-09 17:43:35 +08:00
sys.exit(1)
except OSError as e:
2026-02-09 17:43:35 +08:00
click.echo(f"Error during rollback: {e}", err=True)
ctx.logger.error(f"Rollback failed: {e}", exc_info=True)
2026-02-09 17:43:35 +08:00
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)
2026-02-09 17:43:35 +08:00
@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(path_type=Path),
default=lambda: default_artifact_path('inventory.csv'),
help='Input inventory CSV file (default: artifacts/inventory.csv)'
2026-02-09 17:43:35 +08:00
)
@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
2026-02-13 13:36:39 +08:00
from vlm.io import load_inventory_csv
2026-02-09 17:43:35 +08:00
config = ctx.config
logger = ctx.logger
try:
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
2026-02-09 17:43:35 +08:00
# Load inventory from CSV
click.echo(f"Loading inventory from: {input}")
2026-02-13 13:36:39 +08:00
video_files = load_inventory_csv(input)
2026-02-09 17:43:35 +08:00
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(path_type=Path),
default=lambda: default_artifact_path('analysis.json'),
help='Input analysis JSON file (default: artifacts/analysis.json)'
2026-02-09 17:43:35 +08:00
)
@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'
)
2026-02-09 17:43:35 +08:00
@pass_context
def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]):
2026-02-09 17:43:35 +08:00
"""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
2026-02-09 17:43:35 +08:00
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
2026-02-13 13:36:39 +08:00
from vlm.io import load_analysis_json
2026-02-09 17:43:35 +08:00
config = ctx.config
logger = ctx.logger
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
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)
2026-02-09 17:43:35 +08:00
try:
# Load analysis from JSON
click.echo(f"Loading analysis from: {input}")
2026-02-13 13:36:39 +08:00
analysis_data = load_analysis_json(input)
2026-02-09 17:43:35 +08:00
# 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
)
2026-02-09 17:43:35 +08:00
# 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(path_type=Path),
default=lambda: default_artifact_path('analysis.json'),
help='Input analysis JSON file (default: artifacts/analysis.json)'
2026-02-09 17:43:35 +08:00
)
@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'
)
2026-02-09 17:43:35 +08:00
@pass_context
def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]):
2026-02-09 17:43:35 +08:00
"""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
2026-02-09 17:43:35 +08:00
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
2026-02-09 17:43:35 +08:00
from datetime import datetime, timezone
2026-02-13 13:36:39 +08:00
from vlm.io import load_analysis_json
2026-02-09 17:43:35 +08:00
config = ctx.config
logger = ctx.logger
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
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)
2026-02-09 17:43:35 +08:00
try:
# Load analysis from JSON
click.echo(f"Loading analysis from: {input}")
2026-02-13 13:36:39 +08:00
analysis_data = load_analysis_json(input)
2026-02-09 17:43:35 +08:00
# 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=""
)
2026-02-13 13:36:39 +08:00
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
2026-02-09 17:43:35 +08:00
files = []
for file_path in d['files']:
2026-02-13 13:36:39 +08:00
quality = quality_by_path.get(str(file_path), {})
2026-02-09 17:43:35 +08:00
files.append(VideoFile(
path=Path(file_path),
filename=Path(file_path).name,
2026-02-13 13:36:39 +08:00
size_bytes=int(quality.get("size_bytes", 0) or 0),
2026-02-09 17:43:35 +08:00
modified_timestamp=datetime.now(timezone.utc),
category="",
2026-02-13 13:36:39 +08:00
resolution=quality.get("resolution"),
codec=quality.get("codec"),
duration_seconds=quality.get("duration_seconds"),
bitrate_kbps=quality.get("bitrate_kbps"),
2026-02-09 17:43:35 +08:00
))
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
)
2026-02-09 17:43:35 +08:00
# 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(path_type=Path),
default=lambda: default_artifact_path('inventory.csv'),
help='Input inventory CSV file (default: artifacts/inventory.csv)'
2026-02-09 17:43:35 +08:00
)
@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
2026-02-13 13:36:39 +08:00
from vlm.io import load_inventory_csv
2026-02-09 17:43:35 +08:00
config = ctx.config
logger = ctx.logger
try:
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
2026-02-09 17:43:35 +08:00
# Load inventory from CSV
click.echo(f"Loading inventory from: {input}")
2026-02-13 13:36:39 +08:00
video_files = load_inventory_csv(input)
2026-02-09 17:43:35 +08:00
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,
2026-02-09 17:43:35 +08:00
help='Path where configuration file should be created'
)
@pass_context
def config_init(ctx: CLIContext, path: Path):
2026-02-09 17:43:35 +08:00
"""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" Workspace directory: {cfg.workspace_dir}")
2026-02-09 17:43:35 +08:00
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()