refactor CLI command modules and synchronize docs

This commit is contained in:
windyboy
2026-02-16 12:31:26 +08:00
parent 0be802eac5
commit 2dd329cba9
41 changed files with 1066 additions and 753 deletions
+14 -17
View File
@@ -1,6 +1,5 @@
"""Analyze command implementation."""
import json
from pathlib import Path
from typing import Optional
@@ -8,9 +7,13 @@ import click
from vlm.analysis import analyze_series_completeness, detect_duplicates
from vlm.context import CLIContext
from vlm.io import identities_to_analysis_input, load_identities_json, load_inventory_csv
from vlm.io import (
identities_to_analysis_input,
load_identities_json,
load_inventory_csv,
save_analysis_json,
)
from vlm.models import MovieIdentity, SeriesIdentity
from vlm.utils import utc_now
def analyze_cmd(
@@ -68,7 +71,6 @@ def analyze_cmd(
click.echo(f"Saving analysis results to: {output}")
output.parent.mkdir(parents=True, exist_ok=True)
generation_timestamp = utc_now().strftime("%Y-%m-%dT%H:%M:%S")
completeness_list = [
{
"series_title": c.series_title,
@@ -96,19 +98,14 @@ def analyze_cmd(
"quality_comparison": d.quality_comparison,
}
)
analysis_data = {
"vlm_schema_version": "1.0",
"metadata": {
"generated": generation_timestamp,
"source_identities": str(input),
"total_movies": len(movies_data),
"total_series": len(series_data),
},
"completeness": completeness_list,
"duplicates": duplicates_list,
}
with open(output, "w", encoding="utf-8") as jsonfile:
json.dump(analysis_data, jsonfile, indent=2, ensure_ascii=False)
save_analysis_json(
completeness=completeness_list,
duplicates=duplicates_list,
source_identities=input,
total_movies=len(movies_data),
total_series=len(series_data),
output=output,
)
click.echo("Analysis results saved successfully!")
logger.info(
+146
View File
@@ -0,0 +1,146 @@
"""Enrich command implementation."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional
import click
from vlm.context import CLIContext
from vlm.enrichment import enrich_identities_data
from vlm.io import load_json_file, save_json_file
def enrich_cmd(
ctx: CLIContext,
input: Path,
output: Optional[Path],
refresh_changed_only: bool,
refresh_all: bool,
timeout: int,
retries: int,
) -> None:
"""Enrich identities with translation and reputation metadata."""
logger = ctx.logger
config = ctx.config
if output is None:
output = input
click.echo(f"Enriching identities from: {input}")
click.echo(f"Output file: {output}")
click.echo()
identities_data = load_json_file(input)
if refresh_all and refresh_changed_only:
raise ValueError("--refresh-all and --refresh-changed-only are mutually exclusive.")
if timeout < 1:
raise ValueError("--timeout must be >= 1")
if retries < 0:
raise ValueError("--retries must be >= 0")
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()
save_json_file(enriched_data, output)
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,
)
+257
View File
@@ -0,0 +1,257 @@
"""Execute and rollback command implementations."""
from __future__ import annotations
from pathlib import Path
from typing import Optional
import click
from vlm.context import CLIContext
from vlm.executor import ExecutionEngine
from vlm.planner import load_plan
from vlm.state import StateManager
def _validate_plan_structure(execution_plan) -> list[str]:
"""Validate source/destination paths for move/rename operations."""
validation_errors: list[str] = []
for operation in execution_plan.operations:
if operation.operation_type in ("move", "rename"):
if not operation.source_path.exists():
validation_errors.append(f"Source file does not exist: {operation.source_path}")
elif not operation.source_path.is_file():
validation_errors.append(f"Source path is not a file: {operation.source_path}")
for operation in execution_plan.operations:
if operation.operation_type in ("move", "rename") and operation.destination_path:
dest_dir = operation.destination_path.parent
if not dest_dir.exists() and not dest_dir.parent.exists():
validation_errors.append(f"Cannot create destination directory (parent missing): {dest_dir}")
return validation_errors
def execute_cmd(
ctx: CLIContext,
plan: Path,
confirm: bool,
yes: bool,
verbose_ops: bool,
preserve_directories: bool,
safe_mode: bool,
) -> None:
"""Execute an execution plan in dry-run or execute mode."""
config = ctx.config
logger = ctx.logger
mode = "execute" if confirm else "dry-run"
click.echo(f"Loading execution plan from: {plan}")
click.echo()
execution_plan = load_plan(plan)
if preserve_directories:
click.echo("Directory preservation is enabled (plan metadata/operations will be honored).")
click.echo()
if safe_mode:
click.echo("Safe mode enabled - validating plan for directory preservation...")
emptied_dirs = execution_plan.metadata.get("emptied_directories", [])
if emptied_dirs:
raise ValueError(
"SAFE MODE VIOLATION: plan would empty directories. "
"Regenerate/adjust the plan to preserve directory structure."
)
click.echo("Safe mode validation passed - no directories would be destroyed")
click.echo()
click.echo("Validating directory structure...")
validation_errors = _validate_plan_structure(execution_plan)
if validation_errors:
formatted = "\n".join(f" - {error}" for error in validation_errors[:10])
if len(validation_errors) > 10:
formatted += f"\n ... and {len(validation_errors) - 10} more errors"
raise ValueError(f"DIRECTORY STRUCTURE VALIDATION FAILED:\n{formatted}")
click.echo("Directory structure validation passed")
click.echo()
click.echo(f"Execution plan loaded: {execution_plan.plan_id}")
click.echo(f"Created at: {execution_plan.created_at}")
click.echo(f"Total operations: {len(execution_plan.operations)}")
if execution_plan.human_summary:
click.echo()
click.echo(execution_plan.human_summary)
elif execution_plan.summary or execution_plan.summary_by_reason:
s = execution_plan.summary or {}
by_r = execution_plan.summary_by_reason or {}
parts = [
"操作统计:共 "
f"{s.get('total', len(execution_plan.operations))}"
f"move {s.get('move', 0)}rename {s.get('rename', 0)}"
f"quarantine {s.get('quarantine', 0)}no-op {s.get('no-op', 0)}"
]
if by_r:
parts.append("原因分布:" + "".join(f"{r}: {c}" for r, c in list(by_r.items())[:5]))
click.echo()
click.echo("\n".join(parts))
click.echo()
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()
if not yes and not click.confirm("Are you sure you want to proceed?"):
click.echo("Execution cancelled.")
return
if yes:
click.echo("Auto-approved via --yes flag")
click.echo()
click.echo(f"Executing {len(execution_plan.operations)} operations...")
click.echo()
state_path = Path.home() / ".vlm" / "state.json"
state_manager = StateManager(state_path)
engine = ExecutionEngine(
logger=logger,
config=config,
verbose_operations=verbose_ops,
state_manager=state_manager,
)
results, summary, rollback_log = engine.execute_plan(
execution_plan,
mode=mode,
confirmed=confirm,
)
if mode == "dry-run":
click.echo("Sample operations (dry-run):")
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:
for i, result in enumerate(results):
op = result.operation
if op.operation_type != "no-op" and not op.has_conflict:
status = "OK" if result.success else "FAIL"
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}")
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()
if mode == "execute" and rollback_log:
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()
if mode == "dry-run":
click.echo("Dry-run complete! No files were modified.")
click.echo("Review the operations above and use --confirm to execute.")
elif 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(
"Execution completed in %s mode: %s successful, %s failed, %s skipped",
mode,
summary["successful"],
summary["failed"],
summary["skipped"],
)
def rollback_cmd(ctx: CLIContext, log: Optional[Path]) -> None:
"""Rollback a prior execution from rollback log."""
config = ctx.config
logger = ctx.logger
if log is None:
rollback_dir = Path.home() / ".vlm" / "rollback"
if not rollback_dir.exists():
raise FileNotFoundError(f"No rollback logs found. Rollback directory does not exist: {rollback_dir}")
rollback_logs = sorted(
rollback_dir.glob("rollback_*.json"), key=lambda p: p.stat().st_mtime, reverse=True
)
if not rollback_logs:
raise FileNotFoundError(f"No rollback logs found. No rollback_*.json files in: {rollback_dir}")
log = rollback_logs[0]
click.echo(f"Loading rollback log: {log}")
engine = ExecutionEngine(logger=logger, config=config)
rollback_log = engine.load_rollback_log(log)
click.echo(f"Rollback log loaded: {rollback_log.log_id}")
click.echo(f"Plan ID: {rollback_log.execution_plan_id}")
click.echo(f"Executed at: {rollback_log.executed_at}")
click.echo(f"Operations to rollback: {len(rollback_log.operations)}")
click.echo()
if not click.confirm("Proceed with rollback?"):
click.echo("Rollback cancelled.")
return
click.echo(f"Rolling back {len(rollback_log.operations)} operations...")
click.echo()
click.echo("Performing rollback...")
rollback_results, rollback_summary = engine.rollback(rollback_log)
click.echo()
click.echo("Rollback Summary")
click.echo("=" * 60)
click.echo(f" Total operations: {rollback_summary['total']}")
click.echo(f" Successful: {rollback_summary['successful']}")
click.echo(f" Failed: {rollback_summary['failed']}")
click.echo(f" Skipped: {rollback_summary['skipped']}")
click.echo()
if rollback_summary["failed"] > 0:
click.echo("Failed operations:")
for result in rollback_results:
if not result.success:
op = result.operation
click.echo(f" - {op.operation_type}: {op.source_path}")
if result.error_message:
click.echo(f" Error: {result.error_message}")
click.echo()
if rollback_summary["failed"] == 0:
click.echo("Rollback completed successfully!")
else:
click.echo(f"Rollback completed with {rollback_summary['failed']} failures.")
logger.info(
"Rollback completed: %s successful, %s failed",
rollback_summary["successful"],
rollback_summary["failed"],
)
+165
View File
@@ -0,0 +1,165 @@
"""Parse command implementation."""
from __future__ import annotations
from pathlib import Path
from typing import Optional
import click
from vlm.context import CLIContext
from vlm.io import load_inventory_csv, save_identities_json
from vlm.parser import parse_movie, parse_series
from vlm.utils import utc_now
def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]) -> None:
"""Parse identities from scanned inventory."""
config = ctx.config
logger = ctx.logger
click.echo(f"Parsing identities from: {input}")
path_to_metadata: dict[str, object] = {}
if inventory:
click.echo(f"Loading video metadata from: {inventory}")
inventory_files = load_inventory_csv(inventory)
path_to_metadata = {str(vf.path): vf for vf in inventory_files}
click.echo(f"Loaded metadata for {len(path_to_metadata)} files")
click.echo()
inventory_files = load_inventory_csv(input)
video_files = [
{
"path": str(vf.path),
"filename": vf.filename,
"category": vf.category,
}
for vf in inventory_files
]
click.echo(f"Loaded {len(video_files)} files from inventory")
click.echo()
movie_identities: list[dict] = []
series_identities: list[dict] = []
anime_files: list[dict] = []
other_files: list[dict] = []
def get_video_metadata(file_path: str) -> dict:
"""Extract video metadata from inventory if available."""
if not path_to_metadata:
return {}
vf = path_to_metadata.get(file_path)
if not vf:
return {}
return {
"size_bytes": vf.size_bytes,
"modified_timestamp": vf.modified_timestamp.isoformat(),
"resolution": vf.resolution,
"codec": vf.codec,
"duration_seconds": vf.duration_seconds,
"bitrate_kbps": vf.bitrate_kbps,
}
for vf in video_files:
filename = vf["filename"]
category = vf["category"]
file_path = vf["path"]
video_metadata = get_video_metadata(file_path)
if category == "movie":
identity = parse_movie(filename, extensions=config.video_extensions)
record = {
"path": file_path,
"filename": filename,
"category": category,
"title": identity.title,
"year": identity.year,
"confidence": identity.confidence,
"needs_review": identity.needs_review,
}
if video_metadata:
record["video_metadata"] = video_metadata
movie_identities.append(record)
elif category == "series":
identity = parse_series(filename, extensions=config.video_extensions)
record = {
"path": file_path,
"filename": filename,
"category": category,
"title": identity.title,
"season": identity.season,
"episodes": identity.episodes,
"confidence": identity.confidence,
"needs_review": identity.needs_review,
}
if video_metadata:
record["video_metadata"] = video_metadata
series_identities.append(record)
elif category == "anime":
anime_files.append(
{
"path": vf["path"],
"filename": filename,
"category": category,
"note": "Anime parsing deferred in v1",
}
)
else:
other_files.append(
{
"path": vf["path"],
"filename": filename,
"category": category,
"note": "Not categorized for parsing",
}
)
click.echo("Parsing complete!")
click.echo()
click.echo("Results by category:")
click.echo(f" Movies: {len(movie_identities)}")
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)}")
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)")
click.echo()
click.echo(f"Saving parsed identities to: {output}")
generation_timestamp = utc_now().strftime("%Y-%m-%dT%H:%M:%S")
schema_version = "2.0" if path_to_metadata else "1.0"
identities_data = {
"vlm_schema_version": schema_version,
"metadata": {
"generated": generation_timestamp,
"source_inventory": str(input),
"total_files": len(video_files),
},
"movies": movie_identities,
"series": series_identities,
"anime": anime_files,
"other": other_files,
}
save_identities_json(identities_data, output)
click.echo("Parsed identities saved successfully!")
logger.info(
"Parse completed: %s movies, %s series, saved to %s",
len(movie_identities),
len(series_identities),
output,
)
+9
View File
@@ -81,6 +81,15 @@ def plan_cmd(ctx: CLIContext, input: Path, output: Path, analysis: Optional[Path
click.echo(f" ⚠️ Conflicts detected: {conflicts}")
click.echo(" Review the plan file for details on conflicting operations.")
# Check for directory warnings
directory_warning = execution_plan.metadata.get("directory_warning", False)
if directory_warning:
emptied_dirs = execution_plan.metadata.get("emptied_directories", [])
click.echo()
click.echo(f" ⚠️ Directory preservation warning: {len(emptied_dirs)} directories will be emptied")
click.echo(" These directories will be preserved but may be empty after execution.")
click.echo(" Review the plan file for details on preserved directories.")
click.echo()
click.echo(f"Saving execution plan to: {output}")
output.parent.mkdir(parents=True, exist_ok=True)