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
|
2026-02-10 16:56:17 +08:00
|
|
|
import traceback
|
2026-02-09 17:43:35 +08:00
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
import click
|
|
|
|
|
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.cli_helpers import (
|
|
|
|
|
command_error,
|
|
|
|
|
default_artifact_path,
|
|
|
|
|
default_config_path,
|
|
|
|
|
initialize_cli_context,
|
|
|
|
|
resolve_legacy_default_input_path,
|
2026-05-21 09:24:38 +08:00
|
|
|
)
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.context import CLIContext, pass_context
|
2026-04-02 11:31:49 +08:00
|
|
|
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
@click.group()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--config',
|
|
|
|
|
type=click.Path(path_type=Path),
|
2026-02-09 20:16:39 +08:00
|
|
|
default=default_config_path,
|
2026-02-09 17:43:35 +08:00
|
|
|
help='Path to configuration file (default: ~/.vlm/config.yaml)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--log-level',
|
|
|
|
|
type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR'], case_sensitive=False),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Set logging level (overrides config file)'
|
|
|
|
|
)
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def main(ctx, config: Path, log_level: Optional[str]):
|
|
|
|
|
"""Video Library Manager - A tool for managing personal video collections.
|
|
|
|
|
|
|
|
|
|
VLM helps you organize, analyze, and maintain your video library with a
|
|
|
|
|
safety-first approach. All operations are reversible and require explicit
|
|
|
|
|
confirmation before making changes.
|
|
|
|
|
|
|
|
|
|
Common workflow:
|
|
|
|
|
|
|
|
|
|
1. vlm scan - Discover all video files
|
|
|
|
|
2. vlm parse - Extract titles, years, seasons, episodes
|
2026-04-02 11:31:49 +08:00
|
|
|
3. vlm enrich - Enrich parsed identities with external metadata
|
|
|
|
|
4. vlm analyze - Detect gaps and duplicates
|
|
|
|
|
5. vlm plan - Generate execution plan
|
|
|
|
|
6. vlm execute - Execute plan (dry-run by default)
|
|
|
|
|
7. vlm execute --confirm - Actually execute operations
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
Use 'vlm COMMAND --help' for more information on a specific command.
|
|
|
|
|
"""
|
|
|
|
|
# Ensure context object exists
|
|
|
|
|
ctx.ensure_object(dict)
|
|
|
|
|
|
|
|
|
|
try:
|
2026-05-21 10:36:03 +08:00
|
|
|
ctx.obj = initialize_cli_context(config, log_level)
|
2026-02-09 17:43:35 +08:00
|
|
|
except Exception as e:
|
2026-02-10 16:56:17 +08:00
|
|
|
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),
|
2026-02-16 13:23:01 +08:00
|
|
|
default=lambda: default_artifact_path('inventory.csv'),
|
|
|
|
|
help='Output file for inventory (default: artifacts/inventory.csv)'
|
2026-02-09 17:43:35 +08:00
|
|
|
)
|
2026-02-09 23:55:13 +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
|
2026-02-09 23:55:13 +08:00
|
|
|
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
|
2026-02-09 23:55:13 +08:00
|
|
|
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:
|
2026-02-10 16:56:17 +08:00
|
|
|
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)
|
2026-02-10 16:56:17 +08:00
|
|
|
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',
|
2026-02-16 13:23:01 +08:00
|
|
|
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),
|
2026-02-16 13:23:01 +08:00
|
|
|
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
|
|
|
)
|
2026-02-13 09:44:51 +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
|
2026-02-13 09:44:51 +08:00
|
|
|
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-13 09:44:51 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
Extracts movie titles, years, series titles, seasons, and episodes
|
|
|
|
|
from video filenames in the inventory.
|
2026-02-13 09:44:51 +08:00
|
|
|
|
|
|
|
|
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:
|
2026-02-13 09:44:51 +08:00
|
|
|
|
|
|
|
|
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:
|
2026-02-16 13:23:01 +08:00
|
|
|
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
2026-02-16 12:31:26 +08:00
|
|
|
from vlm.commands.parse import parse_cmd
|
|
|
|
|
parse_cmd(ctx, input, output, inventory)
|
2026-02-09 17:43:35 +08:00
|
|
|
except FileNotFoundError:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error: Input file not found: {input}",
|
|
|
|
|
f"Input file not found: {input}",
|
|
|
|
|
)
|
2026-02-16 12:31:26 +08:00
|
|
|
except ValueError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(ctx, f"Error: {e}", f"Parse failed: {e}")
|
2026-02-16 12:31:26 +08:00
|
|
|
except OSError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error reading/writing files: {e}",
|
|
|
|
|
f"Parse file I/O failed: {e}",
|
|
|
|
|
exc_info=True,
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
2026-02-09 23:55:13 +08:00
|
|
|
@main.command()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
2026-02-16 13:23:01 +08:00
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=lambda: default_artifact_path('identities.json'),
|
|
|
|
|
help='Path to identities JSON file (default: artifacts/identities.json)'
|
2026-02-09 23:55:13 +08:00
|
|
|
)
|
|
|
|
|
@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:
|
2026-02-16 13:23:01 +08:00
|
|
|
input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
|
2026-02-16 12:31:26 +08:00
|
|
|
from vlm.commands.enrich import enrich_cmd
|
|
|
|
|
enrich_cmd(
|
|
|
|
|
ctx,
|
|
|
|
|
input,
|
|
|
|
|
output,
|
|
|
|
|
refresh_changed_only,
|
|
|
|
|
refresh_all,
|
|
|
|
|
timeout,
|
|
|
|
|
retries,
|
2026-02-09 23:55:13 +08:00
|
|
|
)
|
|
|
|
|
except FileNotFoundError:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error: Input file not found: {input}",
|
|
|
|
|
f"Input file not found: {input}",
|
|
|
|
|
)
|
2026-02-09 23:55:13 +08:00
|
|
|
except json.JSONDecodeError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error: Failed to parse JSON file: {e}",
|
|
|
|
|
f"JSON parsing failed during enrich: {e}",
|
|
|
|
|
exc_info=True,
|
|
|
|
|
)
|
2026-02-16 12:31:26 +08:00
|
|
|
except ValueError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(ctx, f"Error: {e}", f"Enrich validation failed: {e}")
|
2026-02-16 12:31:26 +08:00
|
|
|
except OSError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error reading/writing files: {e}",
|
|
|
|
|
f"Enrich file I/O failed: {e}",
|
|
|
|
|
exc_info=True,
|
|
|
|
|
)
|
2026-02-09 23:55:13 +08:00
|
|
|
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
@main.command()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
2026-02-16 13:23:01 +08:00
|
|
|
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),
|
2026-02-16 13:23:01 +08:00
|
|
|
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
|
|
|
)
|
2026-02-10 16:56:17 +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
|
2026-02-10 16:56:17 +08:00
|
|
|
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
|
2026-02-10 16:56:17 +08:00
|
|
|
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:
|
2026-02-16 13:23:01 +08:00
|
|
|
input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
|
2026-02-10 16:56:17 +08:00
|
|
|
from vlm.commands.analyze import analyze_cmd
|
|
|
|
|
analyze_cmd(ctx, input, output, inventory)
|
2026-02-09 17:43:35 +08:00
|
|
|
except FileNotFoundError:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error: Input file not found: {input}",
|
|
|
|
|
f"Input file not found: {input}",
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
except json.JSONDecodeError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error: Failed to parse JSON file: {e}",
|
|
|
|
|
f"JSON parsing failed: {e}",
|
|
|
|
|
exc_info=True,
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
except Exception as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error during analysis: {e}",
|
|
|
|
|
f"Analysis failed: {e}",
|
|
|
|
|
exc_info=True,
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
2026-02-16 13:23:01 +08:00
|
|
|
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),
|
2026-02-16 13:23:01 +08:00
|
|
|
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
|
|
|
)
|
2026-02-10 18:07:38 +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
|
2026-02-10 18:07:38 +08:00
|
|
|
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
|
2026-02-10 18:07:38 +08:00
|
|
|
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:
|
2026-02-16 13:23:01 +08:00
|
|
|
input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
|
2026-02-10 16:56:17 +08:00
|
|
|
from vlm.commands.plan import plan_cmd
|
2026-02-10 18:07:38 +08:00
|
|
|
plan_cmd(ctx, input, output, analysis)
|
2026-02-09 17:43:35 +08:00
|
|
|
except FileNotFoundError:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error: Input file not found: {input}",
|
|
|
|
|
f"Input file not found: {input}",
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
except json.JSONDecodeError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error: Failed to parse JSON file: {e}",
|
|
|
|
|
f"JSON parsing failed: {e}",
|
|
|
|
|
exc_info=True,
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
except Exception as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error during plan generation: {e}",
|
|
|
|
|
f"Plan generation failed: {e}",
|
|
|
|
|
exc_info=True,
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
@main.command(name="review-plan")
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
2026-02-16 13:23:01 +08:00
|
|
|
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),
|
2026-02-16 13:23:01 +08:00
|
|
|
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'
|
|
|
|
|
)
|
2026-04-02 11:31:49 +08:00
|
|
|
@click.option(
|
|
|
|
|
'--preview-limit',
|
|
|
|
|
type=int,
|
|
|
|
|
default=10,
|
|
|
|
|
show_default=True,
|
|
|
|
|
help='Number of high-risk operations to preview in console output'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--show-all',
|
|
|
|
|
is_flag=True,
|
|
|
|
|
default=False,
|
|
|
|
|
help='Show all high-risk operations in console preview'
|
|
|
|
|
)
|
2026-04-07 08:07:18 +08:00
|
|
|
@click.option(
|
|
|
|
|
'--tui',
|
|
|
|
|
is_flag=True,
|
|
|
|
|
default=False,
|
|
|
|
|
help='Interactive Textual UI (requires: uv pip install -e ".[tui]")'
|
|
|
|
|
)
|
2026-05-21 09:24:38 +08:00
|
|
|
@click.option(
|
|
|
|
|
'--identities',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Identities JSON for enriched review rows (default: artifacts/identities.json if present)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--analysis',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Analysis JSON for duplicate grouping and TUI quality pane'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--group-by',
|
|
|
|
|
type=click.Choice(['none', 'reason', 'title', 'duplicate'], case_sensitive=False),
|
|
|
|
|
default='none',
|
|
|
|
|
show_default=True,
|
|
|
|
|
help='Reorder review rows for display/export'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--sample-safe',
|
|
|
|
|
type=int,
|
|
|
|
|
default=0,
|
|
|
|
|
show_default=True,
|
|
|
|
|
help='Include N random non-high-risk move operations for spot-checking'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--structure-preview',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Write target library tree preview to this file (e.g. artifacts/plan_structure.txt)'
|
|
|
|
|
)
|
2026-02-13 13:36:39 +08:00
|
|
|
@pass_context
|
|
|
|
|
def review_plan_cmd(
|
|
|
|
|
ctx: CLIContext,
|
|
|
|
|
input: Path,
|
|
|
|
|
output: Path,
|
|
|
|
|
season_threshold: int,
|
|
|
|
|
episode_threshold: int,
|
2026-04-02 11:31:49 +08:00
|
|
|
preview_limit: int,
|
|
|
|
|
show_all: bool,
|
2026-04-07 08:07:18 +08:00
|
|
|
tui: bool,
|
2026-05-21 09:24:38 +08:00
|
|
|
identities: Optional[Path],
|
|
|
|
|
analysis: Optional[Path],
|
|
|
|
|
group_by: str,
|
|
|
|
|
sample_safe: int,
|
|
|
|
|
structure_preview: Optional[Path],
|
2026-02-13 13:36:39 +08:00
|
|
|
):
|
|
|
|
|
"""Review a plan and export high-risk operations for manual confirmation."""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.review_plan import review_plan_cmd as run_review_plan
|
2026-02-13 13:36:39 +08:00
|
|
|
|
2026-05-21 10:36:03 +08:00
|
|
|
run_review_plan(
|
|
|
|
|
ctx,
|
|
|
|
|
input,
|
|
|
|
|
output,
|
|
|
|
|
season_threshold,
|
|
|
|
|
episode_threshold,
|
|
|
|
|
preview_limit,
|
|
|
|
|
show_all,
|
|
|
|
|
tui,
|
|
|
|
|
identities,
|
|
|
|
|
analysis,
|
|
|
|
|
group_by,
|
|
|
|
|
sample_safe,
|
|
|
|
|
structure_preview,
|
|
|
|
|
)
|
2026-04-02 11:31:49 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command(name="apply-review")
|
|
|
|
|
@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(
|
|
|
|
|
'--csv',
|
|
|
|
|
type=click.Path(exists=True, path_type=Path),
|
|
|
|
|
default=Path('plan_manual_review.csv'),
|
|
|
|
|
help='Path to the modified manual review CSV (default: plan_manual_review.csv)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--output',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Path to save updated plan (default: overwrite input plan)'
|
|
|
|
|
)
|
|
|
|
|
@pass_context
|
|
|
|
|
def apply_review_cmd(
|
|
|
|
|
ctx: CLIContext,
|
|
|
|
|
plan: Path,
|
|
|
|
|
csv: Path,
|
|
|
|
|
output: Optional[Path],
|
|
|
|
|
):
|
|
|
|
|
"""Apply modifications from a manual review CSV back to the plan JSON.
|
2026-05-21 10:36:03 +08:00
|
|
|
|
2026-04-02 11:31:49 +08:00
|
|
|
This command reads the 'operation_type' column from the CSV and updates
|
|
|
|
|
the corresponding operations in the plan. This is the primary way to
|
|
|
|
|
manually approve or reject high-risk operations.
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.review_plan import apply_review_cmd as run_apply_review
|
|
|
|
|
|
|
|
|
|
run_apply_review(ctx, plan, csv, output)
|
2026-02-13 13:36:39 +08:00
|
|
|
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
@main.command()
|
|
|
|
|
@click.option(
|
|
|
|
|
'--plan',
|
2026-02-16 13:23:01 +08:00
|
|
|
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'
|
|
|
|
|
)
|
2026-02-16 12:31:26 +08:00
|
|
|
@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-05-21 09:24:38 +08:00
|
|
|
@click.option(
|
|
|
|
|
'--require-review',
|
|
|
|
|
is_flag=True,
|
|
|
|
|
default=False,
|
|
|
|
|
help='With --confirm, require a current plan_manual_review.csv when high-risk ops exist'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--review-csv',
|
|
|
|
|
type=click.Path(path_type=Path),
|
|
|
|
|
default=None,
|
|
|
|
|
help='Review CSV path for --require-review (default: beside plan file)'
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
@pass_context
|
2026-05-21 09:24:38 +08:00
|
|
|
def execute(
|
|
|
|
|
ctx: CLIContext,
|
|
|
|
|
plan: Path,
|
|
|
|
|
confirm: bool,
|
|
|
|
|
yes: bool,
|
|
|
|
|
verbose_ops: bool,
|
|
|
|
|
preserve_directories: bool,
|
|
|
|
|
safe_mode: bool,
|
|
|
|
|
require_review: bool,
|
|
|
|
|
review_csv: Optional[Path],
|
|
|
|
|
):
|
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:
|
2026-02-16 13:23:01 +08:00
|
|
|
plan = resolve_legacy_default_input_path(plan, "plan", "plan.json", "--plan")
|
2026-02-16 12:31:26 +08:00
|
|
|
from vlm.commands.execute import execute_cmd
|
2026-05-21 09:24:38 +08:00
|
|
|
execute_cmd(
|
|
|
|
|
ctx,
|
|
|
|
|
plan,
|
|
|
|
|
confirm,
|
|
|
|
|
yes,
|
|
|
|
|
verbose_ops,
|
|
|
|
|
preserve_directories,
|
|
|
|
|
safe_mode,
|
|
|
|
|
require_review=require_review,
|
|
|
|
|
review_csv=review_csv,
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
except FileNotFoundError:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error: File not found: {plan}",
|
|
|
|
|
f"Execution file not found: {plan}",
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
except ValueError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(ctx, f"Error: {e}", f"Execution validation failed: {e}")
|
2026-02-16 12:31:26 +08:00
|
|
|
except OSError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error during execution: {e}",
|
|
|
|
|
f"Execution I/O failed: {e}",
|
|
|
|
|
exc_info=True,
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.quarantine_cmd import quarantine_list_cmd
|
|
|
|
|
|
|
|
|
|
quarantine_list_cmd(ctx, category)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.quarantine_cmd import quarantine_add_cmd
|
2026-04-02 11:31:49 +08:00
|
|
|
|
2026-05-21 10:36:03 +08:00
|
|
|
quarantine_add_cmd(ctx, file, reason)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.quarantine_cmd import quarantine_restore_cmd
|
|
|
|
|
|
|
|
|
|
quarantine_restore_cmd(ctx, file)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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:
|
2026-02-16 12:31:26 +08:00
|
|
|
from vlm.commands.execute import rollback_cmd
|
|
|
|
|
rollback_cmd(ctx, log)
|
2026-02-09 17:43:35 +08:00
|
|
|
except FileNotFoundError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(ctx, f"Error: {e}", f"Rollback log not found: {e}")
|
2026-02-09 17:43:35 +08:00
|
|
|
except ValueError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(ctx, f"Error: {e}", f"Rollback failed: {e}")
|
2026-02-16 12:31:26 +08:00
|
|
|
except OSError as e:
|
2026-05-21 10:36:03 +08:00
|
|
|
command_error(
|
2026-04-02 11:31:49 +08:00
|
|
|
ctx,
|
|
|
|
|
f"Error during rollback: {e}",
|
|
|
|
|
f"Rollback failed: {e}",
|
|
|
|
|
exc_info=True,
|
|
|
|
|
)
|
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',
|
2026-02-16 13:23:01 +08:00
|
|
|
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
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.report import report_inventory_cmd
|
|
|
|
|
|
|
|
|
|
report_inventory_cmd(ctx, format, input, output)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@report.command('completeness')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--format',
|
|
|
|
|
type=click.Choice(['text', 'json'], case_sensitive=False),
|
|
|
|
|
default='text',
|
|
|
|
|
help='Output format (default: text)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
2026-02-16 13:23:01 +08:00
|
|
|
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)'
|
|
|
|
|
)
|
2026-02-10 18:07:38 +08:00
|
|
|
@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
|
2026-02-10 18:07:38 +08:00
|
|
|
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
|
2026-02-10 18:07:38 +08:00
|
|
|
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
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.report import report_completeness_cmd
|
2026-02-10 18:07:38 +08:00
|
|
|
|
2026-05-21 10:36:03 +08:00
|
|
|
report_completeness_cmd(ctx, format, input, output, plan)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@report.command('duplicates')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--format',
|
|
|
|
|
type=click.Choice(['text', 'json'], case_sensitive=False),
|
|
|
|
|
default='text',
|
|
|
|
|
help='Output format (default: text)'
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
2026-02-16 13:23:01 +08:00
|
|
|
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)'
|
|
|
|
|
)
|
2026-02-10 18:07:38 +08:00
|
|
|
@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
|
2026-02-10 18:07:38 +08:00
|
|
|
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
|
2026-02-10 18:07:38 +08:00
|
|
|
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
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.report import report_duplicates_cmd
|
2026-02-10 18:07:38 +08:00
|
|
|
|
2026-05-21 10:36:03 +08:00
|
|
|
report_duplicates_cmd(ctx, format, input, output, plan)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@report.command('summary')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--input',
|
2026-02-16 13:23:01 +08:00
|
|
|
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
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.report import report_summary_cmd
|
|
|
|
|
|
|
|
|
|
report_summary_cmd(ctx, input, output)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.state_cmd import state_show_cmd
|
|
|
|
|
|
|
|
|
|
state_show_cmd(ctx, file)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.state_cmd import state_set_cmd
|
|
|
|
|
|
|
|
|
|
state_set_cmd(ctx, file, status, reason)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.state_cmd import state_query_cmd
|
|
|
|
|
|
|
|
|
|
state_query_cmd(ctx, status)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
"""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.state_cmd import state_clear_cmd
|
|
|
|
|
|
|
|
|
|
state_clear_cmd(ctx, file)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.group(name='config')
|
|
|
|
|
@pass_context
|
|
|
|
|
def config_cmd(ctx: CLIContext):
|
|
|
|
|
"""Manage configuration.
|
|
|
|
|
|
|
|
|
|
Initialize, view, and validate configuration settings.
|
|
|
|
|
"""
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@config_cmd.command('init')
|
|
|
|
|
@click.option(
|
|
|
|
|
'--path',
|
|
|
|
|
type=click.Path(path_type=Path),
|
2026-02-09 20:16:39 +08:00
|
|
|
default=default_config_path,
|
2026-02-09 17:43:35 +08:00
|
|
|
help='Path where configuration file should be created'
|
|
|
|
|
)
|
2026-02-10 16:56:17 +08:00
|
|
|
@pass_context
|
|
|
|
|
def config_init(ctx: CLIContext, path: Path):
|
2026-02-09 17:43:35 +08:00
|
|
|
"""Initialize configuration file with defaults."""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.config_cmd import config_init_cmd
|
|
|
|
|
|
|
|
|
|
config_init_cmd(ctx, path)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@config_cmd.command('show')
|
|
|
|
|
@pass_context
|
|
|
|
|
def config_show(ctx: CLIContext):
|
|
|
|
|
"""Show current configuration."""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.config_cmd import config_show_cmd
|
|
|
|
|
|
|
|
|
|
config_show_cmd(ctx)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@config_cmd.command('validate')
|
|
|
|
|
@pass_context
|
|
|
|
|
def config_validate(ctx: CLIContext):
|
|
|
|
|
"""Validate configuration."""
|
2026-05-21 10:36:03 +08:00
|
|
|
from vlm.commands.config_cmd import config_validate_cmd
|
|
|
|
|
|
|
|
|
|
config_validate_cmd(ctx)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
main()
|