chore: snapshot current project updates

This commit is contained in:
windyboy
2026-02-13 13:36:39 +08:00
parent 5931f16a71
commit 0be802eac5
74 changed files with 594899 additions and 66082 deletions
+163 -133
View File
@@ -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)