chore: snapshot current project updates
This commit is contained in:
+163
-133
@@ -4,6 +4,7 @@ This module provides the main CLI entry point using Click framework.
|
||||
It implements global options (--config, --log-level) and error handling.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
@@ -192,11 +193,9 @@ 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
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from vlm.parser import parse_movie, parse_series
|
||||
from vlm.io import load_inventory_csv
|
||||
from vlm.io import load_inventory_csv, save_identities_json
|
||||
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
@@ -215,23 +214,16 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path])
|
||||
|
||||
click.echo()
|
||||
|
||||
# Load inventory from CSV
|
||||
video_files = []
|
||||
with open(input, 'r', encoding='utf-8') as csvfile:
|
||||
# Skip comment lines
|
||||
lines = []
|
||||
for line in csvfile:
|
||||
if not line.startswith('#'):
|
||||
lines.append(line)
|
||||
|
||||
# Parse CSV
|
||||
reader = csv.DictReader(lines)
|
||||
for row in reader:
|
||||
video_files.append({
|
||||
'path': row['path'],
|
||||
'filename': row['filename'],
|
||||
'category': row['category']
|
||||
})
|
||||
# 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()
|
||||
@@ -360,9 +352,8 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path])
|
||||
'other': other_files
|
||||
}
|
||||
|
||||
# Write JSON file with pretty formatting
|
||||
with open(output, 'w', encoding='utf-8') as jsonfile:
|
||||
json.dump(identities_data, jsonfile, indent=2, ensure_ascii=False)
|
||||
# Write JSON via unified I/O layer
|
||||
save_identities_json(identities_data, output)
|
||||
|
||||
click.echo(f"Parsed identities saved successfully!")
|
||||
|
||||
@@ -373,11 +364,6 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path])
|
||||
logger.error(f"Input file not found: {input}")
|
||||
sys.exit(1)
|
||||
|
||||
except csv.Error as e:
|
||||
click.echo(f"Error: Failed to parse CSV file: {e}", err=True)
|
||||
logger.error(f"CSV parsing failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error during parsing: {e}", err=True)
|
||||
logger.error(f"Parse failed: {e}", exc_info=True)
|
||||
@@ -685,6 +671,101 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command(name="review-plan")
|
||||
@click.option(
|
||||
'--input',
|
||||
type=click.Path(exists=True, path_type=Path),
|
||||
default=Path('plan.json'),
|
||||
help='Path to execution plan JSON file (default: plan.json)'
|
||||
)
|
||||
@click.option(
|
||||
'--output',
|
||||
type=click.Path(path_type=Path),
|
||||
default=Path('plan_manual_review.csv'),
|
||||
help='Path to save manual review CSV (default: plan_manual_review.csv)'
|
||||
)
|
||||
@click.option(
|
||||
'--season-threshold',
|
||||
type=int,
|
||||
default=20,
|
||||
show_default=True,
|
||||
help='Flag operations with season >= this value as high risk'
|
||||
)
|
||||
@click.option(
|
||||
'--episode-threshold',
|
||||
type=int,
|
||||
default=40,
|
||||
show_default=True,
|
||||
help='Flag operations with episode >= this value as high risk'
|
||||
)
|
||||
@pass_context
|
||||
def review_plan_cmd(
|
||||
ctx: CLIContext,
|
||||
input: Path,
|
||||
output: Path,
|
||||
season_threshold: int,
|
||||
episode_threshold: int,
|
||||
):
|
||||
"""Review a plan and export high-risk operations for manual confirmation."""
|
||||
from vlm.planner import load_plan
|
||||
from vlm.plan_review import review_plan, save_review_csv
|
||||
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
if season_threshold < 1 or episode_threshold < 1:
|
||||
click.echo("Error: thresholds must be >= 1", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(f"Loading plan: {input}")
|
||||
execution_plan = load_plan(input)
|
||||
rows, counters = review_plan(
|
||||
execution_plan,
|
||||
season_threshold=season_threshold,
|
||||
episode_threshold=episode_threshold,
|
||||
)
|
||||
|
||||
save_review_csv(rows, output)
|
||||
|
||||
click.echo()
|
||||
click.echo("Plan review summary:")
|
||||
click.echo(f" Total operations: {counters['total_operations']}")
|
||||
click.echo(f" High-risk operations: {counters['high_risk_operations']}")
|
||||
click.echo(f" manual_review: {counters['manual_review']}")
|
||||
click.echo(f" sample_source: {counters['sample_source']}")
|
||||
click.echo(f" high_season: {counters['high_season']}")
|
||||
click.echo(f" high_episode: {counters['high_episode']}")
|
||||
click.echo(f" conflicts: {counters['conflicts']}")
|
||||
click.echo()
|
||||
click.echo(f"Saved manual review CSV to: {output}")
|
||||
|
||||
if rows:
|
||||
click.echo("Top review samples:")
|
||||
for row in rows[:5]:
|
||||
click.echo(
|
||||
f" - [{row['index']}] {row['operation_type']} {Path(row['source_path']).name} ({row['risk_flags']})"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Plan review completed: total=%s high_risk=%s output=%s",
|
||||
counters["total_operations"],
|
||||
counters["high_risk_operations"],
|
||||
output,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
click.echo(f"Error: Plan file not found: {input}", err=True)
|
||||
logger.error(f"Plan file not found: {input}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
click.echo(f"Error: Failed to parse plan JSON: {e}", err=True)
|
||||
logger.error(f"Plan review JSON parsing failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
click.echo(f"Error during plan review: {e}", err=True)
|
||||
logger.error(f"Plan review failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
'--plan',
|
||||
@@ -698,19 +779,32 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None):
|
||||
default=False,
|
||||
help='Actually execute operations (default is dry-run)'
|
||||
)
|
||||
@click.option(
|
||||
'--yes',
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help='Skip confirmation prompt (auto-approve)'
|
||||
)
|
||||
@click.option(
|
||||
'--verbose-ops',
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help='Print per-operation dry-run logs at INFO level'
|
||||
)
|
||||
@pass_context
|
||||
def execute(ctx: CLIContext, plan: Path, confirm: bool):
|
||||
def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops: bool):
|
||||
"""Execute plan (defaults to dry-run, requires --confirm).
|
||||
|
||||
|
||||
Executes file operations from a plan. Defaults to dry-run mode which
|
||||
simulates operations without making changes. Use --confirm to actually
|
||||
execute operations.
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
vlm execute # Dry-run with plan.json
|
||||
vlm execute --plan my_plan.json # Dry-run with custom plan
|
||||
vlm execute --confirm # Actually execute operations
|
||||
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
|
||||
@@ -754,16 +848,29 @@ def execute(ctx: CLIContext, plan: Path, confirm: bool):
|
||||
click.echo("⚠️ EXECUTE MODE - Files will be modified!")
|
||||
click.echo(" This operation cannot be undone without rollback")
|
||||
click.echo()
|
||||
if not click.confirm("Are you sure you want to proceed?"):
|
||||
click.echo("Execution cancelled.")
|
||||
return
|
||||
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)
|
||||
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,
|
||||
@@ -1237,10 +1344,8 @@ def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional
|
||||
vlm report inventory --format csv # CSV format to console
|
||||
vlm report inventory --format json --output inventory_report.json
|
||||
"""
|
||||
import csv
|
||||
from datetime import datetime, timezone
|
||||
from vlm.reports import generate_inventory_report
|
||||
from vlm.models import VideoFile
|
||||
from vlm.io import load_inventory_csv
|
||||
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
@@ -1249,40 +1354,7 @@ def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional
|
||||
# Load inventory from CSV
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
|
||||
video_files = []
|
||||
with open(input, 'r', encoding='utf-8') as csvfile:
|
||||
# Skip comment lines
|
||||
lines = []
|
||||
for line in csvfile:
|
||||
if not line.startswith('#'):
|
||||
lines.append(line)
|
||||
|
||||
# Parse CSV
|
||||
reader = csv.DictReader(lines)
|
||||
for row in reader:
|
||||
# Parse timestamp
|
||||
modified_timestamp = datetime.fromisoformat(row['modified_timestamp'])
|
||||
if modified_timestamp.tzinfo is None:
|
||||
modified_timestamp = modified_timestamp.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Parse optional fields
|
||||
resolution = row.get('resolution') if row.get('resolution') else None
|
||||
codec = row.get('codec') if row.get('codec') else None
|
||||
duration_seconds = float(row['duration_seconds']) if row.get('duration_seconds') else None
|
||||
bitrate_kbps = int(row['bitrate_kbps']) if row.get('bitrate_kbps') else None
|
||||
|
||||
video_file = VideoFile(
|
||||
path=Path(row['path']),
|
||||
filename=row['filename'],
|
||||
size_bytes=int(row['size_bytes']),
|
||||
modified_timestamp=modified_timestamp,
|
||||
category=row['category'],
|
||||
resolution=resolution,
|
||||
codec=codec,
|
||||
duration_seconds=duration_seconds,
|
||||
bitrate_kbps=bitrate_kbps
|
||||
)
|
||||
video_files.append(video_file)
|
||||
video_files = load_inventory_csv(input)
|
||||
|
||||
click.echo(f"Loaded {len(video_files)} files")
|
||||
click.echo()
|
||||
@@ -1313,11 +1385,6 @@ def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional
|
||||
logger.error(f"Input file not found: {input}")
|
||||
sys.exit(1)
|
||||
|
||||
except csv.Error as e:
|
||||
click.echo(f"Error: Failed to parse CSV file: {e}", err=True)
|
||||
logger.error(f"CSV parsing failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error generating inventory report: {e}", err=True)
|
||||
logger.error(f"Inventory report generation failed: {e}", exc_info=True)
|
||||
@@ -1362,10 +1429,10 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio
|
||||
vlm report completeness --plan plan.json # Include plan content summary
|
||||
vlm report completeness --format text --output completeness.txt
|
||||
"""
|
||||
import json
|
||||
from vlm.reports import generate_completeness_report
|
||||
from vlm.models import SeasonCompleteness
|
||||
from vlm.planner import load_plan
|
||||
from vlm.io import load_analysis_json
|
||||
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
@@ -1382,8 +1449,7 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio
|
||||
# Load analysis from JSON
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
|
||||
with open(input, 'r', encoding='utf-8') as jsonfile:
|
||||
analysis_data = json.load(jsonfile)
|
||||
analysis_data = load_analysis_json(input)
|
||||
|
||||
# Extract completeness data
|
||||
completeness_list = analysis_data.get('completeness', [])
|
||||
@@ -1476,11 +1542,11 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona
|
||||
vlm report duplicates --plan plan.json # Include plan content summary
|
||||
vlm report duplicates --format text --output duplicates.txt
|
||||
"""
|
||||
import json
|
||||
from vlm.reports import generate_duplicate_report
|
||||
from vlm.models import DuplicateGroup, MovieIdentity, SeriesIdentity, VideoFile
|
||||
from vlm.planner import load_plan
|
||||
from datetime import datetime, timezone
|
||||
from vlm.io import load_analysis_json
|
||||
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
@@ -1497,8 +1563,7 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona
|
||||
# Load analysis from JSON
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
|
||||
with open(input, 'r', encoding='utf-8') as jsonfile:
|
||||
analysis_data = json.load(jsonfile)
|
||||
analysis_data = load_analysis_json(input)
|
||||
|
||||
# Extract duplicates data
|
||||
duplicates_list = analysis_data.get('duplicates', [])
|
||||
@@ -1527,19 +1592,24 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona
|
||||
original_filename=""
|
||||
)
|
||||
|
||||
# Reconstruct VideoFile objects from file paths
|
||||
quality_by_path = {
|
||||
str(item.get("path", "")): item for item in d.get("quality_comparison", [])
|
||||
}
|
||||
|
||||
# Reconstruct VideoFile objects from file paths and preserve size metadata
|
||||
files = []
|
||||
for file_path in d['files']:
|
||||
quality = quality_by_path.get(str(file_path), {})
|
||||
files.append(VideoFile(
|
||||
path=Path(file_path),
|
||||
filename=Path(file_path).name,
|
||||
size_bytes=0,
|
||||
size_bytes=int(quality.get("size_bytes", 0) or 0),
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="",
|
||||
resolution=None,
|
||||
codec=None,
|
||||
duration_seconds=None,
|
||||
bitrate_kbps=None
|
||||
resolution=quality.get("resolution"),
|
||||
codec=quality.get("codec"),
|
||||
duration_seconds=quality.get("duration_seconds"),
|
||||
bitrate_kbps=quality.get("bitrate_kbps"),
|
||||
))
|
||||
|
||||
duplicate_groups.append(DuplicateGroup(
|
||||
@@ -1612,10 +1682,8 @@ def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]):
|
||||
vlm report summary # Print to console
|
||||
vlm report summary --output summary.txt # Save to file
|
||||
"""
|
||||
import csv
|
||||
from datetime import datetime, timezone
|
||||
from vlm.reports import generate_summary_report
|
||||
from vlm.models import VideoFile
|
||||
from vlm.io import load_inventory_csv
|
||||
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
@@ -1624,40 +1692,7 @@ def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]):
|
||||
# Load inventory from CSV
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
|
||||
video_files = []
|
||||
with open(input, 'r', encoding='utf-8') as csvfile:
|
||||
# Skip comment lines
|
||||
lines = []
|
||||
for line in csvfile:
|
||||
if not line.startswith('#'):
|
||||
lines.append(line)
|
||||
|
||||
# Parse CSV
|
||||
reader = csv.DictReader(lines)
|
||||
for row in reader:
|
||||
# Parse timestamp
|
||||
modified_timestamp = datetime.fromisoformat(row['modified_timestamp'])
|
||||
if modified_timestamp.tzinfo is None:
|
||||
modified_timestamp = modified_timestamp.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Parse optional fields
|
||||
resolution = row.get('resolution') if row.get('resolution') else None
|
||||
codec = row.get('codec') if row.get('codec') else None
|
||||
duration_seconds = float(row['duration_seconds']) if row.get('duration_seconds') else None
|
||||
bitrate_kbps = int(row['bitrate_kbps']) if row.get('bitrate_kbps') else None
|
||||
|
||||
video_file = VideoFile(
|
||||
path=Path(row['path']),
|
||||
filename=row['filename'],
|
||||
size_bytes=int(row['size_bytes']),
|
||||
modified_timestamp=modified_timestamp,
|
||||
category=row['category'],
|
||||
resolution=resolution,
|
||||
codec=codec,
|
||||
duration_seconds=duration_seconds,
|
||||
bitrate_kbps=bitrate_kbps
|
||||
)
|
||||
video_files.append(video_file)
|
||||
video_files = load_inventory_csv(input)
|
||||
|
||||
click.echo(f"Loaded {len(video_files)} files")
|
||||
click.echo()
|
||||
@@ -1685,11 +1720,6 @@ def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]):
|
||||
logger.error(f"Input file not found: {input}")
|
||||
sys.exit(1)
|
||||
|
||||
except csv.Error as e:
|
||||
click.echo(f"Error: Failed to parse CSV file: {e}", err=True)
|
||||
logger.error(f"CSV parsing failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error generating summary report: {e}", err=True)
|
||||
logger.error(f"Summary report generation failed: {e}", exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user