refactor: DLO-16/17/18/20 — CLI simplification, config Pydantic, planner split, type system unification

DLO-16: Reduce cli.py from 1073 to 83 lines by registering Click commands from commands/*.py modules
DLO-17: Migrate Config to Pydantic BaseModel for validation
DLO-18: Split planner.py (826 lines) into orchestration, path rendering, and duplicate handling modules
DLO-20: Unify type system — convert 14 dataclasses to Pydantic BaseModel, keep TypedDicts as JSON schema hints

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
windyboy
2026-09-27 10:47:04 +08:00
co-authored by Claude Sonnet 4.5
parent 8a60aaf9a9
commit fe03a31dd4
23 changed files with 1964 additions and 2714 deletions
+31 -1016
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import click
from vlm.cli_helpers import command_error
from vlm.cli_helpers import command_error, default_config_path
from vlm.config import create_default_config, validate_config
from vlm.context import CLIContext, pass_context
+15 -1
View File
@@ -7,7 +7,8 @@ from typing import Optional
import click
from vlm.context import CLIContext
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
from vlm.context import CLIContext, pass_context
from vlm.io import load_inventory_csv, save_identities_json
from vlm.models import (
IdentityRecord,
@@ -170,3 +171,16 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
len(series_identities),
output,
)
@click.command()
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json"))
@click.option("--inventory", type=click.Path(exists=True, path_type=Path), default=None)
@pass_context
def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]):
"""Parse identities from filenames."""
def _run():
input_resolved = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
parse_cmd(ctx, input_resolved, output, inventory)
run_command(ctx, _run, stage="parse", json_errors=True)
+15 -1
View File
@@ -6,7 +6,8 @@ from typing import Optional
import click
from vlm.context import CLIContext
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
from vlm.context import CLIContext, pass_context
from vlm.io import identities_to_plan_input, load_analysis_json, load_identities_json
from vlm.planner import generate_plan, save_plan
@@ -110,3 +111,16 @@ def plan_cmd(ctx: CLIContext, input: Path, output: Path, analysis: Optional[Path
f"Plan generated: {execution_plan.summary['total']} operations, "
f"{conflicts} conflicts, saved to {output}"
)
@click.command()
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json"))
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("plan.json"))
@click.option("--analysis", type=click.Path(path_type=Path), default=None)
@pass_context
def plan(ctx: CLIContext, input: Path, output: Path, analysis: Optional[Path]):
"""Generate execution plan."""
def _run():
input_resolved = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
plan_cmd(ctx, input_resolved, output, analysis)
run_command(ctx, _run, stage="plan", json_errors=True)
+58 -1
View File
@@ -15,7 +15,7 @@ from vlm.cli_helpers import (
resolve_legacy_default_input_path,
review_plan_tui_streams_ok,
)
from vlm.context import CLIContext
from vlm.context import CLIContext, pass_context
from vlm.io import load_analysis_json, load_identities_json
from vlm.plan_render import (
duplicate_groups_from_plan,
@@ -242,3 +242,60 @@ def apply_review_cmd(
f"Apply review failed: {e}",
exc_info=True,
)
@click.command(name="review-plan")
@click.option("--input", type=click.Path(exists=True, path_type=Path), default=lambda: default_artifact_path("plan.json"))
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("plan_manual_review.csv"))
@click.option("--season-threshold", type=int, default=20, show_default=True)
@click.option("--episode-threshold", type=int, default=40, show_default=True)
@click.option("--preview-limit", type=int, default=10, show_default=True)
@click.option("--show-all", is_flag=True, default=False)
@click.option("--tui", is_flag=True, default=False)
@click.option("--identities", type=click.Path(path_type=Path), default=None)
@click.option("--analysis", type=click.Path(path_type=Path), default=None)
@click.option("--group-by", type=click.Choice(["none", "reason", "title", "duplicate"], case_sensitive=False), default="none", show_default=True)
@click.option("--sample-safe", type=int, default=0, show_default=True)
@click.option("--structure-preview", type=click.Path(path_type=Path), default=None)
@pass_context
def review_plan(
ctx: CLIContext,
input: Path,
output: Path,
season_threshold: int,
episode_threshold: int,
preview_limit: int,
show_all: bool,
tui: bool,
identities: Optional[Path],
analysis: Optional[Path],
group_by: str,
sample_safe: int,
structure_preview: Optional[Path],
):
"""Review a plan and export high-risk operations for manual confirmation."""
review_plan_cmd(
ctx,
input,
output,
season_threshold,
episode_threshold,
preview_limit,
show_all,
tui,
identities,
analysis,
group_by,
sample_safe,
structure_preview,
)
@click.command(name="apply-review")
@click.option("--plan", type=click.Path(exists=True, path_type=Path), default=Path("plan.json"))
@click.option("--csv", type=click.Path(exists=True, path_type=Path), default=Path("plan_manual_review.csv"))
@click.option("--output", type=click.Path(path_type=Path), default=None)
@pass_context
def apply_review(ctx: CLIContext, plan: Path, csv: Path, output: Optional[Path]):
"""Apply modifications from a manual review CSV back to the plan JSON."""
apply_review_cmd(ctx, plan, csv, output)
+269 -240
View File
@@ -1,22 +1,35 @@
"""Configuration management for Video Library Manager."""
from dataclasses import dataclass, field
from __future__ import annotations
from pathlib import Path
from typing import Optional
from typing import Any, Optional
import yaml
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
DEFAULT_VIDEO_EXTENSIONS = [
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
]
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
_VALID_DUPLICATE_KEEP = {
"by_reputation",
"by_reputation_quality_time",
"first_seen",
"manual",
"by_quality",
}
_VALID_PROVIDERS = {"tmdb"}
@dataclass
class Config:
class Config(BaseModel):
"""Configuration for Video Library Manager."""
model_config = ConfigDict(arbitrary_types_allowed=True)
library_root: Path
video_extensions: list[str] = field(default_factory=lambda: list(DEFAULT_VIDEO_EXTENSIONS))
video_extensions: list[str] = list(DEFAULT_VIDEO_EXTENSIONS)
movie_template: str = "movie/{title} ({year})/"
series_template: str = "series/{title}/Season {season:02d}/"
movie_filename_template: str = "{title} ({year}){ext}"
@@ -24,18 +37,17 @@ class Config:
log_level: str = "INFO"
quarantine_dir: str = ".quarantine"
workspace_dir: Path = Path("artifacts")
categories: dict[str, list[str]] = field(default_factory=lambda: {
categories: dict[str, list[str]] = {
"movie": ["movie", "movies"],
"series": ["series", "tv", "shows"],
"anime": ["anime"]
})
"anime": ["anime"],
}
# Enrichment settings
enrichment_enabled: bool = True
enrichment_incremental: bool = True
enrichment_refresh_mode: str = "manual"
enrichment_providers: list[str] = field(default_factory=lambda: ["tmdb"])
enrichment_cache_db: Path = field(default_factory=lambda: Path.home() / ".vlm" / "enrichment_cache.db")
enrichment_providers: list[str] = ["tmdb"]
enrichment_cache_db: Path = Path.home() / ".vlm" / "enrichment_cache.db"
enrichment_max_concurrency: int = 6
enrichment_min_match_score: float = 0.75
translation_mode: str = "bidirectional"
@@ -51,12 +63,245 @@ class Config:
reputation_policy: str = "flag_for_review"
naming_title_format: str = "{title_zh} {title_en}"
# Plan settings (e.g. duplicate handling when consuming analysis)
duplicate_keep: str = "by_reputation"
plan_max_season: int = 15
plan_max_episode: int = 100
plan_include_sample_files: bool = False
@field_validator("library_root", mode="before")
@classmethod
def _coerce_library_root(cls, v: Any) -> Path:
if isinstance(v, str):
v = Path(v).expanduser()
if isinstance(v, Path) and (not str(v) or str(v) == "."):
raise ValueError("library_root cannot be empty")
return v
@field_validator("video_extensions")
@classmethod
def _validate_video_extensions(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError("video_extensions cannot be empty")
for ext in v:
if not ext.startswith("."):
raise ValueError(f"video extension must start with '.': {ext}")
return v
@field_validator("movie_template", "series_template", "movie_filename_template", "series_filename_template")
@classmethod
def _nonempty_template(cls, v: str, info: Any) -> str:
if not v:
raise ValueError(f"{info.field_name} cannot be empty")
return v
@field_validator("log_level")
@classmethod
def _validate_log_level(cls, v: str) -> str:
if v.upper() not in _VALID_LOG_LEVELS:
raise ValueError(f"log_level must be one of {sorted(_VALID_LOG_LEVELS)}, got: {v}")
return v
@field_validator("quarantine_dir")
@classmethod
def _validate_quarantine_dir(cls, v: str) -> str:
if not v:
raise ValueError("quarantine_dir cannot be empty")
if v.startswith("/") or v.startswith("\\"):
raise ValueError("quarantine_dir must be relative to category root, not absolute")
return v
@field_validator("workspace_dir", mode="before")
@classmethod
def _coerce_workspace_dir(cls, v: Any) -> Path:
if isinstance(v, str):
if not v.strip():
raise ValueError("workspace_dir cannot be empty")
return Path(v).expanduser()
if isinstance(v, Path):
if not str(v).strip():
raise ValueError("workspace_dir cannot be empty")
return v
raise ValueError("workspace_dir must be a Path object")
@field_validator("enrichment_max_concurrency")
@classmethod
def _validate_concurrency(cls, v: int) -> int:
if v < 1:
raise ValueError("enrichment_max_concurrency must be >= 1")
return v
@field_validator("enrichment_min_match_score")
@classmethod
def _validate_match_score(cls, v: float) -> float:
if not 0.0 <= v <= 1.0:
raise ValueError("enrichment_min_match_score must be between 0.0 and 1.0")
return v
@field_validator("enrichment_providers")
@classmethod
def _validate_providers(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError("enrichment_providers must be a non-empty list")
invalid = [p for p in v if p.lower() not in _VALID_PROVIDERS]
if invalid:
raise ValueError(
f"enrichment_providers contains unsupported providers: {invalid}; "
f"supported providers: ['tmdb']"
)
return v
@field_validator("enrichment_refresh_mode")
@classmethod
def _validate_refresh_mode(cls, v: str) -> str:
if v not in {"manual", "incremental", "full"}:
raise ValueError("enrichment_refresh_mode must be 'manual', 'incremental', or 'full'")
return v
@field_validator("reputation_min_votes")
@classmethod
def _validate_min_votes(cls, v: int) -> int:
if v < 0:
raise ValueError("reputation_min_votes must be >= 0")
return v
@field_validator("reputation_low_score_threshold")
@classmethod
def _validate_low_score(cls, v: float) -> float:
if not 0.0 <= v <= 10.0:
raise ValueError("reputation_low_score_threshold must be between 0.0 and 10.0")
return v
@field_validator("tmdb_language")
@classmethod
def _validate_tmdb_language(cls, v: str) -> str:
if not v.strip():
raise ValueError("tmdb_language must be a non-empty string")
return v
@field_validator("duplicate_keep")
@classmethod
def _validate_duplicate_keep(cls, v: str) -> str:
if v not in _VALID_DUPLICATE_KEEP:
raise ValueError(
f"duplicate_keep must be one of {sorted(_VALID_DUPLICATE_KEEP)}, got: {v!r}"
)
return v
@field_validator("plan_max_season", "plan_max_episode")
@classmethod
def _validate_plan_thresholds(cls, v: int, info: Any) -> int:
if v < 1:
raise ValueError(f"{info.field_name} must be an integer >= 1")
return v
@field_validator("enrichment_cache_db", mode="before")
@classmethod
def _coerce_cache_db(cls, v: Any) -> Path:
if isinstance(v, str):
return Path(v).expanduser()
return v
@model_validator(mode="after")
def _validate_categories(self) -> Config:
categories = self.categories
if not isinstance(categories, dict):
raise ValueError("categories must be a dictionary")
if not categories:
raise ValueError("categories cannot be empty")
required = {"movie", "series", "anime"}
missing = required - set(categories.keys())
if missing:
raise ValueError(f"categories must include keys: {sorted(missing)}")
seen_dirs: dict[str, str] = {}
for category, dir_list in categories.items():
if not isinstance(dir_list, list):
raise ValueError(f"categories['{category}'] must be a list")
if not dir_list:
raise ValueError(f"categories['{category}'] cannot be empty")
for dir_name in dir_list:
if not isinstance(dir_name, str):
raise ValueError(f"categories['{category}'] must contain strings")
if not dir_name.strip():
raise ValueError(f"categories['{category}'] contains empty directory name")
dir_lower = dir_name.lower()
if dir_lower in seen_dirs:
raise ValueError(
f"Duplicate directory name '{dir_name}' in categories "
f"'{category}' and '{seen_dirs[dir_lower]}'"
)
seen_dirs[dir_lower] = category
return self
def _flatten_yaml(data: dict) -> dict[str, Any]:
"""Flatten nested YAML structure into flat Config fields."""
if not data:
raise ValueError("Configuration must specify 'library_root'")
library_root = data.get("library_root")
if not library_root:
raise ValueError("Configuration must specify 'library_root'")
flat: dict[str, Any] = {"library_root": library_root}
if "video_extensions" in data:
flat["video_extensions"] = data["video_extensions"]
templates = data.get("templates", {})
if templates:
flat["movie_template"] = templates.get("movie_dir", "movie/{title} ({year})/")
flat["series_template"] = templates.get("series_dir", "series/{title}/Season {season:02d}/")
flat["movie_filename_template"] = templates.get("movie_filename", "{title} ({year}){ext}")
flat["series_filename_template"] = templates.get("series_filename", "S{season:02d}E{episode:02d}{ext}")
for key in ("quarantine_dir", "log_level", "workspace_dir", "categories"):
if key in data:
flat[key] = data[key]
plan = data.get("plan", {})
if plan:
flat["duplicate_keep"] = plan.get("duplicate_keep", "by_reputation")
flat["plan_max_season"] = int(plan.get("max_season", 15))
flat["plan_max_episode"] = int(plan.get("max_episode", 100))
flat["plan_include_sample_files"] = bool(plan.get("include_sample_files", False))
enrichment = data.get("enrichment")
if enrichment is None:
enrichment = data.get("enrich", {})
if enrichment:
translation = enrichment.get("translation", {})
api_keys = enrichment.get("api_keys", {})
reputation = enrichment.get("reputation", {})
naming = enrichment.get("naming", {})
tmdb = enrichment.get("tmdb", {})
flat["enrichment_enabled"] = enrichment.get("enabled", True)
flat["enrichment_incremental"] = enrichment.get("incremental", True)
flat["enrichment_refresh_mode"] = enrichment.get("refresh_mode", "manual")
flat["enrichment_providers"] = enrichment.get("providers", ["tmdb"])
flat["enrichment_cache_db"] = enrichment.get(
"cache_db", str(Path.home() / ".vlm" / "enrichment_cache.db")
)
flat["enrichment_max_concurrency"] = enrichment.get("max_concurrency", 6)
flat["enrichment_min_match_score"] = enrichment.get("min_match_score", 0.75)
flat["translation_mode"] = translation.get("mode", "bidirectional")
flat["translation_fallback_machine"] = translation.get("fallback_machine", True)
flat["tmdb_api_key"] = api_keys.get("tmdb")
flat["tmdb_bearer_token"] = api_keys.get("tmdb_bearer")
flat["openai_api_key"] = api_keys.get("openai")
flat["tmdb_language"] = tmdb.get("language", "zh-CN")
flat["tmdb_region"] = tmdb.get("region")
flat["tmdb_include_adult"] = tmdb.get("include_adult", False)
flat["reputation_min_votes"] = reputation.get("min_votes", 50)
flat["reputation_low_score_threshold"] = reputation.get("low_score_threshold", 6.0)
flat["reputation_policy"] = reputation.get("policy", "flag_for_review")
flat["naming_title_format"] = naming.get("title_format", "{title_zh} {title_en}")
return flat
def load_config(path: Path) -> Config:
"""Load configuration from YAML file."""
@@ -69,84 +314,8 @@ def load_config(path: Path) -> Config:
except yaml.YAMLError as e:
raise yaml.YAMLError(f"Invalid YAML syntax in configuration file: {e}")
if data is None:
data = {}
library_root_str = data.get("library_root")
if not library_root_str:
raise ValueError("Configuration must specify 'library_root'")
library_root = Path(library_root_str).expanduser()
video_extensions = data.get("video_extensions", list(DEFAULT_VIDEO_EXTENSIONS))
templates = data.get("templates", {})
movie_template = templates.get("movie_dir", "movie/{title} ({year})/")
series_template = templates.get("series_dir", "series/{title}/Season {season:02d}/")
movie_filename_template = templates.get("movie_filename", "{title} ({year}){ext}")
series_filename_template = templates.get("series_filename", "S{season:02d}E{episode:02d}{ext}")
quarantine_dir = data.get("quarantine_dir", ".quarantine")
workspace_dir = Path(data.get("workspace_dir", "artifacts")).expanduser()
log_level = data.get("log_level", "INFO")
categories = data.get("categories", {
"movie": ["movie", "movies"],
"series": ["series", "tv", "shows"],
"anime": ["anime"]
})
plan = data.get("plan", {})
duplicate_keep = plan.get("duplicate_keep", "by_reputation")
plan_max_season = int(plan.get("max_season", 15))
plan_max_episode = int(plan.get("max_episode", 100))
plan_include_sample_files = bool(plan.get("include_sample_files", False))
enrichment = data.get("enrichment")
if enrichment is None:
enrichment = data.get("enrich", {})
translation = enrichment.get("translation", {})
api_keys = enrichment.get("api_keys", {})
reputation = enrichment.get("reputation", {})
naming = enrichment.get("naming", {})
tmdb = enrichment.get("tmdb", {})
return Config(
library_root=library_root,
video_extensions=video_extensions,
movie_template=movie_template,
series_template=series_template,
movie_filename_template=movie_filename_template,
series_filename_template=series_filename_template,
log_level=log_level,
quarantine_dir=quarantine_dir,
workspace_dir=workspace_dir,
categories=categories,
enrichment_enabled=enrichment.get("enabled", True),
enrichment_incremental=enrichment.get("incremental", True),
enrichment_refresh_mode=enrichment.get("refresh_mode", "manual"),
enrichment_providers=enrichment.get("providers", ["tmdb"]),
enrichment_cache_db=Path(
enrichment.get("cache_db", str(Path.home() / ".vlm" / "enrichment_cache.db"))
).expanduser(),
enrichment_max_concurrency=enrichment.get("max_concurrency", 6),
enrichment_min_match_score=enrichment.get("min_match_score", 0.75),
translation_mode=translation.get("mode", "bidirectional"),
translation_fallback_machine=translation.get("fallback_machine", True),
tmdb_api_key=api_keys.get("tmdb"),
tmdb_bearer_token=api_keys.get("tmdb_bearer"),
tmdb_language=tmdb.get("language", "zh-CN"),
tmdb_region=tmdb.get("region"),
tmdb_include_adult=tmdb.get("include_adult", False),
openai_api_key=api_keys.get("openai"),
reputation_min_votes=reputation.get("min_votes", 50),
reputation_low_score_threshold=reputation.get("low_score_threshold", 6.0),
reputation_policy=reputation.get("policy", "flag_for_review"),
naming_title_format=naming.get("title_format", "{title_zh} {title_en}"),
duplicate_keep=duplicate_keep,
plan_max_season=plan_max_season,
plan_max_episode=plan_max_episode,
plan_include_sample_files=plan_include_sample_files,
)
flat = _flatten_yaml(data or {})
return Config(**flat)
def create_default_config(path: Path) -> Config:
@@ -210,7 +379,6 @@ def create_default_config(path: Path) -> Config:
"log_level": default_config.log_level,
"categories": default_config.categories,
"enrichment": enrichment_content,
# Backward-compatible alias for users who prefer `enrich`.
"enrich": enrichment_content,
}
@@ -223,153 +391,14 @@ def create_default_config(path: Path) -> Config:
def validate_config(config: Config) -> list[str]:
"""Validate configuration and return list of error messages."""
errors = []
"""Validate configuration and return list of error messages.
if not isinstance(config.library_root, Path):
errors.append("library_root must be a Path object")
elif not str(config.library_root) or str(config.library_root) == ".":
errors.append("library_root cannot be empty")
if not config.video_extensions:
errors.append("video_extensions cannot be empty")
elif not isinstance(config.video_extensions, list):
errors.append("video_extensions must be a list")
else:
for ext in config.video_extensions:
if not isinstance(ext, str):
errors.append(f"video_extensions must contain strings, found: {type(ext)}")
break
if not ext.startswith("."):
errors.append(f"video extension must start with '.': {ext}")
if not config.movie_template:
errors.append("movie_template cannot be empty")
elif not isinstance(config.movie_template, str):
errors.append("movie_template must be a string")
if not config.series_template:
errors.append("series_template cannot be empty")
elif not isinstance(config.series_template, str):
errors.append("series_template must be a string")
if not config.movie_filename_template:
errors.append("movie_filename_template cannot be empty")
elif not isinstance(config.movie_filename_template, str):
errors.append("movie_filename_template must be a string")
if not config.series_filename_template:
errors.append("series_filename_template cannot be empty")
elif not isinstance(config.series_filename_template, str):
errors.append("series_filename_template must be a string")
if not isinstance(config.enrichment_max_concurrency, int):
errors.append("enrichment_max_concurrency must be an integer")
elif config.enrichment_max_concurrency < 1:
errors.append("enrichment_max_concurrency must be >= 1")
valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
if not config.log_level:
errors.append("log_level cannot be empty")
elif not isinstance(config.log_level, str):
errors.append("log_level must be a string")
elif config.log_level.upper() not in valid_log_levels:
errors.append(f"log_level must be one of {valid_log_levels}, got: {config.log_level}")
if not config.quarantine_dir:
errors.append("quarantine_dir cannot be empty")
elif not isinstance(config.quarantine_dir, str):
errors.append("quarantine_dir must be a string")
elif config.quarantine_dir.startswith("/") or config.quarantine_dir.startswith("\\"):
errors.append("quarantine_dir must be relative to category root, not absolute")
if not isinstance(config.workspace_dir, Path):
errors.append("workspace_dir must be a Path object")
elif not str(config.workspace_dir).strip():
errors.append("workspace_dir cannot be empty")
if not config.categories:
errors.append("categories cannot be empty")
elif not isinstance(config.categories, dict):
errors.append("categories must be a dictionary")
else:
required_categories = {"movie", "series", "anime"}
missing = required_categories - set(config.categories.keys())
if missing:
errors.append(f"categories must include keys: {sorted(missing)}")
seen_dirs = {}
for category, dir_list in config.categories.items():
if not isinstance(dir_list, list):
errors.append(f"categories['{category}'] must be a list")
continue
if not dir_list:
errors.append(f"categories['{category}'] cannot be empty")
continue
for dir_name in dir_list:
if not isinstance(dir_name, str):
errors.append(f"categories['{category}'] must contain strings")
break
if not dir_name.strip():
errors.append(f"categories['{category}'] contains empty directory name")
break
dir_lower = dir_name.lower()
if dir_lower in seen_dirs:
errors.append(
f"Duplicate directory name '{dir_name}' in categories "
f"'{category}' and '{seen_dirs[dir_lower]}'"
)
else:
seen_dirs[dir_lower] = category
if not isinstance(config.enrichment_cache_db, Path):
errors.append("enrichment_cache_db must be a Path object")
if not isinstance(config.enrichment_providers, list) or not config.enrichment_providers:
errors.append("enrichment_providers must be a non-empty list")
else:
allowed_providers = {"tmdb"}
invalid = [provider for provider in config.enrichment_providers if provider.lower() not in allowed_providers]
if invalid:
errors.append(
f"enrichment_providers contains unsupported providers: {invalid}; supported providers: ['tmdb']"
)
if config.enrichment_max_concurrency < 1:
errors.append("enrichment_max_concurrency must be >= 1")
if not (0.0 <= config.enrichment_min_match_score <= 1.0):
errors.append("enrichment_min_match_score must be between 0.0 and 1.0")
if config.enrichment_refresh_mode not in {"manual"}:
errors.append("enrichment_refresh_mode must be 'manual'")
if config.reputation_min_votes < 0:
errors.append("reputation_min_votes must be >= 0")
if not (0.0 <= config.reputation_low_score_threshold <= 10.0):
errors.append("reputation_low_score_threshold must be between 0.0 and 10.0")
if not isinstance(config.tmdb_language, str) or not config.tmdb_language.strip():
errors.append("tmdb_language must be a non-empty string")
if config.tmdb_region is not None and not isinstance(config.tmdb_region, str):
errors.append("tmdb_region must be a string when set")
if not isinstance(config.tmdb_include_adult, bool):
errors.append("tmdb_include_adult must be a boolean")
if config.duplicate_keep not in (
"by_reputation",
"by_reputation_quality_time",
"first_seen",
"manual",
"by_quality",
):
errors.append(
"duplicate_keep must be one of "
"'by_reputation', 'by_reputation_quality_time', 'first_seen', 'manual', 'by_quality', "
f"got: {config.duplicate_keep!r}"
)
if not isinstance(config.plan_max_season, int) or config.plan_max_season < 1:
errors.append("plan_max_season must be an integer >= 1")
if not isinstance(config.plan_max_episode, int) or config.plan_max_episode < 1:
errors.append("plan_max_episode must be an integer >= 1")
if not isinstance(config.plan_include_sample_files, bool):
errors.append("plan_include_sample_files must be a boolean")
return errors
With Pydantic, most validation happens at construction time. This function
re-validates by reconstructing the model, catching any errors that may have
been bypassed (e.g. via model_construct). Returns empty list for valid configs.
"""
try:
Config.model_validate(config.model_dump())
return []
except ValidationError as e:
return [f"{err['loc'][0]}: {err['msg']}" for err in e.errors()]
+46 -45
View File
@@ -4,14 +4,14 @@ This module defines the core data structures used throughout the application
for representing video files and their parsed identities.
"""
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional, TypedDict
from pydantic import BaseModel, Field, field_validator
@dataclass
class VideoFile:
class VideoFile(BaseModel):
"""Represents a video file discovered during inventory scanning.
Attributes:
@@ -25,26 +25,27 @@ class VideoFile:
duration_seconds: Optional video duration in seconds
bitrate_kbps: Optional video bitrate in kilobits per second
"""
path: Path
filename: str
size_bytes: int
modified_timestamp: datetime
category: str
# Optional metadata (if ffprobe available)
resolution: Optional[str] = None
codec: Optional[str] = None
duration_seconds: Optional[float] = None
bitrate_kbps: Optional[int] = None
def __post_init__(self):
"""Canonicalize path on creation."""
@field_validator("path", mode="before")
@classmethod
def canonicalize_path(cls, v):
from vlm.utils import canonical_path
object.__setattr__(self, 'path', canonical_path(self.path))
return canonical_path(v)
@dataclass
class MovieIdentity:
class MovieIdentity(BaseModel):
"""Represents the parsed identity of a movie file.
review_status (pending/approved/rejected) and needs_review overlap in meaning:
@@ -59,6 +60,7 @@ class MovieIdentity:
needs_review: Flag indicating if manual review is needed
original_filename: Original filename before parsing
"""
title: str
year: Optional[int]
confidence: float
@@ -73,11 +75,10 @@ class MovieIdentity:
reputation_source: Optional[str] = None
review_status: str = "pending"
enrichment_confidence: Optional[float] = None
provider_metadata: dict[str, str] = field(default_factory=dict)
provider_metadata: dict[str, str] = Field(default_factory=dict)
@dataclass
class SeriesIdentity:
class SeriesIdentity(BaseModel):
"""Represents the parsed identity of a TV series episode file.
review_status (pending/approved/rejected) and needs_review overlap in meaning:
@@ -92,6 +93,7 @@ class SeriesIdentity:
needs_review: Flag indicating if manual review is needed
original_filename: Original filename before parsing
"""
title: str
season: Optional[int]
episodes: list[int]
@@ -107,11 +109,10 @@ class SeriesIdentity:
reputation_source: Optional[str] = None
review_status: str = "pending"
enrichment_confidence: Optional[float] = None
provider_metadata: dict[str, str] = field(default_factory=dict)
provider_metadata: dict[str, str] = Field(default_factory=dict)
@dataclass
class FileOperation:
class FileOperation(BaseModel):
"""Represents a single file operation in an execution plan.
Attributes:
@@ -122,17 +123,17 @@ class FileOperation:
has_conflict: Flag indicating if destination already exists
conflict_reason: Description of the conflict (None if no conflict)
"""
operation_type: str
source_path: Path
destination_path: Optional[Path]
reason: str
has_conflict: bool
conflict_reason: Optional[str] = None
review_context: dict = field(default_factory=dict)
review_context: dict = Field(default_factory=dict)
@dataclass
class ExecutionPlan:
class ExecutionPlan(BaseModel):
"""Represents a complete execution plan with all file operations.
Attributes:
@@ -145,49 +146,49 @@ class ExecutionPlan:
metadata: Optional dict (e.g. analysis_source, duplicate_groups_considered,
completeness_seasons_with_gaps) when plan was built from analysis
"""
plan_id: str
created_at: datetime
operations: list[FileOperation]
summary: dict
summary_by_reason: dict = field(default_factory=dict)
summary_by_reason: dict = Field(default_factory=dict)
human_summary: str = ""
metadata: dict = field(default_factory=dict)
metadata: dict = Field(default_factory=dict)
@dataclass
class OperationResult:
class OperationResult(BaseModel):
"""Represents the result of executing a single file operation.
Attributes:
operation: The file operation that was executed
success: Flag indicating if the operation succeeded
error_message: Error message if operation failed (None if successful)
executed_at: Timestamp when the operation was executed
"""
operation: FileOperation
success: bool
error_message: Optional[str]
executed_at: datetime
@dataclass
class RollbackLog:
class RollbackLog(BaseModel):
"""Represents a log of executed operations for rollback purposes.
Attributes:
log_id: Unique identifier for the rollback log (UUID)
execution_plan_id: ID of the execution plan that was executed
executed_at: Timestamp when the operations were executed
operations: List of operation results that were executed
"""
log_id: str
execution_plan_id: str
executed_at: datetime
operations: list[OperationResult]
@dataclass
class QuarantineEntry:
class QuarantineEntry(BaseModel):
"""Represents a single file in quarantine.
Attributes:
@@ -199,80 +200,81 @@ class QuarantineEntry:
category: Category of the video ("movie" or "series")
status: Operation status ("pending" | "committed") for two-phase commit
"""
original_path: Path
quarantine_path: Path
quarantined_at: datetime
reason: Optional[str]
size_bytes: int
category: str
status: str = "committed" # Default for backward compatibility
status: str = "committed"
@dataclass
class QuarantineManifest:
class QuarantineManifest(BaseModel):
"""Represents a manifest of all quarantined files in a category.
Attributes:
entries: List of quarantine entries
"""
entries: list[QuarantineEntry]
@dataclass
class FileState:
class FileState(BaseModel):
"""Represents the state of a file in the workflow.
Attributes:
file_path: Path to the file
status: Current status ("reviewed", "ignored", "planned", "executed", "quarantined")
reason: Optional reason for the status
updated_at: Timestamp when the state was last updated
"""
file_path: Path
status: str
reason: Optional[str]
updated_at: datetime
@dataclass
class StateStore:
class StateStore(BaseModel):
"""Represents the persistent state store for all files.
Attributes:
states: Dictionary mapping file path strings to FileState objects
version: Version of the state store format
last_updated: Timestamp when the state store was last updated
"""
states: dict[str, FileState]
version: str
last_updated: datetime
@dataclass
class SeasonCompleteness:
class SeasonCompleteness(BaseModel):
"""Represents completeness analysis for a single season of a series.
Attributes:
series_title: Normalized series title
season: Season number
episodes_found: List of episode numbers that were found
episodes_missing: List of episode numbers missing in the range [min, max]
"""
series_title: str
season: int
episodes_found: list[int]
episodes_missing: list[int]
@dataclass
class DuplicateGroup:
class DuplicateGroup(BaseModel):
"""Represents a group of duplicate video files.
Attributes:
identity: The shared identity (MovieIdentity or SeriesIdentity)
files: List of VideoFile objects that are duplicates
quality_comparison: List of dictionaries with quality metrics for each file
"""
identity: MovieIdentity | SeriesIdentity
files: list[VideoFile]
quality_comparison: list[dict]
@@ -383,4 +385,3 @@ class PlanJSON(TypedDict, total=False):
summary_by_reason: dict[str, int]
human_summary: str
metadata: dict[str, object]
+60
View File
@@ -0,0 +1,60 @@
"""Duplicate handling for planner operations.
Resolves duplicate file groups: decides which to keep, which to quarantine,
and generates appropriate reason strings.
"""
from typing import Union
from vlm.models import FileOperation, MovieIdentity, SeriesIdentity
from vlm.plan_review import normalized_path_key
QUARANTINE_REASON_DUPLICATE = "重复项(已按外部评分保留一条)"
QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY = "重复项(外部评分缺失/并列,按画质回退保留一条)"
QUARANTINE_REASON_DUPLICATE_BY_QUALITY = "重复项(已按画质保留最优)"
QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME = "重复项(按外部评分>画质>时间保留一条)"
def _build_duplicate_quality_lookup(quality_comparison: list[dict]) -> dict[str, dict]:
"""Index duplicate quality entries by canonicalized path."""
lookup: dict[str, dict] = {}
for quality in quality_comparison:
quality_path = quality.get("path")
if isinstance(quality_path, str) and quality_path.strip():
lookup[normalized_path_key(quality_path)] = quality
return lookup
def _mark_duplicate_group_manual_review(
operations: list[FileOperation],
indices: list[int],
message: str,
) -> None:
"""Convert unresolved duplicate operations into explicit manual-review no-ops."""
for index in indices:
current = operations[index]
operations[index] = FileOperation(
operation_type="no-op",
source_path=current.source_path,
destination_path=None,
reason=f"Duplicate group needs manual review: {message}",
has_conflict=False,
conflict_reason=None,
)
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
+231
View File
@@ -0,0 +1,231 @@
"""Path rendering for planner operations.
Computes destination paths from config templates and identity data
for movie and series files.
"""
from vlm.config import Config
from vlm.models import FileOperation, MovieIdentity, SeriesIdentity, VideoFile
from vlm.utils import is_within_root, sanitize_path_component
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 _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
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
)
safe_title = sanitize_path_component(identity.title, fallback="untitled")
# Apply movie directory template
target_dir = config.movie_template.format(
title=safe_title,
year=identity.year
)
# Get file extension
ext = video_file.path.suffix
# Apply movie filename template
target_filename = config.movie_filename_template.format(
title=safe_title,
year=identity.year,
ext=ext
)
# Construct full destination path
destination = config.library_root / target_dir / target_filename
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
)
# 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
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
)
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
)
safe_title = sanitize_path_component(identity.title, fallback="untitled")
# Apply series directory template
target_dir = config.series_template.format(
title=safe_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
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
)
# 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
)
+12 -274
View File
@@ -5,7 +5,6 @@ should be organized based on their parsed identities and configuration templates
"""
import uuid
from dataclasses import replace
from pathlib import Path
from typing import Optional, Union
@@ -19,6 +18,15 @@ from vlm.models import (
SeriesIdentity,
VideoFile,
)
from vlm.plan_duplicates import (
_build_duplicate_quality_lookup,
_mark_duplicate_group_manual_review,
_select_duplicate_quarantine_reason,
)
from vlm.plan_paths import (
_create_movie_operation,
_create_series_operation,
)
from vlm.plan_review import (
REVIEW_APPLIED_AT_KEY,
REVIEW_CSV_PATH_KEY,
@@ -29,46 +37,12 @@ from vlm.plan_review import (
from vlm.scanner import find_sidecar_companions
from vlm.utils import (
is_sample_path,
is_within_root,
sanitize_path_component,
utc_now,
)
QUARANTINE_REASON_DUPLICATE = "重复项(已按外部评分保留一条)"
QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY = "重复项(外部评分缺失/并列,按画质回退保留一条)"
QUARANTINE_REASON_DUPLICATE_BY_QUALITY = "重复项(已按画质保留最优)"
QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME = "重复项(按外部评分>画质>时间保留一条)"
__all__ = ["generate_plan", "save_plan", "load_plan", "apply_review_to_plan"]
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 _build_duplicate_quality_lookup(quality_comparison: list[dict]) -> dict[str, dict]:
"""Index duplicate quality entries by canonicalized path."""
lookup: dict[str, dict] = {}
for quality in quality_comparison:
quality_path = quality.get("path")
if isinstance(quality_path, str) and quality_path.strip():
lookup[normalized_path_key(quality_path)] = quality
return lookup
def _mark_duplicate_group_manual_review(
operations: list[FileOperation],
indices: list[int],
message: str,
) -> None:
"""Convert unresolved duplicate operations into explicit manual-review no-ops."""
for index in indices:
current = operations[index]
operations[index] = FileOperation(
operation_type="no-op",
source_path=current.source_path,
destination_path=None,
reason=f"Duplicate group needs manual review: {message}",
has_conflict=False,
conflict_reason=None,
)
def _analyze_directory_impact(operations: list[FileOperation]) -> dict:
@@ -342,225 +316,6 @@ def _create_operation(
)
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
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
)
safe_title = sanitize_path_component(identity.title, fallback="untitled")
# Apply movie directory template
target_dir = config.movie_template.format(
title=safe_title,
year=identity.year
)
# Get file extension
ext = video_file.path.suffix
# Apply movie filename template
target_filename = config.movie_filename_template.format(
title=safe_title,
year=identity.year,
ext=ext
)
# Construct full destination path
destination = config.library_root / target_dir / target_filename
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
)
# 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
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
)
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
)
safe_title = sanitize_path_component(identity.title, fallback="untitled")
# Apply series directory template
target_dir = config.series_template.format(
title=safe_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
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
)
# 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 _stamp_review_context_on_operations(
operations: list[FileOperation],
identities: list[tuple],
@@ -597,7 +352,7 @@ def _stamp_review_context_on_operations(
}
for s in sidecars
]
stamped.append(replace(op, review_context=ctx))
stamped.append(op.model_copy(update={"review_context": ctx}))
continue
stamped.append(op)
return stamped
@@ -688,23 +443,6 @@ def _generate_human_summary(
return "\n".join(parts)
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"]
+4 -4
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Optional, Protocol
from pydantic import BaseModel, Field
class RequestRateLimiter:
"""Thread-safe rate limiter for API requests.
@@ -31,8 +32,7 @@ class RequestRateLimiter:
self._last_request_time = time.monotonic()
@dataclass
class ProviderResult:
class ProviderResult(BaseModel):
"""Normalized provider output used by enrichment pipeline."""
provider: str
@@ -44,7 +44,7 @@ class ProviderResult:
reputation_votes: Optional[int] = None
reputation_source: Optional[str] = None
match_score: Optional[float] = None
raw_metadata: dict[str, str] = field(default_factory=dict)
raw_metadata: dict[str, str] = Field(default_factory=dict)
class EnrichmentProvider(Protocol):
+6 -4
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
from vlm.plan_review import save_review_csv
from vlm.review_display import (
build_csv_rows,
@@ -36,17 +37,18 @@ def _change_label(row: dict[str, str]) -> str:
return src
@dataclass(frozen=True)
class ReviewTUIContext:
class ReviewTUIContext(BaseModel):
"""Inputs for the plan review TUI."""
model_config = ConfigDict(frozen=True)
rows: list[dict[str, str]]
counters: dict[str, int]
library_root: Path
output_csv: Path
plan_input: Path
summary_text: str
path_to_quality: dict[str, dict] = field(default_factory=dict)
path_to_quality: dict[str, dict] = Field(default_factory=dict)
try:
+2 -74
View File
@@ -1,7 +1,7 @@
"""Inventory scanner for discovering and cataloging video files.
This module implements the core scanning functionality for the Video Library Manager,
including file discovery via `find`, metadata extraction, and categorization based on
including file discovery, metadata extraction, and categorization based on
directory structure.
"""
@@ -143,79 +143,7 @@ def scan_library(
def _discover_video_paths(root: Path, video_extensions: list[str]) -> list[Path]:
"""Discover matching video files under root.
Uses the system `find` command for traversal speed and falls back to Python
recursion if `find` is unavailable.
"""
try:
return _discover_video_paths_with_find(root, video_extensions)
except FileNotFoundError:
logger.warning("`find` command not available - falling back to Python recursion")
return _discover_video_paths_recursive(root, video_extensions)
def _log_find_nonzero_exit(returncode: int, stderr_text: str, discovered_count: int) -> None:
"""Log the explicit contract for non-zero `find` exits.
Contract: if `find` emits partial stdout before failing, keep those paths and
continue with a warning. If no paths were emitted, return an empty result and
log that scan discovery was incomplete.
"""
stderr_suffix = f": {stderr_text}" if stderr_text else ""
if discovered_count > 0:
logger.warning(
"find exited with code %s; using %s partial scan result(s)%s",
returncode,
discovered_count,
stderr_suffix,
)
else:
logger.warning(
"find exited with code %s and produced no scan results%s",
returncode,
stderr_suffix,
)
def _discover_video_paths_with_find(root: Path, video_extensions: list[str]) -> list[Path]:
"""Discover matching video files using the system `find` command."""
normalized_extensions = [ext.lower() for ext in video_extensions if ext]
if not normalized_extensions:
return []
command: list[str] = ["find", str(root), "-type", "f", "("]
for index, extension in enumerate(normalized_extensions):
if index > 0:
command.append("-o")
command.extend(["-iname", f"*{extension}"])
command.extend([")", "-print0"])
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
stdout, stderr = process.communicate()
discovered_paths: list[Path] = []
for path_bytes in stdout.split(b"\0"):
if not path_bytes:
continue
file_path = Path(os.fsdecode(path_bytes))
if _is_hidden_path(file_path, root):
continue
discovered_paths.append(file_path)
if process.returncode != 0:
stderr_text = stderr.decode(errors="replace").strip()
_log_find_nonzero_exit(process.returncode, stderr_text, len(discovered_paths))
return discovered_paths
def _discover_video_paths_recursive(root: Path, video_extensions: list[str]) -> list[Path]:
"""Fallback discovery using Python directory traversal."""
"""Discover matching video files under root using os.scandir recursion."""
discovered_paths: list[Path] = []
for file_path in _scan_directory_recursive(root, video_extensions):
if _is_hidden_path(file_path, root):