refactor CLI command modules and synchronize docs
This commit is contained in:
+14
-17
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user