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

1884 lines
64 KiB
Python
Raw Normal View History

2026-02-09 17:43:35 +08:00
"""Command-line interface for Video Library Manager.
This module provides the main CLI entry point using Click framework.
It implements global options (--config, --log-level) and error handling.
"""
import sys
import traceback
2026-02-09 17:43:35 +08:00
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
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
@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=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'
)
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(exists=True, path_type=Path),
default=Path('inventory.csv'),
help='Input inventory CSV file (default: inventory.csv)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=Path('identities.json'),
help='Output file for parsed identities (default: identities.json)'
)
@pass_context
def parse(ctx: CLIContext, input: Path, output: Path):
"""Parse identities from filenames.
Extracts movie titles, years, series titles, seasons, and episodes
from video filenames in the inventory.
Example:
vlm parse # Use default files
vlm parse --input my_inventory.csv # Custom input
vlm parse --output parsed_identities.json # Custom output
"""
import csv
import json
from datetime import datetime, timezone
from vlm.parser import parse_movie, parse_series
config = ctx.config
logger = ctx.logger
try:
# Display parse start message
click.echo(f"Parsing identities from: {input}")
click.echo()
# Load inventory from CSV
video_files = []
with open(input, 'r', encoding='utf-8') as csvfile:
# Skip comment lines
lines = []
for line in csvfile:
if not line.startswith('#'):
lines.append(line)
# Parse CSV
reader = csv.DictReader(lines)
for row in reader:
video_files.append({
'path': row['path'],
'filename': row['filename'],
'category': row['category']
})
click.echo(f"Loaded {len(video_files)} files from inventory")
click.echo()
# Parse identities based on category
movie_identities = []
series_identities = []
anime_files = []
other_files = []
for vf in video_files:
filename = vf['filename']
category = vf['category']
if category == 'movie':
identity = parse_movie(filename, extensions=config.video_extensions)
2026-02-09 17:43:35 +08:00
movie_identities.append({
'path': vf['path'],
'filename': filename,
'category': category,
'title': identity.title,
'year': identity.year,
'confidence': identity.confidence,
'needs_review': identity.needs_review
})
elif category == 'series':
identity = parse_series(filename, extensions=config.video_extensions)
2026-02-09 17:43:35 +08:00
series_identities.append({
'path': vf['path'],
'filename': filename,
'category': category,
'title': identity.title,
'season': identity.season,
'episodes': identity.episodes,
'confidence': identity.confidence,
'needs_review': identity.needs_review
})
elif category == 'anime':
# Anime files are not parsed in v1
anime_files.append({
'path': vf['path'],
'filename': filename,
'category': category,
'note': 'Anime parsing deferred in v1'
})
else:
# Other files are not parsed
other_files.append({
'path': vf['path'],
'filename': filename,
'category': category,
'note': 'Not categorized for parsing'
})
# Display parsing statistics
click.echo("Parsing complete!")
click.echo()
click.echo("Results by category:")
click.echo(f" Movies: {len(movie_identities)}")
# Count movies needing review
movies_need_review = sum(1 for m in movie_identities if m['needs_review'])
if movies_need_review > 0:
click.echo(f" - Need review: {movies_need_review}")
click.echo(f" Series: {len(series_identities)}")
# Count series needing review
series_need_review = sum(1 for s in series_identities if s['needs_review'])
if series_need_review > 0:
click.echo(f" - Need review: {series_need_review}")
click.echo(f" Anime: {len(anime_files)} (not parsed in v1)")
click.echo(f" Other: {len(other_files)} (not parsed)")
# Save parsed identities to JSON
click.echo()
click.echo(f"Saving parsed identities to: {output}")
# Ensure output directory exists
output.parent.mkdir(parents=True, exist_ok=True)
# Build JSON structure
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
identities_data = {
'metadata': {
'generated': generation_timestamp,
'source_inventory': str(input),
'total_files': len(video_files)
},
'movies': movie_identities,
'series': series_identities,
'anime': anime_files,
'other': other_files
}
# Write JSON file with pretty formatting
with open(output, 'w', encoding='utf-8') as jsonfile:
json.dump(identities_data, jsonfile, indent=2, ensure_ascii=False)
click.echo(f"Parsed identities saved successfully!")
logger.info(f"Parse completed: {len(movie_identities)} movies, {len(series_identities)} series, saved to {output}")
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
sys.exit(1)
except csv.Error as e:
click.echo(f"Error: Failed to parse CSV file: {e}", err=True)
logger.error(f"CSV parsing failed: {e}", exc_info=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error during parsing: {e}", err=True)
logger.error(f"Parse failed: {e}", exc_info=True)
sys.exit(1)
@main.command()
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('identities.json'),
help='Path to 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)
2026-02-09 17:43:35 +08:00
@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'
)
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:
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(exists=True, path_type=Path),
default=Path('identities.json'),
help='Path to parsed identities JSON file (default: identities.json)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=Path('plan.json'),
help='Path to save execution plan (default: plan.json)'
)
@pass_context
def plan(ctx: CLIContext, input: Path, output: Path):
"""Generate execution plan.
Creates a structured, reviewable plan of all file operations to be performed.
The plan can be edited before execution.
Example:
vlm plan # Use default files
vlm plan --input my_identities.json # Custom input
vlm plan --output my_plan.json # Custom output
"""
try:
from vlm.commands.plan import plan_cmd
plan_cmd(ctx, input, output)
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)
@main.command()
@click.option(
'--plan',
type=click.Path(exists=True, path_type=Path),
default=Path('plan.json'),
help='Path to execution plan JSON file (default: plan.json)'
)
@click.option(
'--confirm',
is_flag=True,
default=False,
help='Actually execute operations (default is dry-run)'
)
@pass_context
def execute(ctx: CLIContext, plan: Path, confirm: bool):
"""Execute plan (defaults to dry-run, requires --confirm).
Executes file operations from a plan. Defaults to dry-run mode which
simulates operations without making changes. Use --confirm to actually
execute operations.
Example:
vlm execute # Dry-run with plan.json
vlm execute --plan my_plan.json # Dry-run with custom plan
vlm execute --confirm # Actually execute operations
"""
from vlm.planner import load_plan
from vlm.executor import ExecutionEngine
config = ctx.config
logger = ctx.logger
try:
# Determine execution mode
mode = "execute" if confirm else "dry-run"
# Display execution start message
click.echo(f"Loading execution plan from: {plan}")
click.echo()
# Load execution plan
execution_plan = load_plan(plan)
# Display plan summary
click.echo(f"Execution plan loaded: {execution_plan.plan_id}")
click.echo(f"Created at: {execution_plan.created_at}")
click.echo(f"Total operations: {len(execution_plan.operations)}")
click.echo()
# Display mode warning
if mode == "dry-run":
click.echo("⚠️ DRY-RUN MODE - No files will be modified")
click.echo(" Use --confirm to actually execute operations")
else:
click.echo("⚠️ EXECUTE MODE - Files will be modified!")
click.echo(" This operation cannot be undone without rollback")
click.echo()
if not click.confirm("Are you sure you want to proceed?"):
click.echo("Execution cancelled.")
return
click.echo()
click.echo(f"Executing {len(execution_plan.operations)} operations...")
click.echo()
# Create execution engine and execute plan
engine = ExecutionEngine(logger=logger)
results, summary, rollback_log = engine.execute_plan(
execution_plan,
mode=mode,
confirmed=confirm
)
# Display execution progress (show some operations)
if mode == "dry-run":
click.echo("Sample operations (dry-run):")
# Show first 5 operations as examples
for i, result in enumerate(results[:5]):
op = result.operation
if op.operation_type != "no-op":
click.echo(f" [{i+1}] {op.operation_type}: {op.source_path.name}")
if op.destination_path:
click.echo(f" -> {op.destination_path}")
if len(results) > 5:
click.echo(f" ... and {len(results) - 5} more operations")
else:
# In execute mode, show progress for all operations
for i, result in enumerate(results):
op = result.operation
if op.operation_type != "no-op" and not op.has_conflict:
status = "✓" if result.success else "✗"
click.echo(f" [{i+1}/{len(results)}] {status} {op.operation_type}: {op.source_path.name}")
if result.error_message:
click.echo(f" Error: {result.error_message}")
# Display execution summary
click.echo()
click.echo("=" * 60)
click.echo(f"Execution Summary ({mode} mode)")
click.echo("=" * 60)
click.echo(f" Total operations: {summary['total']}")
click.echo(f" Successful: {summary['successful']}")
click.echo(f" Failed: {summary['failed']}")
click.echo(f" Skipped: {summary['skipped']}")
click.echo()
# Save rollback log if in execute mode
if mode == "execute" and rollback_log:
# Save to ~/.vlm/rollback/ directory
rollback_dir = Path.home() / ".vlm" / "rollback"
rollback_dir.mkdir(parents=True, exist_ok=True)
rollback_path = rollback_dir / f"rollback_{rollback_log.log_id}.json"
engine.save_rollback_log(rollback_log, rollback_path)
click.echo(f"Rollback log saved to: {rollback_path}")
click.echo()
click.echo("To undo these operations, run:")
click.echo(f" vlm rollback --log {rollback_path}")
click.echo()
# Log completion
if mode == "dry-run":
click.echo("Dry-run complete! No files were modified.")
click.echo("Review the operations above and use --confirm to execute.")
else:
if summary['failed'] > 0:
click.echo(f"⚠️ Execution completed with {summary['failed']} failures.")
click.echo(" Check the log file for details.")
else:
click.echo("✓ Execution completed successfully!")
logger.info(
f"Execution completed in {mode} mode: "
f"{summary['successful']} successful, {summary['failed']} failed, {summary['skipped']} skipped"
)
except FileNotFoundError:
click.echo(f"Error: Plan file not found: {plan}", err=True)
logger.error(f"Plan file not found: {plan}")
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
logger.error(f"Execution failed: {e}")
sys.exit(1)
except Exception as e:
click.echo(f"Error during execution: {e}", err=True)
logger.error(f"Execution failed: {e}", exc_info=True)
sys.exit(1)
@main.group()
@pass_context
def quarantine(ctx: CLIContext):
"""Manage quarantined files.
Quarantine operations allow you to safely isolate unwanted files
for review before deletion. Only movie and series files can be
quarantined in v1.
"""
pass
@quarantine.command('list')
@click.option(
'--category',
type=click.Choice(['movie', 'series'], case_sensitive=False),
default=None,
help='Filter by category (movie or series)'
)
@pass_context
def quarantine_list(ctx: CLIContext, category: Optional[str]):
"""List quarantined files.
Shows all files currently in quarantine with their original locations,
quarantine timestamps, and reasons.
Example:
vlm quarantine list # List all quarantined files
vlm quarantine list --category movie # List only movie files
"""
from vlm.quarantine import QuarantineManager
config = ctx.config
logger = ctx.logger
try:
# Create quarantine manager
manager = QuarantineManager(config, logger)
# List quarantined files
entries = manager.list_quarantined(category=category)
if not entries:
if category:
click.echo(f"No quarantined files found in category '{category}'.")
else:
click.echo("No quarantined files found.")
return
# Display quarantined files
click.echo()
if category:
click.echo(f"Quarantined files in category '{category}':")
else:
click.echo("Quarantined files:")
click.echo("=" * 80)
for i, entry in enumerate(entries, 1):
click.echo(f"\n[{i}] {entry.quarantine_path.name}")
click.echo(f" Category: {entry.category}")
click.echo(f" Original: {entry.original_path}")
click.echo(f" Quarantine: {entry.quarantine_path}")
click.echo(f" Size: {format_size(entry.size_bytes)}")
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
"""
from vlm.executor import ExecutionEngine
config = ctx.config
logger = ctx.logger
try:
# If no log specified, find the most recent rollback log
if log is None:
rollback_dir = Path.home() / ".vlm" / "rollback"
if not rollback_dir.exists():
click.echo("Error: No rollback logs found.", err=True)
click.echo(f"Rollback directory does not exist: {rollback_dir}", err=True)
sys.exit(1)
# Find all rollback log files
rollback_logs = sorted(rollback_dir.glob("rollback_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
if not rollback_logs:
click.echo("Error: No rollback logs found.", err=True)
click.echo(f"No rollback_*.json files in: {rollback_dir}", err=True)
sys.exit(1)
# Use the most recent log
log = rollback_logs[0]
click.echo(f"Using most recent rollback log: {log}")
click.echo()
# Display rollback start message
click.echo(f"Loading rollback log from: {log}")
click.echo()
# Create execution engine
engine = ExecutionEngine(logger=logger)
# Load rollback log
rollback_log = engine.load_rollback_log(log)
# Display rollback log info
click.echo(f"Rollback log loaded: {rollback_log.log_id}")
click.echo(f"Original execution: {rollback_log.execution_plan_id}")
click.echo(f"Executed at: {rollback_log.executed_at.strftime('%Y-%m-%d %H:%M:%S')}")
click.echo(f"Operations to rollback: {len(rollback_log.operations)}")
click.echo()
# Display warning
click.echo("⚠️ ROLLBACK OPERATION - Best-effort restoration")
click.echo(" This will attempt to move files back to their original locations.")
click.echo(" Some operations may fail if files have been modified or moved.")
click.echo()
if not click.confirm("Are you sure you want to proceed with rollback?"):
click.echo("Rollback cancelled.")
return
click.echo()
click.echo(f"Rolling back {len(rollback_log.operations)} operations...")
click.echo()
# Perform rollback
results, summary = engine.rollback(rollback_log)
# Display rollback progress
for i, result in enumerate(results):
op = result.operation
if op.operation_type != "no-op":
status = "✓" if result.success else "✗"
click.echo(f" [{i+1}/{len(results)}] {status} Rollback: {op.destination_path.name if op.destination_path else op.source_path.name}")
if result.error_message:
click.echo(f" Error: {result.error_message}")
# Display rollback summary
click.echo()
click.echo("=" * 60)
click.echo("Rollback Summary")
click.echo("=" * 60)
click.echo(f" Total operations: {summary['total']}")
click.echo(f" Successful: {summary['successful']}")
click.echo(f" Failed: {summary['failed']}")
click.echo(f" Skipped: {summary['skipped']}")
click.echo()
# Display completion message
if summary['failed'] > 0:
click.echo(f"⚠️ Rollback completed with {summary['failed']} failures.")
click.echo(" Check the log file for details.")
click.echo(" Some files may not have been restored to their original locations.")
else:
click.echo("✓ Rollback completed successfully!")
click.echo(" All files have been restored to their original locations.")
logger.info(
f"Rollback completed: "
f"{summary['successful']} successful, {summary['failed']} failed, {summary['skipped']} skipped"
)
except FileNotFoundError as e:
click.echo(f"Error: {e}", err=True)
logger.error(f"Rollback log not found: {e}")
sys.exit(1)
except ValueError as e:
click.echo(f"Error: Invalid rollback log format: {e}", err=True)
logger.error(f"Invalid rollback log: {e}")
sys.exit(1)
except Exception as e:
click.echo(f"Error during rollback: {e}", err=True)
logger.error(f"Rollback failed: {e}", exc_info=True)
sys.exit(1)
@main.group()
@pass_context
def report(ctx: CLIContext):
"""Generate and export reports.
Generate various reports about your video library including inventory,
completeness analysis, duplicate detection, and summary statistics.
"""
pass
@report.command('inventory')
@click.option(
'--format',
type=click.Choice(['csv', 'json', 'text'], case_sensitive=False),
default='text',
help='Output format (default: text)'
)
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('inventory.csv'),
help='Input inventory CSV file (default: inventory.csv)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=None,
help='Output file (default: print to console)'
)
@pass_context
def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
"""Generate inventory report.
Lists all discovered video files with metadata in the specified format.
Example:
vlm report inventory # Text format to console
vlm report inventory --format csv # CSV format to console
vlm report inventory --format json --output inventory_report.json
"""
import csv
from datetime import datetime, timezone
from vlm.reports import generate_inventory_report
from vlm.models import VideoFile
config = ctx.config
logger = ctx.logger
try:
# Load inventory from CSV
click.echo(f"Loading inventory from: {input}")
video_files = []
with open(input, 'r', encoding='utf-8') as csvfile:
# Skip comment lines
lines = []
for line in csvfile:
if not line.startswith('#'):
lines.append(line)
# Parse CSV
reader = csv.DictReader(lines)
for row in reader:
# Parse timestamp
modified_timestamp = datetime.fromisoformat(row['modified_timestamp'])
if modified_timestamp.tzinfo is None:
modified_timestamp = modified_timestamp.replace(tzinfo=timezone.utc)
# Parse optional fields
resolution = row.get('resolution') if row.get('resolution') else None
codec = row.get('codec') if row.get('codec') else None
duration_seconds = float(row['duration_seconds']) if row.get('duration_seconds') else None
bitrate_kbps = int(row['bitrate_kbps']) if row.get('bitrate_kbps') else None
video_file = VideoFile(
path=Path(row['path']),
filename=row['filename'],
size_bytes=int(row['size_bytes']),
modified_timestamp=modified_timestamp,
category=row['category'],
resolution=resolution,
codec=codec,
duration_seconds=duration_seconds,
bitrate_kbps=bitrate_kbps
)
video_files.append(video_file)
click.echo(f"Loaded {len(video_files)} files")
click.echo()
# Generate report
click.echo(f"Generating inventory report in {format} format...")
# For text format, use CSV format as the text representation
report_format = 'csv' if format == 'text' else format
report_content = generate_inventory_report(video_files, report_format, config.library_root)
# Output report
if output:
# Save to file
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, 'w', encoding='utf-8') as f:
f.write(report_content)
click.echo(f"Report saved to: {output}")
else:
# Print to console
click.echo()
click.echo(report_content)
logger.info(f"Generated inventory report in {format} format with {len(video_files)} files")
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
sys.exit(1)
except csv.Error as e:
click.echo(f"Error: Failed to parse CSV file: {e}", err=True)
logger.error(f"CSV parsing failed: {e}", exc_info=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error generating inventory report: {e}", err=True)
logger.error(f"Inventory report generation failed: {e}", exc_info=True)
sys.exit(1)
@report.command('completeness')
@click.option(
'--format',
type=click.Choice(['text', 'json'], case_sensitive=False),
default='text',
help='Output format (default: text)'
)
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('analysis.json'),
help='Input analysis JSON file (default: analysis.json)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=None,
help='Output file (default: print to console)'
)
@pass_context
def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
"""Generate completeness report.
Shows series with episode gaps detected through heuristic analysis.
Example:
vlm report completeness # Text format to console
vlm report completeness --format json # JSON format to console
vlm report completeness --format text --output completeness.txt
"""
import json
from vlm.reports import generate_completeness_report
from vlm.models import SeasonCompleteness
config = ctx.config
logger = ctx.logger
try:
# Load analysis from JSON
click.echo(f"Loading analysis from: {input}")
with open(input, 'r', encoding='utf-8') as jsonfile:
analysis_data = json.load(jsonfile)
# Extract completeness data
completeness_list = analysis_data.get('completeness', [])
# Convert to SeasonCompleteness objects
season_completeness = []
for c in completeness_list:
season_completeness.append(SeasonCompleteness(
series_title=c['series_title'],
season=c['season'],
episodes_found=c['episodes_found'],
episodes_missing=c['episodes_missing']
))
click.echo(f"Loaded {len(season_completeness)} series with gaps")
click.echo()
# Generate report
click.echo(f"Generating completeness report in {format} format...")
report_content = generate_completeness_report(season_completeness, format, config.library_root)
# Output report
if output:
# Save to file
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, 'w', encoding='utf-8') as f:
f.write(report_content)
click.echo(f"Report saved to: {output}")
else:
# Print to console
click.echo()
click.echo(report_content)
logger.info(f"Generated completeness report in {format} format with {len(season_completeness)} series")
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
sys.exit(1)
except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
logger.error(f"JSON parsing failed: {e}", exc_info=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error generating completeness report: {e}", err=True)
logger.error(f"Completeness report generation failed: {e}", exc_info=True)
sys.exit(1)
@report.command('duplicates')
@click.option(
'--format',
type=click.Choice(['text', 'json'], case_sensitive=False),
default='text',
help='Output format (default: text)'
)
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('analysis.json'),
help='Input analysis JSON file (default: analysis.json)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=None,
help='Output file (default: print to console)'
)
@pass_context
def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
"""Generate duplicate report.
Shows duplicate files with quality comparison data to help decide which
files to keep.
Example:
vlm report duplicates # Text format to console
vlm report duplicates --format json # JSON format to console
vlm report duplicates --format text --output duplicates.txt
"""
import json
from vlm.reports import generate_duplicate_report
from vlm.models import DuplicateGroup, MovieIdentity, SeriesIdentity, VideoFile
from datetime import datetime, timezone
config = ctx.config
logger = ctx.logger
try:
# Load analysis from JSON
click.echo(f"Loading analysis from: {input}")
with open(input, 'r', encoding='utf-8') as jsonfile:
analysis_data = json.load(jsonfile)
# Extract duplicates data
duplicates_list = analysis_data.get('duplicates', [])
# Convert to DuplicateGroup objects
duplicate_groups = []
for d in duplicates_list:
identity_data = d['identity']
# Reconstruct identity
if identity_data['type'] == 'movie':
identity = MovieIdentity(
title=identity_data['title'],
year=identity_data.get('year'),
confidence=1.0,
needs_review=False,
original_filename=""
)
else: # series
identity = SeriesIdentity(
title=identity_data['title'],
season=identity_data.get('season'),
episodes=identity_data.get('episodes', []),
confidence=1.0,
needs_review=False,
original_filename=""
)
# Reconstruct VideoFile objects from file paths
files = []
for file_path in d['files']:
files.append(VideoFile(
path=Path(file_path),
filename=Path(file_path).name,
size_bytes=0,
modified_timestamp=datetime.now(timezone.utc),
category="",
resolution=None,
codec=None,
duration_seconds=None,
bitrate_kbps=None
))
duplicate_groups.append(DuplicateGroup(
identity=identity,
files=files,
quality_comparison=d['quality_comparison']
))
click.echo(f"Loaded {len(duplicate_groups)} duplicate groups")
click.echo()
# Generate report
click.echo(f"Generating duplicate report in {format} format...")
report_content = generate_duplicate_report(duplicate_groups, format, config.library_root)
# Output report
if output:
# Save to file
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, 'w', encoding='utf-8') as f:
f.write(report_content)
click.echo(f"Report saved to: {output}")
else:
# Print to console
click.echo()
click.echo(report_content)
logger.info(f"Generated duplicate report in {format} format with {len(duplicate_groups)} groups")
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
sys.exit(1)
except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
logger.error(f"JSON parsing failed: {e}", exc_info=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error generating duplicate report: {e}", err=True)
logger.error(f"Duplicate report generation failed: {e}", exc_info=True)
sys.exit(1)
@report.command('summary')
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('inventory.csv'),
help='Input inventory CSV file (default: inventory.csv)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=None,
help='Output file (default: print to console)'
)
@pass_context
def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]):
"""Generate summary report.
Shows library statistics including total file count, size, and category
breakdown.
Example:
vlm report summary # Print to console
vlm report summary --output summary.txt # Save to file
"""
import csv
from datetime import datetime, timezone
from vlm.reports import generate_summary_report
from vlm.models import VideoFile
config = ctx.config
logger = ctx.logger
try:
# Load inventory from CSV
click.echo(f"Loading inventory from: {input}")
video_files = []
with open(input, 'r', encoding='utf-8') as csvfile:
# Skip comment lines
lines = []
for line in csvfile:
if not line.startswith('#'):
lines.append(line)
# Parse CSV
reader = csv.DictReader(lines)
for row in reader:
# Parse timestamp
modified_timestamp = datetime.fromisoformat(row['modified_timestamp'])
if modified_timestamp.tzinfo is None:
modified_timestamp = modified_timestamp.replace(tzinfo=timezone.utc)
# Parse optional fields
resolution = row.get('resolution') if row.get('resolution') else None
codec = row.get('codec') if row.get('codec') else None
duration_seconds = float(row['duration_seconds']) if row.get('duration_seconds') else None
bitrate_kbps = int(row['bitrate_kbps']) if row.get('bitrate_kbps') else None
video_file = VideoFile(
path=Path(row['path']),
filename=row['filename'],
size_bytes=int(row['size_bytes']),
modified_timestamp=modified_timestamp,
category=row['category'],
resolution=resolution,
codec=codec,
duration_seconds=duration_seconds,
bitrate_kbps=bitrate_kbps
)
video_files.append(video_file)
click.echo(f"Loaded {len(video_files)} files")
click.echo()
# Generate report
click.echo("Generating summary report...")
report_content = generate_summary_report(video_files, config.library_root)
# Output report
if output:
# Save to file
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, 'w', encoding='utf-8') as f:
f.write(report_content)
click.echo(f"Report saved to: {output}")
else:
# Print to console
click.echo()
click.echo(report_content)
logger.info(f"Generated summary report with {len(video_files)} files")
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
sys.exit(1)
except csv.Error as e:
click.echo(f"Error: Failed to parse CSV file: {e}", err=True)
logger.error(f"CSV parsing failed: {e}", exc_info=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error generating summary report: {e}", err=True)
logger.error(f"Summary report generation failed: {e}", exc_info=True)
sys.exit(1)
@main.group()
@pass_context
def state(ctx: CLIContext):
"""Manage file states.
Track file statuses and user decisions throughout the workflow.
"""
pass
@state.command('show')
@click.argument('file', type=click.Path(path_type=Path))
@pass_context
def state_show(ctx: CLIContext, file: Path):
"""Show state for a file.
Displays the current status, reason, and last update timestamp for a file.
Example:
vlm state show /path/to/movie.mkv
"""
from vlm.state import StateManager
config = ctx.config
logger = ctx.logger
try:
# Get state store path
state_path = Path.home() / ".vlm" / "state.json"
# Create state manager
manager = StateManager(state_path)
# Get file state
file_state = manager.get_file_state(file)
if file_state is None:
click.echo(f"No state found for file: {file}")
click.echo("This file has not been tracked yet.")
else:
click.echo(f"State for file: {file}")
click.echo()
click.echo(f" Status: {file_state.status}")
if file_state.reason:
click.echo(f" Reason: {file_state.reason}")
click.echo(f" Updated: {file_state.updated_at.strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f"Showed state for file: {file}")
except Exception as e:
click.echo(f"Error showing file state: {e}", err=True)
logger.error(f"Failed to show file state: {e}", exc_info=True)
sys.exit(1)
@state.command('set')
@click.argument('file', type=click.Path(path_type=Path))
@click.option(
'--status',
type=click.Choice(['reviewed', 'ignored', 'planned', 'executed', 'quarantined'], case_sensitive=False),
required=True,
help='Status to set for the file'
)
@click.option(
'--reason',
type=str,
default=None,
help='Optional reason for the status'
)
@pass_context
def state_set(ctx: CLIContext, file: Path, status: str, reason: Optional[str]):
"""Set state for a file.
Updates the status and optional reason for a file. This operation is
idempotent - setting the same status multiple times will update the
timestamp and reason.
Valid statuses: reviewed, ignored, planned, executed, quarantined
Example:
vlm state set /path/to/movie.mkv --status reviewed
vlm state set /path/to/movie.mkv --status ignored --reason "duplicate"
"""
from vlm.state import StateManager
config = ctx.config
logger = ctx.logger
try:
# Get state store path
state_path = Path.home() / ".vlm" / "state.json"
# Create state manager
manager = StateManager(state_path)
# Set file state
manager.set_file_state(file, status, reason)
# Save state
manager.save()
# Display confirmation
click.echo(f"✓ State updated for file: {file}")
click.echo(f" Status: {status}")
if reason:
click.echo(f" Reason: {reason}")
logger.info(f"Set state for file {file}: status={status}, reason={reason}")
except ValueError as e:
click.echo(f"Error: {e}", err=True)
logger.error(f"Invalid status: {e}")
sys.exit(1)
except Exception as e:
click.echo(f"Error setting file state: {e}", err=True)
logger.error(f"Failed to set file state: {e}", exc_info=True)
sys.exit(1)
@state.command('query')
@click.option(
'--status',
type=click.Choice(['reviewed', 'ignored', 'planned', 'executed', 'quarantined'], case_sensitive=False),
required=True,
help='Status to query for'
)
@pass_context
def state_query(ctx: CLIContext, status: str):
"""Query files by status.
Lists all files with the specified status.
Example:
vlm state query --status ignored
vlm state query --status reviewed
"""
from vlm.state import StateManager
config = ctx.config
logger = ctx.logger
try:
# Get state store path
state_path = Path.home() / ".vlm" / "state.json"
# Create state manager
manager = StateManager(state_path)
# Query files by status
file_states = manager.query_by_status(status)
if not file_states:
click.echo(f"No files found with status '{status}'.")
return
# Display results
click.echo(f"Files with status '{status}':")
click.echo("=" * 80)
click.echo()
for i, file_state in enumerate(file_states, 1):
click.echo(f"[{i}] {file_state.file_path}")
if file_state.reason:
click.echo(f" Reason: {file_state.reason}")
click.echo(f" Updated: {file_state.updated_at.strftime('%Y-%m-%d %H:%M:%S')}")
click.echo()
click.echo("=" * 80)
click.echo(f"Total: {len(file_states)} file(s)")
logger.info(f"Queried files with status '{status}': {len(file_states)} found")
except Exception as e:
click.echo(f"Error querying file states: {e}", err=True)
logger.error(f"Failed to query file states: {e}", exc_info=True)
sys.exit(1)
@state.command('clear')
@click.argument('file', type=click.Path(path_type=Path))
@pass_context
def state_clear(ctx: CLIContext, file: Path):
"""Clear state for a file.
Removes the state tracking for a file.
Example:
vlm state clear /path/to/movie.mkv
"""
from vlm.state import StateManager
config = ctx.config
logger = ctx.logger
try:
# Get state store path
state_path = Path.home() / ".vlm" / "state.json"
# Create state manager
manager = StateManager(state_path)
# Check if state exists
file_state = manager.get_file_state(file)
if file_state is None:
click.echo(f"No state found for file: {file}")
click.echo("Nothing to clear.")
return
# Clear file state
manager.clear_state(file)
# Save state
manager.save()
# Display confirmation
click.echo(f"✓ State cleared for file: {file}")
logger.info(f"Cleared state for file: {file}")
except Exception as e:
click.echo(f"Error clearing file state: {e}", err=True)
logger.error(f"Failed to clear file state: {e}", exc_info=True)
sys.exit(1)
@main.group(name='config')
@pass_context
def config_cmd(ctx: CLIContext):
"""Manage configuration.
Initialize, view, and validate configuration settings.
"""
pass
@config_cmd.command('init')
@click.option(
'--path',
type=click.Path(path_type=Path),
default=default_config_path,
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" 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()