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
+1
View File
@@ -11,6 +11,7 @@ authors = [
dependencies = [ dependencies = [
"click>=8.1.0", "click>=8.1.0",
"pyyaml>=6.0", "pyyaml>=6.0",
"pydantic>=2.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+26 -1011
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 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.config import create_default_config, validate_config
from vlm.context import CLIContext, pass_context from vlm.context import CLIContext, pass_context
+15 -1
View File
@@ -7,7 +7,8 @@ from typing import Optional
import click 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.io import load_inventory_csv, save_identities_json
from vlm.models import ( from vlm.models import (
IdentityRecord, IdentityRecord,
@@ -170,3 +171,16 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
len(series_identities), len(series_identities),
output, 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 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.io import identities_to_plan_input, load_analysis_json, load_identities_json
from vlm.planner import generate_plan, save_plan 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"Plan generated: {execution_plan.summary['total']} operations, "
f"{conflicts} conflicts, saved to {output}" 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, resolve_legacy_default_input_path,
review_plan_tui_streams_ok, 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.io import load_analysis_json, load_identities_json
from vlm.plan_render import ( from vlm.plan_render import (
duplicate_groups_from_plan, duplicate_groups_from_plan,
@@ -242,3 +242,60 @@ def apply_review_cmd(
f"Apply review failed: {e}", f"Apply review failed: {e}",
exc_info=True, 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.""" """Configuration management for Video Library Manager."""
from dataclasses import dataclass, field from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Optional
import yaml import yaml
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
DEFAULT_VIDEO_EXTENSIONS = [ DEFAULT_VIDEO_EXTENSIONS = [
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v" ".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.""" """Configuration for Video Library Manager."""
model_config = ConfigDict(arbitrary_types_allowed=True)
library_root: Path 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})/" movie_template: str = "movie/{title} ({year})/"
series_template: str = "series/{title}/Season {season:02d}/" series_template: str = "series/{title}/Season {season:02d}/"
movie_filename_template: str = "{title} ({year}){ext}" movie_filename_template: str = "{title} ({year}){ext}"
@@ -24,18 +37,17 @@ class Config:
log_level: str = "INFO" log_level: str = "INFO"
quarantine_dir: str = ".quarantine" quarantine_dir: str = ".quarantine"
workspace_dir: Path = Path("artifacts") workspace_dir: Path = Path("artifacts")
categories: dict[str, list[str]] = field(default_factory=lambda: { categories: dict[str, list[str]] = {
"movie": ["movie", "movies"], "movie": ["movie", "movies"],
"series": ["series", "tv", "shows"], "series": ["series", "tv", "shows"],
"anime": ["anime"] "anime": ["anime"],
}) }
# Enrichment settings
enrichment_enabled: bool = True enrichment_enabled: bool = True
enrichment_incremental: bool = True enrichment_incremental: bool = True
enrichment_refresh_mode: str = "manual" enrichment_refresh_mode: str = "manual"
enrichment_providers: list[str] = field(default_factory=lambda: ["tmdb"]) enrichment_providers: list[str] = ["tmdb"]
enrichment_cache_db: Path = field(default_factory=lambda: Path.home() / ".vlm" / "enrichment_cache.db") enrichment_cache_db: Path = Path.home() / ".vlm" / "enrichment_cache.db"
enrichment_max_concurrency: int = 6 enrichment_max_concurrency: int = 6
enrichment_min_match_score: float = 0.75 enrichment_min_match_score: float = 0.75
translation_mode: str = "bidirectional" translation_mode: str = "bidirectional"
@@ -51,12 +63,245 @@ class Config:
reputation_policy: str = "flag_for_review" reputation_policy: str = "flag_for_review"
naming_title_format: str = "{title_zh} {title_en}" naming_title_format: str = "{title_zh} {title_en}"
# Plan settings (e.g. duplicate handling when consuming analysis)
duplicate_keep: str = "by_reputation" duplicate_keep: str = "by_reputation"
plan_max_season: int = 15 plan_max_season: int = 15
plan_max_episode: int = 100 plan_max_episode: int = 100
plan_include_sample_files: bool = False 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: def load_config(path: Path) -> Config:
"""Load configuration from YAML file.""" """Load configuration from YAML file."""
@@ -69,84 +314,8 @@ def load_config(path: Path) -> Config:
except yaml.YAMLError as e: except yaml.YAMLError as e:
raise yaml.YAMLError(f"Invalid YAML syntax in configuration file: {e}") raise yaml.YAMLError(f"Invalid YAML syntax in configuration file: {e}")
if data is None: flat = _flatten_yaml(data or {})
data = {} return Config(**flat)
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,
)
def create_default_config(path: Path) -> Config: def create_default_config(path: Path) -> Config:
@@ -210,7 +379,6 @@ def create_default_config(path: Path) -> Config:
"log_level": default_config.log_level, "log_level": default_config.log_level,
"categories": default_config.categories, "categories": default_config.categories,
"enrichment": enrichment_content, "enrichment": enrichment_content,
# Backward-compatible alias for users who prefer `enrich`.
"enrich": enrichment_content, "enrich": enrichment_content,
} }
@@ -223,153 +391,14 @@ def create_default_config(path: Path) -> Config:
def validate_config(config: Config) -> list[str]: def validate_config(config: Config) -> list[str]:
"""Validate configuration and return list of error messages.""" """Validate configuration and return list of error messages.
errors = []
if not isinstance(config.library_root, Path): With Pydantic, most validation happens at construction time. This function
errors.append("library_root must be a Path object") re-validates by reconstructing the model, catching any errors that may have
elif not str(config.library_root) or str(config.library_root) == ".": been bypassed (e.g. via model_construct). Returns empty list for valid configs.
errors.append("library_root cannot be empty") """
try:
if not config.video_extensions: Config.model_validate(config.model_dump())
errors.append("video_extensions cannot be empty") return []
elif not isinstance(config.video_extensions, list): except ValidationError as e:
errors.append("video_extensions must be a list") return [f"{err['loc'][0]}: {err['msg']}" for err in e.errors()]
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
+39 -38
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. for representing video files and their parsed identities.
""" """
from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, TypedDict 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. """Represents a video file discovered during inventory scanning.
Attributes: Attributes:
@@ -25,26 +25,27 @@ class VideoFile:
duration_seconds: Optional video duration in seconds duration_seconds: Optional video duration in seconds
bitrate_kbps: Optional video bitrate in kilobits per second bitrate_kbps: Optional video bitrate in kilobits per second
""" """
path: Path path: Path
filename: str filename: str
size_bytes: int size_bytes: int
modified_timestamp: datetime modified_timestamp: datetime
category: str category: str
# Optional metadata (if ffprobe available)
resolution: Optional[str] = None resolution: Optional[str] = None
codec: Optional[str] = None codec: Optional[str] = None
duration_seconds: Optional[float] = None duration_seconds: Optional[float] = None
bitrate_kbps: Optional[int] = None bitrate_kbps: Optional[int] = None
def __post_init__(self): @field_validator("path", mode="before")
"""Canonicalize path on creation.""" @classmethod
def canonicalize_path(cls, v):
from vlm.utils import canonical_path from vlm.utils import canonical_path
object.__setattr__(self, 'path', canonical_path(self.path))
return canonical_path(v)
@dataclass class MovieIdentity(BaseModel):
class MovieIdentity:
"""Represents the parsed identity of a movie file. """Represents the parsed identity of a movie file.
review_status (pending/approved/rejected) and needs_review overlap in meaning: 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 needs_review: Flag indicating if manual review is needed
original_filename: Original filename before parsing original_filename: Original filename before parsing
""" """
title: str title: str
year: Optional[int] year: Optional[int]
confidence: float confidence: float
@@ -73,11 +75,10 @@ class MovieIdentity:
reputation_source: Optional[str] = None reputation_source: Optional[str] = None
review_status: str = "pending" review_status: str = "pending"
enrichment_confidence: Optional[float] = None 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(BaseModel):
class SeriesIdentity:
"""Represents the parsed identity of a TV series episode file. """Represents the parsed identity of a TV series episode file.
review_status (pending/approved/rejected) and needs_review overlap in meaning: 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 needs_review: Flag indicating if manual review is needed
original_filename: Original filename before parsing original_filename: Original filename before parsing
""" """
title: str title: str
season: Optional[int] season: Optional[int]
episodes: list[int] episodes: list[int]
@@ -107,11 +109,10 @@ class SeriesIdentity:
reputation_source: Optional[str] = None reputation_source: Optional[str] = None
review_status: str = "pending" review_status: str = "pending"
enrichment_confidence: Optional[float] = None 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(BaseModel):
class FileOperation:
"""Represents a single file operation in an execution plan. """Represents a single file operation in an execution plan.
Attributes: Attributes:
@@ -122,17 +123,17 @@ class FileOperation:
has_conflict: Flag indicating if destination already exists has_conflict: Flag indicating if destination already exists
conflict_reason: Description of the conflict (None if no conflict) conflict_reason: Description of the conflict (None if no conflict)
""" """
operation_type: str operation_type: str
source_path: Path source_path: Path
destination_path: Optional[Path] destination_path: Optional[Path]
reason: str reason: str
has_conflict: bool has_conflict: bool
conflict_reason: Optional[str] = None conflict_reason: Optional[str] = None
review_context: dict = field(default_factory=dict) review_context: dict = Field(default_factory=dict)
@dataclass class ExecutionPlan(BaseModel):
class ExecutionPlan:
"""Represents a complete execution plan with all file operations. """Represents a complete execution plan with all file operations.
Attributes: Attributes:
@@ -145,17 +146,17 @@ class ExecutionPlan:
metadata: Optional dict (e.g. analysis_source, duplicate_groups_considered, metadata: Optional dict (e.g. analysis_source, duplicate_groups_considered,
completeness_seasons_with_gaps) when plan was built from analysis completeness_seasons_with_gaps) when plan was built from analysis
""" """
plan_id: str plan_id: str
created_at: datetime created_at: datetime
operations: list[FileOperation] operations: list[FileOperation]
summary: dict summary: dict
summary_by_reason: dict = field(default_factory=dict) summary_by_reason: dict = Field(default_factory=dict)
human_summary: str = "" human_summary: str = ""
metadata: dict = field(default_factory=dict) metadata: dict = Field(default_factory=dict)
@dataclass class OperationResult(BaseModel):
class OperationResult:
"""Represents the result of executing a single file operation. """Represents the result of executing a single file operation.
Attributes: Attributes:
@@ -164,14 +165,14 @@ class OperationResult:
error_message: Error message if operation failed (None if successful) error_message: Error message if operation failed (None if successful)
executed_at: Timestamp when the operation was executed executed_at: Timestamp when the operation was executed
""" """
operation: FileOperation operation: FileOperation
success: bool success: bool
error_message: Optional[str] error_message: Optional[str]
executed_at: datetime executed_at: datetime
@dataclass class RollbackLog(BaseModel):
class RollbackLog:
"""Represents a log of executed operations for rollback purposes. """Represents a log of executed operations for rollback purposes.
Attributes: Attributes:
@@ -180,14 +181,14 @@ class RollbackLog:
executed_at: Timestamp when the operations were executed executed_at: Timestamp when the operations were executed
operations: List of operation results that were executed operations: List of operation results that were executed
""" """
log_id: str log_id: str
execution_plan_id: str execution_plan_id: str
executed_at: datetime executed_at: datetime
operations: list[OperationResult] operations: list[OperationResult]
@dataclass class QuarantineEntry(BaseModel):
class QuarantineEntry:
"""Represents a single file in quarantine. """Represents a single file in quarantine.
Attributes: Attributes:
@@ -199,27 +200,27 @@ class QuarantineEntry:
category: Category of the video ("movie" or "series") category: Category of the video ("movie" or "series")
status: Operation status ("pending" | "committed") for two-phase commit status: Operation status ("pending" | "committed") for two-phase commit
""" """
original_path: Path original_path: Path
quarantine_path: Path quarantine_path: Path
quarantined_at: datetime quarantined_at: datetime
reason: Optional[str] reason: Optional[str]
size_bytes: int size_bytes: int
category: str category: str
status: str = "committed" # Default for backward compatibility status: str = "committed"
@dataclass class QuarantineManifest(BaseModel):
class QuarantineManifest:
"""Represents a manifest of all quarantined files in a category. """Represents a manifest of all quarantined files in a category.
Attributes: Attributes:
entries: List of quarantine entries entries: List of quarantine entries
""" """
entries: list[QuarantineEntry] entries: list[QuarantineEntry]
@dataclass class FileState(BaseModel):
class FileState:
"""Represents the state of a file in the workflow. """Represents the state of a file in the workflow.
Attributes: Attributes:
@@ -228,14 +229,14 @@ class FileState:
reason: Optional reason for the status reason: Optional reason for the status
updated_at: Timestamp when the state was last updated updated_at: Timestamp when the state was last updated
""" """
file_path: Path file_path: Path
status: str status: str
reason: Optional[str] reason: Optional[str]
updated_at: datetime updated_at: datetime
@dataclass class StateStore(BaseModel):
class StateStore:
"""Represents the persistent state store for all files. """Represents the persistent state store for all files.
Attributes: Attributes:
@@ -243,13 +244,13 @@ class StateStore:
version: Version of the state store format version: Version of the state store format
last_updated: Timestamp when the state store was last updated last_updated: Timestamp when the state store was last updated
""" """
states: dict[str, FileState] states: dict[str, FileState]
version: str version: str
last_updated: datetime last_updated: datetime
@dataclass class SeasonCompleteness(BaseModel):
class SeasonCompleteness:
"""Represents completeness analysis for a single season of a series. """Represents completeness analysis for a single season of a series.
Attributes: Attributes:
@@ -258,14 +259,14 @@ class SeasonCompleteness:
episodes_found: List of episode numbers that were found episodes_found: List of episode numbers that were found
episodes_missing: List of episode numbers missing in the range [min, max] episodes_missing: List of episode numbers missing in the range [min, max]
""" """
series_title: str series_title: str
season: int season: int
episodes_found: list[int] episodes_found: list[int]
episodes_missing: list[int] episodes_missing: list[int]
@dataclass class DuplicateGroup(BaseModel):
class DuplicateGroup:
"""Represents a group of duplicate video files. """Represents a group of duplicate video files.
Attributes: Attributes:
@@ -273,6 +274,7 @@ class DuplicateGroup:
files: List of VideoFile objects that are duplicates files: List of VideoFile objects that are duplicates
quality_comparison: List of dictionaries with quality metrics for each file quality_comparison: List of dictionaries with quality metrics for each file
""" """
identity: MovieIdentity | SeriesIdentity identity: MovieIdentity | SeriesIdentity
files: list[VideoFile] files: list[VideoFile]
quality_comparison: list[dict] quality_comparison: list[dict]
@@ -383,4 +385,3 @@ class PlanJSON(TypedDict, total=False):
summary_by_reason: dict[str, int] summary_by_reason: dict[str, int]
human_summary: str human_summary: str
metadata: dict[str, object] 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 import uuid
from dataclasses import replace
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
@@ -19,6 +18,15 @@ from vlm.models import (
SeriesIdentity, SeriesIdentity,
VideoFile, 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 ( from vlm.plan_review import (
REVIEW_APPLIED_AT_KEY, REVIEW_APPLIED_AT_KEY,
REVIEW_CSV_PATH_KEY, REVIEW_CSV_PATH_KEY,
@@ -29,46 +37,12 @@ from vlm.plan_review import (
from vlm.scanner import find_sidecar_companions from vlm.scanner import find_sidecar_companions
from vlm.utils import ( from vlm.utils import (
is_sample_path, is_sample_path,
is_within_root,
sanitize_path_component,
utc_now, utc_now,
) )
QUARANTINE_REASON_DUPLICATE = "重复项(已按外部评分保留一条)" __all__ = ["generate_plan", "save_plan", "load_plan", "apply_review_to_plan"]
QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY = "重复项(外部评分缺失/并列,按画质回退保留一条)"
QUARANTINE_REASON_DUPLICATE_BY_QUALITY = "重复项(已按画质保留最优)"
QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME = "重复项(按外部评分>画质>时间保留一条)"
NO_OP_REASON_SAMPLE_EXCLUDED = "Sample file excluded by plan include_sample_files=false" 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: 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( def _stamp_review_context_on_operations(
operations: list[FileOperation], operations: list[FileOperation],
identities: list[tuple], identities: list[tuple],
@@ -597,7 +352,7 @@ def _stamp_review_context_on_operations(
} }
for s in sidecars for s in sidecars
] ]
stamped.append(replace(op, review_context=ctx)) stamped.append(op.model_copy(update={"review_context": ctx}))
continue continue
stamped.append(op) stamped.append(op)
return stamped return stamped
@@ -688,23 +443,6 @@ def _generate_human_summary(
return "\n".join(parts) 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]: def _build_quarantine_recommendation_lines(operations: list[FileOperation], limit: int = 20) -> list[str]:
"""Build human-readable quarantine recommendations with reasons.""" """Build human-readable quarantine recommendations with reasons."""
quarantines = [op for op in operations if op.operation_type == "quarantine"] 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 threading
import time import time
from dataclasses import dataclass, field
from typing import Optional, Protocol from typing import Optional, Protocol
from pydantic import BaseModel, Field
class RequestRateLimiter: class RequestRateLimiter:
"""Thread-safe rate limiter for API requests. """Thread-safe rate limiter for API requests.
@@ -31,8 +32,7 @@ class RequestRateLimiter:
self._last_request_time = time.monotonic() self._last_request_time = time.monotonic()
@dataclass class ProviderResult(BaseModel):
class ProviderResult:
"""Normalized provider output used by enrichment pipeline.""" """Normalized provider output used by enrichment pipeline."""
provider: str provider: str
@@ -44,7 +44,7 @@ class ProviderResult:
reputation_votes: Optional[int] = None reputation_votes: Optional[int] = None
reputation_source: Optional[str] = None reputation_source: Optional[str] = None
match_score: Optional[float] = 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): class EnrichmentProvider(Protocol):
+6 -4
View File
@@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
from vlm.plan_review import save_review_csv from vlm.plan_review import save_review_csv
from vlm.review_display import ( from vlm.review_display import (
build_csv_rows, build_csv_rows,
@@ -36,17 +37,18 @@ def _change_label(row: dict[str, str]) -> str:
return src return src
@dataclass(frozen=True) class ReviewTUIContext(BaseModel):
class ReviewTUIContext:
"""Inputs for the plan review TUI.""" """Inputs for the plan review TUI."""
model_config = ConfigDict(frozen=True)
rows: list[dict[str, str]] rows: list[dict[str, str]]
counters: dict[str, int] counters: dict[str, int]
library_root: Path library_root: Path
output_csv: Path output_csv: Path
plan_input: Path plan_input: Path
summary_text: str summary_text: str
path_to_quality: dict[str, dict] = field(default_factory=dict) path_to_quality: dict[str, dict] = Field(default_factory=dict)
try: try:
+2 -74
View File
@@ -1,7 +1,7 @@
"""Inventory scanner for discovering and cataloging video files. """Inventory scanner for discovering and cataloging video files.
This module implements the core scanning functionality for the Video Library Manager, 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. directory structure.
""" """
@@ -143,79 +143,7 @@ def scan_library(
def _discover_video_paths(root: Path, video_extensions: list[str]) -> list[Path]: def _discover_video_paths(root: Path, video_extensions: list[str]) -> list[Path]:
"""Discover matching video files under root. """Discover matching video files under root using os.scandir recursion."""
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."""
discovered_paths: list[Path] = [] discovered_paths: list[Path] = []
for file_path in _scan_directory_recursive(root, video_extensions): for file_path in _scan_directory_recursive(root, video_extensions):
if _is_hidden_path(file_path, root): if _is_hidden_path(file_path, root):
+232 -190
View File
@@ -10,16 +10,55 @@ from vlm.analysis import analyze_series_completeness, compare_quality, detect_du
from vlm.models import MovieIdentity, SeriesIdentity, VideoFile from vlm.models import MovieIdentity, SeriesIdentity, VideoFile
def _movie(title="Movie", year=2020, **kw):
return MovieIdentity(
title=title, year=year, confidence=kw.pop("confidence", 0.9),
needs_review=kw.pop("needs_review", False),
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
**kw,
)
def _series(title="Show", season=1, episodes=None, **kw):
if episodes is None:
episodes = [1]
return SeriesIdentity(
title=title, season=season, episodes=episodes,
confidence=kw.pop("confidence", 0.9),
needs_review=kw.pop("needs_review", False),
original_filename=kw.pop(
"original_filename",
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
if season is not None
else f"{title.replace(' ', '.')}.E01.mkv",
),
**kw,
)
def _video(filename="file.mkv", size=1000, category="movie", **kw):
return VideoFile(
path=kw.pop("path", Path(f"/tmp/{filename}")),
filename=filename, size_bytes=size,
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
category=category, **kw,
)
class TestSeriesCompletenessAnalysis: class TestSeriesCompletenessAnalysis:
"""Test series completeness analysis functionality.""" """Test series completeness analysis functionality."""
def test_detect_single_gap(self): def test_detect_single_gap(self):
"""Test detection of a single missing episode.""" """Test detection of a single missing episode."""
episodes = [ episodes = [
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"), _series("Show Name", episodes=[1],
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"), original_filename="Show.Name.S01E01.mkv"),
SeriesIdentity("Show Name", 1, [4], 0.9, False, "Show.Name.S01E04.mkv"), _series("Show Name", episodes=[2],
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"), original_filename="Show.Name.S01E02.mkv"),
_series("Show Name", episodes=[4],
original_filename="Show.Name.S01E04.mkv"),
_series("Show Name", episodes=[5],
original_filename="Show.Name.S01E05.mkv"),
] ]
result = analyze_series_completeness(episodes) result = analyze_series_completeness(episodes)
@@ -33,10 +72,14 @@ class TestSeriesCompletenessAnalysis:
def test_detect_multiple_gaps(self): def test_detect_multiple_gaps(self):
"""Test detection of multiple missing episodes.""" """Test detection of multiple missing episodes."""
episodes = [ episodes = [
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"), _series("Show Name", episodes=[1],
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"), original_filename="Show.Name.S01E01.mkv"),
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"), _series("Show Name", episodes=[3],
SeriesIdentity("Show Name", 1, [7], 0.9, False, "Show.Name.S01E07.mkv"), original_filename="Show.Name.S01E03.mkv"),
_series("Show Name", episodes=[5],
original_filename="Show.Name.S01E05.mkv"),
_series("Show Name", episodes=[7],
original_filename="Show.Name.S01E07.mkv"),
] ]
result = analyze_series_completeness(episodes) result = analyze_series_completeness(episodes)
@@ -48,9 +91,12 @@ class TestSeriesCompletenessAnalysis:
def test_no_gaps_returns_empty(self): def test_no_gaps_returns_empty(self):
"""Test that complete seasons are not included in results.""" """Test that complete seasons are not included in results."""
episodes = [ episodes = [
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"), _series("Show Name", episodes=[1],
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"), original_filename="Show.Name.S01E01.mkv"),
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"), _series("Show Name", episodes=[2],
original_filename="Show.Name.S01E02.mkv"),
_series("Show Name", episodes=[3],
original_filename="Show.Name.S01E03.mkv"),
] ]
result = analyze_series_completeness(episodes) result = analyze_series_completeness(episodes)
@@ -61,14 +107,20 @@ class TestSeriesCompletenessAnalysis:
"""Test that gap detection for one season doesn't affect others.""" """Test that gap detection for one season doesn't affect others."""
episodes = [ episodes = [
# Season 1 - has gap at episode 2 # Season 1 - has gap at episode 2
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"), _series("Show Name", season=1, episodes=[1],
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"), original_filename="Show.Name.S01E01.mkv"),
_series("Show Name", season=1, episodes=[3],
original_filename="Show.Name.S01E03.mkv"),
# Season 2 - complete # Season 2 - complete
SeriesIdentity("Show Name", 2, [1], 0.9, False, "Show.Name.S02E01.mkv"), _series("Show Name", season=2, episodes=[1],
SeriesIdentity("Show Name", 2, [2], 0.9, False, "Show.Name.S02E02.mkv"), original_filename="Show.Name.S02E01.mkv"),
_series("Show Name", season=2, episodes=[2],
original_filename="Show.Name.S02E02.mkv"),
# Season 3 - has gap at episode 5 # Season 3 - has gap at episode 5
SeriesIdentity("Show Name", 3, [4], 0.9, False, "Show.Name.S03E04.mkv"), _series("Show Name", season=3, episodes=[4],
SeriesIdentity("Show Name", 3, [6], 0.9, False, "Show.Name.S03E06.mkv"), original_filename="Show.Name.S03E04.mkv"),
_series("Show Name", season=3, episodes=[6],
original_filename="Show.Name.S03E06.mkv"),
] ]
result = analyze_series_completeness(episodes) result = analyze_series_completeness(episodes)
@@ -89,8 +141,10 @@ class TestSeriesCompletenessAnalysis:
def test_multi_episode_files(self): def test_multi_episode_files(self):
"""Test handling of multi-episode files.""" """Test handling of multi-episode files."""
episodes = [ episodes = [
SeriesIdentity("Show Name", 1, [1, 2], 0.9, False, "Show.Name.S01E01-E02.mkv"), _series("Show Name", episodes=[1, 2],
SeriesIdentity("Show Name", 1, [4], 0.9, False, "Show.Name.S01E04.mkv"), original_filename="Show.Name.S01E01-E02.mkv"),
_series("Show Name", episodes=[4],
original_filename="Show.Name.S01E04.mkv"),
] ]
result = analyze_series_completeness(episodes) result = analyze_series_completeness(episodes)
@@ -103,11 +157,15 @@ class TestSeriesCompletenessAnalysis:
"""Test that different series are analyzed separately.""" """Test that different series are analyzed separately."""
episodes = [ episodes = [
# Series A - has gap # Series A - has gap
SeriesIdentity("Series A", 1, [1], 0.9, False, "Series.A.S01E01.mkv"), _series("Series A", episodes=[1],
SeriesIdentity("Series A", 1, [3], 0.9, False, "Series.A.S01E03.mkv"), original_filename="Series.A.S01E01.mkv"),
_series("Series A", episodes=[3],
original_filename="Series.A.S01E03.mkv"),
# Series B - complete # Series B - complete
SeriesIdentity("Series B", 1, [1], 0.9, False, "Series.B.S01E01.mkv"), _series("Series B", episodes=[1],
SeriesIdentity("Series B", 1, [2], 0.9, False, "Series.B.S01E02.mkv"), original_filename="Series.B.S01E01.mkv"),
_series("Series B", episodes=[2],
original_filename="Series.B.S01E02.mkv"),
] ]
result = analyze_series_completeness(episodes) result = analyze_series_completeness(episodes)
@@ -120,9 +178,13 @@ class TestSeriesCompletenessAnalysis:
def test_skip_episodes_without_season(self): def test_skip_episodes_without_season(self):
"""Test that episodes with season=None are excluded from analysis.""" """Test that episodes with season=None are excluded from analysis."""
episodes = [ episodes = [
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"), _series("Show Name", episodes=[1],
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"), original_filename="Show.Name.S01E01.mkv"),
SeriesIdentity("Show Name", None, [1], 0.3, True, "Show.Name.Episode.1.mkv"), _series("Show Name", episodes=[2],
original_filename="Show.Name.S01E02.mkv"),
_series("Show Name", season=None, confidence=0.3,
needs_review=True,
original_filename="Show.Name.Episode.1.mkv"),
] ]
result = analyze_series_completeness(episodes) result = analyze_series_completeness(episodes)
@@ -133,9 +195,13 @@ class TestSeriesCompletenessAnalysis:
def test_skip_episodes_with_empty_episode_list(self): def test_skip_episodes_with_empty_episode_list(self):
"""Test that episodes with empty episode list are excluded from analysis.""" """Test that episodes with empty episode list are excluded from analysis."""
episodes = [ episodes = [
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"), _series("Show Name", episodes=[1],
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"), original_filename="Show.Name.S01E01.mkv"),
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.S01.mkv"), _series("Show Name", episodes=[3],
original_filename="Show.Name.S01E03.mkv"),
_series("Show Name", episodes=[], confidence=0.3,
needs_review=True,
original_filename="Show.Name.S01.mkv"),
] ]
result = analyze_series_completeness(episodes) result = analyze_series_completeness(episodes)
@@ -147,9 +213,12 @@ class TestSeriesCompletenessAnalysis:
def test_non_sequential_start(self): def test_non_sequential_start(self):
"""Test gap detection when episodes don't start at 1.""" """Test gap detection when episodes don't start at 1."""
episodes = [ episodes = [
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"), _series("Show Name", episodes=[5],
SeriesIdentity("Show Name", 1, [6], 0.9, False, "Show.Name.S01E06.mkv"), original_filename="Show.Name.S01E05.mkv"),
SeriesIdentity("Show Name", 1, [8], 0.9, False, "Show.Name.S01E08.mkv"), _series("Show Name", episodes=[6],
original_filename="Show.Name.S01E06.mkv"),
_series("Show Name", episodes=[8],
original_filename="Show.Name.S01E08.mkv"),
] ]
result = analyze_series_completeness(episodes) result = analyze_series_completeness(episodes)
@@ -167,7 +236,8 @@ class TestSeriesCompletenessAnalysis:
def test_single_episode_no_gap(self): def test_single_episode_no_gap(self):
"""Test that a single episode has no gaps.""" """Test that a single episode has no gaps."""
episodes = [ episodes = [
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"), _series("Show Name", episodes=[1],
original_filename="Show.Name.S01E01.mkv"),
] ]
result = analyze_series_completeness(episodes) result = analyze_series_completeness(episodes)
@@ -176,44 +246,29 @@ class TestSeriesCompletenessAnalysis:
assert len(result) == 0 assert len(result) == 0
class TestDuplicateDetection: class TestDuplicateDetection:
"""Test duplicate detection functionality.""" """Test duplicate detection functionality."""
def test_detect_movie_duplicates(self): def test_detect_movie_duplicates(self):
"""Test detection of duplicate movies with identical title and year.""" """Test detection of duplicate movies with identical title and year."""
now = datetime.now(timezone.utc)
identities = [ identities = [
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"), _movie("The Matrix", 1999,
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.720p.mkv"), original_filename="The.Matrix.1999.1080p.mkv"),
MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.mkv"), _movie("The Matrix", 1999,
original_filename="The.Matrix.1999.720p.mkv"),
_movie("Inception", 2010),
] ]
files = [ files = [
VideoFile( _video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
Path("/movies/The.Matrix.1999.1080p.mkv"), modified_timestamp=now, resolution="1920x1080",
"The.Matrix.1999.1080p.mkv", codec="h264"),
2000000000, _video("The.Matrix.1999.720p.mkv", 1_000_000_000,
datetime.now(timezone.utc), modified_timestamp=now, resolution="1280x720",
"movie", codec="h264"),
resolution="1920x1080", _video("Inception.2010.mkv", 1_500_000_000,
codec="h264" modified_timestamp=now),
),
VideoFile(
Path("/movies/The.Matrix.1999.720p.mkv"),
"The.Matrix.1999.720p.mkv",
1000000000,
datetime.now(timezone.utc),
"movie",
resolution="1280x720",
codec="h264"
),
VideoFile(
Path("/movies/Inception.2010.mkv"),
"Inception.2010.mkv",
1500000000,
datetime.now(timezone.utc),
"movie"
),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -228,36 +283,25 @@ class TestDuplicateDetection:
def test_detect_series_duplicates(self): def test_detect_series_duplicates(self):
"""Test detection of duplicate series episodes.""" """Test detection of duplicate series episodes."""
now = datetime.now(timezone.utc)
identities = [ identities = [
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.1080p.mkv"), _series("Breaking Bad", episodes=[1],
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.720p.mkv"), original_filename="Breaking.Bad.S01E01.1080p.mkv"),
SeriesIdentity("Breaking Bad", 1, [2], 0.9, False, "Breaking.Bad.S01E02.mkv"), _series("Breaking Bad", episodes=[1],
original_filename="Breaking.Bad.S01E01.720p.mkv"),
_series("Breaking Bad", episodes=[2],
original_filename="Breaking.Bad.S01E02.mkv"),
] ]
files = [ files = [
VideoFile( _video("Breaking.Bad.S01E01.1080p.mkv", 1_500_000_000,
Path("/series/Breaking.Bad.S01E01.1080p.mkv"), "series", modified_timestamp=now,
"Breaking.Bad.S01E01.1080p.mkv", resolution="1920x1080"),
1500000000, _video("Breaking.Bad.S01E01.720p.mkv", 800_000_000,
datetime.now(timezone.utc), "series", modified_timestamp=now,
"series", resolution="1280x720"),
resolution="1920x1080" _video("Breaking.Bad.S01E02.mkv", 1_200_000_000,
), "series", modified_timestamp=now),
VideoFile(
Path("/series/Breaking.Bad.S01E01.720p.mkv"),
"Breaking.Bad.S01E01.720p.mkv",
800000000,
datetime.now(timezone.utc),
"series",
resolution="1280x720"
),
VideoFile(
Path("/series/Breaking.Bad.S01E02.mkv"),
"Breaking.Bad.S01E02.mkv",
1200000000,
datetime.now(timezone.utc),
"series"
),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -272,14 +316,17 @@ class TestDuplicateDetection:
def test_no_duplicates(self): def test_no_duplicates(self):
"""Test that unique files are not flagged as duplicates.""" """Test that unique files are not flagged as duplicates."""
now = datetime.now(timezone.utc)
identities = [ identities = [
MovieIdentity("Movie A", 2020, 0.9, False, "Movie.A.2020.mkv"), _movie("Movie A", 2020),
MovieIdentity("Movie B", 2021, 0.9, False, "Movie.B.2021.mkv"), _movie("Movie B", 2021),
] ]
files = [ files = [
VideoFile(Path("/movies/Movie.A.2020.mkv"), "Movie.A.2020.mkv", 1000000000, datetime.now(timezone.utc), "movie"), _video("Movie.A.2020.mkv", 1_000_000_000,
VideoFile(Path("/movies/Movie.B.2021.mkv"), "Movie.B.2021.mkv", 1000000000, datetime.now(timezone.utc), "movie"), modified_timestamp=now),
_video("Movie.B.2021.mkv", 1_000_000_000,
modified_timestamp=now),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -288,14 +335,21 @@ class TestDuplicateDetection:
def test_skip_movies_without_year(self): def test_skip_movies_without_year(self):
"""Test that movies without year are excluded from duplicate detection.""" """Test that movies without year are excluded from duplicate detection."""
now = datetime.now(timezone.utc)
identities = [ identities = [
MovieIdentity("Unknown Movie", None, 0.3, True, "Unknown.Movie.mkv"), _movie("Unknown Movie", year=None, confidence=0.3,
MovieIdentity("Unknown Movie", None, 0.3, True, "Unknown.Movie.2.mkv"), needs_review=True,
original_filename="Unknown.Movie.mkv"),
_movie("Unknown Movie", year=None, confidence=0.3,
needs_review=True,
original_filename="Unknown.Movie.2.mkv"),
] ]
files = [ files = [
VideoFile(Path("/movies/Unknown.Movie.mkv"), "Unknown.Movie.mkv", 1000000000, datetime.now(timezone.utc), "movie"), _video("Unknown.Movie.mkv", 1_000_000_000,
VideoFile(Path("/movies/Unknown.Movie.2.mkv"), "Unknown.Movie.2.mkv", 1000000000, datetime.now(timezone.utc), "movie"), modified_timestamp=now),
_video("Unknown.Movie.2.mkv", 1_000_000_000,
modified_timestamp=now),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -305,14 +359,21 @@ class TestDuplicateDetection:
def test_skip_series_without_season(self): def test_skip_series_without_season(self):
"""Test that series without season are excluded from duplicate detection.""" """Test that series without season are excluded from duplicate detection."""
now = datetime.now(timezone.utc)
identities = [ identities = [
SeriesIdentity("Unknown Show", None, [1], 0.3, True, "Unknown.Show.E01.mkv"), _series("Unknown Show", season=None, confidence=0.3,
SeriesIdentity("Unknown Show", None, [1], 0.3, True, "Unknown.Show.Episode.1.mkv"), needs_review=True,
original_filename="Unknown.Show.E01.mkv"),
_series("Unknown Show", season=None, confidence=0.3,
needs_review=True,
original_filename="Unknown.Show.Episode.1.mkv"),
] ]
files = [ files = [
VideoFile(Path("/series/Unknown.Show.E01.mkv"), "Unknown.Show.E01.mkv", 1000000000, datetime.now(timezone.utc), "series"), _video("Unknown.Show.E01.mkv", 1_000_000_000, "series",
VideoFile(Path("/series/Unknown.Show.Episode.1.mkv"), "Unknown.Show.Episode.1.mkv", 1000000000, datetime.now(timezone.utc), "series"), modified_timestamp=now),
_video("Unknown.Show.Episode.1.mkv", 1_000_000_000, "series",
modified_timestamp=now),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -321,14 +382,21 @@ class TestDuplicateDetection:
def test_skip_series_with_empty_episodes(self): def test_skip_series_with_empty_episodes(self):
"""Test that series with empty episode list are excluded.""" """Test that series with empty episode list are excluded."""
now = datetime.now(timezone.utc)
identities = [ identities = [
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.S01.mkv"), _series("Show Name", episodes=[], confidence=0.3,
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.Season.1.mkv"), needs_review=True,
original_filename="Show.Name.S01.mkv"),
_series("Show Name", episodes=[], confidence=0.3,
needs_review=True,
original_filename="Show.Name.Season.1.mkv"),
] ]
files = [ files = [
VideoFile(Path("/series/Show.Name.S01.mkv"), "Show.Name.S01.mkv", 1000000000, datetime.now(timezone.utc), "series"), _video("Show.Name.S01.mkv", 1_000_000_000, "series",
VideoFile(Path("/series/Show.Name.Season.1.mkv"), "Show.Name.Season.1.mkv", 1000000000, datetime.now(timezone.utc), "series"), modified_timestamp=now),
_video("Show.Name.Season.1.mkv", 1_000_000_000, "series",
modified_timestamp=now),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -337,34 +405,23 @@ class TestDuplicateDetection:
def test_quality_comparison_includes_all_metadata(self): def test_quality_comparison_includes_all_metadata(self):
"""Test that quality comparison includes all available metadata.""" """Test that quality comparison includes all available metadata."""
now = datetime.now(timezone.utc)
identities = [ identities = [
MovieIdentity("Test Movie", 2020, 0.9, False, "Test.Movie.2020.1080p.mkv"), _movie("Test Movie", 2020,
MovieIdentity("Test Movie", 2020, 0.9, False, "Test.Movie.2020.720p.mkv"), original_filename="Test.Movie.2020.1080p.mkv"),
_movie("Test Movie", 2020,
original_filename="Test.Movie.2020.720p.mkv"),
] ]
files = [ files = [
VideoFile( _video("Test.Movie.2020.1080p.mkv", 2_000_000_000,
Path("/movies/Test.Movie.2020.1080p.mkv"), modified_timestamp=now, resolution="1920x1080",
"Test.Movie.2020.1080p.mkv", codec="h264", duration_seconds=7200.0,
2000000000, bitrate_kbps=5000),
datetime.now(timezone.utc), _video("Test.Movie.2020.720p.mkv", 1_000_000_000,
"movie", modified_timestamp=now, resolution="1280x720",
resolution="1920x1080", codec="h264", duration_seconds=7200.0,
codec="h264", bitrate_kbps=2500),
duration_seconds=7200.0,
bitrate_kbps=5000
),
VideoFile(
Path("/movies/Test.Movie.2020.720p.mkv"),
"Test.Movie.2020.720p.mkv",
1000000000,
datetime.now(timezone.utc),
"movie",
resolution="1280x720",
codec="h264",
duration_seconds=7200.0,
bitrate_kbps=2500
),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -388,16 +445,23 @@ class TestDuplicateDetection:
def test_multi_episode_file_duplicates(self): def test_multi_episode_file_duplicates(self):
"""Test duplicate detection for multi-episode files.""" """Test duplicate detection for multi-episode files."""
now = datetime.now(timezone.utc)
identities = [ identities = [
SeriesIdentity("Show", 1, [1, 2], 0.9, False, "Show.S01E01-E02.mkv"), _series("Show", episodes=[1, 2],
SeriesIdentity("Show", 1, [1], 0.9, False, "Show.S01E01.mkv"), original_filename="Show.S01E01-E02.mkv"),
SeriesIdentity("Show", 1, [2], 0.9, False, "Show.S01E02.mkv"), _series("Show", episodes=[1],
original_filename="Show.S01E01.mkv"),
_series("Show", episodes=[2],
original_filename="Show.S01E02.mkv"),
] ]
files = [ files = [
VideoFile(Path("/series/Show.S01E01-E02.mkv"), "Show.S01E01-E02.mkv", 2000000000, datetime.now(timezone.utc), "series"), _video("Show.S01E01-E02.mkv", 2_000_000_000, "series",
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"), modified_timestamp=now),
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"), _video("Show.S01E01.mkv", 1_000_000_000, "series",
modified_timestamp=now),
_video("Show.S01E02.mkv", 1_000_000_000, "series",
modified_timestamp=now),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -407,14 +471,17 @@ class TestDuplicateDetection:
def test_different_years_not_duplicates(self): def test_different_years_not_duplicates(self):
"""Test that same title with different years are not duplicates.""" """Test that same title with different years are not duplicates."""
now = datetime.now(timezone.utc)
identities = [ identities = [
MovieIdentity("The Thing", 1982, 0.9, False, "The.Thing.1982.mkv"), _movie("The Thing", 1982),
MovieIdentity("The Thing", 2011, 0.9, False, "The.Thing.2011.mkv"), _movie("The Thing", 2011),
] ]
files = [ files = [
VideoFile(Path("/movies/The.Thing.1982.mkv"), "The.Thing.1982.mkv", 1000000000, datetime.now(timezone.utc), "movie"), _video("The.Thing.1982.mkv", 1_000_000_000,
VideoFile(Path("/movies/The.Thing.2011.mkv"), "The.Thing.2011.mkv", 1000000000, datetime.now(timezone.utc), "movie"), modified_timestamp=now),
_video("The.Thing.2011.mkv", 1_000_000_000,
modified_timestamp=now),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -423,14 +490,19 @@ class TestDuplicateDetection:
def test_different_seasons_not_duplicates(self): def test_different_seasons_not_duplicates(self):
"""Test that same series/episode in different seasons are not duplicates.""" """Test that same series/episode in different seasons are not duplicates."""
now = datetime.now(timezone.utc)
identities = [ identities = [
SeriesIdentity("Show", 1, [1], 0.9, False, "Show.S01E01.mkv"), _series("Show", season=1,
SeriesIdentity("Show", 2, [1], 0.9, False, "Show.S02E01.mkv"), original_filename="Show.S01E01.mkv"),
_series("Show", season=2,
original_filename="Show.S02E01.mkv"),
] ]
files = [ files = [
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"), _video("Show.S01E01.mkv", 1_000_000_000, "series",
VideoFile(Path("/series/Show.S02E01.mkv"), "Show.S02E01.mkv", 1000000000, datetime.now(timezone.utc), "series"), modified_timestamp=now),
_video("Show.S02E01.mkv", 1_000_000_000, "series",
modified_timestamp=now),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -443,29 +515,16 @@ class TestQualityComparison:
def test_compare_quality_with_all_metadata(self): def test_compare_quality_with_all_metadata(self):
"""Test quality comparison with all metadata available.""" """Test quality comparison with all metadata available."""
now = datetime.now(timezone.utc)
files = [ files = [
VideoFile( _video("file1.mkv", 2_000_000_000,
Path("/test/file1.mkv"), modified_timestamp=now, resolution="1920x1080",
"file1.mkv", codec="h264", duration_seconds=7200.0,
2000000000, bitrate_kbps=5000),
datetime.now(timezone.utc), _video("file2.mkv", 1_000_000_000,
"movie", modified_timestamp=now, resolution="1280x720",
resolution="1920x1080", codec="h265", duration_seconds=7200.0,
codec="h264", bitrate_kbps=2500),
duration_seconds=7200.0,
bitrate_kbps=5000
),
VideoFile(
Path("/test/file2.mkv"),
"file2.mkv",
1000000000,
datetime.now(timezone.utc),
"movie",
resolution="1280x720",
codec="h265",
duration_seconds=7200.0,
bitrate_kbps=2500
),
] ]
result = compare_quality(files) result = compare_quality(files)
@@ -485,24 +544,12 @@ class TestQualityComparison:
def test_compare_quality_with_partial_metadata(self): def test_compare_quality_with_partial_metadata(self):
"""Test quality comparison when some metadata is missing.""" """Test quality comparison when some metadata is missing."""
now = datetime.now(timezone.utc)
files = [ files = [
VideoFile( _video("file1.mkv", 2_000_000_000,
Path("/test/file1.mkv"), modified_timestamp=now, resolution="1920x1080"),
"file1.mkv", _video("file2.mkv", 1_000_000_000,
2000000000, modified_timestamp=now),
datetime.now(timezone.utc),
"movie",
resolution="1920x1080"
# codec, duration, bitrate not available
),
VideoFile(
Path("/test/file2.mkv"),
"file2.mkv",
1000000000,
datetime.now(timezone.utc),
"movie"
# No optional metadata
),
] ]
result = compare_quality(files) result = compare_quality(files)
@@ -527,16 +574,11 @@ class TestQualityComparison:
def test_compare_quality_single_file(self): def test_compare_quality_single_file(self):
"""Test quality comparison with single file.""" """Test quality comparison with single file."""
now = datetime.now(timezone.utc)
files = [ files = [
VideoFile( _video("file.mkv", 1_500_000_000,
Path("/test/file.mkv"), modified_timestamp=now, resolution="1920x1080",
"file.mkv", codec="h264"),
1500000000,
datetime.now(timezone.utc),
"movie",
resolution="1920x1080",
codec="h264"
),
] ]
result = compare_quality(files) result = compare_quality(files)
+98 -65
View File
@@ -18,6 +18,42 @@ from vlm.reports import (
generate_summary_report, generate_summary_report,
) )
def _movie(title="Movie", year=2020, **kw):
return MovieIdentity(
title=title, year=year, confidence=kw.pop("confidence", 0.9),
needs_review=kw.pop("needs_review", False),
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
**kw,
)
def _series(title="Show", season=1, episodes=None, **kw):
if episodes is None:
episodes = [1]
return SeriesIdentity(
title=title, season=season, episodes=episodes,
confidence=kw.pop("confidence", 0.9),
needs_review=kw.pop("needs_review", False),
original_filename=kw.pop(
"original_filename",
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
if season is not None
else f"{title.replace(' ', '.')}.E01.mkv",
),
**kw,
)
def _video(filename="file.mkv", size=1000, category="movie", **kw):
return VideoFile(
path=kw.pop("path", Path(f"/tmp/{filename}")),
filename=filename, size_bytes=size,
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
category=category, **kw,
)
# Custom strategies for generating test data # Custom strategies for generating test data
@st.composite @st.composite
@@ -41,10 +77,11 @@ def series_identity_strategy(draw, title=None, season=None):
)) ))
confidence = draw(st.floats(min_value=0.5, max_value=1.0)) confidence = draw(st.floats(min_value=0.5, max_value=1.0))
needs_review = False
original_filename = f"{title.replace(' ', '.')}.S{season:02d}E{episodes[0]:02d}.mkv" original_filename = f"{title.replace(' ', '.')}.S{season:02d}E{episodes[0]:02d}.mkv"
return SeriesIdentity(title, season, sorted(episodes), confidence, needs_review, original_filename) return _series(title, season, sorted(episodes),
confidence=confidence,
original_filename=original_filename)
@st.composite @st.composite
@@ -59,10 +96,10 @@ def movie_identity_strategy(draw, title=None, year=None):
year = draw(st.integers(min_value=1900, max_value=2030)) year = draw(st.integers(min_value=1900, max_value=2030))
confidence = draw(st.floats(min_value=0.5, max_value=1.0)) confidence = draw(st.floats(min_value=0.5, max_value=1.0))
needs_review = False
original_filename = f"{title.replace(' ', '.')}.{year}.mkv" original_filename = f"{title.replace(' ', '.')}.{year}.mkv"
return MovieIdentity(title, year, confidence, needs_review, original_filename) return _movie(title, year, confidence=confidence,
original_filename=original_filename)
@st.composite @st.composite
@@ -73,9 +110,8 @@ def video_file_strategy(draw, filename=None, category="movie"):
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters='.-_' whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters='.-_'
))) + ".mkv" ))) + ".mkv"
path = Path(f"/{category}/{filename}")
size_bytes = draw(st.integers(min_value=1000000, max_value=10000000000)) size_bytes = draw(st.integers(min_value=1000000, max_value=10000000000))
modified_timestamp = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# Optional metadata # Optional metadata
has_metadata = draw(st.booleans()) has_metadata = draw(st.booleans())
@@ -84,10 +120,17 @@ def video_file_strategy(draw, filename=None, category="movie"):
codec = draw(st.sampled_from(["h264", "h265", "vp9", "av1"])) codec = draw(st.sampled_from(["h264", "h265", "vp9", "av1"]))
duration_seconds = draw(st.floats(min_value=300, max_value=10800)) duration_seconds = draw(st.floats(min_value=300, max_value=10800))
bitrate_kbps = draw(st.integers(min_value=500, max_value=20000)) bitrate_kbps = draw(st.integers(min_value=500, max_value=20000))
return VideoFile(path, filename, size_bytes, modified_timestamp, category, return _video(
resolution, codec, duration_seconds, bitrate_kbps) filename, size_bytes, category,
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
resolution=resolution, codec=codec,
duration_seconds=duration_seconds, bitrate_kbps=bitrate_kbps,
)
else: else:
return VideoFile(path, filename, size_bytes, modified_timestamp, category) return _video(
filename, size_bytes, category,
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
)
# Property 10: Gap detection # Property 10: Gap detection
@@ -124,7 +167,8 @@ def test_property_10_gap_detection(title, season, episodes_data):
# Create SeriesIdentity objects # Create SeriesIdentity objects
episode_identities = [ episode_identities = [
SeriesIdentity(title, season, [ep], 0.9, False, f"{title}.S{season:02d}E{ep:02d}.mkv") _series(title, season, [ep],
original_filename=f"{title}.S{season:02d}E{ep:02d}.mkv")
for ep in episodes_with_gap for ep in episodes_with_gap
] ]
@@ -179,11 +223,13 @@ def test_property_11_multi_season_independence(title, season1_episodes, season2_
episode_identities = [] episode_identities = []
for ep in s1_with_gap: for ep in s1_with_gap:
episode_identities.append( episode_identities.append(
SeriesIdentity(title, 1, [ep], 0.9, False, f"{title}.S01E{ep:02d}.mkv") _series(title, 1, [ep],
original_filename=f"{title}.S01E{ep:02d}.mkv")
) )
for ep in s2_complete: for ep in s2_complete:
episode_identities.append( episode_identities.append(
SeriesIdentity(title, 2, [ep], 0.9, False, f"{title}.S02E{ep:02d}.mkv") _series(title, 2, [ep],
original_filename=f"{title}.S02E{ep:02d}.mkv")
) )
# Analyze completeness # Analyze completeness
@@ -219,16 +265,14 @@ def test_property_12_duplicate_detection_movies(title, year, duplicate_count):
# Create multiple movie identities with same title and year # Create multiple movie identities with same title and year
identities = [] identities = []
files = [] files = []
now = datetime.now(timezone.utc)
for i in range(duplicate_count): for i in range(duplicate_count):
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv" filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
identities.append(MovieIdentity(title, year, 0.9, False, filename)) identities.append(_movie(title, year, original_filename=filename))
files.append(VideoFile( files.append(_video(
Path(f"/movies/{filename}"), filename, 1_000_000_000 + i * 100_000_000,
filename, modified_timestamp=now, path=Path(f"/movies/{filename}"),
1000000000 + i * 100000000,
datetime.now(timezone.utc),
"movie"
)) ))
# Detect duplicates # Detect duplicates
@@ -265,16 +309,16 @@ def test_property_13_duplicate_detection_series(title, season, episode, duplicat
# Create multiple series identities with same title, season, and episode # Create multiple series identities with same title, season, and episode
identities = [] identities = []
files = [] files = []
now = datetime.now(timezone.utc)
for i in range(duplicate_count): for i in range(duplicate_count):
filename = f"{title.replace(' ', '.')}.S{season:02d}E{episode:02d}.{i}.mkv" filename = f"{title.replace(' ', '.')}.S{season:02d}E{episode:02d}.{i}.mkv"
identities.append(SeriesIdentity(title, season, [episode], 0.9, False, filename)) identities.append(
files.append(VideoFile( _series(title, season, [episode], original_filename=filename)
Path(f"/series/{filename}"), )
filename, files.append(_video(
1000000000 + i * 100000000, filename, 1_000_000_000 + i * 100_000_000, "series",
datetime.now(timezone.utc), modified_timestamp=now, path=Path(f"/series/{filename}"),
"series"
)) ))
# Detect duplicates # Detect duplicates
@@ -311,31 +355,24 @@ def test_property_14_duplicate_quality_comparison(title, year, file_count):
# Create movie identities and files with varying metadata # Create movie identities and files with varying metadata
identities = [] identities = []
files = [] files = []
now = datetime.now(timezone.utc)
for i in range(file_count): for i in range(file_count):
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv" filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
identities.append(MovieIdentity(title, year, 0.9, False, filename)) identities.append(_movie(title, year, original_filename=filename))
# Some files have full metadata, some don't # Some files have full metadata, some don't
if i % 2 == 0: if i % 2 == 0:
files.append(VideoFile( files.append(_video(
Path(f"/movies/{filename}"), filename, 1_000_000_000 + i * 100_000_000,
filename, modified_timestamp=now, path=Path(f"/movies/{filename}"),
1000000000 + i * 100000000, resolution="1920x1080", codec="h264",
datetime.now(timezone.utc), duration_seconds=7200.0, bitrate_kbps=5000,
"movie",
resolution="1920x1080",
codec="h264",
duration_seconds=7200.0,
bitrate_kbps=5000
)) ))
else: else:
files.append(VideoFile( files.append(_video(
Path(f"/movies/{filename}"), filename, 1_000_000_000 + i * 100_000_000,
filename, modified_timestamp=now, path=Path(f"/movies/{filename}"),
1000000000 + i * 100000000,
datetime.now(timezone.utc),
"movie"
)) ))
# Detect duplicates # Detect duplicates
@@ -377,15 +414,12 @@ def test_property_42_completeness_report(series_count, format):
for i in range(series_count): for i in range(series_count):
title = f"Series {i}" title = f"Series {i}"
season = 1
episodes_found = [1, 2, 4, 5] # Gap at episode 3
episodes_missing = [3]
analysis_results.append(SeasonCompleteness( analysis_results.append(SeasonCompleteness(
series_title=title, series_title=title,
season=season, season=1,
episodes_found=episodes_found, episodes_found=[1, 2, 4, 5], # Gap at episode 3
episodes_missing=episodes_missing episodes_missing=[3]
)) ))
# Generate report # Generate report
@@ -415,6 +449,7 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
""" """
# Create duplicate groups # Create duplicate groups
duplicate_groups = [] duplicate_groups = []
now = datetime.now(timezone.utc)
for i in range(duplicate_count): for i in range(duplicate_count):
title = f"Movie {i}" title = f"Movie {i}"
@@ -426,14 +461,11 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
for j in range(2): for j in range(2):
filename = f"{title.replace(' ', '.')}.{year}.{j}.mkv" filename = f"{title.replace(' ', '.')}.{year}.{j}.mkv"
file = VideoFile( file = _video(
Path(f"/movies/{filename}"), filename, 1_000_000_000 + j * 500_000_000,
filename, modified_timestamp=now, path=Path(f"/movies/{filename}"),
1000000000 + j * 500000000,
datetime.now(timezone.utc),
"movie",
resolution="1920x1080" if j == 0 else "1280x720", resolution="1920x1080" if j == 0 else "1280x720",
codec="h264" codec="h264",
) )
files.append(file) files.append(file)
quality_comparison.append({ quality_comparison.append({
@@ -444,8 +476,11 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
'codec': file.codec 'codec': file.codec
}) })
identity = MovieIdentity(title, year, 0.9, False, files[0].filename) identity = _movie(title, year, original_filename=files[0].filename)
duplicate_groups.append(DuplicateGroup(identity, files, quality_comparison)) duplicate_groups.append(DuplicateGroup(
identity=identity, files=files,
quality_comparison=quality_comparison,
))
# Generate report # Generate report
library_root = Path("/test/library") library_root = Path("/test/library")
@@ -483,18 +518,16 @@ def test_property_44_summary_report_accuracy(file_count, categories):
files = [] files = []
total_size = 0 total_size = 0
category_counts = {} category_counts = {}
now = datetime.now(timezone.utc)
for i in range(file_count): for i in range(file_count):
category = categories[i % len(categories)] category = categories[i % len(categories)]
size = 1000000000 + i * 100000000 size = 1_000_000_000 + i * 100_000_000
filename = f"file_{i}.mkv" filename = f"file_{i}.mkv"
files.append(VideoFile( files.append(_video(
Path(f"/{category}/{filename}"), filename, size, category,
filename, modified_timestamp=now, path=Path(f"/{category}/{filename}"),
size,
datetime.now(timezone.utc),
category
)) ))
total_size += size total_size += size
+130 -176
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest import pytest
import yaml import yaml
from pydantic import ValidationError
from vlm.config import Config, create_default_config, load_config, validate_config from vlm.config import Config, create_default_config, load_config, validate_config
@@ -334,277 +335,235 @@ class TestCreateDefaultConfig:
class TestValidateConfig: class TestValidateConfig:
"""Test validate_config function.""" """Test validation — with Pydantic, invalid values raise ValidationError at construction."""
def test_validate_valid_config(self): def test_validate_valid_config(self):
"""Test validating a valid configuration."""
config = Config(library_root=Path("/mnt/nas/videos")) config = Config(library_root=Path("/mnt/nas/videos"))
assert validate_config(config) == []
errors = validate_config(config)
assert errors == []
def test_validate_empty_library_root(self): def test_validate_empty_library_root(self):
"""Test validating config with empty library_root.""" with pytest.raises(ValidationError, match="library_root"):
config = Config(library_root=Path("")) Config(library_root=Path(""))
errors = validate_config(config)
assert len(errors) > 0
assert any("library_root" in err for err in errors)
def test_validate_empty_video_extensions(self): def test_validate_empty_video_extensions(self):
"""Test validating config with empty video_extensions.""" with pytest.raises(ValidationError, match="video_extensions"):
config = Config( Config(library_root=Path("/mnt/nas/videos"), video_extensions=[])
library_root=Path("/mnt/nas/videos"),
video_extensions=[]
)
errors = validate_config(config)
assert len(errors) > 0
assert any("video_extensions" in err for err in errors)
def test_validate_invalid_video_extension_format(self): def test_validate_invalid_video_extension_format(self):
"""Test validating config with invalid video extension format.""" with pytest.raises(ValidationError, match="must start with"):
config = Config( Config(
library_root=Path("/mnt/nas/videos"), library_root=Path("/mnt/nas/videos"),
video_extensions=["mp4", ".mkv"] # Missing dot on first one video_extensions=["mp4", ".mkv"],
) )
errors = validate_config(config)
assert len(errors) > 0
assert any("must start with '.'" in err for err in errors)
def test_validate_empty_templates(self): def test_validate_empty_templates(self):
"""Test validating config with empty templates.""" with pytest.raises(ValidationError) as exc_info:
config = Config( Config(
library_root=Path("/mnt/nas/videos"), library_root=Path("/mnt/nas/videos"),
movie_template="", movie_template="",
series_template="" series_template="",
) )
errors = exc_info.value.errors()
errors = validate_config(config) fields = {e["loc"][0] for e in errors}
assert "movie_template" in fields
assert len(errors) >= 2 assert "series_template" in fields
assert any("movie_template" in err for err in errors)
assert any("series_template" in err for err in errors)
def test_validate_invalid_log_level(self): def test_validate_invalid_log_level(self):
"""Test validating config with invalid log level.""" with pytest.raises(ValidationError, match="log_level"):
config = Config( Config(library_root=Path("/mnt/nas/videos"), log_level="INVALID")
library_root=Path("/mnt/nas/videos"),
log_level="INVALID"
)
errors = validate_config(config)
assert len(errors) > 0
assert any("log_level" in err for err in errors)
def test_validate_valid_log_levels(self): def test_validate_valid_log_levels(self):
"""Test validating config with all valid log levels.""" for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] config = Config(library_root=Path("/mnt/nas/videos"), log_level=level)
assert validate_config(config) == [], f"Log level {level} should be valid"
for level in valid_levels:
config = Config(
library_root=Path("/mnt/nas/videos"),
log_level=level
)
errors = validate_config(config)
assert errors == [], f"Log level {level} should be valid"
def test_validate_absolute_quarantine_dir(self): def test_validate_absolute_quarantine_dir(self):
"""Test validating config with absolute quarantine_dir.""" with pytest.raises(ValidationError, match="must be relative"):
config = Config( Config(
library_root=Path("/mnt/nas/videos"), library_root=Path("/mnt/nas/videos"),
quarantine_dir="/absolute/path" quarantine_dir="/absolute/path",
) )
errors = validate_config(config)
assert len(errors) > 0
assert any("must be relative" in err for err in errors)
def test_validate_empty_quarantine_dir(self): def test_validate_empty_quarantine_dir(self):
"""Test validating config with empty quarantine_dir.""" with pytest.raises(ValidationError, match="quarantine_dir"):
Config(library_root=Path("/mnt/nas/videos"), quarantine_dir="")
def test_workspace_dir_coerces_from_string(self):
config = Config( config = Config(
library_root=Path("/mnt/nas/videos"), library_root=Path("/mnt/nas/videos"),
quarantine_dir="" workspace_dir="artifacts",
) )
assert config.workspace_dir == Path("artifacts")
errors = validate_config(config)
assert len(errors) > 0
assert any("quarantine_dir" in err for err in errors)
def test_validate_workspace_dir_type(self):
"""workspace_dir must be a Path object."""
config = Config(
library_root=Path("/mnt/nas/videos"),
workspace_dir="artifacts", # type: ignore[arg-type]
)
errors = validate_config(config)
assert any("workspace_dir must be a Path object" in err for err in errors)
def test_validate_multiple_errors(self): def test_validate_multiple_errors(self):
"""Test validating config with multiple errors.""" with pytest.raises(ValidationError) as exc_info:
config = Config( Config(
library_root=Path(""), library_root=Path(""),
video_extensions=[], video_extensions=[],
movie_template="", movie_template="",
log_level="INVALID" log_level="INVALID",
) )
assert len(exc_info.value.errors()) >= 4
errors = validate_config(config)
# Should have multiple errors
assert len(errors) >= 4
def test_validate_duplicate_keep_reputation_quality_time(self): def test_validate_duplicate_keep_reputation_quality_time(self):
"""Test validating config with by_reputation_quality_time strategy."""
config = Config( config = Config(
library_root=Path("/mnt/nas/videos"), library_root=Path("/mnt/nas/videos"),
duplicate_keep="by_reputation_quality_time" duplicate_keep="by_reputation_quality_time",
) )
errors = validate_config(config) assert validate_config(config) == []
assert errors == []
def test_validate_empty_categories(self): def test_validate_empty_categories(self):
"""Test validating config with empty categories.""" with pytest.raises(ValidationError, match="categories"):
config = Config(library_root=Path("/test"), categories={}) Config(library_root=Path("/test"), categories={})
errors = validate_config(config)
assert any("categories" in e and "empty" in e for e in errors)
def test_validate_missing_required_category(self): def test_validate_missing_required_category(self):
"""Test validating config with missing required categories.""" with pytest.raises(ValidationError, match="categories"):
config = Config( Config(
library_root=Path("/test"), library_root=Path("/test"),
categories={"movie": ["movie"]} # Missing series, anime categories={"movie": ["movie"]},
) )
errors = validate_config(config)
assert any("series" in e or "anime" in e for e in errors)
def test_validate_duplicate_directory_names(self): def test_validate_duplicate_directory_names(self):
"""Test validating config with duplicate directory names.""" with pytest.raises(ValidationError, match="Duplicate.*videos"):
config = Config( Config(
library_root=Path("/test"), library_root=Path("/test"),
categories={ categories={
"movie": ["movie", "videos"], "movie": ["movie", "videos"],
"series": ["series", "videos"], # Duplicate "series": ["series", "videos"],
"anime": ["anime"] "anime": ["anime"],
} },
) )
errors = validate_config(config)
assert any("Duplicate" in e and "videos" in e for e in errors)
def test_validate_case_insensitive_duplicates(self): def test_validate_case_insensitive_duplicates(self):
"""Test validating config with case-insensitive duplicates.""" with pytest.raises(ValidationError, match="Duplicate"):
config = Config( Config(
library_root=Path("/test"), library_root=Path("/test"),
categories={ categories={
"movie": ["Movie"], "movie": ["Movie"],
"series": ["movie"], # Case-insensitive duplicate "series": ["movie"],
"anime": ["anime"] "anime": ["anime"],
} },
) )
errors = validate_config(config)
assert any("Duplicate" in e for e in errors)
def test_validate_valid_custom_categories(self): def test_validate_valid_custom_categories(self):
"""Test validating config with valid custom categories."""
config = Config( config = Config(
library_root=Path("/test"), library_root=Path("/test"),
categories={ categories={
"movie": ["movie", "movies"], "movie": ["movie", "movies"],
"series": ["series", "tv"], "series": ["series", "tv"],
"anime": ["anime"] "anime": ["anime"],
} },
) )
errors = validate_config(config) assert validate_config(config) == []
assert errors == []
def test_validate_categories_not_dict(self): def test_validate_categories_not_dict(self):
"""Test validating config with categories not a dict.""" with pytest.raises(ValidationError):
config = Config( Config(
library_root=Path("/test"), library_root=Path("/test"),
categories=["movie", "series"] # Wrong type categories=["movie", "series"],
) )
errors = validate_config(config)
assert any("must be a dictionary" in e for e in errors)
def test_validate_category_list_not_list(self): def test_validate_category_list_not_list(self):
"""Test validating config with category value not a list.""" with pytest.raises(ValidationError):
config = Config( Config(
library_root=Path("/test"), library_root=Path("/test"),
categories={ categories={
"movie": "movie", # Should be a list "movie": "movie",
"series": ["series"], "series": ["series"],
"anime": ["anime"] "anime": ["anime"],
} },
) )
errors = validate_config(config)
assert any("must be a list" in e for e in errors)
def test_validate_empty_category_list(self): def test_validate_empty_category_list(self):
"""Test validating config with empty category list.""" with pytest.raises(ValidationError, match="cannot be empty"):
config = Config( Config(
library_root=Path("/test"), library_root=Path("/test"),
categories={ categories={
"movie": [], # Empty list "movie": [],
"series": ["series"], "series": ["series"],
"anime": ["anime"] "anime": ["anime"],
} },
) )
errors = validate_config(config)
assert any("cannot be empty" in e for e in errors)
def test_validate_rejects_unsupported_enrichment_provider(self): def test_validate_rejects_unsupported_enrichment_provider(self):
"""Test validating config with unsupported enrichment provider.""" with pytest.raises(ValidationError, match="unsupported providers"):
config = Config( Config(
library_root=Path("/test"), library_root=Path("/test"),
enrichment_providers=["tmdb", "douban"], enrichment_providers=["tmdb", "douban"],
) )
errors = validate_config(config)
assert any("unsupported providers" in e for e in errors)
def test_validate_category_list_with_non_string(self): def test_validate_category_list_with_non_string(self):
"""Test validating config with non-string in category list.""" with pytest.raises(ValidationError):
config = Config( Config(
library_root=Path("/test"), library_root=Path("/test"),
categories={ categories={
"movie": ["movie", 123], # Non-string "movie": ["movie", 123],
"series": ["series"], "series": ["series"],
"anime": ["anime"] "anime": ["anime"],
} },
) )
errors = validate_config(config)
assert any("must contain strings" in e for e in errors)
def test_validate_category_list_with_empty_string(self): def test_validate_category_list_with_empty_string(self):
"""Test validating config with empty string in category list.""" with pytest.raises(ValidationError, match="empty directory name"):
config = Config( Config(
library_root=Path("/test"), library_root=Path("/test"),
categories={ categories={
"movie": ["movie", ""], # Empty string "movie": ["movie", ""],
"series": ["series"], "series": ["series"],
"anime": ["anime"] "anime": ["anime"],
} },
) )
errors = validate_config(config)
assert any("empty directory name" in e for e in errors)
def test_validate_invalid_plan_thresholds(self): def test_validate_invalid_plan_thresholds(self):
"""Plan season/episode thresholds must be positive integers.""" with pytest.raises(ValidationError) as exc_info:
config = Config( Config(
library_root=Path("/test"), library_root=Path("/test"),
plan_max_season=0, plan_max_season=0,
plan_max_episode=-1, plan_max_episode=-1,
) )
fields = {e["loc"][0] for e in exc_info.value.errors()}
assert "plan_max_season" in fields
assert "plan_max_episode" in fields
def test_validate_config_with_model_construct_bypass(self):
"""validate_config catches errors bypassed via model_construct."""
config = Config.model_construct(
library_root=Path("/test"),
video_extensions=[],
movie_template="movie/{title} ({year})/",
series_template="series/{title}/Season {season:02d}/",
movie_filename_template="{title} ({year}){ext}",
series_filename_template="S{season:02d}E{episode:02d}{ext}",
log_level="INFO",
quarantine_dir=".quarantine",
workspace_dir=Path("artifacts"),
categories={"movie": ["movie"], "series": ["series"], "anime": ["anime"]},
enrichment_enabled=True,
enrichment_incremental=True,
enrichment_refresh_mode="manual",
enrichment_providers=["tmdb"],
enrichment_cache_db=Path.home() / ".vlm" / "enrichment_cache.db",
enrichment_max_concurrency=6,
enrichment_min_match_score=0.75,
translation_mode="bidirectional",
translation_fallback_machine=True,
tmdb_api_key=None,
tmdb_bearer_token=None,
tmdb_language="zh-CN",
tmdb_region=None,
tmdb_include_adult=False,
openai_api_key=None,
reputation_min_votes=50,
reputation_low_score_threshold=6.0,
reputation_policy="flag_for_review",
naming_title_format="{title_zh} {title_en}",
duplicate_keep="by_reputation",
plan_max_season=15,
plan_max_episode=100,
plan_include_sample_files=False,
)
errors = validate_config(config) errors = validate_config(config)
assert any("plan_max_season" in e for e in errors) assert any("video_extensions" in e for e in errors)
assert any("plan_max_episode" in e for e in errors)
class TestConfigIntegration: class TestConfigIntegration:
@@ -654,10 +613,9 @@ class TestConfigIntegration:
assert loaded_config.library_root == default_config.library_root assert loaded_config.library_root == default_config.library_root
def test_validation_workflow(self, tmp_path): def test_validation_workflow(self, tmp_path):
"""Test workflow: load config -> validate -> report errors.""" """Test workflow: load config with invalid values raises ValidationError."""
config_file = tmp_path / "config.yaml" config_file = tmp_path / "config.yaml"
# Create config with some invalid values
config_data = { config_data = {
'library_root': '/mnt/nas/videos', 'library_root': '/mnt/nas/videos',
'video_extensions': ['mp4', '.mkv'], # First one missing dot 'video_extensions': ['mp4', '.mkv'], # First one missing dot
@@ -667,13 +625,9 @@ class TestConfigIntegration:
with open(config_file, 'w') as f: with open(config_file, 'w') as f:
yaml.dump(config_data, f) yaml.dump(config_data, f)
# Load config with pytest.raises(ValidationError) as exc_info:
config = load_config(config_file) load_config(config_file)
# Validate messages = [e["msg"] for e in exc_info.value.errors()]
errors = validate_config(config) assert any("must start with" in m for m in messages)
assert any("log_level" in m for m in messages)
# Should have errors
assert len(errors) > 0
assert any("must start with '.'" in err for err in errors)
assert any("log_level" in err for err in errors)
+6 -4
View File
@@ -2,10 +2,12 @@
from pathlib import Path from pathlib import Path
from vlm.config import Config, validate_config import pytest
from pydantic import ValidationError
from vlm.config import Config
def test_validate_rejects_non_positive_enrichment_concurrency(): def test_validate_rejects_non_positive_enrichment_concurrency():
config = Config(library_root=Path("/test"), enrichment_max_concurrency=0) with pytest.raises(ValidationError, match="enrichment_max_concurrency"):
errors = validate_config(config) Config(library_root=Path("/test"), enrichment_max_concurrency=0)
assert any("enrichment_max_concurrency must be >= 1" in e for e in errors)
+12 -6
View File
@@ -38,9 +38,10 @@ def test_enrich_incremental_cache_hit(tmp_path, monkeypatch):
config = Config( config = Config(
library_root=tmp_path, library_root=tmp_path,
enrichment_cache_db=tmp_path / "cache.db", enrichment_cache_db=tmp_path / "cache.db",
enrichment_providers=["dummy"], enrichment_providers=["tmdb"],
translation_fallback_machine=False, translation_fallback_machine=False,
) )
config.enrichment_providers = ["dummy"]
provider = DummyProvider() provider = DummyProvider()
monkeypatch.setattr( monkeypatch.setattr(
@@ -104,11 +105,12 @@ def test_enrich_flags_low_reputation_for_review(tmp_path, monkeypatch):
config = Config( config = Config(
library_root=tmp_path, library_root=tmp_path,
enrichment_cache_db=tmp_path / "cache.db", enrichment_cache_db=tmp_path / "cache.db",
enrichment_providers=["dummy"], enrichment_providers=["tmdb"],
translation_fallback_machine=False, translation_fallback_machine=False,
reputation_low_score_threshold=6.0, reputation_low_score_threshold=6.0,
reputation_min_votes=50, reputation_min_votes=50,
) )
config.enrichment_providers = ["dummy"]
provider = LowScoreProvider() provider = LowScoreProvider()
monkeypatch.setattr( monkeypatch.setattr(
@@ -144,9 +146,10 @@ def test_enrich_refresh_all_bypasses_cache(tmp_path, monkeypatch):
config = Config( config = Config(
library_root=tmp_path, library_root=tmp_path,
enrichment_cache_db=tmp_path / "cache.db", enrichment_cache_db=tmp_path / "cache.db",
enrichment_providers=["dummy"], enrichment_providers=["tmdb"],
translation_fallback_machine=False, translation_fallback_machine=False,
) )
config.enrichment_providers = ["dummy"]
provider = DummyProvider() provider = DummyProvider()
monkeypatch.setattr( monkeypatch.setattr(
@@ -184,8 +187,9 @@ def test_build_providers_rejects_unknown_provider(tmp_path):
"""Unknown providers should fail fast with a clear error.""" """Unknown providers should fail fast with a clear error."""
config = Config( config = Config(
library_root=tmp_path, library_root=tmp_path,
enrichment_providers=["tmdb", "tmdb_typo"], enrichment_providers=["tmdb"],
) )
config.enrichment_providers = ["tmdb", "tmdb_typo"]
with pytest.raises(ValueError, match="Unsupported enrichment providers"): with pytest.raises(ValueError, match="Unsupported enrichment providers"):
_build_providers(config, request_timeout=3, retries=1, rate_limiter=RequestRateLimiter(0.25)) _build_providers(config, request_timeout=3, retries=1, rate_limiter=RequestRateLimiter(0.25))
@@ -220,9 +224,10 @@ def test_refresh_all_clears_stale_enrichment_fields(tmp_path, monkeypatch):
config = Config( config = Config(
library_root=tmp_path, library_root=tmp_path,
enrichment_cache_db=tmp_path / "cache.db", enrichment_cache_db=tmp_path / "cache.db",
enrichment_providers=["dummy"], enrichment_providers=["tmdb"],
translation_fallback_machine=False, translation_fallback_machine=False,
) )
config.enrichment_providers = ["dummy"]
provider = FlakyProvider() provider = FlakyProvider()
monkeypatch.setattr( monkeypatch.setattr(
@@ -361,10 +366,11 @@ def test_enrich_respects_max_concurrency_for_uncached_records(tmp_path, monkeypa
config = Config( config = Config(
library_root=tmp_path, library_root=tmp_path,
enrichment_cache_db=tmp_path / "cache.db", enrichment_cache_db=tmp_path / "cache.db",
enrichment_providers=["dummy"], enrichment_providers=["tmdb"],
translation_fallback_machine=False, translation_fallback_machine=False,
enrichment_max_concurrency=4, enrichment_max_concurrency=4,
) )
config.enrichment_providers = ["dummy"]
identities = { identities = {
"metadata": {}, "metadata": {},
+154 -145
View File
@@ -24,32 +24,54 @@ from vlm.reports import (
) )
def _movie(title="Movie", year=2020, **kw):
return MovieIdentity(
title=title, year=year, confidence=kw.pop("confidence", 0.9),
needs_review=kw.pop("needs_review", False),
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
**kw,
)
def _series(title="Show", season=1, episodes=None, **kw):
if episodes is None:
episodes = [1]
return SeriesIdentity(
title=title, season=season, episodes=episodes,
confidence=kw.pop("confidence", 0.9),
needs_review=kw.pop("needs_review", False),
original_filename=kw.pop(
"original_filename",
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
if season is not None
else f"{title.replace(' ', '.')}.E01.mkv",
),
**kw,
)
def _video(filename="file.mkv", size=1000, category="movie", **kw):
return VideoFile(
path=kw.pop("path", Path(f"/tmp/{filename}")),
filename=filename, size_bytes=size,
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
category=category, **kw,
)
class TestInventoryReport: class TestInventoryReport:
"""Test inventory report generation.""" """Test inventory report generation."""
def test_generate_csv_report(self): def test_generate_csv_report(self):
"""Test generating CSV format inventory report.""" """Test generating CSV format inventory report."""
files = [ files = [
VideoFile( _video("Movie1.mkv", 2_000_000_000,
Path("/movies/Movie1.mkv"), modified_timestamp=datetime(2023, 1, 15, 10, 30, 0),
"Movie1.mkv", resolution="1920x1080", codec="h264",
2000000000, duration_seconds=7200.0, bitrate_kbps=5000),
datetime(2023, 1, 15, 10, 30, 0), _video("Show.S01E01.mkv", 1_000_000_000, "series",
"movie", modified_timestamp=datetime(2023, 2, 20, 14, 45, 0),
resolution="1920x1080", resolution="1280x720", codec="h265"),
codec="h264",
duration_seconds=7200.0,
bitrate_kbps=5000
),
VideoFile(
Path("/series/Show.S01E01.mkv"),
"Show.S01E01.mkv",
1000000000,
datetime(2023, 2, 20, 14, 45, 0),
"series",
resolution="1280x720",
codec="h265"
),
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -90,22 +112,11 @@ class TestInventoryReport:
def test_generate_json_report(self): def test_generate_json_report(self):
"""Test generating JSON format inventory report.""" """Test generating JSON format inventory report."""
files = [ files = [
VideoFile( _video("Movie1.mkv", 2_000_000_000,
Path("/movies/Movie1.mkv"), modified_timestamp=datetime(2023, 1, 15, 10, 30, 0),
"Movie1.mkv", resolution="1920x1080", codec="h264"),
2000000000, _video("Anime1.mkv", 800_000_000, "anime",
datetime(2023, 1, 15, 10, 30, 0), modified_timestamp=datetime(2023, 3, 10, 8, 15, 0)),
"movie",
resolution="1920x1080",
codec="h264"
),
VideoFile(
Path("/anime/Anime1.mkv"),
"Anime1.mkv",
800000000,
datetime(2023, 3, 10, 8, 15, 0),
"anime"
),
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -182,13 +193,8 @@ class TestInventoryReport:
def test_csv_schema_columns(self): def test_csv_schema_columns(self):
"""Test that CSV has all required columns in correct order.""" """Test that CSV has all required columns in correct order."""
files = [ files = [
VideoFile( _video("test.mkv", 1000,
Path("/test.mkv"), modified_timestamp=datetime.now(timezone.utc)),
"test.mkv",
1000,
datetime.now(timezone.utc),
"movie"
)
] ]
library_root = Path("/test") library_root = Path("/test")
@@ -210,13 +216,8 @@ class TestInventoryReport:
"""Test that timestamps are formatted as ISO 8601.""" """Test that timestamps are formatted as ISO 8601."""
naive_local = datetime(2023, 6, 15, 14, 30, 45) naive_local = datetime(2023, 6, 15, 14, 30, 45)
files = [ files = [
VideoFile( _video("test.mkv", 1000,
Path("/test.mkv"), modified_timestamp=naive_local),
"test.mkv",
1000,
naive_local,
"movie"
)
] ]
library_root = Path("/test") library_root = Path("/test")
@@ -238,13 +239,8 @@ class TestInventoryReport:
expected_utc = naive_local.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") expected_utc = naive_local.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
files = [ files = [
VideoFile( _video("test.mkv", 1000,
Path("/test.mkv"), modified_timestamp=naive_local),
"test.mkv",
1000,
naive_local,
"movie"
)
] ]
report = generate_inventory_report(files, "json", Path("/test")) report = generate_inventory_report(files, "json", Path("/test"))
data = json.loads(report) data = json.loads(report)
@@ -263,9 +259,18 @@ class TestCompletenessReport:
def test_generate_text_report_with_gaps(self): def test_generate_text_report_with_gaps(self):
"""Test generating text format completeness report with gaps.""" """Test generating text format completeness report with gaps."""
analysis = [ analysis = [
SeasonCompleteness("Breaking Bad", 1, [1, 2, 4, 5], [3]), SeasonCompleteness(
SeasonCompleteness("Breaking Bad", 2, [1, 3, 5], [2, 4]), series_title="Breaking Bad", season=1,
SeasonCompleteness("The Wire", 1, [1, 2, 4], [3]), episodes_found=[1, 2, 4, 5], episodes_missing=[3],
),
SeasonCompleteness(
series_title="Breaking Bad", season=2,
episodes_found=[1, 3, 5], episodes_missing=[2, 4],
),
SeasonCompleteness(
series_title="The Wire", season=1,
episodes_found=[1, 2, 4], episodes_missing=[3],
),
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -290,8 +295,14 @@ class TestCompletenessReport:
def test_generate_json_report_with_gaps(self): def test_generate_json_report_with_gaps(self):
"""Test generating JSON format completeness report with gaps.""" """Test generating JSON format completeness report with gaps."""
analysis = [ analysis = [
SeasonCompleteness("Breaking Bad", 1, [1, 2, 4, 5], [3]), SeasonCompleteness(
SeasonCompleteness("The Wire", 1, [1, 2, 4], [3]), series_title="Breaking Bad", season=1,
episodes_found=[1, 2, 4, 5], episodes_missing=[3],
),
SeasonCompleteness(
series_title="The Wire", season=1,
episodes_found=[1, 2, 4], episodes_missing=[3],
),
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -349,9 +360,18 @@ class TestCompletenessReport:
def test_multiple_seasons_same_series(self): def test_multiple_seasons_same_series(self):
"""Test report with multiple seasons of same series.""" """Test report with multiple seasons of same series."""
analysis = [ analysis = [
SeasonCompleteness("Show Name", 1, [1, 3], [2]), SeasonCompleteness(
SeasonCompleteness("Show Name", 2, [1, 2, 4], [3]), series_title="Show Name", season=1,
SeasonCompleteness("Show Name", 3, [5, 7], [6]), episodes_found=[1, 3], episodes_missing=[2],
),
SeasonCompleteness(
series_title="Show Name", season=2,
episodes_found=[1, 2, 4], episodes_missing=[3],
),
SeasonCompleteness(
series_title="Show Name", season=3,
episodes_found=[5, 7], episodes_missing=[6],
),
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -369,31 +389,18 @@ class TestDuplicateReport:
def test_generate_text_report_with_duplicates(self): def test_generate_text_report_with_duplicates(self):
"""Test generating text format duplicate report.""" """Test generating text format duplicate report."""
identities = [ now = datetime.now(timezone.utc)
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"), identity = _movie("The Matrix", 1999,
] original_filename="The.Matrix.1999.1080p.mkv")
files = [ files = [
VideoFile( _video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
Path("/movies/The.Matrix.1999.1080p.mkv"), modified_timestamp=now, resolution="1920x1080",
"The.Matrix.1999.1080p.mkv", codec="h264", duration_seconds=7200.0,
2000000000, bitrate_kbps=5000),
datetime.now(timezone.utc), _video("The.Matrix.1999.720p.mkv", 1_000_000_000,
"movie", modified_timestamp=now, resolution="1280x720",
resolution="1920x1080", codec="h264"),
codec="h264",
duration_seconds=7200.0,
bitrate_kbps=5000
),
VideoFile(
Path("/movies/The.Matrix.1999.720p.mkv"),
"The.Matrix.1999.720p.mkv",
1000000000,
datetime.now(timezone.utc),
"movie",
resolution="1280x720",
codec="h264"
),
] ]
quality_comparison = [ quality_comparison = [
@@ -416,7 +423,7 @@ class TestDuplicateReport:
] ]
duplicates = [ duplicates = [
DuplicateGroup(identities[0], files, quality_comparison) DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -442,23 +449,15 @@ class TestDuplicateReport:
def test_generate_json_report_with_duplicates(self): def test_generate_json_report_with_duplicates(self):
"""Test generating JSON format duplicate report.""" """Test generating JSON format duplicate report."""
identity = MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.1080p.mkv") now = datetime.now(timezone.utc)
identity = _movie("Inception", 2010,
original_filename="Inception.2010.1080p.mkv")
files = [ files = [
VideoFile( _video("Inception.2010.1080p.mkv", 2_000_000_000,
Path("/movies/Inception.2010.1080p.mkv"), modified_timestamp=now),
"Inception.2010.1080p.mkv", _video("Inception.2010.720p.mkv", 1_000_000_000,
2000000000, modified_timestamp=now),
datetime.now(timezone.utc),
"movie"
),
VideoFile(
Path("/movies/Inception.2010.720p.mkv"),
"Inception.2010.720p.mkv",
1000000000,
datetime.now(timezone.utc),
"movie"
),
] ]
quality_comparison = [ quality_comparison = [
@@ -467,7 +466,7 @@ class TestDuplicateReport:
] ]
duplicates = [ duplicates = [
DuplicateGroup(identity, files, quality_comparison) DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -495,23 +494,15 @@ class TestDuplicateReport:
def test_generate_text_report_series_duplicates(self): def test_generate_text_report_series_duplicates(self):
"""Test generating text report with series duplicates.""" """Test generating text report with series duplicates."""
identity = SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.mkv") now = datetime.now(timezone.utc)
identity = _series("Breaking Bad", episodes=[1],
original_filename="Breaking.Bad.S01E01.mkv")
files = [ files = [
VideoFile( _video("Breaking.Bad.S01E01.1080p.mkv", 1_500_000_000, "series",
Path("/series/Breaking.Bad.S01E01.1080p.mkv"), modified_timestamp=now),
"Breaking.Bad.S01E01.1080p.mkv", _video("Breaking.Bad.S01E01.720p.mkv", 800_000_000, "series",
1500000000, modified_timestamp=now),
datetime.now(timezone.utc),
"series"
),
VideoFile(
Path("/series/Breaking.Bad.S01E01.720p.mkv"),
"Breaking.Bad.S01E01.720p.mkv",
800000000,
datetime.now(timezone.utc),
"series"
),
] ]
quality_comparison = [ quality_comparison = [
@@ -520,7 +511,7 @@ class TestDuplicateReport:
] ]
duplicates = [ duplicates = [
DuplicateGroup(identity, files, quality_comparison) DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -562,21 +553,28 @@ class TestDuplicateReport:
def test_sorted_by_file_size(self): def test_sorted_by_file_size(self):
"""Test that duplicate groups are sorted by largest file size.""" """Test that duplicate groups are sorted by largest file size."""
now = datetime.now(timezone.utc)
# Create two duplicate groups with different sizes # Create two duplicate groups with different sizes
identity1 = MovieIdentity("Small Movie", 2020, 0.9, False, "Small.Movie.mkv") identity1 = _movie("Small Movie", 2020,
original_filename="Small.Movie.mkv")
files1 = [ files1 = [
VideoFile(Path("/movies/Small.Movie.1.mkv"), "Small.Movie.1.mkv", 500000000, datetime.now(timezone.utc), "movie"), _video("Small.Movie.1.mkv", 500_000_000,
VideoFile(Path("/movies/Small.Movie.2.mkv"), "Small.Movie.2.mkv", 600000000, datetime.now(timezone.utc), "movie"), modified_timestamp=now),
_video("Small.Movie.2.mkv", 600_000_000,
modified_timestamp=now),
] ]
quality1 = [ quality1 = [
{'filename': 'Small.Movie.1.mkv', 'path': '/movies/Small.Movie.1.mkv', 'size_bytes': 500000000}, {'filename': 'Small.Movie.1.mkv', 'path': '/movies/Small.Movie.1.mkv', 'size_bytes': 500000000},
{'filename': 'Small.Movie.2.mkv', 'path': '/movies/Small.Movie.2.mkv', 'size_bytes': 600000000} {'filename': 'Small.Movie.2.mkv', 'path': '/movies/Small.Movie.2.mkv', 'size_bytes': 600000000}
] ]
identity2 = MovieIdentity("Large Movie", 2021, 0.9, False, "Large.Movie.mkv") identity2 = _movie("Large Movie", 2021,
original_filename="Large.Movie.mkv")
files2 = [ files2 = [
VideoFile(Path("/movies/Large.Movie.1.mkv"), "Large.Movie.1.mkv", 2000000000, datetime.now(timezone.utc), "movie"), _video("Large.Movie.1.mkv", 2_000_000_000,
VideoFile(Path("/movies/Large.Movie.2.mkv"), "Large.Movie.2.mkv", 1800000000, datetime.now(timezone.utc), "movie"), modified_timestamp=now),
_video("Large.Movie.2.mkv", 1_800_000_000,
modified_timestamp=now),
] ]
quality2 = [ quality2 = [
{'filename': 'Large.Movie.1.mkv', 'path': '/movies/Large.Movie.1.mkv', 'size_bytes': 2000000000}, {'filename': 'Large.Movie.1.mkv', 'path': '/movies/Large.Movie.1.mkv', 'size_bytes': 2000000000},
@@ -584,8 +582,8 @@ class TestDuplicateReport:
] ]
duplicates = [ duplicates = [
DuplicateGroup(identity1, files1, quality1), DuplicateGroup(identity=identity1, files=files1, quality_comparison=quality1),
DuplicateGroup(identity2, files2, quality2) DuplicateGroup(identity=identity2, files=files2, quality_comparison=quality2)
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -599,20 +597,21 @@ class TestDuplicateReport:
def test_sorted_by_quality_size_when_file_sizes_missing(self): def test_sorted_by_quality_size_when_file_sizes_missing(self):
"""Sort order should use quality_comparison sizes when VideoFile sizes are zero.""" """Sort order should use quality_comparison sizes when VideoFile sizes are zero."""
identity1 = MovieIdentity("Tiny", 2020, 0.9, False, "Tiny.mkv") now = datetime.now(timezone.utc)
identity1 = _movie("Tiny", 2020, original_filename="Tiny.mkv")
files1 = [ files1 = [
VideoFile(Path("/movies/Tiny.1.mkv"), "Tiny.1.mkv", 0, datetime.now(timezone.utc), "movie"), _video("Tiny.1.mkv", 0, modified_timestamp=now),
VideoFile(Path("/movies/Tiny.2.mkv"), "Tiny.2.mkv", 0, datetime.now(timezone.utc), "movie"), _video("Tiny.2.mkv", 0, modified_timestamp=now),
] ]
quality1 = [ quality1 = [
{"filename": "Tiny.1.mkv", "path": "/movies/Tiny.1.mkv", "size_bytes": 600000000}, {"filename": "Tiny.1.mkv", "path": "/movies/Tiny.1.mkv", "size_bytes": 600000000},
{"filename": "Tiny.2.mkv", "path": "/movies/Tiny.2.mkv", "size_bytes": 500000000}, {"filename": "Tiny.2.mkv", "path": "/movies/Tiny.2.mkv", "size_bytes": 500000000},
] ]
identity2 = MovieIdentity("Huge", 2021, 0.9, False, "Huge.mkv") identity2 = _movie("Huge", 2021, original_filename="Huge.mkv")
files2 = [ files2 = [
VideoFile(Path("/movies/Huge.1.mkv"), "Huge.1.mkv", 0, datetime.now(timezone.utc), "movie"), _video("Huge.1.mkv", 0, modified_timestamp=now),
VideoFile(Path("/movies/Huge.2.mkv"), "Huge.2.mkv", 0, datetime.now(timezone.utc), "movie"), _video("Huge.2.mkv", 0, modified_timestamp=now),
] ]
quality2 = [ quality2 = [
{"filename": "Huge.1.mkv", "path": "/movies/Huge.1.mkv", "size_bytes": 3000000000}, {"filename": "Huge.1.mkv", "path": "/movies/Huge.1.mkv", "size_bytes": 3000000000},
@@ -620,8 +619,8 @@ class TestDuplicateReport:
] ]
duplicates = [ duplicates = [
DuplicateGroup(identity1, files1, quality1), DuplicateGroup(identity=identity1, files=files1, quality_comparison=quality1),
DuplicateGroup(identity2, files2, quality2), DuplicateGroup(identity=identity2, files=files2, quality_comparison=quality2),
] ]
report = generate_duplicate_report(duplicates, "text", Path("/mnt/nas/videos")) report = generate_duplicate_report(duplicates, "text", Path("/mnt/nas/videos"))
assert report.find("Huge") < report.find("Tiny") assert report.find("Huge") < report.find("Tiny")
@@ -632,13 +631,20 @@ class TestSummaryReport:
def test_generate_summary_report(self): def test_generate_summary_report(self):
"""Test generating summary report with various files.""" """Test generating summary report with various files."""
now = datetime.now(timezone.utc)
files = [ files = [
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(timezone.utc), "movie"), _video("Movie1.mkv", 2_000_000_000, "movie",
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(timezone.utc), "movie"), modified_timestamp=now),
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"), _video("Movie2.mkv", 1_500_000_000, "movie",
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"), modified_timestamp=now),
VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(timezone.utc), "anime"), _video("Show.S01E01.mkv", 1_000_000_000, "series",
VideoFile(Path("/other/Random.mkv"), "Random.mkv", 500000000, datetime.now(timezone.utc), "other"), modified_timestamp=now),
_video("Show.S01E02.mkv", 1_000_000_000, "series",
modified_timestamp=now),
_video("Anime1.mkv", 800_000_000, "anime",
modified_timestamp=now),
_video("Random.mkv", 500_000_000, "other",
modified_timestamp=now),
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -677,9 +683,12 @@ class TestSummaryReport:
def test_generate_summary_report_single_category(self): def test_generate_summary_report_single_category(self):
"""Test generating summary report with files in single category.""" """Test generating summary report with files in single category."""
now = datetime.now(timezone.utc)
files = [ files = [
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 1000000000, datetime.now(timezone.utc), "movie"), _video("Movie1.mkv", 1_000_000_000, "movie",
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 2000000000, datetime.now(timezone.utc), "movie"), modified_timestamp=now),
_video("Movie2.mkv", 2_000_000_000, "movie",
modified_timestamp=now),
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
+68 -38
View File
@@ -16,6 +16,41 @@ from vlm.reports import (
) )
def _movie(title="Movie", year=2020, **kw):
return MovieIdentity(
title=title, year=year, confidence=kw.pop("confidence", 0.9),
needs_review=kw.pop("needs_review", False),
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
**kw,
)
def _series(title="Show", season=1, episodes=None, **kw):
if episodes is None:
episodes = [1]
return SeriesIdentity(
title=title, season=season, episodes=episodes,
confidence=kw.pop("confidence", 0.9),
needs_review=kw.pop("needs_review", False),
original_filename=kw.pop(
"original_filename",
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
if season is not None
else f"{title.replace(' ', '.')}.E01.mkv",
),
**kw,
)
def _video(filename="file.mkv", size=1000, category="movie", **kw):
return VideoFile(
path=kw.pop("path", Path(f"/tmp/{filename}")),
filename=filename, size_bytes=size,
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
category=category, **kw,
)
class TestReportsIntegration: class TestReportsIntegration:
"""Test report generation integrated with analysis engine.""" """Test report generation integrated with analysis engine."""
@@ -23,11 +58,16 @@ class TestReportsIntegration:
"""Test complete workflow from series analysis to completeness report.""" """Test complete workflow from series analysis to completeness report."""
# Create test episodes with gaps # Create test episodes with gaps
episodes = [ episodes = [
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.mkv"), _series("Breaking Bad", episodes=[1],
SeriesIdentity("Breaking Bad", 1, [2], 0.9, False, "Breaking.Bad.S01E02.mkv"), original_filename="Breaking.Bad.S01E01.mkv"),
SeriesIdentity("Breaking Bad", 1, [4], 0.9, False, "Breaking.Bad.S01E04.mkv"), _series("Breaking Bad", episodes=[2],
SeriesIdentity("The Wire", 1, [1], 0.9, False, "The.Wire.S01E01.mkv"), original_filename="Breaking.Bad.S01E02.mkv"),
SeriesIdentity("The Wire", 1, [3], 0.9, False, "The.Wire.S01E03.mkv"), _series("Breaking Bad", episodes=[4],
original_filename="Breaking.Bad.S01E04.mkv"),
_series("The Wire", episodes=[1],
original_filename="The.Wire.S01E01.mkv"),
_series("The Wire", episodes=[3],
original_filename="The.Wire.S01E03.mkv"),
] ]
# Analyze completeness # Analyze completeness
@@ -53,38 +93,22 @@ class TestReportsIntegration:
def test_duplicate_workflow(self): def test_duplicate_workflow(self):
"""Test complete workflow from duplicate detection to duplicate report.""" """Test complete workflow from duplicate detection to duplicate report."""
# Create test identities and files # Create test identities and files
now = datetime.now(timezone.utc)
identities = [ identities = [
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"), _movie("The Matrix", 1999,
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.720p.mkv"), original_filename="The.Matrix.1999.1080p.mkv"),
MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.mkv"), _movie("The Matrix", 1999,
original_filename="The.Matrix.1999.720p.mkv"),
_movie("Inception", 2010),
] ]
files = [ files = [
VideoFile( _video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
Path("/movies/The.Matrix.1999.1080p.mkv"), modified_timestamp=now, resolution="1920x1080", codec="h264"),
"The.Matrix.1999.1080p.mkv", _video("The.Matrix.1999.720p.mkv", 1_000_000_000,
2000000000, modified_timestamp=now, resolution="1280x720", codec="h264"),
datetime.now(timezone.utc), _video("Inception.2010.mkv", 1_500_000_000,
"movie", modified_timestamp=now),
resolution="1920x1080",
codec="h264"
),
VideoFile(
Path("/movies/The.Matrix.1999.720p.mkv"),
"The.Matrix.1999.720p.mkv",
1000000000,
datetime.now(timezone.utc),
"movie",
resolution="1280x720",
codec="h264"
),
VideoFile(
Path("/movies/Inception.2010.mkv"),
"Inception.2010.mkv",
1500000000,
datetime.now(timezone.utc),
"movie"
),
] ]
# Detect duplicates # Detect duplicates
@@ -111,12 +135,18 @@ class TestReportsIntegration:
def test_summary_workflow(self): def test_summary_workflow(self):
"""Test summary report generation with mixed file types.""" """Test summary report generation with mixed file types."""
# Create test files # Create test files
now = datetime.now(timezone.utc)
files = [ files = [
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(timezone.utc), "movie"), _video("Movie1.mkv", 2_000_000_000, "movie",
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(timezone.utc), "movie"), modified_timestamp=now),
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"), _video("Movie2.mkv", 1_500_000_000, "movie",
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"), modified_timestamp=now),
VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(timezone.utc), "anime"), _video("Show.S01E01.mkv", 1_000_000_000, "series",
modified_timestamp=now),
_video("Show.S01E02.mkv", 1_000_000_000, "series",
modified_timestamp=now),
_video("Anime1.mkv", 800_000_000, "anime",
modified_timestamp=now),
] ]
# Generate summary report # Generate summary report
+5 -75
View File
@@ -110,8 +110,8 @@ class TestScanLibrary:
filenames = {vf.filename for vf in result} filenames = {vf.filename for vf in result}
assert filenames == {"video.mp4", "video.mkv"} assert filenames == {"video.mp4", "video.mkv"}
def test_scan_uses_find_output_and_filters_hidden_paths(self, tmp_path): def test_scan_filters_hidden_paths(self, tmp_path):
"""Test scan_library filters hidden paths from find output.""" """Test scan_library filters hidden paths from discovery."""
movie_dir = tmp_path / "movie" movie_dir = tmp_path / "movie"
hidden_dir = tmp_path / ".hidden" hidden_dir = tmp_path / ".hidden"
movie_dir.mkdir() movie_dir.mkdir()
@@ -122,81 +122,11 @@ class TestScanLibrary:
visible_file.touch() visible_file.touch()
hidden_file.touch() hidden_file.touch()
fake_stdout = f"{visible_file}\0{hidden_file}\0".encode()
with patch('subprocess.Popen') as mock_popen:
process = MagicMock()
process.communicate.return_value = (fake_stdout, b"")
process.returncode = 0
mock_popen.return_value = process
config = Config(library_root=tmp_path)
result = scan_library(tmp_path, config)
assert len(result) == 1
assert result[0].path == visible_file
def test_scan_keeps_partial_find_results_when_find_exits_nonzero(self, tmp_path):
"""Non-zero find exits should keep partial stdout and log the contract."""
movie_dir = tmp_path / "movie"
movie_dir.mkdir()
visible_file = movie_dir / "visible.mp4"
visible_file.touch()
fake_stdout = f"{visible_file}\0".encode()
with patch('subprocess.Popen') as mock_popen, patch("vlm.scanner.logger.warning") as mock_warning:
process = MagicMock()
process.communicate.return_value = (fake_stdout, b"Permission denied")
process.returncode = 1
mock_popen.return_value = process
config = Config(library_root=tmp_path) config = Config(library_root=tmp_path)
result = scan_library(tmp_path, config, include_video_metadata=False) result = scan_library(tmp_path, config, include_video_metadata=False)
warning_messages = [
call.args[0] % call.args[1:] if len(call.args) > 1 else call.args[0]
for call in mock_warning.call_args_list
]
assert len(result) == 1 assert len(result) == 1
assert result[0].path == visible_file assert result[0].path == visible_file
assert any("using 1 partial scan result" in message for message in warning_messages)
assert any("Permission denied" in message for message in warning_messages)
def test_scan_returns_empty_when_find_exits_nonzero_without_stdout(self, tmp_path):
"""Non-zero find exits without stdout should produce an empty result deterministically."""
(tmp_path / "movie").mkdir()
with patch('subprocess.Popen') as mock_popen, patch("vlm.scanner.logger.warning") as mock_warning:
process = MagicMock()
process.communicate.return_value = (b"", b"Permission denied")
process.returncode = 1
mock_popen.return_value = process
config = Config(library_root=tmp_path)
result = scan_library(tmp_path, config, include_video_metadata=False)
warning_messages = [
call.args[0] % call.args[1:] if len(call.args) > 1 else call.args[0]
for call in mock_warning.call_args_list
]
assert result == []
assert any("produced no scan results" in message for message in warning_messages)
assert any("Permission denied" in message for message in warning_messages)
def test_scan_falls_back_when_find_is_unavailable(self, tmp_path):
"""Test scan_library falls back to recursive scanning if find is unavailable."""
movie_dir = tmp_path / "movie"
movie_dir.mkdir()
video_file = movie_dir / "fallback.mp4"
video_file.touch()
with patch('subprocess.Popen', side_effect=FileNotFoundError):
config = Config(library_root=tmp_path)
result = scan_library(tmp_path, config)
assert len(result) == 1
assert result[0].path == video_file
def test_scan_records_metadata(self, tmp_path): def test_scan_records_metadata(self, tmp_path):
"""Test scanning records file metadata correctly.""" """Test scanning records file metadata correctly."""
@@ -356,7 +286,7 @@ class TestScanLibrary:
with patch("vlm.scanner._discover_video_paths", return_value=fake_paths), patch( with patch("vlm.scanner._discover_video_paths", return_value=fake_paths), patch(
"vlm.scanner._create_video_file", "vlm.scanner._create_video_file",
side_effect=_fake_create, side_effect=_fake_create,
): ), patch("shutil.which", return_value="/usr/bin/ffprobe"):
result = scan_library(tmp_path, config, include_video_metadata=True) result = scan_library(tmp_path, config, include_video_metadata=True)
assert len(result) == len(fake_paths) assert len(result) == len(fake_paths)
@@ -736,7 +666,7 @@ class TestExtractMetadata:
} }
} }
with patch('subprocess.run') as mock_run: with patch('shutil.which', return_value='/usr/bin/ffprobe'), patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=0, returncode=0,
stdout=json.dumps(mock_output), stdout=json.dumps(mock_output),
@@ -823,7 +753,7 @@ class TestExtractMetadata:
metadata_cache = {str(vf.path): vf for vf in cached_entries} metadata_cache = {str(vf.path): vf for vf in cached_entries}
config = Config(library_root=tmp_path) config = Config(library_root=tmp_path)
with patch("subprocess.run") as mock_run: with patch("shutil.which", return_value="/usr/bin/ffprobe"), patch("subprocess.run") as mock_run:
result = scan_library(tmp_path, config, metadata_cache=metadata_cache) result = scan_library(tmp_path, config, metadata_cache=metadata_cache)
assert len(result) == 1 assert len(result) == 1
Generated
+155 -1
View File
@@ -2,6 +2,15 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.10" requires-python = ">=3.10"
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.1" version = "8.3.1"
@@ -146,7 +155,7 @@ name = "exceptiongroup"
version = "1.3.1" version = "1.3.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [ wheels = [
@@ -252,6 +261,137 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" },
{ url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" },
{ url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" },
{ url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" },
{ url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" },
{ url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" },
{ url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" },
{ url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" },
{ url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" },
{ url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" },
{ url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" },
{ url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" },
{ url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" },
{ url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" },
{ url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" },
{ url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" },
{ url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" },
{ url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" },
{ url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" },
{ url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" },
{ url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" },
{ url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" },
{ url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
{ url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" },
{ url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" },
{ url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
{ url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" },
{ url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" },
{ url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" },
{ url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" },
{ url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" },
{ url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" },
{ url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.19.2"
@@ -484,6 +624,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
] ]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]] [[package]]
name = "uc-micro-py" name = "uc-micro-py"
version = "2.0.0" version = "2.0.0"
@@ -499,6 +651,7 @@ version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "pydantic" },
{ name = "pyyaml" }, { name = "pyyaml" },
] ]
@@ -517,6 +670,7 @@ tui = [
requires-dist = [ requires-dist = [
{ name = "click", specifier = ">=8.1.0" }, { name = "click", specifier = ">=8.1.0" },
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.82.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.82.0" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },