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:
+103
-23
@@ -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", {}),
|
||||
)
|
||||
Reference in New Issue
Block a user