2026-02-09 17:43:35 +08:00
|
|
|
|
"""Plan generator for creating execution plans from parsed identities.
|
|
|
|
|
|
|
|
|
|
|
|
This module generates structured execution plans that specify how video files
|
|
|
|
|
|
should be organized based on their parsed identities and configuration templates.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
2026-02-13 13:36:39 +08:00
|
|
|
|
import re
|
2026-02-09 17:43:35 +08:00
|
|
|
|
import uuid
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
2026-02-10 18:07:38 +08:00
|
|
|
|
from typing import Optional, Union
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
from vlm.config import Config
|
2026-02-10 18:07:38 +08:00
|
|
|
|
from vlm.duplicate_resolve import choose_keep_index
|
2026-02-13 13:36:39 +08:00
|
|
|
|
from vlm.utils import ensure_utc, is_within_root, sanitize_path_component, utc_now
|
2026-02-09 17:43:35 +08:00
|
|
|
|
from vlm.models import (
|
|
|
|
|
|
ExecutionPlan,
|
|
|
|
|
|
FileOperation,
|
|
|
|
|
|
MovieIdentity,
|
|
|
|
|
|
SeriesIdentity,
|
|
|
|
|
|
VideoFile,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-10 18:07:38 +08:00
|
|
|
|
QUARANTINE_REASON_DUPLICATE = "重复项(已按外部评分保留一条)"
|
2026-02-13 13:36:39 +08:00
|
|
|
|
QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY = "重复项(外部评分缺失/并列,按画质回退保留一条)"
|
2026-02-11 08:44:48 +08:00
|
|
|
|
QUARANTINE_REASON_DUPLICATE_BY_QUALITY = "重复项(已按画质保留最优)"
|
2026-02-13 13:36:39 +08:00
|
|
|
|
QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME = "重复项(按外部评分>画质>时间保留一条)"
|
|
|
|
|
|
NO_OP_REASON_SAMPLE_EXCLUDED = "Sample file excluded by plan include_sample_files=false"
|
|
|
|
|
|
NO_OP_REASON_SEASON_OUT_OF_RANGE = "Series needs manual review (season exceeds configured threshold)"
|
|
|
|
|
|
NO_OP_REASON_EPISODE_OUT_OF_RANGE = "Series needs manual review (episode exceeds configured threshold)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_sample_path(path: Path) -> bool:
|
|
|
|
|
|
"""Return True if path appears to be a sample clip."""
|
|
|
|
|
|
parts = [part.casefold() for part in path.parts]
|
|
|
|
|
|
if "sample" in parts:
|
|
|
|
|
|
return True
|
|
|
|
|
|
stem = path.stem.casefold()
|
|
|
|
|
|
return bool(re.search(r"(^|[\s._-])sample($|[\s._-])", stem))
|
2026-02-10 18:07:38 +08:00
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
2026-02-16 12:31:26 +08:00
|
|
|
|
def _analyze_directory_impact(operations: list[FileOperation]) -> dict:
|
|
|
|
|
|
"""Analyze which directories will be emptied by the plan."""
|
|
|
|
|
|
# Get all source directories that have files being moved/renamed
|
|
|
|
|
|
source_dirs = set()
|
|
|
|
|
|
moved_files = set()
|
|
|
|
|
|
for operation in operations:
|
|
|
|
|
|
if operation.operation_type in ("move", "rename"):
|
|
|
|
|
|
source_dirs.add(operation.source_path.parent)
|
|
|
|
|
|
moved_files.add(operation.source_path)
|
|
|
|
|
|
|
|
|
|
|
|
emptied_dirs = []
|
|
|
|
|
|
for dir_path in source_dirs:
|
|
|
|
|
|
# Only analyze directories that actually exist
|
|
|
|
|
|
if not dir_path.exists():
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Count files that will remain in this directory after operations
|
|
|
|
|
|
remaining_count = 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
for item in dir_path.iterdir():
|
|
|
|
|
|
if item.is_file() and item not in moved_files:
|
|
|
|
|
|
remaining_count += 1
|
|
|
|
|
|
except (OSError, PermissionError):
|
|
|
|
|
|
# If we can't read the directory, skip analysis
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# If no files will remain, this directory will be emptied
|
|
|
|
|
|
if remaining_count == 0:
|
|
|
|
|
|
emptied_dirs.append(dir_path)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"emptied_directories": emptied_dirs,
|
|
|
|
|
|
"warning_required": len(emptied_dirs) > 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
def generate_plan(
|
|
|
|
|
|
identities: list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]],
|
2026-02-10 18:07:38 +08:00
|
|
|
|
config: Config,
|
|
|
|
|
|
analysis_data: Optional[dict] = None,
|
2026-02-13 13:36:39 +08:00
|
|
|
|
ignored_paths: Optional[set[str]] = None,
|
2026-02-09 17:43:35 +08:00
|
|
|
|
) -> ExecutionPlan:
|
2026-02-10 18:07:38 +08:00
|
|
|
|
"""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.
|
2026-02-13 13:36:39 +08:00
|
|
|
|
|
|
|
|
|
|
If ignored_paths is provided, files in that set will generate no-op operations.
|
2026-02-09 17:43:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
operations = []
|
|
|
|
|
|
for video_file, identity in identities:
|
2026-02-13 13:36:39 +08:00
|
|
|
|
# Check if file is ignored in state
|
|
|
|
|
|
if ignored_paths and str(video_file.path) in ignored_paths:
|
|
|
|
|
|
operation = FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="User marked as ignored in state",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
operation = _create_operation(video_file, identity, config)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
operations.append(operation)
|
2026-02-10 18:07:38 +08:00
|
|
|
|
|
|
|
|
|
|
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]
|
2026-02-11 08:44:48 +08:00
|
|
|
|
path_to_qc = {qc.get("path"): qc for qc in dup.get("quality_comparison", [])}
|
|
|
|
|
|
items = []
|
|
|
|
|
|
valid_indices = []
|
|
|
|
|
|
for i in indices:
|
|
|
|
|
|
identity = identities[i][1]
|
|
|
|
|
|
if identity is not None and isinstance(
|
|
|
|
|
|
identity, (MovieIdentity, SeriesIdentity)
|
|
|
|
|
|
):
|
2026-02-13 13:36:39 +08:00
|
|
|
|
if not config.plan_include_sample_files and _is_sample_path(identities[i][0].path):
|
|
|
|
|
|
# Keep sample files out of duplicate keep/quarantine competition by default.
|
|
|
|
|
|
continue
|
2026-02-11 08:44:48 +08:00
|
|
|
|
items.append((identities[i][0].path, identity))
|
|
|
|
|
|
valid_indices.append(i)
|
2026-02-10 18:07:38 +08:00
|
|
|
|
if not items:
|
|
|
|
|
|
continue
|
2026-02-11 08:44:48 +08:00
|
|
|
|
quality_list = [
|
|
|
|
|
|
path_to_qc.get(str(p), {}) for p, _ in items
|
|
|
|
|
|
]
|
|
|
|
|
|
keep_idx = choose_keep_index(
|
|
|
|
|
|
items, config.duplicate_keep, quality_comparison=quality_list
|
|
|
|
|
|
)
|
2026-02-10 18:07:38 +08:00
|
|
|
|
if keep_idx is None:
|
|
|
|
|
|
continue
|
2026-02-11 08:44:48 +08:00
|
|
|
|
keep_identity_index = valid_indices[keep_idx]
|
|
|
|
|
|
quarantine_indices = set(valid_indices) - {keep_identity_index}
|
2026-02-13 13:36:39 +08:00
|
|
|
|
reason = _select_duplicate_quarantine_reason(
|
|
|
|
|
|
config.duplicate_keep,
|
|
|
|
|
|
[identity for _, identity in items],
|
2026-02-11 08:44:48 +08:00
|
|
|
|
)
|
2026-02-10 18:07:38 +08:00
|
|
|
|
for i in quarantine_indices:
|
|
|
|
|
|
vf = identities[i][0]
|
|
|
|
|
|
operations[i] = FileOperation(
|
|
|
|
|
|
operation_type="quarantine",
|
|
|
|
|
|
source_path=vf.path,
|
|
|
|
|
|
destination_path=None,
|
2026-02-11 08:44:48 +08:00
|
|
|
|
reason=reason,
|
2026-02-10 18:07:38 +08:00
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-16 12:31:26 +08:00
|
|
|
|
# Analyze directory impact and add preservation operations
|
|
|
|
|
|
directory_analysis = _analyze_directory_impact(operations)
|
|
|
|
|
|
if directory_analysis["warning_required"]:
|
|
|
|
|
|
# Add directory preservation operations for emptied directories
|
|
|
|
|
|
for emptied_dir in directory_analysis["emptied_directories"]:
|
|
|
|
|
|
operations.append(FileOperation(
|
|
|
|
|
|
operation_type="preserve-directory",
|
|
|
|
|
|
source_path=emptied_dir,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason=f"Preserve empty source directory: {emptied_dir.name}",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
))
|
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
summary = _generate_summary(operations)
|
2026-02-10 18:07:38 +08:00
|
|
|
|
summary_by_reason = _generate_summary_by_reason(operations)
|
|
|
|
|
|
human_summary = _generate_human_summary(operations, summary, summary_by_reason, metadata)
|
|
|
|
|
|
|
2026-02-16 12:31:26 +08:00
|
|
|
|
# Add directory warnings to metadata
|
|
|
|
|
|
if directory_analysis["warning_required"]:
|
|
|
|
|
|
metadata["emptied_directories"] = [str(d) for d in directory_analysis["emptied_directories"]]
|
|
|
|
|
|
metadata["directory_warning"] = True
|
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
return ExecutionPlan(
|
|
|
|
|
|
plan_id=str(uuid.uuid4()),
|
2026-02-10 16:56:17 +08:00
|
|
|
|
created_at=utc_now(),
|
2026-02-09 17:43:35 +08:00
|
|
|
|
operations=operations,
|
2026-02-10 18:07:38 +08:00
|
|
|
|
summary=summary,
|
|
|
|
|
|
summary_by_reason=summary_by_reason,
|
|
|
|
|
|
human_summary=human_summary,
|
|
|
|
|
|
metadata=metadata,
|
2026-02-09 17:43:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_operation(
|
|
|
|
|
|
video_file: VideoFile,
|
|
|
|
|
|
identity: Union[MovieIdentity, SeriesIdentity, None],
|
|
|
|
|
|
config: Config
|
|
|
|
|
|
) -> FileOperation:
|
|
|
|
|
|
"""Create a file operation for a single video file.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
video_file: The video file to create an operation for
|
|
|
|
|
|
identity: Parsed identity (MovieIdentity, SeriesIdentity, or None)
|
|
|
|
|
|
config: Configuration with templates
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
FileOperation specifying what to do with the file
|
|
|
|
|
|
"""
|
2026-02-13 13:36:39 +08:00
|
|
|
|
if not config.plan_include_sample_files and _is_sample_path(video_file.path):
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason=NO_OP_REASON_SAMPLE_EXCLUDED,
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
# Handle anime category - generate no-op (v1 constraint)
|
|
|
|
|
|
if video_file.category == "anime":
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="Anime files not organized in v1",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Handle other category - generate no-op (v1 constraint)
|
|
|
|
|
|
if video_file.category == "other":
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="Other files not organized in v1",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Handle files without identity - generate no-op
|
|
|
|
|
|
if identity is None:
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="No identity parsed",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Handle movie identity
|
|
|
|
|
|
if isinstance(identity, MovieIdentity):
|
|
|
|
|
|
return _create_movie_operation(video_file, identity, config)
|
|
|
|
|
|
|
|
|
|
|
|
# Handle series identity
|
|
|
|
|
|
if isinstance(identity, SeriesIdentity):
|
|
|
|
|
|
return _create_series_operation(video_file, identity, config)
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback - should not reach here
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="Unknown identity type",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_movie_operation(
|
|
|
|
|
|
video_file: VideoFile,
|
|
|
|
|
|
identity: MovieIdentity,
|
|
|
|
|
|
config: Config
|
|
|
|
|
|
) -> FileOperation:
|
|
|
|
|
|
"""Create operation for a movie file.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
video_file: The movie file
|
|
|
|
|
|
identity: Parsed movie identity
|
|
|
|
|
|
config: Configuration with templates
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
FileOperation for organizing the movie
|
|
|
|
|
|
"""
|
2026-02-09 23:55:13 +08:00
|
|
|
|
# Explicitly blocked by manual review workflow.
|
|
|
|
|
|
if identity.review_status == "rejected":
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="Movie rejected during manual review",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# If movie needs review (no year or low-confidence enrichment), generate no-op
|
2026-02-09 17:43:35 +08:00
|
|
|
|
if identity.needs_review or identity.year is None:
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="Movie needs manual review (no year found)",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
|
safe_title = sanitize_path_component(identity.title, fallback="untitled")
|
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
# Apply movie directory template
|
|
|
|
|
|
target_dir = config.movie_template.format(
|
2026-02-13 13:36:39 +08:00
|
|
|
|
title=safe_title,
|
2026-02-09 17:43:35 +08:00
|
|
|
|
year=identity.year
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Get file extension
|
|
|
|
|
|
ext = video_file.path.suffix
|
|
|
|
|
|
|
|
|
|
|
|
# Apply movie filename template
|
|
|
|
|
|
target_filename = config.movie_filename_template.format(
|
2026-02-13 13:36:39 +08:00
|
|
|
|
title=safe_title,
|
2026-02-09 17:43:35 +08:00
|
|
|
|
year=identity.year,
|
|
|
|
|
|
ext=ext
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Construct full destination path
|
|
|
|
|
|
destination = config.library_root / target_dir / target_filename
|
2026-02-13 13:36:39 +08:00
|
|
|
|
if not is_within_root(destination, config.library_root):
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason=f"Unsafe destination outside library root: {destination}",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
# Check if source and destination are the same
|
|
|
|
|
|
if video_file.path.resolve() == destination.resolve():
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="File already at target location",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Determine operation type (move or rename)
|
|
|
|
|
|
if video_file.path.parent == destination.parent:
|
|
|
|
|
|
operation_type = "rename"
|
|
|
|
|
|
else:
|
|
|
|
|
|
operation_type = "move"
|
|
|
|
|
|
|
|
|
|
|
|
# Check for conflicts - destination file already exists
|
|
|
|
|
|
has_conflict = destination.exists()
|
|
|
|
|
|
conflict_reason = None
|
|
|
|
|
|
if has_conflict:
|
|
|
|
|
|
conflict_reason = f"Destination file already exists: {destination}"
|
|
|
|
|
|
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type=operation_type,
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=destination,
|
|
|
|
|
|
reason=f"Organize movie: {identity.title} ({identity.year})",
|
|
|
|
|
|
has_conflict=has_conflict,
|
|
|
|
|
|
conflict_reason=conflict_reason
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_series_operation(
|
|
|
|
|
|
video_file: VideoFile,
|
|
|
|
|
|
identity: SeriesIdentity,
|
|
|
|
|
|
config: Config
|
|
|
|
|
|
) -> FileOperation:
|
|
|
|
|
|
"""Create operation for a series file.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
video_file: The series file
|
|
|
|
|
|
identity: Parsed series identity
|
|
|
|
|
|
config: Configuration with templates
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
FileOperation for organizing the series episode
|
|
|
|
|
|
"""
|
2026-02-09 23:55:13 +08:00
|
|
|
|
# Explicitly blocked by manual review workflow.
|
|
|
|
|
|
if identity.review_status == "rejected":
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="Series rejected during manual review",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# If series needs review (no season or no episodes), generate no-op
|
2026-02-09 17:43:35 +08:00
|
|
|
|
if identity.needs_review or identity.season is None or len(identity.episodes) == 0:
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="Series needs manual review (no season/episode found)",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
2026-02-13 13:36:39 +08:00
|
|
|
|
if identity.season > config.plan_max_season:
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason=NO_OP_REASON_SEASON_OUT_OF_RANGE,
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
if any(ep > config.plan_max_episode for ep in identity.episodes):
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason=NO_OP_REASON_EPISODE_OUT_OF_RANGE,
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
|
safe_title = sanitize_path_component(identity.title, fallback="untitled")
|
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
# Apply series directory template
|
|
|
|
|
|
target_dir = config.series_template.format(
|
2026-02-13 13:36:39 +08:00
|
|
|
|
title=safe_title,
|
2026-02-09 17:43:35 +08:00
|
|
|
|
season=identity.season
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Get file extension
|
|
|
|
|
|
ext = video_file.path.suffix
|
|
|
|
|
|
|
|
|
|
|
|
# Apply series filename template
|
|
|
|
|
|
# For multi-episode files, use the first episode number
|
|
|
|
|
|
target_filename = config.series_filename_template.format(
|
|
|
|
|
|
season=identity.season,
|
|
|
|
|
|
episode=identity.episodes[0],
|
|
|
|
|
|
ext=ext
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Construct full destination path
|
|
|
|
|
|
destination = config.library_root / target_dir / target_filename
|
2026-02-13 13:36:39 +08:00
|
|
|
|
if not is_within_root(destination, config.library_root):
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason=f"Unsafe destination outside library root: {destination}",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
# Check if source and destination are the same
|
|
|
|
|
|
if video_file.path.resolve() == destination.resolve():
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type="no-op",
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=None,
|
|
|
|
|
|
reason="File already at target location",
|
|
|
|
|
|
has_conflict=False,
|
|
|
|
|
|
conflict_reason=None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Determine operation type (move or rename)
|
|
|
|
|
|
if video_file.path.parent == destination.parent:
|
|
|
|
|
|
operation_type = "rename"
|
|
|
|
|
|
else:
|
|
|
|
|
|
operation_type = "move"
|
|
|
|
|
|
|
|
|
|
|
|
# Check for conflicts - destination file already exists
|
|
|
|
|
|
has_conflict = destination.exists()
|
|
|
|
|
|
conflict_reason = None
|
|
|
|
|
|
if has_conflict:
|
|
|
|
|
|
conflict_reason = f"Destination file already exists: {destination}"
|
|
|
|
|
|
|
|
|
|
|
|
return FileOperation(
|
|
|
|
|
|
operation_type=operation_type,
|
|
|
|
|
|
source_path=video_file.path,
|
|
|
|
|
|
destination_path=destination,
|
|
|
|
|
|
reason=f"Organize series: {identity.title} S{identity.season:02d}E{identity.episodes[0]:02d}",
|
|
|
|
|
|
has_conflict=has_conflict,
|
|
|
|
|
|
conflict_reason=conflict_reason
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _generate_summary(operations: list[FileOperation]) -> dict:
|
|
|
|
|
|
"""Generate summary statistics for operations.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
operations: List of file operations
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dictionary with operation counts by type
|
|
|
|
|
|
"""
|
|
|
|
|
|
summary = {
|
|
|
|
|
|
"total": len(operations),
|
|
|
|
|
|
"move": 0,
|
|
|
|
|
|
"rename": 0,
|
|
|
|
|
|
"quarantine": 0,
|
2026-02-16 12:31:26 +08:00
|
|
|
|
"no-op": 0,
|
|
|
|
|
|
"preserve-directory": 0
|
2026-02-09 17:43:35 +08:00
|
|
|
|
}
|
2026-02-16 12:31:26 +08:00
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
for operation in operations:
|
|
|
|
|
|
op_type = operation.operation_type
|
|
|
|
|
|
if op_type in summary:
|
|
|
|
|
|
summary[op_type] += 1
|
2026-02-10 18:07:38 +08:00
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
return summary
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-10 18:07:38 +08:00
|
|
|
|
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} 季。")
|
2026-02-13 13:36:39 +08:00
|
|
|
|
quarantine_lines = _build_quarantine_recommendation_lines(operations)
|
|
|
|
|
|
if quarantine_lines:
|
|
|
|
|
|
parts.append("删除建议(仅隔离建议,执行删除前请人工复核):")
|
|
|
|
|
|
parts.extend(quarantine_lines)
|
2026-02-10 18:07:38 +08:00
|
|
|
|
return "\n".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
|
def _select_duplicate_quarantine_reason(
|
|
|
|
|
|
strategy: str,
|
|
|
|
|
|
identities: list[Union[MovieIdentity, SeriesIdentity]],
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""Choose a user-facing reason string for duplicate quarantine."""
|
|
|
|
|
|
if strategy == "by_quality":
|
|
|
|
|
|
return QUARANTINE_REASON_DUPLICATE_BY_QUALITY
|
|
|
|
|
|
if strategy == "by_reputation_quality_time":
|
|
|
|
|
|
return QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME
|
|
|
|
|
|
if strategy == "by_reputation":
|
|
|
|
|
|
rep_values = [i.reputation_score for i in identities if i.reputation_score is not None]
|
|
|
|
|
|
if len(rep_values) <= 1:
|
|
|
|
|
|
return QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY
|
|
|
|
|
|
return QUARANTINE_REASON_DUPLICATE
|
|
|
|
|
|
return QUARANTINE_REASON_DUPLICATE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_quarantine_recommendation_lines(operations: list[FileOperation], limit: int = 20) -> list[str]:
|
|
|
|
|
|
"""Build human-readable quarantine recommendations with reasons."""
|
|
|
|
|
|
quarantines = [op for op in operations if op.operation_type == "quarantine"]
|
|
|
|
|
|
if not quarantines:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
lines: list[str] = []
|
|
|
|
|
|
for op in quarantines[:limit]:
|
|
|
|
|
|
risk_tags = []
|
|
|
|
|
|
name_l = op.source_path.name.casefold()
|
|
|
|
|
|
if any(tok in name_l for tok in ("disc1", "disc2", "part.", " part ", "cd1", "cd2")):
|
|
|
|
|
|
risk_tags.append("疑似多碟/分段文件,建议勿直接删除")
|
|
|
|
|
|
if "评分缺失/并列" in op.reason:
|
|
|
|
|
|
risk_tags.append("评分依据不足,已回退画质规则")
|
|
|
|
|
|
risk_text = f"({'; '.join(risk_tags)})" if risk_tags else ""
|
|
|
|
|
|
lines.append(f" - {op.source_path.name} -> {op.reason}{risk_text}")
|
|
|
|
|
|
if len(quarantines) > limit:
|
|
|
|
|
|
lines.append(f" - 其余 {len(quarantines) - limit} 条请查看 plan.json 的 quarantine 操作。")
|
|
|
|
|
|
return lines
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
def save_plan(plan: ExecutionPlan, output_path: Path) -> None:
|
|
|
|
|
|
"""Save execution plan to JSON file.
|
|
|
|
|
|
|
|
|
|
|
|
Serializes the execution plan to a human-readable and editable JSON format.
|
|
|
|
|
|
Includes plan_id, created_at timestamp, operations list, and summary.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
plan: ExecutionPlan to save
|
|
|
|
|
|
output_path: Path where the JSON file should be saved
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Convert ExecutionPlan to dictionary
|
|
|
|
|
|
plan_dict = {
|
2026-02-13 09:54:11 +08:00
|
|
|
|
"vlm_schema_version": "1.0",
|
2026-02-09 17:43:35 +08:00
|
|
|
|
"plan_id": plan.plan_id,
|
|
|
|
|
|
"created_at": plan.created_at.isoformat(),
|
|
|
|
|
|
"operations": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"operation_type": op.operation_type,
|
|
|
|
|
|
"source_path": str(op.source_path),
|
|
|
|
|
|
"destination_path": str(op.destination_path) if op.destination_path else None,
|
|
|
|
|
|
"reason": op.reason,
|
|
|
|
|
|
"has_conflict": op.has_conflict,
|
|
|
|
|
|
"conflict_reason": op.conflict_reason
|
|
|
|
|
|
}
|
|
|
|
|
|
for op in plan.operations
|
|
|
|
|
|
],
|
2026-02-10 18:07:38 +08:00
|
|
|
|
"summary": plan.summary,
|
|
|
|
|
|
"summary_by_reason": plan.summary_by_reason,
|
|
|
|
|
|
"human_summary": plan.human_summary,
|
|
|
|
|
|
"metadata": plan.metadata,
|
2026-02-09 17:43:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Write to JSON file with indentation for human readability
|
|
|
|
|
|
with open(output_path, 'w', encoding='utf-8') as f:
|
|
|
|
|
|
json.dump(plan_dict, f, indent=2, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_plan(input_path: Path) -> ExecutionPlan:
|
|
|
|
|
|
"""Load execution plan from JSON file.
|
|
|
|
|
|
|
|
|
|
|
|
Deserializes an execution plan from JSON format, reconstructing all
|
|
|
|
|
|
data structures including Path and datetime objects.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
input_path: Path to the JSON file to load
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
ExecutionPlan reconstructed from JSON
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
FileNotFoundError: If the input file does not exist
|
|
|
|
|
|
json.JSONDecodeError: If the file contains invalid JSON
|
|
|
|
|
|
KeyError: If required fields are missing from the JSON
|
|
|
|
|
|
"""
|
|
|
|
|
|
with open(input_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
|
plan_dict = json.load(f)
|
|
|
|
|
|
|
|
|
|
|
|
# Reconstruct FileOperation objects
|
|
|
|
|
|
operations = [
|
|
|
|
|
|
FileOperation(
|
|
|
|
|
|
operation_type=op["operation_type"],
|
|
|
|
|
|
source_path=Path(op["source_path"]),
|
|
|
|
|
|
destination_path=Path(op["destination_path"]) if op["destination_path"] else None,
|
|
|
|
|
|
reason=op["reason"],
|
|
|
|
|
|
has_conflict=op["has_conflict"],
|
|
|
|
|
|
conflict_reason=op.get("conflict_reason")
|
|
|
|
|
|
)
|
|
|
|
|
|
for op in plan_dict["operations"]
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-02-10 16:56:17 +08:00
|
|
|
|
# Reconstruct ExecutionPlan (normalize naive datetime to UTC for backward compatibility)
|
|
|
|
|
|
created_at = ensure_utc(datetime.fromisoformat(plan_dict["created_at"]))
|
2026-02-09 17:43:35 +08:00
|
|
|
|
return ExecutionPlan(
|
|
|
|
|
|
plan_id=plan_dict["plan_id"],
|
2026-02-10 16:56:17 +08:00
|
|
|
|
created_at=created_at,
|
2026-02-09 17:43:35 +08:00
|
|
|
|
operations=operations,
|
2026-02-10 18:07:38 +08:00
|
|
|
|
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", {}),
|
2026-02-13 13:36:39 +08:00
|
|
|
|
)
|