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
|
|
|
|
|
import uuid
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Union
|
|
|
|
|
|
|
|
|
|
from vlm.config import Config
|
2026-02-10 16:56:17 +08:00
|
|
|
from vlm.utils import ensure_utc, utc_now
|
2026-02-09 17:43:35 +08:00
|
|
|
from vlm.models import (
|
|
|
|
|
ExecutionPlan,
|
|
|
|
|
FileOperation,
|
|
|
|
|
MovieIdentity,
|
|
|
|
|
SeriesIdentity,
|
|
|
|
|
VideoFile,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_plan(
|
|
|
|
|
identities: list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]],
|
|
|
|
|
config: Config
|
|
|
|
|
) -> 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
|
|
|
|
|
"""
|
|
|
|
|
operations = []
|
|
|
|
|
|
|
|
|
|
for video_file, identity in identities:
|
|
|
|
|
operation = _create_operation(video_file, identity, config)
|
|
|
|
|
operations.append(operation)
|
|
|
|
|
|
|
|
|
|
# Generate summary counts
|
|
|
|
|
summary = _generate_summary(operations)
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
summary=summary
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
"""
|
|
|
|
|
# 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
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Apply movie directory template
|
|
|
|
|
target_dir = config.movie_template.format(
|
|
|
|
|
title=identity.title,
|
|
|
|
|
year=identity.year
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Get file extension
|
|
|
|
|
ext = video_file.path.suffix
|
|
|
|
|
|
|
|
|
|
# Apply movie filename template
|
|
|
|
|
target_filename = config.movie_filename_template.format(
|
|
|
|
|
title=identity.title,
|
|
|
|
|
year=identity.year,
|
|
|
|
|
ext=ext
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Construct full destination path
|
|
|
|
|
destination = config.library_root / target_dir / target_filename
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Apply series directory template
|
|
|
|
|
target_dir = config.series_template.format(
|
|
|
|
|
title=identity.title,
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# 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,
|
|
|
|
|
"no-op": 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for operation in operations:
|
|
|
|
|
op_type = operation.operation_type
|
|
|
|
|
if op_type in summary:
|
|
|
|
|
summary[op_type] += 1
|
|
|
|
|
|
|
|
|
|
return summary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = {
|
|
|
|
|
"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
|
|
|
|
|
],
|
|
|
|
|
"summary": plan.summary
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 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,
|
|
|
|
|
summary=plan_dict["summary"]
|
|
|
|
|
)
|