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]
+31 -1016
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import click 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
+46 -45
View File
@@ -4,14 +4,14 @@ This module defines the core data structures used throughout the application
for representing video files and their parsed identities. 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,49 +146,49 @@ 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:
operation: The file operation that was executed operation: The file operation that was executed
success: Flag indicating if the operation succeeded success: Flag indicating if the operation succeeded
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:
log_id: Unique identifier for the rollback log (UUID) log_id: Unique identifier for the rollback log (UUID)
execution_plan_id: ID of the execution plan that was executed execution_plan_id: ID of the execution plan that was executed
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,80 +200,81 @@ 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:
file_path: Path to the file file_path: Path to the file
status: Current status ("reviewed", "ignored", "planned", "executed", "quarantined") status: Current status ("reviewed", "ignored", "planned", "executed", "quarantined")
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:
states: Dictionary mapping file path strings to FileState objects states: Dictionary mapping file path strings to FileState objects
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:
series_title: Normalized series title series_title: Normalized series title
season: Season number season: Season number
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:
identity: The shared identity (MovieIdentity or SeriesIdentity) identity: The shared identity (MovieIdentity or SeriesIdentity)
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):
+319 -277
View File
@@ -10,214 +10,269 @@ 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)
assert len(result) == 1 assert len(result) == 1
assert result[0].series_title == "Show Name" assert result[0].series_title == "Show Name"
assert result[0].season == 1 assert result[0].season == 1
assert result[0].episodes_found == [1, 2, 4, 5] assert result[0].episodes_found == [1, 2, 4, 5]
assert result[0].episodes_missing == [3] assert result[0].episodes_missing == [3]
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)
assert len(result) == 1 assert len(result) == 1
assert result[0].episodes_found == [1, 3, 5, 7] assert result[0].episodes_found == [1, 3, 5, 7]
assert result[0].episodes_missing == [2, 4, 6] assert result[0].episodes_missing == [2, 4, 6]
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)
assert len(result) == 0 assert len(result) == 0
def test_multi_season_independence(self): def test_multi_season_independence(self):
"""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)
# Should have 2 results (seasons 1 and 3 with gaps) # Should have 2 results (seasons 1 and 3 with gaps)
assert len(result) == 2 assert len(result) == 2
# Find season 1 result # Find season 1 result
season1 = next(r for r in result if r.season == 1) season1 = next(r for r in result if r.season == 1)
assert season1.episodes_found == [1, 3] assert season1.episodes_found == [1, 3]
assert season1.episodes_missing == [2] assert season1.episodes_missing == [2]
# Find season 3 result # Find season 3 result
season3 = next(r for r in result if r.season == 3) season3 = next(r for r in result if r.season == 3)
assert season3.episodes_found == [4, 6] assert season3.episodes_found == [4, 6]
assert season3.episodes_missing == [5] assert season3.episodes_missing == [5]
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)
assert len(result) == 1 assert len(result) == 1
assert result[0].episodes_found == [1, 2, 4] assert result[0].episodes_found == [1, 2, 4]
assert result[0].episodes_missing == [3] assert result[0].episodes_missing == [3]
def test_different_series_separate_analysis(self): def test_different_series_separate_analysis(self):
"""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)
# Only Series A should be in results # Only Series A should be in results
assert len(result) == 1 assert len(result) == 1
assert result[0].series_title == "Series A" assert result[0].series_title == "Series A"
assert result[0].episodes_missing == [2] assert result[0].episodes_missing == [2]
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)
# Should only analyze season 1, which is complete # Should only analyze season 1, which is complete
assert len(result) == 0 assert len(result) == 0
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)
# Should detect gap at episode 2 # Should detect gap at episode 2
assert len(result) == 1 assert len(result) == 1
assert result[0].episodes_missing == [2] assert result[0].episodes_missing == [2]
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)
# Should detect gap at episode 7 in range [5, 8] # Should detect gap at episode 7 in range [5, 8]
assert len(result) == 1 assert len(result) == 1
assert result[0].episodes_found == [5, 6, 8] assert result[0].episodes_found == [5, 6, 8]
assert result[0].episodes_missing == [7] assert result[0].episodes_missing == [7]
def test_empty_input(self): def test_empty_input(self):
"""Test handling of empty episode list.""" """Test handling of empty episode list."""
result = analyze_series_completeness([]) result = analyze_series_completeness([])
assert len(result) == 0 assert len(result) == 0
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)
# Single episode has no gaps # Single episode has no gaps
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)))
# Should find one duplicate group (The Matrix) # Should find one duplicate group (The Matrix)
assert len(result) == 1 assert len(result) == 1
assert isinstance(result[0].identity, MovieIdentity) assert isinstance(result[0].identity, MovieIdentity)
@@ -225,43 +280,32 @@ class TestDuplicateDetection:
assert result[0].identity.year == 1999 assert result[0].identity.year == 1999
assert len(result[0].files) == 2 assert len(result[0].files) == 2
assert len(result[0].quality_comparison) == 2 assert len(result[0].quality_comparison) == 2
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)))
# Should find one duplicate group (S01E01) # Should find one duplicate group (S01E01)
assert len(result) == 1 assert len(result) == 1
assert isinstance(result[0].identity, SeriesIdentity) assert isinstance(result[0].identity, SeriesIdentity)
@@ -269,110 +313,123 @@ class TestDuplicateDetection:
assert result[0].identity.season == 1 assert result[0].identity.season == 1
assert 1 in result[0].identity.episodes assert 1 in result[0].identity.episodes
assert len(result[0].files) == 2 assert len(result[0].files) == 2
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)))
assert len(result) == 0 assert len(result) == 0
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)))
# Should not detect duplicates for files needing review # Should not detect duplicates for files needing review
assert len(result) == 0 assert len(result) == 0
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)))
assert len(result) == 0 assert len(result) == 0
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)))
assert len(result) == 0 assert len(result) == 0
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)))
assert len(result) == 1 assert len(result) == 1
comparison = result[0].quality_comparison comparison = result[0].quality_comparison
assert len(comparison) == 2 assert len(comparison) == 2
# Check first file comparison data # Check first file comparison data
assert comparison[0]['filename'] == "Test.Movie.2020.1080p.mkv" assert comparison[0]['filename'] == "Test.Movie.2020.1080p.mkv"
assert comparison[0]['size_bytes'] == 2000000000 assert comparison[0]['size_bytes'] == 2000000000
@@ -380,96 +437,98 @@ class TestDuplicateDetection:
assert comparison[0]['codec'] == "h264" assert comparison[0]['codec'] == "h264"
assert comparison[0]['duration_seconds'] == 7200.0 assert comparison[0]['duration_seconds'] == 7200.0
assert comparison[0]['bitrate_kbps'] == 5000 assert comparison[0]['bitrate_kbps'] == 5000
# Check second file comparison data # Check second file comparison data
assert comparison[1]['filename'] == "Test.Movie.2020.720p.mkv" assert comparison[1]['filename'] == "Test.Movie.2020.720p.mkv"
assert comparison[1]['size_bytes'] == 1000000000 assert comparison[1]['size_bytes'] == 1000000000
assert comparison[1]['resolution'] == "1280x720" assert comparison[1]['resolution'] == "1280x720"
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)))
# Should find duplicates for both E01 and E02 # Should find duplicates for both E01 and E02
assert len(result) == 2 assert len(result) == 2
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)))
assert len(result) == 0 assert len(result) == 0
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)))
assert len(result) == 0 assert len(result) == 0
class TestQualityComparison: class TestQualityComparison:
"""Test quality comparison functionality.""" """Test quality comparison functionality."""
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)
assert len(result) == 2 assert len(result) == 2
assert result[0]['filename'] == "file1.mkv" assert result[0]['filename'] == "file1.mkv"
assert result[0]['size_bytes'] == 2000000000 assert result[0]['size_bytes'] == 2000000000
@@ -477,36 +536,24 @@ class TestQualityComparison:
assert result[0]['codec'] == "h264" assert result[0]['codec'] == "h264"
assert result[0]['duration_seconds'] == 7200.0 assert result[0]['duration_seconds'] == 7200.0
assert result[0]['bitrate_kbps'] == 5000 assert result[0]['bitrate_kbps'] == 5000
assert result[1]['filename'] == "file2.mkv" assert result[1]['filename'] == "file2.mkv"
assert result[1]['size_bytes'] == 1000000000 assert result[1]['size_bytes'] == 1000000000
assert result[1]['resolution'] == "1280x720" assert result[1]['resolution'] == "1280x720"
assert result[1]['codec'] == "h265" assert result[1]['codec'] == "h265"
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)
assert len(result) == 2 assert len(result) == 2
assert result[0]['filename'] == "file1.mkv" assert result[0]['filename'] == "file1.mkv"
assert result[0]['size_bytes'] == 2000000000 assert result[0]['size_bytes'] == 2000000000
@@ -514,33 +561,28 @@ class TestQualityComparison:
assert 'codec' not in result[0] assert 'codec' not in result[0]
assert 'duration_seconds' not in result[0] assert 'duration_seconds' not in result[0]
assert 'bitrate_kbps' not in result[0] assert 'bitrate_kbps' not in result[0]
assert result[1]['filename'] == "file2.mkv" assert result[1]['filename'] == "file2.mkv"
assert result[1]['size_bytes'] == 1000000000 assert result[1]['size_bytes'] == 1000000000
assert 'resolution' not in result[1] assert 'resolution' not in result[1]
assert 'codec' not in result[1] assert 'codec' not in result[1]
def test_compare_quality_empty_list(self): def test_compare_quality_empty_list(self):
"""Test quality comparison with empty file list.""" """Test quality comparison with empty file list."""
result = compare_quality([]) result = compare_quality([])
assert len(result) == 0 assert len(result) == 0
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)
assert len(result) == 1 assert len(result) == 1
assert result[0]['filename'] == "file.mkv" assert result[0]['filename'] == "file.mkv"
assert result[0]['size_bytes'] == 1500000000 assert result[0]['size_bytes'] == 1500000000
+162 -129
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
@@ -27,10 +63,10 @@ def series_identity_strategy(draw, title=None, season=None):
title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters( title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' ' whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' '
))) )))
if season is None: if season is None:
season = draw(st.integers(min_value=1, max_value=20)) season = draw(st.integers(min_value=1, max_value=20))
# Generate 1-3 episode numbers # Generate 1-3 episode numbers
episode_count = draw(st.integers(min_value=1, max_value=3)) episode_count = draw(st.integers(min_value=1, max_value=3))
episodes = draw(st.lists( episodes = draw(st.lists(
@@ -39,12 +75,13 @@ def series_identity_strategy(draw, title=None, season=None):
max_size=episode_count, max_size=episode_count,
unique=True unique=True
)) ))
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
@@ -54,15 +91,15 @@ def movie_identity_strategy(draw, title=None, year=None):
title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters( title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' ' whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' '
))) )))
if year is None: if year is 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
@@ -72,11 +109,10 @@ def video_file_strategy(draw, filename=None, category="movie"):
filename = draw(st.text(min_size=5, max_size=50, alphabet=st.characters( filename = draw(st.text(min_size=5, max_size=50, alphabet=st.characters(
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())
if has_metadata: if has_metadata:
@@ -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
@@ -109,38 +152,39 @@ def video_file_strategy(draw, filename=None, category="movie"):
def test_property_10_gap_detection(title, season, episodes_data): def test_property_10_gap_detection(title, season, episodes_data):
"""Property 10: For any set of episodes within season, analysis SHALL detect """Property 10: For any set of episodes within season, analysis SHALL detect
missing episode numbers in range [min, max]. missing episode numbers in range [min, max].
Validates: Requirements 4.1, 4.2 Validates: Requirements 4.1, 4.2
""" """
# Sort episodes and ensure there's at least one gap # Sort episodes and ensure there's at least one gap
sorted_episodes = sorted(episodes_data) sorted_episodes = sorted(episodes_data)
# Create episodes, intentionally removing one to create a gap # Create episodes, intentionally removing one to create a gap
if len(sorted_episodes) >= 3: if len(sorted_episodes) >= 3:
# Remove a middle episode to guarantee a gap # Remove a middle episode to guarantee a gap
gap_index = len(sorted_episodes) // 2 gap_index = len(sorted_episodes) // 2
removed_episode = sorted_episodes[gap_index] removed_episode = sorted_episodes[gap_index]
episodes_with_gap = sorted_episodes[:gap_index] + sorted_episodes[gap_index + 1:] episodes_with_gap = sorted_episodes[:gap_index] + sorted_episodes[gap_index + 1:]
# 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
] ]
# Analyze completeness # Analyze completeness
result = analyze_series_completeness(episode_identities) result = analyze_series_completeness(episode_identities)
# Should detect the gap # Should detect the gap
if len(result) > 0: if len(result) > 0:
assert result[0].series_title == title assert result[0].series_title == title
assert result[0].season == season assert result[0].season == season
# The missing episode should be in the detected gaps # The missing episode should be in the detected gaps
min_ep = min(episodes_with_gap) min_ep = min(episodes_with_gap)
max_ep = max(episodes_with_gap) max_ep = max(episodes_with_gap)
expected_missing = set(range(min_ep, max_ep + 1)) - set(episodes_with_gap) expected_missing = set(range(min_ep, max_ep + 1)) - set(episodes_with_gap)
assert set(result[0].episodes_missing) == expected_missing assert set(result[0].episodes_missing) == expected_missing
assert removed_episode in result[0].episodes_missing assert removed_episode in result[0].episodes_missing
@@ -158,7 +202,7 @@ def test_property_10_gap_detection(title, season, episodes_data):
def test_property_11_multi_season_independence(title, season1_episodes, season2_episodes): def test_property_11_multi_season_independence(title, season1_episodes, season2_episodes):
"""Property 11: For any series with multiple seasons, gap detection of one """Property 11: For any series with multiple seasons, gap detection of one
season SHALL not affect others. season SHALL not affect others.
Validates: Requirements 4.4 Validates: Requirements 4.4
""" """
# Create episodes for season 1 with a gap # Create episodes for season 1 with a gap
@@ -170,29 +214,31 @@ def test_property_11_multi_season_independence(title, season1_episodes, season2_
else: else:
s1_with_gap = s1_sorted s1_with_gap = s1_sorted
s1_missing = None s1_missing = None
# Create episodes for season 2 (complete, no gaps) # Create episodes for season 2 (complete, no gaps)
s2_sorted = sorted(season2_episodes) s2_sorted = sorted(season2_episodes)
s2_complete = list(range(min(s2_sorted), max(s2_sorted) + 1)) s2_complete = list(range(min(s2_sorted), max(s2_sorted) + 1))
# Create SeriesIdentity objects # Create SeriesIdentity objects
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
result = analyze_series_completeness(episode_identities) result = analyze_series_completeness(episode_identities)
# Season 2 should not appear in results (it's complete) # Season 2 should not appear in results (it's complete)
season2_results = [r for r in result if r.season == 2] season2_results = [r for r in result if r.season == 2]
assert len(season2_results) == 0 assert len(season2_results) == 0
# Season 1 should appear if there's a gap # Season 1 should appear if there's a gap
if s1_missing is not None: if s1_missing is not None:
season1_results = [r for r in result if r.season == 1] season1_results = [r for r in result if r.season == 1]
@@ -213,33 +259,31 @@ def test_property_11_multi_season_independence(title, season1_episodes, season2_
def test_property_12_duplicate_detection_movies(title, year, duplicate_count): def test_property_12_duplicate_detection_movies(title, year, duplicate_count):
"""Property 12: For any set of movies with identical normalized titles and years, """Property 12: For any set of movies with identical normalized titles and years,
all SHALL be grouped as duplicates. all SHALL be grouped as duplicates.
Validates: Requirements 5.1 Validates: Requirements 5.1
""" """
# 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
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
# Should find exactly one duplicate group # Should find exactly one duplicate group
assert len(result) == 1 assert len(result) == 1
# The group should contain all files # The group should contain all files
assert len(result[0].files) == duplicate_count assert len(result[0].files) == duplicate_count
# Identity should match # Identity should match
assert result[0].identity.title == title assert result[0].identity.title == title
assert result[0].identity.year == year assert result[0].identity.year == year
@@ -259,33 +303,33 @@ def test_property_12_duplicate_detection_movies(title, year, duplicate_count):
def test_property_13_duplicate_detection_series(title, season, episode, duplicate_count): def test_property_13_duplicate_detection_series(title, season, episode, duplicate_count):
"""Property 13: For any set of series files with identical normalized titles, """Property 13: For any set of series files with identical normalized titles,
seasons, and episodes, all SHALL be grouped as duplicates. seasons, and episodes, all SHALL be grouped as duplicates.
Validates: Requirements 5.2 Validates: Requirements 5.2
""" """
# 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
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
# Should find exactly one duplicate group # Should find exactly one duplicate group
assert len(result) == 1 assert len(result) == 1
# The group should contain all files # The group should contain all files
assert len(result[0].files) == duplicate_count assert len(result[0].files) == duplicate_count
# Identity should match # Identity should match
assert result[0].identity.title == title assert result[0].identity.title == title
assert result[0].identity.season == season assert result[0].identity.season == season
@@ -305,52 +349,45 @@ def test_property_13_duplicate_detection_series(title, season, episode, duplicat
def test_property_14_duplicate_quality_comparison(title, year, file_count): def test_property_14_duplicate_quality_comparison(title, year, file_count):
"""Property 14: For any duplicate group, comparison data SHALL include """Property 14: For any duplicate group, comparison data SHALL include
available metadata for each file. available metadata for each file.
Validates: Requirements 5.3 Validates: Requirements 5.3
""" """
# 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
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
# Should have quality comparison data # Should have quality comparison data
assert len(result) == 1 assert len(result) == 1
assert len(result[0].quality_comparison) == file_count assert len(result[0].quality_comparison) == file_count
# Each comparison entry should have at least filename and size # Each comparison entry should have at least filename and size
for comparison in result[0].quality_comparison: for comparison in result[0].quality_comparison:
assert 'filename' in comparison assert 'filename' in comparison
assert 'size_bytes' in comparison assert 'size_bytes' in comparison
assert 'path' in comparison assert 'path' in comparison
# Files with metadata should have those fields # Files with metadata should have those fields
if comparison['filename'].endswith('.0.mkv') or comparison['filename'].endswith('.2.mkv'): if comparison['filename'].endswith('.0.mkv') or comparison['filename'].endswith('.2.mkv'):
assert 'resolution' in comparison assert 'resolution' in comparison
@@ -369,33 +406,30 @@ def test_property_14_duplicate_quality_comparison(title, year, file_count):
def test_property_42_completeness_report(series_count, format): def test_property_42_completeness_report(series_count, format):
"""Property 42: For any set of analyzed series, completeness report SHALL """Property 42: For any set of analyzed series, completeness report SHALL
include all series with detected gaps. include all series with detected gaps.
Validates: Requirements 11.2 Validates: Requirements 11.2
""" """
# Create series with gaps # Create series with gaps
analysis_results = [] analysis_results = []
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
library_root = Path("/test/library") library_root = Path("/test/library")
report = generate_completeness_report(analysis_results, format, library_root) report = generate_completeness_report(analysis_results, format, library_root)
# Report should include all series # Report should include all series
for i in range(series_count): for i in range(series_count):
assert f"Series {i}" in report assert f"Series {i}" in report
# Report should include metadata # Report should include metadata
assert str(library_root) in report assert str(library_root) in report
@@ -410,30 +444,28 @@ def test_property_42_completeness_report(series_count, format):
def test_property_43_duplicate_report_grouping(duplicate_count, format): def test_property_43_duplicate_report_grouping(duplicate_count, format):
"""Property 43: For any set of detected duplicates, duplicate report SHALL """Property 43: For any set of detected duplicates, duplicate report SHALL
group files by identity with comparison data. group files by identity with comparison data.
Validates: Requirements 11.3 Validates: Requirements 11.3
""" """
# 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}"
year = 2020 + i year = 2020 + i
# Create 2 files for each duplicate group # Create 2 files for each duplicate group
files = [] files = []
quality_comparison = [] quality_comparison = []
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({
@@ -443,21 +475,24 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
'resolution': file.resolution, 'resolution': file.resolution,
'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")
report = generate_duplicate_report(duplicate_groups, format, library_root) report = generate_duplicate_report(duplicate_groups, format, library_root)
# Report should include all duplicate groups # Report should include all duplicate groups
for i in range(duplicate_count): for i in range(duplicate_count):
assert f"Movie {i}" in report assert f"Movie {i}" in report
# Report should include comparison data (file sizes, resolutions) # Report should include comparison data (file sizes, resolutions)
assert "1920x1080" in report or "resolution" in report.lower() assert "1920x1080" in report or "resolution" in report.lower()
# Report should include metadata # Report should include metadata
assert str(library_root) in report assert str(library_root) in report
@@ -476,41 +511,39 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
def test_property_44_summary_report_accuracy(file_count, categories): def test_property_44_summary_report_accuracy(file_count, categories):
"""Property 44: For any scanned library, summary report SHALL contain """Property 44: For any scanned library, summary report SHALL contain
accurate counts and sizes. accurate counts and sizes.
Validates: Requirements 11.4 Validates: Requirements 11.4
""" """
# Create video files # Create video files
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
category_counts[category] = category_counts.get(category, 0) + 1 category_counts[category] = category_counts.get(category, 0) + 1
# Generate summary report # Generate summary report
library_root = Path("/test/library") library_root = Path("/test/library")
report = generate_summary_report(files, library_root) report = generate_summary_report(files, library_root)
# Report should include total file count # Report should include total file count
assert f"Total Files: {file_count}" in report assert f"Total Files: {file_count}" in report
# Report should include category breakdown # Report should include category breakdown
for category, count in category_counts.items(): for category, count in category_counts.items():
assert category.capitalize() in report assert category.capitalize() in report
assert f"Files: {count}" in report assert f"Files: {count}" in report
# Report should include metadata # Report should include metadata
assert str(library_root) in report assert str(library_root) in report
+188 -234
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"),
video_extensions=["mp4", ".mkv"] # Missing dot on first one
)
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):
"""Test validating config with empty templates."""
config = Config(
library_root=Path("/mnt/nas/videos"),
movie_template="",
series_template=""
)
errors = validate_config(config)
assert len(errors) >= 2
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):
"""Test validating config with invalid log level."""
config = Config(
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):
"""Test validating config with all valid log levels."""
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
for level in valid_levels:
config = Config(
library_root=Path("/mnt/nas/videos"), library_root=Path("/mnt/nas/videos"),
log_level=level video_extensions=["mp4", ".mkv"],
) )
errors = validate_config(config)
assert errors == [], f"Log level {level} should be valid" def test_validate_empty_templates(self):
with pytest.raises(ValidationError) as exc_info:
Config(
library_root=Path("/mnt/nas/videos"),
movie_template="",
series_template="",
)
errors = exc_info.value.errors()
fields = {e["loc"][0] for e in errors}
assert "movie_template" in fields
assert "series_template" in fields
def test_validate_invalid_log_level(self):
with pytest.raises(ValidationError, match="log_level"):
Config(library_root=Path("/mnt/nas/videos"), log_level="INVALID")
def test_validate_valid_log_levels(self):
for level in ["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"
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 = Config( Config(library_root=Path("/mnt/nas/videos"), quarantine_dir="")
library_root=Path("/mnt/nas/videos"),
quarantine_dir=""
)
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): def test_workspace_dir_coerces_from_string(self):
"""workspace_dir must be a Path object."""
config = Config( config = Config(
library_root=Path("/mnt/nas/videos"), library_root=Path("/mnt/nas/videos"),
workspace_dir="artifacts", # type: ignore[arg-type] workspace_dir="artifacts",
) )
errors = validate_config(config) assert config.workspace_dir == Path("artifacts")
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"),
plan_max_season=0,
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"), library_root=Path("/test"),
plan_max_season=0, video_extensions=[],
plan_max_episode=-1, 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,26 +613,21 @@ 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
'log_level': 'INVALID' 'log_level': 'INVALID'
} }
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": {},
+275 -266
View File
@@ -24,51 +24,73 @@ 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")
report = generate_inventory_report(files, "csv", library_root) report = generate_inventory_report(files, "csv", library_root)
# Check metadata comments # Check metadata comments
assert "# Generated:" in report assert "# Generated:" in report
assert f"# Library Root: {library_root}" in report assert f"# Library Root: {library_root}" in report
# Parse CSV # Parse CSV
lines = report.strip().split('\n') lines = report.strip().split('\n')
# Skip comment lines # Skip comment lines
csv_lines = [line for line in lines if not line.startswith('#')] csv_lines = [line for line in lines if not line.startswith('#')]
csv_reader = csv.DictReader(csv_lines) csv_reader = csv.DictReader(csv_lines)
rows = list(csv_reader) rows = list(csv_reader)
# Check we have 2 data rows # Check we have 2 data rows
assert len(rows) == 2 assert len(rows) == 2
# Check first file # Check first file
assert rows[0]['filename'] == 'Movie1.mkv' assert rows[0]['filename'] == 'Movie1.mkv'
assert rows[0]['size_bytes'] == '2000000000' assert rows[0]['size_bytes'] == '2000000000'
@@ -77,7 +99,7 @@ class TestInventoryReport:
assert rows[0]['codec'] == 'h264' assert rows[0]['codec'] == 'h264'
assert rows[0]['duration_seconds'] == '7200.0' assert rows[0]['duration_seconds'] == '7200.0'
assert rows[0]['bitrate_kbps'] == '5000' assert rows[0]['bitrate_kbps'] == '5000'
# Check second file # Check second file
assert rows[1]['filename'] == 'Show.S01E01.mkv' assert rows[1]['filename'] == 'Show.S01E01.mkv'
assert rows[1]['category'] == 'series' assert rows[1]['category'] == 'series'
@@ -86,44 +108,33 @@ class TestInventoryReport:
# Optional fields not present should be empty # Optional fields not present should be empty
assert rows[1]['duration_seconds'] == '' assert rows[1]['duration_seconds'] == ''
assert rows[1]['bitrate_kbps'] == '' assert rows[1]['bitrate_kbps'] == ''
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")
report = generate_inventory_report(files, "json", library_root) report = generate_inventory_report(files, "json", library_root)
# Parse JSON # Parse JSON
data = json.loads(report) data = json.loads(report)
# Check metadata # Check metadata
assert "metadata" in data assert "metadata" in data
assert "generated" in data["metadata"] assert "generated" in data["metadata"]
assert data["metadata"]["library_root"] == str(library_root) assert data["metadata"]["library_root"] == str(library_root)
assert data["metadata"]["file_count"] == 2 assert data["metadata"]["file_count"] == 2
# Check files # Check files
assert "files" in data assert "files" in data
assert len(data["files"]) == 2 assert len(data["files"]) == 2
# Check first file # Check first file
file1 = data["files"][0] file1 = data["files"][0]
assert file1["filename"] == "Movie1.mkv" assert file1["filename"] == "Movie1.mkv"
@@ -131,7 +142,7 @@ class TestInventoryReport:
assert file1["category"] == "movie" assert file1["category"] == "movie"
assert file1["resolution"] == "1920x1080" assert file1["resolution"] == "1920x1080"
assert file1["codec"] == "h264" assert file1["codec"] == "h264"
# Check second file # Check second file
file2 = data["files"][1] file2 = data["files"][1]
assert file2["filename"] == "Anime1.mkv" assert file2["filename"] == "Anime1.mkv"
@@ -139,89 +150,79 @@ class TestInventoryReport:
# Optional fields should be null # Optional fields should be null
assert file2["resolution"] is None assert file2["resolution"] is None
assert file2["codec"] is None assert file2["codec"] is None
def test_generate_csv_report_empty(self): def test_generate_csv_report_empty(self):
"""Test generating CSV report with no files.""" """Test generating CSV report with no files."""
files = [] files = []
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
report = generate_inventory_report(files, "csv", library_root) report = generate_inventory_report(files, "csv", library_root)
# Should have metadata and header # Should have metadata and header
assert "# Generated:" in report assert "# Generated:" in report
assert "path,filename,size_bytes" in report assert "path,filename,size_bytes" in report
# Parse CSV # Parse CSV
lines = report.strip().split('\n') lines = report.strip().split('\n')
csv_lines = [line for line in lines if not line.startswith('#')] csv_lines = [line for line in lines if not line.startswith('#')]
csv_reader = csv.DictReader(csv_lines) csv_reader = csv.DictReader(csv_lines)
rows = list(csv_reader) rows = list(csv_reader)
# No data rows # No data rows
assert len(rows) == 0 assert len(rows) == 0
def test_generate_json_report_empty(self): def test_generate_json_report_empty(self):
"""Test generating JSON report with no files.""" """Test generating JSON report with no files."""
files = [] files = []
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
report = generate_inventory_report(files, "json", library_root) report = generate_inventory_report(files, "json", library_root)
data = json.loads(report) data = json.loads(report)
assert data["metadata"]["file_count"] == 0 assert data["metadata"]["file_count"] == 0
assert len(data["files"]) == 0 assert len(data["files"]) == 0
def test_invalid_format_raises_error(self): def test_invalid_format_raises_error(self):
"""Test that invalid format raises ValueError.""" """Test that invalid format raises ValueError."""
files = [] files = []
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
with pytest.raises(ValueError, match="Invalid format"): with pytest.raises(ValueError, match="Invalid format"):
generate_inventory_report(files, "xml", library_root) generate_inventory_report(files, "xml", library_root)
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")
report = generate_inventory_report(files, "csv", library_root) report = generate_inventory_report(files, "csv", library_root)
# Parse CSV header # Parse CSV header
lines = report.strip().split('\n') lines = report.strip().split('\n')
csv_lines = [line for line in lines if not line.startswith('#')] csv_lines = [line for line in lines if not line.startswith('#')]
header = csv_lines[0].strip() # Strip to remove any line ending characters header = csv_lines[0].strip() # Strip to remove any line ending characters
# Check column order # Check column order
expected_columns = [ expected_columns = [
'path', 'filename', 'size_bytes', 'modified_timestamp', 'category', 'path', 'filename', 'size_bytes', 'modified_timestamp', 'category',
'resolution', 'codec', 'duration_seconds', 'bitrate_kbps' 'resolution', 'codec', 'duration_seconds', 'bitrate_kbps'
] ]
assert header == ','.join(expected_columns) assert header == ','.join(expected_columns)
def test_timestamp_formatting(self): def test_timestamp_formatting(self):
"""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")
report = generate_inventory_report(files, "csv", library_root) report = generate_inventory_report(files, "csv", library_root)
# Check timestamp format # Check timestamp format
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")
assert expected_utc in report assert expected_utc in report
@@ -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)
@@ -259,104 +255,128 @@ class TestInventoryReport:
class TestCompletenessReport: class TestCompletenessReport:
"""Test completeness report generation.""" """Test completeness report generation."""
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")
report = generate_completeness_report(analysis, "text", library_root) report = generate_completeness_report(analysis, "text", library_root)
# Check report structure # Check report structure
assert "SERIES COMPLETENESS REPORT" in report assert "SERIES COMPLETENESS REPORT" in report
assert "Generated:" in report assert "Generated:" in report
assert str(library_root) in report assert str(library_root) in report
assert "Series with gaps: 3" in report assert "Series with gaps: 3" in report
# Check series content # Check series content
assert "Breaking Bad" in report assert "Breaking Bad" in report
assert "The Wire" in report assert "The Wire" in report
assert "Season 01:" in report assert "Season 01:" in report
assert "Season 02:" in report assert "Season 02:" in report
# Check episode information # Check episode information
assert "Episodes found:" in report assert "Episodes found:" in report
assert "Episodes missing:" in report assert "Episodes missing:" in report
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")
report = generate_completeness_report(analysis, "json", library_root) report = generate_completeness_report(analysis, "json", library_root)
# Parse JSON # Parse JSON
data = json.loads(report) data = json.loads(report)
# Check metadata # Check metadata
assert "metadata" in data assert "metadata" in data
assert "generated" in data["metadata"] assert "generated" in data["metadata"]
assert data["metadata"]["library_root"] == str(library_root) assert data["metadata"]["library_root"] == str(library_root)
assert data["metadata"]["series_count"] == 2 assert data["metadata"]["series_count"] == 2
# Check series data # Check series data
assert "series" in data assert "series" in data
assert len(data["series"]) == 2 assert len(data["series"]) == 2
# Check Breaking Bad # Check Breaking Bad
breaking_bad = next(s for s in data["series"] if s["title"] == "Breaking Bad") breaking_bad = next(s for s in data["series"] if s["title"] == "Breaking Bad")
assert len(breaking_bad["seasons"]) == 1 assert len(breaking_bad["seasons"]) == 1
assert breaking_bad["seasons"][0]["season"] == 1 assert breaking_bad["seasons"][0]["season"] == 1
assert breaking_bad["seasons"][0]["episodes_found"] == [1, 2, 4, 5] assert breaking_bad["seasons"][0]["episodes_found"] == [1, 2, 4, 5]
assert breaking_bad["seasons"][0]["episodes_missing"] == [3] assert breaking_bad["seasons"][0]["episodes_missing"] == [3]
def test_generate_text_report_empty(self): def test_generate_text_report_empty(self):
"""Test generating text report with no gaps.""" """Test generating text report with no gaps."""
analysis = [] analysis = []
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
report = generate_completeness_report(analysis, "text", library_root) report = generate_completeness_report(analysis, "text", library_root)
assert "SERIES COMPLETENESS REPORT" in report assert "SERIES COMPLETENESS REPORT" in report
assert "No series with episode gaps detected." in report assert "No series with episode gaps detected." in report
def test_generate_json_report_empty(self): def test_generate_json_report_empty(self):
"""Test generating JSON report with no gaps.""" """Test generating JSON report with no gaps."""
analysis = [] analysis = []
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
report = generate_completeness_report(analysis, "json", library_root) report = generate_completeness_report(analysis, "json", library_root)
data = json.loads(report) data = json.loads(report)
assert data["metadata"]["series_count"] == 0 assert data["metadata"]["series_count"] == 0
assert len(data["series"]) == 0 assert len(data["series"]) == 0
def test_invalid_format_raises_error(self): def test_invalid_format_raises_error(self):
"""Test that invalid format raises ValueError.""" """Test that invalid format raises ValueError."""
analysis = [] analysis = []
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
with pytest.raises(ValueError, match="Invalid format"): with pytest.raises(ValueError, match="Invalid format"):
generate_completeness_report(analysis, "xml", library_root) generate_completeness_report(analysis, "xml", library_root)
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")
report = generate_completeness_report(analysis, "text", library_root) report = generate_completeness_report(analysis, "text", library_root)
# Should group all seasons under same series # Should group all seasons under same series
assert report.count("Show Name") == 1 # Series title appears once assert report.count("Show Name") == 1 # Series title appears once
assert "Season 01:" in report assert "Season 01:" in report
@@ -366,36 +386,23 @@ class TestCompletenessReport:
class TestDuplicateReport: class TestDuplicateReport:
"""Test duplicate report generation.""" """Test duplicate report generation."""
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 = [
{ {
'filename': 'The.Matrix.1999.1080p.mkv', 'filename': 'The.Matrix.1999.1080p.mkv',
@@ -414,184 +421,175 @@ class TestDuplicateReport:
'codec': 'h264' 'codec': 'h264'
} }
] ]
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")
report = generate_duplicate_report(duplicates, "text", library_root) report = generate_duplicate_report(duplicates, "text", library_root)
# Check report structure # Check report structure
assert "DUPLICATE FILES REPORT" in report assert "DUPLICATE FILES REPORT" in report
assert "Generated:" in report assert "Generated:" in report
assert str(library_root) in report assert str(library_root) in report
assert "Duplicate groups: 1" in report assert "Duplicate groups: 1" in report
# Check duplicate group content # Check duplicate group content
assert "The Matrix (1999)" in report assert "The Matrix (1999)" in report
assert "Files: 2" in report assert "Files: 2" in report
# Check file details # Check file details
assert "The.Matrix.1999.1080p.mkv" in report assert "The.Matrix.1999.1080p.mkv" in report
assert "The.Matrix.1999.720p.mkv" in report assert "The.Matrix.1999.720p.mkv" in report
assert "1920x1080" in report assert "1920x1080" in report
assert "1280x720" in report assert "1280x720" in report
assert "h264" in report assert "h264" in report
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 = [
{'filename': 'Inception.2010.1080p.mkv', 'path': '/movies/Inception.2010.1080p.mkv', 'size_bytes': 2000000000}, {'filename': 'Inception.2010.1080p.mkv', 'path': '/movies/Inception.2010.1080p.mkv', 'size_bytes': 2000000000},
{'filename': 'Inception.2010.720p.mkv', 'path': '/movies/Inception.2010.720p.mkv', 'size_bytes': 1000000000} {'filename': 'Inception.2010.720p.mkv', 'path': '/movies/Inception.2010.720p.mkv', 'size_bytes': 1000000000}
] ]
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")
report = generate_duplicate_report(duplicates, "json", library_root) report = generate_duplicate_report(duplicates, "json", library_root)
# Parse JSON # Parse JSON
data = json.loads(report) data = json.loads(report)
# Check metadata # Check metadata
assert "metadata" in data assert "metadata" in data
assert data["metadata"]["duplicate_groups"] == 1 assert data["metadata"]["duplicate_groups"] == 1
assert data["metadata"]["library_root"] == str(library_root) assert data["metadata"]["library_root"] == str(library_root)
# Check duplicates # Check duplicates
assert "duplicates" in data assert "duplicates" in data
assert len(data["duplicates"]) == 1 assert len(data["duplicates"]) == 1
dup = data["duplicates"][0] dup = data["duplicates"][0]
assert dup["identity"]["type"] == "movie" assert dup["identity"]["type"] == "movie"
assert dup["identity"]["title"] == "Inception" assert dup["identity"]["title"] == "Inception"
assert dup["identity"]["year"] == 2010 assert dup["identity"]["year"] == 2010
assert dup["file_count"] == 2 assert dup["file_count"] == 2
assert len(dup["files"]) == 2 assert len(dup["files"]) == 2
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 = [
{'filename': 'Breaking.Bad.S01E01.1080p.mkv', 'path': '/series/Breaking.Bad.S01E01.1080p.mkv', 'size_bytes': 1500000000}, {'filename': 'Breaking.Bad.S01E01.1080p.mkv', 'path': '/series/Breaking.Bad.S01E01.1080p.mkv', 'size_bytes': 1500000000},
{'filename': 'Breaking.Bad.S01E01.720p.mkv', 'path': '/series/Breaking.Bad.S01E01.720p.mkv', 'size_bytes': 800000000} {'filename': 'Breaking.Bad.S01E01.720p.mkv', 'path': '/series/Breaking.Bad.S01E01.720p.mkv', 'size_bytes': 800000000}
] ]
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")
report = generate_duplicate_report(duplicates, "text", library_root) report = generate_duplicate_report(duplicates, "text", library_root)
# Check series format # Check series format
assert "Breaking Bad - S01E1" in report assert "Breaking Bad - S01E1" in report
assert "Files: 2" in report assert "Files: 2" in report
def test_generate_text_report_empty(self): def test_generate_text_report_empty(self):
"""Test generating text report with no duplicates.""" """Test generating text report with no duplicates."""
duplicates = [] duplicates = []
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
report = generate_duplicate_report(duplicates, "text", library_root) report = generate_duplicate_report(duplicates, "text", library_root)
assert "DUPLICATE FILES REPORT" in report assert "DUPLICATE FILES REPORT" in report
assert "No duplicate files detected." in report assert "No duplicate files detected." in report
def test_generate_json_report_empty(self): def test_generate_json_report_empty(self):
"""Test generating JSON report with no duplicates.""" """Test generating JSON report with no duplicates."""
duplicates = [] duplicates = []
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
report = generate_duplicate_report(duplicates, "json", library_root) report = generate_duplicate_report(duplicates, "json", library_root)
data = json.loads(report) data = json.loads(report)
assert data["metadata"]["duplicate_groups"] == 0 assert data["metadata"]["duplicate_groups"] == 0
assert len(data["duplicates"]) == 0 assert len(data["duplicates"]) == 0
def test_invalid_format_raises_error(self): def test_invalid_format_raises_error(self):
"""Test that invalid format raises ValueError.""" """Test that invalid format raises ValueError."""
duplicates = [] duplicates = []
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
with pytest.raises(ValueError, match="Invalid format"): with pytest.raises(ValueError, match="Invalid format"):
generate_duplicate_report(duplicates, "csv", library_root) generate_duplicate_report(duplicates, "csv", library_root)
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},
{'filename': 'Large.Movie.2.mkv', 'path': '/movies/Large.Movie.2.mkv', 'size_bytes': 1800000000} {'filename': 'Large.Movie.2.mkv', 'path': '/movies/Large.Movie.2.mkv', 'size_bytes': 1800000000}
] ]
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")
report = generate_duplicate_report(duplicates, "text", library_root) report = generate_duplicate_report(duplicates, "text", library_root)
# Large Movie should appear before Small Movie # Large Movie should appear before Small Movie
large_pos = report.find("Large Movie") large_pos = report.find("Large Movie")
small_pos = report.find("Small Movie") small_pos = report.find("Small Movie")
@@ -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")
@@ -629,63 +628,73 @@ class TestDuplicateReport:
class TestSummaryReport: class TestSummaryReport:
"""Test summary report generation.""" """Test summary report generation."""
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")
report = generate_summary_report(files, library_root) report = generate_summary_report(files, library_root)
# Check report structure # Check report structure
assert "LIBRARY SUMMARY REPORT" in report assert "LIBRARY SUMMARY REPORT" in report
assert "Generated:" in report assert "Generated:" in report
assert str(library_root) in report assert str(library_root) in report
# Check totals # Check totals
assert "Total Files: 6" in report assert "Total Files: 6" in report
assert "Total Size:" in report assert "Total Size:" in report
# Check category breakdown # Check category breakdown
assert "Category Breakdown:" in report assert "Category Breakdown:" in report
assert "Movie:" in report assert "Movie:" in report
assert "Series:" in report assert "Series:" in report
assert "Anime:" in report assert "Anime:" in report
assert "Other:" in report assert "Other:" in report
# Check category counts # Check category counts
assert "Files: 2" in report # Movies assert "Files: 2" in report # Movies
def test_generate_summary_report_empty(self): def test_generate_summary_report_empty(self):
"""Test generating summary report with no files.""" """Test generating summary report with no files."""
files = [] files = []
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
report = generate_summary_report(files, library_root) report = generate_summary_report(files, library_root)
assert "LIBRARY SUMMARY REPORT" in report assert "LIBRARY SUMMARY REPORT" in report
assert "Total Files: 0" in report assert "Total Files: 0" in report
assert "Total Size: 0.00 B" in report assert "Total Size: 0.00 B" in report
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")
report = generate_summary_report(files, library_root) report = generate_summary_report(files, library_root)
assert "Total Files: 2" in report assert "Total Files: 2" in report
assert "Movie:" in report assert "Movie:" in report
assert "Files: 2" in report assert "Files: 2" in report
@@ -693,64 +702,64 @@ class TestSummaryReport:
class TestFormatHelpers: class TestFormatHelpers:
"""Test formatting helper functions.""" """Test formatting helper functions."""
def test_format_episode_list_single(self): def test_format_episode_list_single(self):
"""Test formatting single episode.""" """Test formatting single episode."""
assert _format_episode_list([5]) == "5" assert _format_episode_list([5]) == "5"
def test_format_episode_list_range(self): def test_format_episode_list_range(self):
"""Test formatting consecutive episode range.""" """Test formatting consecutive episode range."""
assert _format_episode_list([1, 2, 3, 4, 5]) == "1-5" assert _format_episode_list([1, 2, 3, 4, 5]) == "1-5"
def test_format_episode_list_mixed(self): def test_format_episode_list_mixed(self):
"""Test formatting mixed ranges and singles.""" """Test formatting mixed ranges and singles."""
assert _format_episode_list([1, 2, 3, 5, 6, 8]) == "1-3, 5-6, 8" assert _format_episode_list([1, 2, 3, 5, 6, 8]) == "1-3, 5-6, 8"
def test_format_episode_list_non_sequential(self): def test_format_episode_list_non_sequential(self):
"""Test formatting non-sequential episodes.""" """Test formatting non-sequential episodes."""
assert _format_episode_list([1, 3, 5, 7]) == "1, 3, 5, 7" assert _format_episode_list([1, 3, 5, 7]) == "1, 3, 5, 7"
def test_format_episode_list_empty(self): def test_format_episode_list_empty(self):
"""Test formatting empty episode list.""" """Test formatting empty episode list."""
assert _format_episode_list([]) == "none" assert _format_episode_list([]) == "none"
def test_format_episode_list_unsorted(self): def test_format_episode_list_unsorted(self):
"""Test formatting unsorted episode list.""" """Test formatting unsorted episode list."""
assert _format_episode_list([5, 1, 3, 2, 4]) == "1-5" assert _format_episode_list([5, 1, 3, 2, 4]) == "1-5"
def test_format_size_bytes(self): def test_format_size_bytes(self):
"""Test formatting bytes.""" """Test formatting bytes."""
assert _format_size(512) == "512.00 B" assert _format_size(512) == "512.00 B"
def test_format_size_kilobytes(self): def test_format_size_kilobytes(self):
"""Test formatting kilobytes.""" """Test formatting kilobytes."""
assert _format_size(1024) == "1.00 KB" assert _format_size(1024) == "1.00 KB"
assert _format_size(2048) == "2.00 KB" assert _format_size(2048) == "2.00 KB"
def test_format_size_megabytes(self): def test_format_size_megabytes(self):
"""Test formatting megabytes.""" """Test formatting megabytes."""
assert _format_size(1048576) == "1.00 MB" assert _format_size(1048576) == "1.00 MB"
assert _format_size(5242880) == "5.00 MB" assert _format_size(5242880) == "5.00 MB"
def test_format_size_gigabytes(self): def test_format_size_gigabytes(self):
"""Test formatting gigabytes.""" """Test formatting gigabytes."""
assert _format_size(1073741824) == "1.00 GB" assert _format_size(1073741824) == "1.00 GB"
assert _format_size(2147483648) == "2.00 GB" assert _format_size(2147483648) == "2.00 GB"
def test_format_size_terabytes(self): def test_format_size_terabytes(self):
"""Test formatting terabytes.""" """Test formatting terabytes."""
assert _format_size(1099511627776) == "1.00 TB" assert _format_size(1099511627776) == "1.00 TB"
def test_format_duration_seconds(self): def test_format_duration_seconds(self):
"""Test formatting seconds only.""" """Test formatting seconds only."""
assert _format_duration(30) == "30s" assert _format_duration(30) == "30s"
assert _format_duration(0) == "0s" assert _format_duration(0) == "0s"
def test_format_duration_minutes(self): def test_format_duration_minutes(self):
"""Test formatting minutes and seconds.""" """Test formatting minutes and seconds."""
assert _format_duration(90) == "1m 30s" assert _format_duration(90) == "1m 30s"
assert _format_duration(120) == "2m" assert _format_duration(120) == "2m"
def test_format_duration_hours(self): def test_format_duration_hours(self):
"""Test formatting hours, minutes, and seconds.""" """Test formatting hours, minutes, and seconds."""
assert _format_duration(3665) == "1h 1m 5s" assert _format_duration(3665) == "1h 1m 5s"
+88 -58
View File
@@ -16,134 +16,164 @@ 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."""
def test_completeness_workflow(self): def test_completeness_workflow(self):
"""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
analysis = analyze_series_completeness(episodes) analysis = analyze_series_completeness(episodes)
# Generate text report # Generate text report
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
text_report = generate_completeness_report(analysis, "text", library_root) text_report = generate_completeness_report(analysis, "text", library_root)
# Verify report contains expected information # Verify report contains expected information
assert "Breaking Bad" in text_report assert "Breaking Bad" in text_report
assert "The Wire" in text_report assert "The Wire" in text_report
assert "Episodes missing:" in text_report assert "Episodes missing:" in text_report
# Generate JSON report # Generate JSON report
json_report = generate_completeness_report(analysis, "json", library_root) json_report = generate_completeness_report(analysis, "json", library_root)
data = json.loads(json_report) data = json.loads(json_report)
# Verify JSON structure # Verify JSON structure
assert data["metadata"]["series_count"] == 2 assert data["metadata"]["series_count"] == 2
assert len(data["series"]) == 2 assert len(data["series"]) == 2
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
duplicates = detect_duplicates(list(zip(identities, files))) duplicates = detect_duplicates(list(zip(identities, files)))
# Generate text report # Generate text report
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
text_report = generate_duplicate_report(duplicates, "text", library_root) text_report = generate_duplicate_report(duplicates, "text", library_root)
# Verify report contains expected information # Verify report contains expected information
assert "The Matrix (1999)" in text_report assert "The Matrix (1999)" in text_report
assert "1920x1080" in text_report assert "1920x1080" in text_report
assert "1280x720" in text_report assert "1280x720" in text_report
# Generate JSON report # Generate JSON report
json_report = generate_duplicate_report(duplicates, "json", library_root) json_report = generate_duplicate_report(duplicates, "json", library_root)
data = json.loads(json_report) data = json.loads(json_report)
# Verify JSON structure # Verify JSON structure
assert data["metadata"]["duplicate_groups"] == 1 assert data["metadata"]["duplicate_groups"] == 1
assert len(data["duplicates"]) == 1 assert len(data["duplicates"]) == 1
assert data["duplicates"][0]["file_count"] == 2 assert data["duplicates"][0]["file_count"] == 2
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
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
report = generate_summary_report(files, library_root) report = generate_summary_report(files, library_root)
# Verify report contains expected information # Verify report contains expected information
assert "Total Files: 5" in report assert "Total Files: 5" in report
assert "Movie:" in report assert "Movie:" in report
assert "Series:" in report assert "Series:" in report
assert "Anime:" in report assert "Anime:" in report
assert "Category Breakdown:" in report assert "Category Breakdown:" in report
def test_all_reports_include_metadata(self): def test_all_reports_include_metadata(self):
"""Test that all reports include generation timestamp and library root.""" """Test that all reports include generation timestamp and library root."""
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
# Test completeness report # Test completeness report
completeness_report = generate_completeness_report([], "text", library_root) completeness_report = generate_completeness_report([], "text", library_root)
assert "Generated:" in completeness_report assert "Generated:" in completeness_report
assert str(library_root) in completeness_report assert str(library_root) in completeness_report
# Test duplicate report # Test duplicate report
duplicate_report = generate_duplicate_report([], "text", library_root) duplicate_report = generate_duplicate_report([], "text", library_root)
assert "Generated:" in duplicate_report assert "Generated:" in duplicate_report
assert str(library_root) in duplicate_report assert str(library_root) in duplicate_report
# Test summary report # Test summary report
summary_report = generate_summary_report([], library_root) summary_report = generate_summary_report([], library_root)
assert "Generated:" in summary_report assert "Generated:" in summary_report
+8 -78
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,82 +122,12 @@ class TestScanLibrary:
visible_file.touch() visible_file.touch()
hidden_file.touch() hidden_file.touch()
fake_stdout = f"{visible_file}\0{hidden_file}\0".encode() config = Config(library_root=tmp_path)
result = scan_library(tmp_path, config, include_video_metadata=False)
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 len(result) == 1
assert result[0].path == visible_file 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)
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 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."""
movie_dir = tmp_path / "movie" movie_dir = tmp_path / "movie"
@@ -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,13 +666,13 @@ 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),
stderr="" stderr=""
) )
config = Config(library_root=tmp_path) config = Config(library_root=tmp_path)
result = scan_library(tmp_path, config) result = scan_library(tmp_path, config)
@@ -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" },