Files
dl-organizer/src/vlm/planner.py
T

403 lines
12 KiB
Python
Raw Normal View History

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
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()),
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
"""
# 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
"""
# 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"]
]
# 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"],
created_at=created_at,
2026-02-09 17:43:35 +08:00
operations=operations,
summary=plan_dict["summary"]
)