Update analysis and plan files to enhance duplicate handling and reporting

- Updated `analysis.json` with a new generation timestamp.
- Modified `plan.json` to include a new plan ID and created timestamp, and changed operation types from "no-op" to "quarantine" for specific files needing manual review.
- Enhanced the README.md to document the new `--analysis` option for generating execution plans, which now includes a human-readable summary and duplicate handling strategies.
- Introduced a new `duplicate_resolve.py` module to manage duplicate file resolution strategies.
- Improved the execution engine to support quarantine operations and added rollback functionality for quarantined files.

These changes improve the functionality of the Video Library Manager by providing better duplicate management and clearer reporting capabilities.
This commit is contained in:
windyboy
2026-02-10 18:07:38 +08:00
parent dcd87754cf
commit 79f5ddf1f5
14 changed files with 882 additions and 456 deletions
+77 -9
View File
@@ -600,8 +600,14 @@ def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path
default=Path('plan.json'),
help='Path to save execution plan (default: plan.json)'
)
@click.option(
'--analysis',
type=click.Path(path_type=Path),
default=None,
help='Path to analysis JSON (optional); when provided, duplicate groups are applied to the plan'
)
@pass_context
def plan(ctx: CLIContext, input: Path, output: Path):
def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None):
"""Generate execution plan.
Creates a structured, reviewable plan of all file operations to be performed.
@@ -611,11 +617,12 @@ def plan(ctx: CLIContext, input: Path, output: Path):
vlm plan # Use default files
vlm plan --input my_identities.json # Custom input
vlm plan --analysis analysis.json # Use analysis for duplicate handling
vlm plan --output my_plan.json # Custom output
"""
try:
from vlm.commands.plan import plan_cmd
plan_cmd(ctx, input, output)
plan_cmd(ctx, input, output, analysis)
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
ctx.logger.error(f"Input file not found: {input}")
@@ -678,6 +685,17 @@ def execute(ctx: CLIContext, plan: Path, confirm: bool):
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
@@ -697,7 +715,7 @@ def execute(ctx: CLIContext, plan: Path, confirm: bool):
click.echo()
# Create execution engine and execute plan
engine = ExecutionEngine(logger=logger)
engine = ExecutionEngine(logger=logger, config=config)
results, summary, rollback_log = engine.execute_plan(
execution_plan,
mode=mode,
@@ -1036,7 +1054,7 @@ def rollback(ctx: CLIContext, log: Optional[Path]):
click.echo()
# Create execution engine
engine = ExecutionEngine(logger=logger)
engine = ExecutionEngine(logger=logger, config=config)
# Load rollback log
rollback_log = engine.load_rollback_log(log)
@@ -1115,6 +1133,20 @@ def rollback(ctx: CLIContext, log: Optional[Path]):
sys.exit(1)
def _fallback_plan_summary(execution_plan) -> str:
"""Build a short plan summary from summary and summary_by_reason when human_summary is empty."""
s = execution_plan.summary or {}
by_r = execution_plan.summary_by_reason or {}
total = s.get("total", len(execution_plan.operations))
parts = [
f"计划操作统计:共 {total} 条(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())[:8]))
return "\n".join(parts)
@main.group()
@pass_context
def report(ctx: CLIContext):
@@ -1263,8 +1295,14 @@ def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional
default=None,
help='Output file (default: print to console)'
)
@click.option(
'--plan',
type=click.Path(path_type=Path),
default=None,
help='Optional plan JSON; when provided, report includes plan content summary'
)
@pass_context
def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]):
"""Generate completeness report.
Shows series with episode gaps detected through heuristic analysis.
@@ -1273,14 +1311,24 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio
vlm report completeness # Text format to console
vlm report completeness --format json # JSON format to console
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
config = ctx.config
logger = ctx.logger
plan_summary = None
if plan:
if not plan.exists():
click.echo(f"Error: Plan file not found: {plan}", err=True)
sys.exit(1)
execution_plan = load_plan(plan)
plan_summary = execution_plan.human_summary or _fallback_plan_summary(execution_plan)
try:
# Load analysis from JSON
@@ -1307,7 +1355,9 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio
# Generate report
click.echo(f"Generating completeness report in {format} format...")
report_content = generate_completeness_report(season_completeness, format, config.library_root)
report_content = generate_completeness_report(
season_completeness, format, config.library_root, plan_summary=plan_summary
)
# Output report
if output:
@@ -1358,8 +1408,14 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio
default=None,
help='Output file (default: print to console)'
)
@click.option(
'--plan',
type=click.Path(path_type=Path),
default=None,
help='Optional plan JSON; when provided, report includes plan content summary'
)
@pass_context
def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]):
"""Generate duplicate report.
Shows duplicate files with quality comparison data to help decide which
@@ -1369,15 +1425,25 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona
vlm report duplicates # Text format to console
vlm report duplicates --format json # JSON format to console
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
config = ctx.config
logger = ctx.logger
plan_summary = None
if plan:
if not plan.exists():
click.echo(f"Error: Plan file not found: {plan}", err=True)
sys.exit(1)
execution_plan = load_plan(plan)
plan_summary = execution_plan.human_summary or _fallback_plan_summary(execution_plan)
try:
# Load analysis from JSON
@@ -1439,7 +1505,9 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona
# Generate report
click.echo(f"Generating duplicate report in {format} format...")
report_content = generate_duplicate_report(duplicate_groups, format, config.library_root)
report_content = generate_duplicate_report(
duplicate_groups, format, config.library_root, plan_summary=plan_summary
)
# Output report
if output: