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
+103 -23
View File
@@ -8,9 +8,10 @@ import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Union
from typing import Optional, Union
from vlm.config import Config
from vlm.duplicate_resolve import choose_keep_index
from vlm.utils import ensure_utc, utc_now
from vlm.models import (
ExecutionPlan,
@@ -20,38 +21,71 @@ from vlm.models import (
VideoFile,
)
QUARANTINE_REASON_DUPLICATE = "重复项(已按外部评分保留一条)"
def generate_plan(
identities: list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]],
config: Config
config: Config,
analysis_data: Optional[dict] = None,
) -> ExecutionPlan:
"""Generate an execution plan from parsed identities.
Creates file operations for organizing video files based on their parsed
identities and configuration templates. Handles movies, series, anime,
and other categories according to v1 constraints.
Args:
identities: List of tuples containing (VideoFile, parsed_identity)
config: Configuration with templates and settings
Returns:
ExecutionPlan with all file operations and summary
"""Generate an execution plan from parsed identities; optionally apply analysis duplicates.
When analysis_data is provided and duplicate_keep is not "manual", duplicate groups
are resolved (one kept, rest quarantined) according to config.duplicate_keep.
"""
operations = []
for video_file, identity in identities:
operation = _create_operation(video_file, identity, config)
operations.append(operation)
# Generate summary counts
metadata: dict = {}
if analysis_data is not None:
metadata["analysis_source"] = analysis_data.get("metadata", {}).get("source_identities", "")
metadata["duplicate_groups_considered"] = len(analysis_data.get("duplicates", []))
metadata["completeness_seasons_with_gaps"] = len(analysis_data.get("completeness", []))
if config.duplicate_keep != "manual":
path_to_index = {str(vf.path): i for i, (vf, _) in enumerate(identities)}
for dup in analysis_data.get("duplicates", []):
paths = dup.get("files", [])
indices = [path_to_index[p] for p in paths if p in path_to_index]
items = [
(identities[i][0].path, identities[i][1])
for i in indices
if identities[i][1] is not None
and isinstance(identities[i][1], (MovieIdentity, SeriesIdentity))
]
if not items:
continue
keep_idx = choose_keep_index(items, config.duplicate_keep)
if keep_idx is None:
continue
keep_identity_index = indices[keep_idx]
quarantine_indices = set(indices) - {keep_identity_index}
for i in quarantine_indices:
vf = identities[i][0]
operations[i] = FileOperation(
operation_type="quarantine",
source_path=vf.path,
destination_path=None,
reason=QUARANTINE_REASON_DUPLICATE,
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)
return ExecutionPlan(
plan_id=str(uuid.uuid4()),
created_at=utc_now(),
operations=operations,
summary=summary
summary=summary,
summary_by_reason=summary_by_reason,
human_summary=human_summary,
metadata=metadata,
)
@@ -322,10 +356,50 @@ def _generate_summary(operations: list[FileOperation]) -> dict:
op_type = operation.operation_type
if op_type in summary:
summary[op_type] += 1
return summary
def _generate_summary_by_reason(operations: list[FileOperation]) -> dict[str, int]:
"""Aggregate operation counts by reason string."""
out: dict[str, int] = {}
for op in operations:
r = op.reason
out[r] = out.get(r, 0) + 1
return out
def _generate_human_summary(
operations: list[FileOperation],
summary: dict,
summary_by_reason: dict[str, int],
metadata: dict,
) -> str:
"""Build a short Chinese narrative summary of the plan."""
total = summary.get("total", len(operations))
move = summary.get("move", 0)
rename = summary.get("rename", 0)
quarantine = summary.get("quarantine", 0)
noop = summary.get("no-op", 0)
conflicts = sum(1 for op in operations if op.has_conflict)
parts = [
f"本计划共 {total} 条操作:move {move}rename {rename}quarantine {quarantine}no-op {noop}"
]
if summary_by_reason:
reason_lines = [f" - {r}: {c}" for r, c in sorted(summary_by_reason.items(), key=lambda x: -x[1])[:10]]
parts.append("原因分布:")
parts.extend(reason_lines)
if conflicts > 0:
parts.append(f"冲突 {conflicts} 条。")
if metadata:
dup = metadata.get("duplicate_groups_considered", 0)
gaps = metadata.get("completeness_seasons_with_gaps", 0)
if dup or gaps:
parts.append(f"依据 analysis:重复组 {dup} 个;剧集缺口 {gaps} 季。")
return "\n".join(parts)
def save_plan(plan: ExecutionPlan, output_path: Path) -> None:
"""Save execution plan to JSON file.
@@ -351,7 +425,10 @@ def save_plan(plan: ExecutionPlan, output_path: Path) -> None:
}
for op in plan.operations
],
"summary": plan.summary
"summary": plan.summary,
"summary_by_reason": plan.summary_by_reason,
"human_summary": plan.human_summary,
"metadata": plan.metadata,
}
# Write to JSON file with indentation for human readability
@@ -398,5 +475,8 @@ def load_plan(input_path: Path) -> ExecutionPlan:
plan_id=plan_dict["plan_id"],
created_at=created_at,
operations=operations,
summary=plan_dict["summary"]
)
summary=plan_dict["summary"],
summary_by_reason=plan_dict.get("summary_by_reason", {}),
human_summary=plan_dict.get("human_summary", ""),
metadata=plan_dict.get("metadata", {}),
)