refactor CLI command modules and synchronize docs
This commit is contained in:
+55
-550
@@ -193,180 +193,20 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path])
|
||||
vlm parse --input my_inventory.csv # Custom input
|
||||
vlm parse --output parsed_identities.json # Custom output
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from vlm.parser import parse_movie, parse_series
|
||||
from vlm.io import load_inventory_csv, save_identities_json
|
||||
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
# Display parse start message
|
||||
click.echo(f"Parsing identities from: {input}")
|
||||
|
||||
# Load video metadata from inventory if provided
|
||||
path_to_metadata = {}
|
||||
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()
|
||||
|
||||
# Load inventory via unified I/O layer
|
||||
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()
|
||||
|
||||
# Parse identities based on category
|
||||
movie_identities = []
|
||||
series_identities = []
|
||||
anime_files = []
|
||||
other_files = []
|
||||
|
||||
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 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")
|
||||
|
||||
# Use v2 schema if video metadata was embedded
|
||||
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
|
||||
}
|
||||
|
||||
# Write JSON via unified I/O layer
|
||||
save_identities_json(identities_data, output)
|
||||
|
||||
click.echo(f"Parsed identities saved successfully!")
|
||||
|
||||
logger.info(f"Parse completed: {len(movie_identities)} movies, {len(series_identities)} series, saved to {output}")
|
||||
|
||||
from vlm.commands.parse import parse_cmd
|
||||
parse_cmd(ctx, input, output, inventory)
|
||||
except FileNotFoundError:
|
||||
click.echo(f"Error: Input file not found: {input}", err=True)
|
||||
logger.error(f"Input file not found: {input}")
|
||||
ctx.logger.error(f"Input file not found: {input}")
|
||||
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)
|
||||
except ValueError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
ctx.logger.error(f"Parse failed: {e}")
|
||||
sys.exit(1)
|
||||
except OSError as e:
|
||||
click.echo(f"Error reading/writing files: {e}", err=True)
|
||||
ctx.logger.error(f"Parse file I/O failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -424,150 +264,32 @@ def enrich(
|
||||
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,
|
||||
from vlm.commands.enrich import enrich_cmd
|
||||
enrich_cmd(
|
||||
ctx,
|
||||
input,
|
||||
output,
|
||||
refresh_changed_only,
|
||||
refresh_all,
|
||||
timeout,
|
||||
retries,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
click.echo(f"Error: Input file not found: {input}", err=True)
|
||||
logger.error(f"Input file not found: {input}")
|
||||
ctx.logger.error(f"Input file not found: {input}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
|
||||
logger.error(f"JSON parsing failed during enrich: {e}", exc_info=True)
|
||||
ctx.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)
|
||||
except ValueError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
ctx.logger.error(f"Enrich validation failed: {e}")
|
||||
sys.exit(1)
|
||||
except OSError as e:
|
||||
click.echo(f"Error reading/writing files: {e}", err=True)
|
||||
ctx.logger.error(f"Enrich file I/O failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -791,8 +513,20 @@ def review_plan_cmd(
|
||||
default=False,
|
||||
help='Print per-operation dry-run logs at INFO level'
|
||||
)
|
||||
@click.option(
|
||||
'--preserve-directories',
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help='Preserve empty source directories instead of allowing them to be destroyed'
|
||||
)
|
||||
@click.option(
|
||||
'--safe-mode',
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help='Enable safe mode: prevent any operations that would destroy directories'
|
||||
)
|
||||
@pass_context
|
||||
def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops: bool):
|
||||
def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops: bool, preserve_directories: bool, safe_mode: bool):
|
||||
"""Execute plan (defaults to dry-run, requires --confirm).
|
||||
|
||||
Executes file operations from a plan. Defaults to dry-run mode which
|
||||
@@ -806,155 +540,20 @@ def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops:
|
||||
vlm execute --confirm # Actually execute operations (with prompt)
|
||||
vlm execute --confirm --yes # Execute without confirmation prompt
|
||||
"""
|
||||
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)}")
|
||||
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))} 条(move {s.get('move', 0)},rename {s.get('rename', 0)},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()
|
||||
|
||||
# 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 yes:
|
||||
if not click.confirm("Are you sure you want to proceed?"):
|
||||
click.echo("Execution cancelled.")
|
||||
return
|
||||
else:
|
||||
click.echo("Auto-approved via --yes flag")
|
||||
|
||||
click.echo()
|
||||
click.echo(f"Executing {len(execution_plan.operations)} operations...")
|
||||
click.echo()
|
||||
|
||||
# Load state manager to update file statuses during execution
|
||||
from vlm.state import StateManager
|
||||
state_path = Path.home() / ".vlm" / "state.json"
|
||||
state_manager = StateManager(state_path)
|
||||
|
||||
# Create execution engine and execute plan
|
||||
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
|
||||
)
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
from vlm.commands.execute import execute_cmd
|
||||
execute_cmd(ctx, plan, confirm, yes, verbose_ops, preserve_directories, safe_mode)
|
||||
except FileNotFoundError:
|
||||
click.echo(f"Error: Plan file not found: {plan}", err=True)
|
||||
logger.error(f"Plan file not found: {plan}")
|
||||
click.echo(f"Error: File not found: {plan}", err=True)
|
||||
ctx.logger.error(f"Execution file not found: {plan}")
|
||||
sys.exit(1)
|
||||
|
||||
except ValueError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
logger.error(f"Execution failed: {e}")
|
||||
ctx.logger.error(f"Execution validation failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
click.echo(f"Error during execution: {e}", err=True)
|
||||
logger.error(f"Execution failed: {e}", exc_info=True)
|
||||
ctx.logger.error(f"Execution I/O failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -1177,114 +776,20 @@ def rollback(ctx: CLIContext, log: Optional[Path]):
|
||||
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, config=config)
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
from vlm.commands.execute import rollback_cmd
|
||||
rollback_cmd(ctx, log)
|
||||
except FileNotFoundError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
logger.error(f"Rollback log not found: {e}")
|
||||
ctx.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}")
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
ctx.logger.error(f"Rollback failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
click.echo(f"Error during rollback: {e}", err=True)
|
||||
logger.error(f"Rollback failed: {e}", exc_info=True)
|
||||
ctx.logger.error(f"Rollback failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
||||
+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)
|
||||
|
||||
+7
-7
@@ -5,15 +5,17 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
import yaml
|
||||
|
||||
DEFAULT_VIDEO_EXTENSIONS = [
|
||||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Configuration for Video Library Manager."""
|
||||
|
||||
library_root: Path
|
||||
video_extensions: list[str] = field(default_factory=lambda: [
|
||||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||||
])
|
||||
video_extensions: list[str] = field(default_factory=lambda: list(DEFAULT_VIDEO_EXTENSIONS))
|
||||
movie_template: str = "movie/{title} ({year})/"
|
||||
series_template: str = "series/{title}/Season {season:02d}/"
|
||||
movie_filename_template: str = "{title} ({year}){ext}"
|
||||
@@ -74,9 +76,7 @@ def load_config(path: Path) -> Config:
|
||||
|
||||
library_root = Path(library_root_str).expanduser()
|
||||
|
||||
video_extensions = data.get("video_extensions", [
|
||||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||||
])
|
||||
video_extensions = data.get("video_extensions", list(DEFAULT_VIDEO_EXTENSIONS))
|
||||
|
||||
templates = data.get("templates", {})
|
||||
movie_template = templates.get("movie_dir", "movie/{title} ({year})/")
|
||||
@@ -149,7 +149,7 @@ def create_default_config(path: Path) -> Config:
|
||||
"""Create a default configuration file and return the Config object."""
|
||||
default_config = Config(
|
||||
library_root=Path.home() / "Videos",
|
||||
video_extensions=[".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"],
|
||||
video_extensions=list(DEFAULT_VIDEO_EXTENSIONS),
|
||||
)
|
||||
|
||||
enrichment_content = {
|
||||
|
||||
+48
-6
@@ -93,8 +93,17 @@ class ExecutionEngine:
|
||||
transaction_log = None
|
||||
if mode == "execute":
|
||||
log_path = Path.home() / ".vlm" / "transaction.json"
|
||||
transaction_log = TransactionLog(log_path)
|
||||
transaction_log.start_transaction(plan)
|
||||
try:
|
||||
transaction_log = TransactionLog(log_path)
|
||||
transaction_log.start_transaction(plan)
|
||||
except OSError as exc:
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.WARNING,
|
||||
f"Transaction log disabled (cannot write {log_path}): {exc}",
|
||||
operation_type="execute",
|
||||
)
|
||||
transaction_log = None
|
||||
|
||||
# Execute all operations
|
||||
results = []
|
||||
@@ -105,9 +114,18 @@ class ExecutionEngine:
|
||||
# Update transaction and state logs in execute mode
|
||||
if mode == "execute":
|
||||
if transaction_log:
|
||||
transaction_log.mark_operation_complete(
|
||||
i, result.success, result.error_message
|
||||
)
|
||||
try:
|
||||
transaction_log.mark_operation_complete(
|
||||
i, result.success, result.error_message
|
||||
)
|
||||
except OSError as exc:
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.WARNING,
|
||||
f"Failed to update transaction log: {exc}",
|
||||
operation_type="execute",
|
||||
)
|
||||
transaction_log = None
|
||||
|
||||
# Update file state if successful and not a no-op
|
||||
if result.success and operation.operation_type != "no-op" and self.state_manager:
|
||||
@@ -126,7 +144,15 @@ class ExecutionEngine:
|
||||
if mode == "execute":
|
||||
if transaction_log:
|
||||
status = "completed" if all(r.success for r in results) else "failed"
|
||||
transaction_log.complete_transaction(status=status)
|
||||
try:
|
||||
transaction_log.complete_transaction(status=status)
|
||||
except OSError as exc:
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.WARNING,
|
||||
f"Failed to finalize transaction log: {exc}",
|
||||
operation_type="execute",
|
||||
)
|
||||
if self.state_manager:
|
||||
self.state_manager.save()
|
||||
|
||||
@@ -189,6 +215,22 @@ class ExecutionEngine:
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
# Handle preserve-directory operations
|
||||
if operation.operation_type == "preserve-directory":
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.DEBUG,
|
||||
f"Preserving directory: {operation.reason}",
|
||||
operation_type="execute",
|
||||
file_path=operation.source_path
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=True,
|
||||
error_message=None,
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
# Handle quarantine operations (no destination_path; use QuarantineManager)
|
||||
if operation.operation_type == "quarantine":
|
||||
if not self._quarantine_manager:
|
||||
|
||||
+44
-7
@@ -15,34 +15,71 @@ from vlm.scanner import load_inventory_csv, save_inventory_csv
|
||||
__all__ = [
|
||||
"load_inventory_csv",
|
||||
"save_inventory_csv",
|
||||
"load_json_file",
|
||||
"save_json_file",
|
||||
"load_identities_json",
|
||||
"save_identities_json",
|
||||
"load_analysis_json",
|
||||
"save_analysis_json",
|
||||
"identities_to_plan_input",
|
||||
"identities_to_analysis_input",
|
||||
]
|
||||
|
||||
|
||||
def load_json_file(path: Path) -> dict:
|
||||
"""Load a JSON object from disk."""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_json_file(data: dict, path: Path) -> None:
|
||||
"""Save a JSON object to disk."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def load_analysis_json(path: Path) -> dict:
|
||||
"""Load analysis result from JSON file (metadata, completeness, duplicates).
|
||||
|
||||
Caller should check file existence and handle missing/invalid keys.
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return load_json_file(path)
|
||||
|
||||
|
||||
def load_identities_json(path: Path) -> dict:
|
||||
"""Load identities from JSON file."""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return load_json_file(path)
|
||||
|
||||
|
||||
def save_identities_json(data: dict, path: Path) -> None:
|
||||
"""Save identities dict to JSON file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
save_json_file(data, path)
|
||||
|
||||
|
||||
def save_analysis_json(
|
||||
*,
|
||||
completeness: list[dict],
|
||||
duplicates: list[dict],
|
||||
source_identities: Path,
|
||||
total_movies: int,
|
||||
total_series: int,
|
||||
output: Path,
|
||||
) -> None:
|
||||
"""Save analysis result JSON using the canonical schema."""
|
||||
generation_timestamp = utc_now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
analysis_data = {
|
||||
"vlm_schema_version": "1.0",
|
||||
"metadata": {
|
||||
"generated": generation_timestamp,
|
||||
"source_identities": str(source_identities),
|
||||
"total_movies": total_movies,
|
||||
"total_series": total_series,
|
||||
},
|
||||
"completeness": completeness,
|
||||
"duplicates": duplicates,
|
||||
}
|
||||
save_json_file(analysis_data, output)
|
||||
|
||||
|
||||
def _video_file_from_record(record: dict) -> VideoFile:
|
||||
|
||||
+2
-2
@@ -113,9 +113,9 @@ class SeriesIdentity:
|
||||
@dataclass
|
||||
class FileOperation:
|
||||
"""Represents a single file operation in an execution plan.
|
||||
|
||||
|
||||
Attributes:
|
||||
operation_type: Type of operation ("move", "rename", "quarantine", "no-op")
|
||||
operation_type: Type of operation ("move", "rename", "quarantine", "no-op", "preserve-directory")
|
||||
source_path: Source file path
|
||||
destination_path: Destination file path (None for no-op operations)
|
||||
reason: Human-readable reason for the operation
|
||||
|
||||
+1
-6
@@ -7,14 +7,9 @@ logical identities such as movie titles/years and series titles/seasons/episodes
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from vlm.config import DEFAULT_VIDEO_EXTENSIONS
|
||||
from vlm.models import MovieIdentity, SeriesIdentity
|
||||
|
||||
# Default extensions used when extensions param is not provided (matches config default)
|
||||
DEFAULT_VIDEO_EXTENSIONS = [
|
||||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||||
]
|
||||
|
||||
|
||||
# Quality tags to remove from titles
|
||||
QUALITY_TAGS = [
|
||||
r'\b1080p\b', r'\b720p\b', r'\b480p\b', r'\b2160p\b',
|
||||
|
||||
+58
-2
@@ -40,6 +40,42 @@ def _is_sample_path(path: Path) -> bool:
|
||||
return bool(re.search(r"(^|[\s._-])sample($|[\s._-])", stem))
|
||||
|
||||
|
||||
def _analyze_directory_impact(operations: list[FileOperation]) -> dict:
|
||||
"""Analyze which directories will be emptied by the plan."""
|
||||
# Get all source directories that have files being moved/renamed
|
||||
source_dirs = set()
|
||||
moved_files = set()
|
||||
for operation in operations:
|
||||
if operation.operation_type in ("move", "rename"):
|
||||
source_dirs.add(operation.source_path.parent)
|
||||
moved_files.add(operation.source_path)
|
||||
|
||||
emptied_dirs = []
|
||||
for dir_path in source_dirs:
|
||||
# Only analyze directories that actually exist
|
||||
if not dir_path.exists():
|
||||
continue
|
||||
|
||||
# Count files that will remain in this directory after operations
|
||||
remaining_count = 0
|
||||
try:
|
||||
for item in dir_path.iterdir():
|
||||
if item.is_file() and item not in moved_files:
|
||||
remaining_count += 1
|
||||
except (OSError, PermissionError):
|
||||
# If we can't read the directory, skip analysis
|
||||
continue
|
||||
|
||||
# If no files will remain, this directory will be emptied
|
||||
if remaining_count == 0:
|
||||
emptied_dirs.append(dir_path)
|
||||
|
||||
return {
|
||||
"emptied_directories": emptied_dirs,
|
||||
"warning_required": len(emptied_dirs) > 0
|
||||
}
|
||||
|
||||
|
||||
def generate_plan(
|
||||
identities: list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]],
|
||||
config: Config,
|
||||
@@ -120,10 +156,29 @@ def generate_plan(
|
||||
conflict_reason=None,
|
||||
)
|
||||
|
||||
# Analyze directory impact and add preservation operations
|
||||
directory_analysis = _analyze_directory_impact(operations)
|
||||
if directory_analysis["warning_required"]:
|
||||
# Add directory preservation operations for emptied directories
|
||||
for emptied_dir in directory_analysis["emptied_directories"]:
|
||||
operations.append(FileOperation(
|
||||
operation_type="preserve-directory",
|
||||
source_path=emptied_dir,
|
||||
destination_path=None,
|
||||
reason=f"Preserve empty source directory: {emptied_dir.name}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
))
|
||||
|
||||
summary = _generate_summary(operations)
|
||||
summary_by_reason = _generate_summary_by_reason(operations)
|
||||
human_summary = _generate_human_summary(operations, summary, summary_by_reason, metadata)
|
||||
|
||||
# Add directory warnings to metadata
|
||||
if directory_analysis["warning_required"]:
|
||||
metadata["emptied_directories"] = [str(d) for d in directory_analysis["emptied_directories"]]
|
||||
metadata["directory_warning"] = True
|
||||
|
||||
return ExecutionPlan(
|
||||
plan_id=str(uuid.uuid4()),
|
||||
created_at=utc_now(),
|
||||
@@ -445,9 +500,10 @@ def _generate_summary(operations: list[FileOperation]) -> dict:
|
||||
"move": 0,
|
||||
"rename": 0,
|
||||
"quarantine": 0,
|
||||
"no-op": 0
|
||||
"no-op": 0,
|
||||
"preserve-directory": 0
|
||||
}
|
||||
|
||||
|
||||
for operation in operations:
|
||||
op_type = operation.operation_type
|
||||
if op_type in summary:
|
||||
|
||||
Reference in New Issue
Block a user