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:
co-authored by
Claude Sonnet 4.5
parent
8a60aaf9a9
commit
fe03a31dd4
@@ -11,6 +11,7 @@ authors = [
|
||||
dependencies = [
|
||||
"click>=8.1.0",
|
||||
"pyyaml>=6.0",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
+31
-1016
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error
|
||||
from vlm.cli_helpers import command_error, default_config_path
|
||||
from vlm.config import create_default_config, validate_config
|
||||
from vlm.context import CLIContext, pass_context
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.io import load_inventory_csv, save_identities_json
|
||||
from vlm.models import (
|
||||
IdentityRecord,
|
||||
@@ -170,3 +171,16 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
|
||||
len(series_identities),
|
||||
output,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json"))
|
||||
@click.option("--inventory", type=click.Path(exists=True, path_type=Path), default=None)
|
||||
@pass_context
|
||||
def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]):
|
||||
"""Parse identities from filenames."""
|
||||
def _run():
|
||||
input_resolved = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
parse_cmd(ctx, input_resolved, output, inventory)
|
||||
run_command(ctx, _run, stage="parse", json_errors=True)
|
||||
|
||||
@@ -6,7 +6,8 @@ from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.io import identities_to_plan_input, load_analysis_json, load_identities_json
|
||||
from vlm.planner import generate_plan, save_plan
|
||||
|
||||
@@ -110,3 +111,16 @@ def plan_cmd(ctx: CLIContext, input: Path, output: Path, analysis: Optional[Path
|
||||
f"Plan generated: {execution_plan.summary['total']} operations, "
|
||||
f"{conflicts} conflicts, saved to {output}"
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("plan.json"))
|
||||
@click.option("--analysis", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def plan(ctx: CLIContext, input: Path, output: Path, analysis: Optional[Path]):
|
||||
"""Generate execution plan."""
|
||||
def _run():
|
||||
input_resolved = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
|
||||
plan_cmd(ctx, input_resolved, output, analysis)
|
||||
run_command(ctx, _run, stage="plan", json_errors=True)
|
||||
|
||||
@@ -15,7 +15,7 @@ from vlm.cli_helpers import (
|
||||
resolve_legacy_default_input_path,
|
||||
review_plan_tui_streams_ok,
|
||||
)
|
||||
from vlm.context import CLIContext
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.io import load_analysis_json, load_identities_json
|
||||
from vlm.plan_render import (
|
||||
duplicate_groups_from_plan,
|
||||
@@ -242,3 +242,60 @@ def apply_review_cmd(
|
||||
f"Apply review failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@click.command(name="review-plan")
|
||||
@click.option("--input", type=click.Path(exists=True, path_type=Path), default=lambda: default_artifact_path("plan.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("plan_manual_review.csv"))
|
||||
@click.option("--season-threshold", type=int, default=20, show_default=True)
|
||||
@click.option("--episode-threshold", type=int, default=40, show_default=True)
|
||||
@click.option("--preview-limit", type=int, default=10, show_default=True)
|
||||
@click.option("--show-all", is_flag=True, default=False)
|
||||
@click.option("--tui", is_flag=True, default=False)
|
||||
@click.option("--identities", type=click.Path(path_type=Path), default=None)
|
||||
@click.option("--analysis", type=click.Path(path_type=Path), default=None)
|
||||
@click.option("--group-by", type=click.Choice(["none", "reason", "title", "duplicate"], case_sensitive=False), default="none", show_default=True)
|
||||
@click.option("--sample-safe", type=int, default=0, show_default=True)
|
||||
@click.option("--structure-preview", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def review_plan(
|
||||
ctx: CLIContext,
|
||||
input: Path,
|
||||
output: Path,
|
||||
season_threshold: int,
|
||||
episode_threshold: int,
|
||||
preview_limit: int,
|
||||
show_all: bool,
|
||||
tui: bool,
|
||||
identities: Optional[Path],
|
||||
analysis: Optional[Path],
|
||||
group_by: str,
|
||||
sample_safe: int,
|
||||
structure_preview: Optional[Path],
|
||||
):
|
||||
"""Review a plan and export high-risk operations for manual confirmation."""
|
||||
review_plan_cmd(
|
||||
ctx,
|
||||
input,
|
||||
output,
|
||||
season_threshold,
|
||||
episode_threshold,
|
||||
preview_limit,
|
||||
show_all,
|
||||
tui,
|
||||
identities,
|
||||
analysis,
|
||||
group_by,
|
||||
sample_safe,
|
||||
structure_preview,
|
||||
)
|
||||
|
||||
|
||||
@click.command(name="apply-review")
|
||||
@click.option("--plan", type=click.Path(exists=True, path_type=Path), default=Path("plan.json"))
|
||||
@click.option("--csv", type=click.Path(exists=True, path_type=Path), default=Path("plan_manual_review.csv"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def apply_review(ctx: CLIContext, plan: Path, csv: Path, output: Optional[Path]):
|
||||
"""Apply modifications from a manual review CSV back to the plan JSON."""
|
||||
apply_review_cmd(ctx, plan, csv, output)
|
||||
|
||||
+269
-240
@@ -1,22 +1,35 @@
|
||||
"""Configuration management for Video Library Manager."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
|
||||
|
||||
DEFAULT_VIDEO_EXTENSIONS = [
|
||||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||||
]
|
||||
|
||||
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
_VALID_DUPLICATE_KEEP = {
|
||||
"by_reputation",
|
||||
"by_reputation_quality_time",
|
||||
"first_seen",
|
||||
"manual",
|
||||
"by_quality",
|
||||
}
|
||||
_VALID_PROVIDERS = {"tmdb"}
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
|
||||
class Config(BaseModel):
|
||||
"""Configuration for Video Library Manager."""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
library_root: Path
|
||||
video_extensions: list[str] = field(default_factory=lambda: list(DEFAULT_VIDEO_EXTENSIONS))
|
||||
video_extensions: list[str] = list(DEFAULT_VIDEO_EXTENSIONS)
|
||||
movie_template: str = "movie/{title} ({year})/"
|
||||
series_template: str = "series/{title}/Season {season:02d}/"
|
||||
movie_filename_template: str = "{title} ({year}){ext}"
|
||||
@@ -24,18 +37,17 @@ class Config:
|
||||
log_level: str = "INFO"
|
||||
quarantine_dir: str = ".quarantine"
|
||||
workspace_dir: Path = Path("artifacts")
|
||||
categories: dict[str, list[str]] = field(default_factory=lambda: {
|
||||
categories: dict[str, list[str]] = {
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series", "tv", "shows"],
|
||||
"anime": ["anime"]
|
||||
})
|
||||
"anime": ["anime"],
|
||||
}
|
||||
|
||||
# Enrichment settings
|
||||
enrichment_enabled: bool = True
|
||||
enrichment_incremental: bool = True
|
||||
enrichment_refresh_mode: str = "manual"
|
||||
enrichment_providers: list[str] = field(default_factory=lambda: ["tmdb"])
|
||||
enrichment_cache_db: Path = field(default_factory=lambda: Path.home() / ".vlm" / "enrichment_cache.db")
|
||||
enrichment_providers: list[str] = ["tmdb"]
|
||||
enrichment_cache_db: Path = Path.home() / ".vlm" / "enrichment_cache.db"
|
||||
enrichment_max_concurrency: int = 6
|
||||
enrichment_min_match_score: float = 0.75
|
||||
translation_mode: str = "bidirectional"
|
||||
@@ -51,12 +63,245 @@ class Config:
|
||||
reputation_policy: str = "flag_for_review"
|
||||
naming_title_format: str = "{title_zh} {title_en}"
|
||||
|
||||
# Plan settings (e.g. duplicate handling when consuming analysis)
|
||||
duplicate_keep: str = "by_reputation"
|
||||
plan_max_season: int = 15
|
||||
plan_max_episode: int = 100
|
||||
plan_include_sample_files: bool = False
|
||||
|
||||
@field_validator("library_root", mode="before")
|
||||
@classmethod
|
||||
def _coerce_library_root(cls, v: Any) -> Path:
|
||||
if isinstance(v, str):
|
||||
v = Path(v).expanduser()
|
||||
if isinstance(v, Path) and (not str(v) or str(v) == "."):
|
||||
raise ValueError("library_root cannot be empty")
|
||||
return v
|
||||
|
||||
@field_validator("video_extensions")
|
||||
@classmethod
|
||||
def _validate_video_extensions(cls, v: list[str]) -> list[str]:
|
||||
if not v:
|
||||
raise ValueError("video_extensions cannot be empty")
|
||||
for ext in v:
|
||||
if not ext.startswith("."):
|
||||
raise ValueError(f"video extension must start with '.': {ext}")
|
||||
return v
|
||||
|
||||
@field_validator("movie_template", "series_template", "movie_filename_template", "series_filename_template")
|
||||
@classmethod
|
||||
def _nonempty_template(cls, v: str, info: Any) -> str:
|
||||
if not v:
|
||||
raise ValueError(f"{info.field_name} cannot be empty")
|
||||
return v
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def _validate_log_level(cls, v: str) -> str:
|
||||
if v.upper() not in _VALID_LOG_LEVELS:
|
||||
raise ValueError(f"log_level must be one of {sorted(_VALID_LOG_LEVELS)}, got: {v}")
|
||||
return v
|
||||
|
||||
@field_validator("quarantine_dir")
|
||||
@classmethod
|
||||
def _validate_quarantine_dir(cls, v: str) -> str:
|
||||
if not v:
|
||||
raise ValueError("quarantine_dir cannot be empty")
|
||||
if v.startswith("/") or v.startswith("\\"):
|
||||
raise ValueError("quarantine_dir must be relative to category root, not absolute")
|
||||
return v
|
||||
|
||||
@field_validator("workspace_dir", mode="before")
|
||||
@classmethod
|
||||
def _coerce_workspace_dir(cls, v: Any) -> Path:
|
||||
if isinstance(v, str):
|
||||
if not v.strip():
|
||||
raise ValueError("workspace_dir cannot be empty")
|
||||
return Path(v).expanduser()
|
||||
if isinstance(v, Path):
|
||||
if not str(v).strip():
|
||||
raise ValueError("workspace_dir cannot be empty")
|
||||
return v
|
||||
raise ValueError("workspace_dir must be a Path object")
|
||||
|
||||
@field_validator("enrichment_max_concurrency")
|
||||
@classmethod
|
||||
def _validate_concurrency(cls, v: int) -> int:
|
||||
if v < 1:
|
||||
raise ValueError("enrichment_max_concurrency must be >= 1")
|
||||
return v
|
||||
|
||||
@field_validator("enrichment_min_match_score")
|
||||
@classmethod
|
||||
def _validate_match_score(cls, v: float) -> float:
|
||||
if not 0.0 <= v <= 1.0:
|
||||
raise ValueError("enrichment_min_match_score must be between 0.0 and 1.0")
|
||||
return v
|
||||
|
||||
@field_validator("enrichment_providers")
|
||||
@classmethod
|
||||
def _validate_providers(cls, v: list[str]) -> list[str]:
|
||||
if not v:
|
||||
raise ValueError("enrichment_providers must be a non-empty list")
|
||||
invalid = [p for p in v if p.lower() not in _VALID_PROVIDERS]
|
||||
if invalid:
|
||||
raise ValueError(
|
||||
f"enrichment_providers contains unsupported providers: {invalid}; "
|
||||
f"supported providers: ['tmdb']"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("enrichment_refresh_mode")
|
||||
@classmethod
|
||||
def _validate_refresh_mode(cls, v: str) -> str:
|
||||
if v not in {"manual", "incremental", "full"}:
|
||||
raise ValueError("enrichment_refresh_mode must be 'manual', 'incremental', or 'full'")
|
||||
return v
|
||||
|
||||
@field_validator("reputation_min_votes")
|
||||
@classmethod
|
||||
def _validate_min_votes(cls, v: int) -> int:
|
||||
if v < 0:
|
||||
raise ValueError("reputation_min_votes must be >= 0")
|
||||
return v
|
||||
|
||||
@field_validator("reputation_low_score_threshold")
|
||||
@classmethod
|
||||
def _validate_low_score(cls, v: float) -> float:
|
||||
if not 0.0 <= v <= 10.0:
|
||||
raise ValueError("reputation_low_score_threshold must be between 0.0 and 10.0")
|
||||
return v
|
||||
|
||||
@field_validator("tmdb_language")
|
||||
@classmethod
|
||||
def _validate_tmdb_language(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("tmdb_language must be a non-empty string")
|
||||
return v
|
||||
|
||||
@field_validator("duplicate_keep")
|
||||
@classmethod
|
||||
def _validate_duplicate_keep(cls, v: str) -> str:
|
||||
if v not in _VALID_DUPLICATE_KEEP:
|
||||
raise ValueError(
|
||||
f"duplicate_keep must be one of {sorted(_VALID_DUPLICATE_KEEP)}, got: {v!r}"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("plan_max_season", "plan_max_episode")
|
||||
@classmethod
|
||||
def _validate_plan_thresholds(cls, v: int, info: Any) -> int:
|
||||
if v < 1:
|
||||
raise ValueError(f"{info.field_name} must be an integer >= 1")
|
||||
return v
|
||||
|
||||
@field_validator("enrichment_cache_db", mode="before")
|
||||
@classmethod
|
||||
def _coerce_cache_db(cls, v: Any) -> Path:
|
||||
if isinstance(v, str):
|
||||
return Path(v).expanduser()
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_categories(self) -> Config:
|
||||
categories = self.categories
|
||||
if not isinstance(categories, dict):
|
||||
raise ValueError("categories must be a dictionary")
|
||||
if not categories:
|
||||
raise ValueError("categories cannot be empty")
|
||||
|
||||
required = {"movie", "series", "anime"}
|
||||
missing = required - set(categories.keys())
|
||||
if missing:
|
||||
raise ValueError(f"categories must include keys: {sorted(missing)}")
|
||||
|
||||
seen_dirs: dict[str, str] = {}
|
||||
for category, dir_list in categories.items():
|
||||
if not isinstance(dir_list, list):
|
||||
raise ValueError(f"categories['{category}'] must be a list")
|
||||
if not dir_list:
|
||||
raise ValueError(f"categories['{category}'] cannot be empty")
|
||||
for dir_name in dir_list:
|
||||
if not isinstance(dir_name, str):
|
||||
raise ValueError(f"categories['{category}'] must contain strings")
|
||||
if not dir_name.strip():
|
||||
raise ValueError(f"categories['{category}'] contains empty directory name")
|
||||
dir_lower = dir_name.lower()
|
||||
if dir_lower in seen_dirs:
|
||||
raise ValueError(
|
||||
f"Duplicate directory name '{dir_name}' in categories "
|
||||
f"'{category}' and '{seen_dirs[dir_lower]}'"
|
||||
)
|
||||
seen_dirs[dir_lower] = category
|
||||
|
||||
return self
|
||||
|
||||
|
||||
def _flatten_yaml(data: dict) -> dict[str, Any]:
|
||||
"""Flatten nested YAML structure into flat Config fields."""
|
||||
if not data:
|
||||
raise ValueError("Configuration must specify 'library_root'")
|
||||
|
||||
library_root = data.get("library_root")
|
||||
if not library_root:
|
||||
raise ValueError("Configuration must specify 'library_root'")
|
||||
|
||||
flat: dict[str, Any] = {"library_root": library_root}
|
||||
|
||||
if "video_extensions" in data:
|
||||
flat["video_extensions"] = data["video_extensions"]
|
||||
|
||||
templates = data.get("templates", {})
|
||||
if templates:
|
||||
flat["movie_template"] = templates.get("movie_dir", "movie/{title} ({year})/")
|
||||
flat["series_template"] = templates.get("series_dir", "series/{title}/Season {season:02d}/")
|
||||
flat["movie_filename_template"] = templates.get("movie_filename", "{title} ({year}){ext}")
|
||||
flat["series_filename_template"] = templates.get("series_filename", "S{season:02d}E{episode:02d}{ext}")
|
||||
|
||||
for key in ("quarantine_dir", "log_level", "workspace_dir", "categories"):
|
||||
if key in data:
|
||||
flat[key] = data[key]
|
||||
|
||||
plan = data.get("plan", {})
|
||||
if plan:
|
||||
flat["duplicate_keep"] = plan.get("duplicate_keep", "by_reputation")
|
||||
flat["plan_max_season"] = int(plan.get("max_season", 15))
|
||||
flat["plan_max_episode"] = int(plan.get("max_episode", 100))
|
||||
flat["plan_include_sample_files"] = bool(plan.get("include_sample_files", False))
|
||||
|
||||
enrichment = data.get("enrichment")
|
||||
if enrichment is None:
|
||||
enrichment = data.get("enrich", {})
|
||||
if enrichment:
|
||||
translation = enrichment.get("translation", {})
|
||||
api_keys = enrichment.get("api_keys", {})
|
||||
reputation = enrichment.get("reputation", {})
|
||||
naming = enrichment.get("naming", {})
|
||||
tmdb = enrichment.get("tmdb", {})
|
||||
|
||||
flat["enrichment_enabled"] = enrichment.get("enabled", True)
|
||||
flat["enrichment_incremental"] = enrichment.get("incremental", True)
|
||||
flat["enrichment_refresh_mode"] = enrichment.get("refresh_mode", "manual")
|
||||
flat["enrichment_providers"] = enrichment.get("providers", ["tmdb"])
|
||||
flat["enrichment_cache_db"] = enrichment.get(
|
||||
"cache_db", str(Path.home() / ".vlm" / "enrichment_cache.db")
|
||||
)
|
||||
flat["enrichment_max_concurrency"] = enrichment.get("max_concurrency", 6)
|
||||
flat["enrichment_min_match_score"] = enrichment.get("min_match_score", 0.75)
|
||||
flat["translation_mode"] = translation.get("mode", "bidirectional")
|
||||
flat["translation_fallback_machine"] = translation.get("fallback_machine", True)
|
||||
flat["tmdb_api_key"] = api_keys.get("tmdb")
|
||||
flat["tmdb_bearer_token"] = api_keys.get("tmdb_bearer")
|
||||
flat["openai_api_key"] = api_keys.get("openai")
|
||||
flat["tmdb_language"] = tmdb.get("language", "zh-CN")
|
||||
flat["tmdb_region"] = tmdb.get("region")
|
||||
flat["tmdb_include_adult"] = tmdb.get("include_adult", False)
|
||||
flat["reputation_min_votes"] = reputation.get("min_votes", 50)
|
||||
flat["reputation_low_score_threshold"] = reputation.get("low_score_threshold", 6.0)
|
||||
flat["reputation_policy"] = reputation.get("policy", "flag_for_review")
|
||||
flat["naming_title_format"] = naming.get("title_format", "{title_zh} {title_en}")
|
||||
|
||||
return flat
|
||||
|
||||
|
||||
def load_config(path: Path) -> Config:
|
||||
"""Load configuration from YAML file."""
|
||||
@@ -69,84 +314,8 @@ def load_config(path: Path) -> Config:
|
||||
except yaml.YAMLError as e:
|
||||
raise yaml.YAMLError(f"Invalid YAML syntax in configuration file: {e}")
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
library_root_str = data.get("library_root")
|
||||
if not library_root_str:
|
||||
raise ValueError("Configuration must specify 'library_root'")
|
||||
|
||||
library_root = Path(library_root_str).expanduser()
|
||||
|
||||
video_extensions = data.get("video_extensions", list(DEFAULT_VIDEO_EXTENSIONS))
|
||||
|
||||
templates = data.get("templates", {})
|
||||
movie_template = templates.get("movie_dir", "movie/{title} ({year})/")
|
||||
series_template = templates.get("series_dir", "series/{title}/Season {season:02d}/")
|
||||
movie_filename_template = templates.get("movie_filename", "{title} ({year}){ext}")
|
||||
series_filename_template = templates.get("series_filename", "S{season:02d}E{episode:02d}{ext}")
|
||||
|
||||
quarantine_dir = data.get("quarantine_dir", ".quarantine")
|
||||
workspace_dir = Path(data.get("workspace_dir", "artifacts")).expanduser()
|
||||
log_level = data.get("log_level", "INFO")
|
||||
categories = data.get("categories", {
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series", "tv", "shows"],
|
||||
"anime": ["anime"]
|
||||
})
|
||||
|
||||
plan = data.get("plan", {})
|
||||
duplicate_keep = plan.get("duplicate_keep", "by_reputation")
|
||||
plan_max_season = int(plan.get("max_season", 15))
|
||||
plan_max_episode = int(plan.get("max_episode", 100))
|
||||
plan_include_sample_files = bool(plan.get("include_sample_files", False))
|
||||
|
||||
enrichment = data.get("enrichment")
|
||||
if enrichment is None:
|
||||
enrichment = data.get("enrich", {})
|
||||
translation = enrichment.get("translation", {})
|
||||
api_keys = enrichment.get("api_keys", {})
|
||||
reputation = enrichment.get("reputation", {})
|
||||
naming = enrichment.get("naming", {})
|
||||
tmdb = enrichment.get("tmdb", {})
|
||||
|
||||
return Config(
|
||||
library_root=library_root,
|
||||
video_extensions=video_extensions,
|
||||
movie_template=movie_template,
|
||||
series_template=series_template,
|
||||
movie_filename_template=movie_filename_template,
|
||||
series_filename_template=series_filename_template,
|
||||
log_level=log_level,
|
||||
quarantine_dir=quarantine_dir,
|
||||
workspace_dir=workspace_dir,
|
||||
categories=categories,
|
||||
enrichment_enabled=enrichment.get("enabled", True),
|
||||
enrichment_incremental=enrichment.get("incremental", True),
|
||||
enrichment_refresh_mode=enrichment.get("refresh_mode", "manual"),
|
||||
enrichment_providers=enrichment.get("providers", ["tmdb"]),
|
||||
enrichment_cache_db=Path(
|
||||
enrichment.get("cache_db", str(Path.home() / ".vlm" / "enrichment_cache.db"))
|
||||
).expanduser(),
|
||||
enrichment_max_concurrency=enrichment.get("max_concurrency", 6),
|
||||
enrichment_min_match_score=enrichment.get("min_match_score", 0.75),
|
||||
translation_mode=translation.get("mode", "bidirectional"),
|
||||
translation_fallback_machine=translation.get("fallback_machine", True),
|
||||
tmdb_api_key=api_keys.get("tmdb"),
|
||||
tmdb_bearer_token=api_keys.get("tmdb_bearer"),
|
||||
tmdb_language=tmdb.get("language", "zh-CN"),
|
||||
tmdb_region=tmdb.get("region"),
|
||||
tmdb_include_adult=tmdb.get("include_adult", False),
|
||||
openai_api_key=api_keys.get("openai"),
|
||||
reputation_min_votes=reputation.get("min_votes", 50),
|
||||
reputation_low_score_threshold=reputation.get("low_score_threshold", 6.0),
|
||||
reputation_policy=reputation.get("policy", "flag_for_review"),
|
||||
naming_title_format=naming.get("title_format", "{title_zh} {title_en}"),
|
||||
duplicate_keep=duplicate_keep,
|
||||
plan_max_season=plan_max_season,
|
||||
plan_max_episode=plan_max_episode,
|
||||
plan_include_sample_files=plan_include_sample_files,
|
||||
)
|
||||
flat = _flatten_yaml(data or {})
|
||||
return Config(**flat)
|
||||
|
||||
|
||||
def create_default_config(path: Path) -> Config:
|
||||
@@ -210,7 +379,6 @@ def create_default_config(path: Path) -> Config:
|
||||
"log_level": default_config.log_level,
|
||||
"categories": default_config.categories,
|
||||
"enrichment": enrichment_content,
|
||||
# Backward-compatible alias for users who prefer `enrich`.
|
||||
"enrich": enrichment_content,
|
||||
}
|
||||
|
||||
@@ -223,153 +391,14 @@ def create_default_config(path: Path) -> Config:
|
||||
|
||||
|
||||
def validate_config(config: Config) -> list[str]:
|
||||
"""Validate configuration and return list of error messages."""
|
||||
errors = []
|
||||
"""Validate configuration and return list of error messages.
|
||||
|
||||
if not isinstance(config.library_root, Path):
|
||||
errors.append("library_root must be a Path object")
|
||||
elif not str(config.library_root) or str(config.library_root) == ".":
|
||||
errors.append("library_root cannot be empty")
|
||||
|
||||
if not config.video_extensions:
|
||||
errors.append("video_extensions cannot be empty")
|
||||
elif not isinstance(config.video_extensions, list):
|
||||
errors.append("video_extensions must be a list")
|
||||
else:
|
||||
for ext in config.video_extensions:
|
||||
if not isinstance(ext, str):
|
||||
errors.append(f"video_extensions must contain strings, found: {type(ext)}")
|
||||
break
|
||||
if not ext.startswith("."):
|
||||
errors.append(f"video extension must start with '.': {ext}")
|
||||
|
||||
if not config.movie_template:
|
||||
errors.append("movie_template cannot be empty")
|
||||
elif not isinstance(config.movie_template, str):
|
||||
errors.append("movie_template must be a string")
|
||||
|
||||
if not config.series_template:
|
||||
errors.append("series_template cannot be empty")
|
||||
elif not isinstance(config.series_template, str):
|
||||
errors.append("series_template must be a string")
|
||||
|
||||
if not config.movie_filename_template:
|
||||
errors.append("movie_filename_template cannot be empty")
|
||||
elif not isinstance(config.movie_filename_template, str):
|
||||
errors.append("movie_filename_template must be a string")
|
||||
|
||||
if not config.series_filename_template:
|
||||
errors.append("series_filename_template cannot be empty")
|
||||
elif not isinstance(config.series_filename_template, str):
|
||||
errors.append("series_filename_template must be a string")
|
||||
|
||||
if not isinstance(config.enrichment_max_concurrency, int):
|
||||
errors.append("enrichment_max_concurrency must be an integer")
|
||||
elif config.enrichment_max_concurrency < 1:
|
||||
errors.append("enrichment_max_concurrency must be >= 1")
|
||||
|
||||
valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if not config.log_level:
|
||||
errors.append("log_level cannot be empty")
|
||||
elif not isinstance(config.log_level, str):
|
||||
errors.append("log_level must be a string")
|
||||
elif config.log_level.upper() not in valid_log_levels:
|
||||
errors.append(f"log_level must be one of {valid_log_levels}, got: {config.log_level}")
|
||||
|
||||
if not config.quarantine_dir:
|
||||
errors.append("quarantine_dir cannot be empty")
|
||||
elif not isinstance(config.quarantine_dir, str):
|
||||
errors.append("quarantine_dir must be a string")
|
||||
elif config.quarantine_dir.startswith("/") or config.quarantine_dir.startswith("\\"):
|
||||
errors.append("quarantine_dir must be relative to category root, not absolute")
|
||||
|
||||
if not isinstance(config.workspace_dir, Path):
|
||||
errors.append("workspace_dir must be a Path object")
|
||||
elif not str(config.workspace_dir).strip():
|
||||
errors.append("workspace_dir cannot be empty")
|
||||
|
||||
if not config.categories:
|
||||
errors.append("categories cannot be empty")
|
||||
elif not isinstance(config.categories, dict):
|
||||
errors.append("categories must be a dictionary")
|
||||
else:
|
||||
required_categories = {"movie", "series", "anime"}
|
||||
missing = required_categories - set(config.categories.keys())
|
||||
if missing:
|
||||
errors.append(f"categories must include keys: {sorted(missing)}")
|
||||
|
||||
seen_dirs = {}
|
||||
for category, dir_list in config.categories.items():
|
||||
if not isinstance(dir_list, list):
|
||||
errors.append(f"categories['{category}'] must be a list")
|
||||
continue
|
||||
|
||||
if not dir_list:
|
||||
errors.append(f"categories['{category}'] cannot be empty")
|
||||
continue
|
||||
|
||||
for dir_name in dir_list:
|
||||
if not isinstance(dir_name, str):
|
||||
errors.append(f"categories['{category}'] must contain strings")
|
||||
break
|
||||
|
||||
if not dir_name.strip():
|
||||
errors.append(f"categories['{category}'] contains empty directory name")
|
||||
break
|
||||
|
||||
dir_lower = dir_name.lower()
|
||||
if dir_lower in seen_dirs:
|
||||
errors.append(
|
||||
f"Duplicate directory name '{dir_name}' in categories "
|
||||
f"'{category}' and '{seen_dirs[dir_lower]}'"
|
||||
)
|
||||
else:
|
||||
seen_dirs[dir_lower] = category
|
||||
|
||||
if not isinstance(config.enrichment_cache_db, Path):
|
||||
errors.append("enrichment_cache_db must be a Path object")
|
||||
if not isinstance(config.enrichment_providers, list) or not config.enrichment_providers:
|
||||
errors.append("enrichment_providers must be a non-empty list")
|
||||
else:
|
||||
allowed_providers = {"tmdb"}
|
||||
invalid = [provider for provider in config.enrichment_providers if provider.lower() not in allowed_providers]
|
||||
if invalid:
|
||||
errors.append(
|
||||
f"enrichment_providers contains unsupported providers: {invalid}; supported providers: ['tmdb']"
|
||||
)
|
||||
if config.enrichment_max_concurrency < 1:
|
||||
errors.append("enrichment_max_concurrency must be >= 1")
|
||||
if not (0.0 <= config.enrichment_min_match_score <= 1.0):
|
||||
errors.append("enrichment_min_match_score must be between 0.0 and 1.0")
|
||||
if config.enrichment_refresh_mode not in {"manual"}:
|
||||
errors.append("enrichment_refresh_mode must be 'manual'")
|
||||
if config.reputation_min_votes < 0:
|
||||
errors.append("reputation_min_votes must be >= 0")
|
||||
if not (0.0 <= config.reputation_low_score_threshold <= 10.0):
|
||||
errors.append("reputation_low_score_threshold must be between 0.0 and 10.0")
|
||||
if not isinstance(config.tmdb_language, str) or not config.tmdb_language.strip():
|
||||
errors.append("tmdb_language must be a non-empty string")
|
||||
if config.tmdb_region is not None and not isinstance(config.tmdb_region, str):
|
||||
errors.append("tmdb_region must be a string when set")
|
||||
if not isinstance(config.tmdb_include_adult, bool):
|
||||
errors.append("tmdb_include_adult must be a boolean")
|
||||
if config.duplicate_keep not in (
|
||||
"by_reputation",
|
||||
"by_reputation_quality_time",
|
||||
"first_seen",
|
||||
"manual",
|
||||
"by_quality",
|
||||
):
|
||||
errors.append(
|
||||
"duplicate_keep must be one of "
|
||||
"'by_reputation', 'by_reputation_quality_time', 'first_seen', 'manual', 'by_quality', "
|
||||
f"got: {config.duplicate_keep!r}"
|
||||
)
|
||||
if not isinstance(config.plan_max_season, int) or config.plan_max_season < 1:
|
||||
errors.append("plan_max_season must be an integer >= 1")
|
||||
if not isinstance(config.plan_max_episode, int) or config.plan_max_episode < 1:
|
||||
errors.append("plan_max_episode must be an integer >= 1")
|
||||
if not isinstance(config.plan_include_sample_files, bool):
|
||||
errors.append("plan_include_sample_files must be a boolean")
|
||||
|
||||
return errors
|
||||
With Pydantic, most validation happens at construction time. This function
|
||||
re-validates by reconstructing the model, catching any errors that may have
|
||||
been bypassed (e.g. via model_construct). Returns empty list for valid configs.
|
||||
"""
|
||||
try:
|
||||
Config.model_validate(config.model_dump())
|
||||
return []
|
||||
except ValidationError as e:
|
||||
return [f"{err['loc'][0]}: {err['msg']}" for err in e.errors()]
|
||||
|
||||
+46
-45
@@ -4,14 +4,14 @@ This module defines the core data structures used throughout the application
|
||||
for representing video files and their parsed identities.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
@dataclass
|
||||
class VideoFile:
|
||||
|
||||
class VideoFile(BaseModel):
|
||||
"""Represents a video file discovered during inventory scanning.
|
||||
|
||||
Attributes:
|
||||
@@ -25,26 +25,27 @@ class VideoFile:
|
||||
duration_seconds: Optional video duration in seconds
|
||||
bitrate_kbps: Optional video bitrate in kilobits per second
|
||||
"""
|
||||
|
||||
path: Path
|
||||
filename: str
|
||||
size_bytes: int
|
||||
modified_timestamp: datetime
|
||||
category: str
|
||||
|
||||
# Optional metadata (if ffprobe available)
|
||||
resolution: Optional[str] = None
|
||||
codec: Optional[str] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
bitrate_kbps: Optional[int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Canonicalize path on creation."""
|
||||
@field_validator("path", mode="before")
|
||||
@classmethod
|
||||
def canonicalize_path(cls, v):
|
||||
from vlm.utils import canonical_path
|
||||
object.__setattr__(self, 'path', canonical_path(self.path))
|
||||
|
||||
return canonical_path(v)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MovieIdentity:
|
||||
class MovieIdentity(BaseModel):
|
||||
"""Represents the parsed identity of a movie file.
|
||||
|
||||
review_status (pending/approved/rejected) and needs_review overlap in meaning:
|
||||
@@ -59,6 +60,7 @@ class MovieIdentity:
|
||||
needs_review: Flag indicating if manual review is needed
|
||||
original_filename: Original filename before parsing
|
||||
"""
|
||||
|
||||
title: str
|
||||
year: Optional[int]
|
||||
confidence: float
|
||||
@@ -73,11 +75,10 @@ class MovieIdentity:
|
||||
reputation_source: Optional[str] = None
|
||||
review_status: str = "pending"
|
||||
enrichment_confidence: Optional[float] = None
|
||||
provider_metadata: dict[str, str] = field(default_factory=dict)
|
||||
provider_metadata: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeriesIdentity:
|
||||
class SeriesIdentity(BaseModel):
|
||||
"""Represents the parsed identity of a TV series episode file.
|
||||
|
||||
review_status (pending/approved/rejected) and needs_review overlap in meaning:
|
||||
@@ -92,6 +93,7 @@ class SeriesIdentity:
|
||||
needs_review: Flag indicating if manual review is needed
|
||||
original_filename: Original filename before parsing
|
||||
"""
|
||||
|
||||
title: str
|
||||
season: Optional[int]
|
||||
episodes: list[int]
|
||||
@@ -107,11 +109,10 @@ class SeriesIdentity:
|
||||
reputation_source: Optional[str] = None
|
||||
review_status: str = "pending"
|
||||
enrichment_confidence: Optional[float] = None
|
||||
provider_metadata: dict[str, str] = field(default_factory=dict)
|
||||
provider_metadata: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileOperation:
|
||||
class FileOperation(BaseModel):
|
||||
"""Represents a single file operation in an execution plan.
|
||||
|
||||
Attributes:
|
||||
@@ -122,17 +123,17 @@ class FileOperation:
|
||||
has_conflict: Flag indicating if destination already exists
|
||||
conflict_reason: Description of the conflict (None if no conflict)
|
||||
"""
|
||||
|
||||
operation_type: str
|
||||
source_path: Path
|
||||
destination_path: Optional[Path]
|
||||
reason: str
|
||||
has_conflict: bool
|
||||
conflict_reason: Optional[str] = None
|
||||
review_context: dict = field(default_factory=dict)
|
||||
review_context: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionPlan:
|
||||
class ExecutionPlan(BaseModel):
|
||||
"""Represents a complete execution plan with all file operations.
|
||||
|
||||
Attributes:
|
||||
@@ -145,49 +146,49 @@ class ExecutionPlan:
|
||||
metadata: Optional dict (e.g. analysis_source, duplicate_groups_considered,
|
||||
completeness_seasons_with_gaps) when plan was built from analysis
|
||||
"""
|
||||
|
||||
plan_id: str
|
||||
created_at: datetime
|
||||
operations: list[FileOperation]
|
||||
summary: dict
|
||||
summary_by_reason: dict = field(default_factory=dict)
|
||||
summary_by_reason: dict = Field(default_factory=dict)
|
||||
human_summary: str = ""
|
||||
metadata: dict = field(default_factory=dict)
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperationResult:
|
||||
class OperationResult(BaseModel):
|
||||
"""Represents the result of executing a single file operation.
|
||||
|
||||
|
||||
Attributes:
|
||||
operation: The file operation that was executed
|
||||
success: Flag indicating if the operation succeeded
|
||||
error_message: Error message if operation failed (None if successful)
|
||||
executed_at: Timestamp when the operation was executed
|
||||
"""
|
||||
|
||||
operation: FileOperation
|
||||
success: bool
|
||||
error_message: Optional[str]
|
||||
executed_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class RollbackLog:
|
||||
class RollbackLog(BaseModel):
|
||||
"""Represents a log of executed operations for rollback purposes.
|
||||
|
||||
|
||||
Attributes:
|
||||
log_id: Unique identifier for the rollback log (UUID)
|
||||
execution_plan_id: ID of the execution plan that was executed
|
||||
executed_at: Timestamp when the operations were executed
|
||||
operations: List of operation results that were executed
|
||||
"""
|
||||
|
||||
log_id: str
|
||||
execution_plan_id: str
|
||||
executed_at: datetime
|
||||
operations: list[OperationResult]
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuarantineEntry:
|
||||
class QuarantineEntry(BaseModel):
|
||||
"""Represents a single file in quarantine.
|
||||
|
||||
Attributes:
|
||||
@@ -199,80 +200,81 @@ class QuarantineEntry:
|
||||
category: Category of the video ("movie" or "series")
|
||||
status: Operation status ("pending" | "committed") for two-phase commit
|
||||
"""
|
||||
|
||||
original_path: Path
|
||||
quarantine_path: Path
|
||||
quarantined_at: datetime
|
||||
reason: Optional[str]
|
||||
size_bytes: int
|
||||
category: str
|
||||
status: str = "committed" # Default for backward compatibility
|
||||
status: str = "committed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuarantineManifest:
|
||||
class QuarantineManifest(BaseModel):
|
||||
"""Represents a manifest of all quarantined files in a category.
|
||||
|
||||
|
||||
Attributes:
|
||||
entries: List of quarantine entries
|
||||
"""
|
||||
|
||||
entries: list[QuarantineEntry]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileState:
|
||||
class FileState(BaseModel):
|
||||
"""Represents the state of a file in the workflow.
|
||||
|
||||
|
||||
Attributes:
|
||||
file_path: Path to the file
|
||||
status: Current status ("reviewed", "ignored", "planned", "executed", "quarantined")
|
||||
reason: Optional reason for the status
|
||||
updated_at: Timestamp when the state was last updated
|
||||
"""
|
||||
|
||||
file_path: Path
|
||||
status: str
|
||||
reason: Optional[str]
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class StateStore:
|
||||
class StateStore(BaseModel):
|
||||
"""Represents the persistent state store for all files.
|
||||
|
||||
|
||||
Attributes:
|
||||
states: Dictionary mapping file path strings to FileState objects
|
||||
version: Version of the state store format
|
||||
last_updated: Timestamp when the state store was last updated
|
||||
"""
|
||||
|
||||
states: dict[str, FileState]
|
||||
version: str
|
||||
last_updated: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeasonCompleteness:
|
||||
class SeasonCompleteness(BaseModel):
|
||||
"""Represents completeness analysis for a single season of a series.
|
||||
|
||||
|
||||
Attributes:
|
||||
series_title: Normalized series title
|
||||
season: Season number
|
||||
episodes_found: List of episode numbers that were found
|
||||
episodes_missing: List of episode numbers missing in the range [min, max]
|
||||
"""
|
||||
|
||||
series_title: str
|
||||
season: int
|
||||
episodes_found: list[int]
|
||||
episodes_missing: list[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplicateGroup:
|
||||
class DuplicateGroup(BaseModel):
|
||||
"""Represents a group of duplicate video files.
|
||||
|
||||
|
||||
Attributes:
|
||||
identity: The shared identity (MovieIdentity or SeriesIdentity)
|
||||
files: List of VideoFile objects that are duplicates
|
||||
quality_comparison: List of dictionaries with quality metrics for each file
|
||||
"""
|
||||
|
||||
identity: MovieIdentity | SeriesIdentity
|
||||
files: list[VideoFile]
|
||||
quality_comparison: list[dict]
|
||||
@@ -383,4 +385,3 @@ class PlanJSON(TypedDict, total=False):
|
||||
summary_by_reason: dict[str, int]
|
||||
human_summary: str
|
||||
metadata: dict[str, object]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -5,7 +5,6 @@ should be organized based on their parsed identities and configuration templates
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
@@ -19,6 +18,15 @@ from vlm.models import (
|
||||
SeriesIdentity,
|
||||
VideoFile,
|
||||
)
|
||||
from vlm.plan_duplicates import (
|
||||
_build_duplicate_quality_lookup,
|
||||
_mark_duplicate_group_manual_review,
|
||||
_select_duplicate_quarantine_reason,
|
||||
)
|
||||
from vlm.plan_paths import (
|
||||
_create_movie_operation,
|
||||
_create_series_operation,
|
||||
)
|
||||
from vlm.plan_review import (
|
||||
REVIEW_APPLIED_AT_KEY,
|
||||
REVIEW_CSV_PATH_KEY,
|
||||
@@ -29,46 +37,12 @@ from vlm.plan_review import (
|
||||
from vlm.scanner import find_sidecar_companions
|
||||
from vlm.utils import (
|
||||
is_sample_path,
|
||||
is_within_root,
|
||||
sanitize_path_component,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
QUARANTINE_REASON_DUPLICATE = "重复项(已按外部评分保留一条)"
|
||||
QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY = "重复项(外部评分缺失/并列,按画质回退保留一条)"
|
||||
QUARANTINE_REASON_DUPLICATE_BY_QUALITY = "重复项(已按画质保留最优)"
|
||||
QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME = "重复项(按外部评分>画质>时间保留一条)"
|
||||
__all__ = ["generate_plan", "save_plan", "load_plan", "apply_review_to_plan"]
|
||||
|
||||
NO_OP_REASON_SAMPLE_EXCLUDED = "Sample file excluded by plan include_sample_files=false"
|
||||
NO_OP_REASON_SEASON_OUT_OF_RANGE = "Series needs manual review (season exceeds configured threshold)"
|
||||
NO_OP_REASON_EPISODE_OUT_OF_RANGE = "Series needs manual review (episode exceeds configured threshold)"
|
||||
|
||||
|
||||
def _build_duplicate_quality_lookup(quality_comparison: list[dict]) -> dict[str, dict]:
|
||||
"""Index duplicate quality entries by canonicalized path."""
|
||||
lookup: dict[str, dict] = {}
|
||||
for quality in quality_comparison:
|
||||
quality_path = quality.get("path")
|
||||
if isinstance(quality_path, str) and quality_path.strip():
|
||||
lookup[normalized_path_key(quality_path)] = quality
|
||||
return lookup
|
||||
|
||||
|
||||
def _mark_duplicate_group_manual_review(
|
||||
operations: list[FileOperation],
|
||||
indices: list[int],
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Convert unresolved duplicate operations into explicit manual-review no-ops."""
|
||||
for index in indices:
|
||||
current = operations[index]
|
||||
operations[index] = FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=current.source_path,
|
||||
destination_path=None,
|
||||
reason=f"Duplicate group needs manual review: {message}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None,
|
||||
)
|
||||
|
||||
|
||||
def _analyze_directory_impact(operations: list[FileOperation]) -> dict:
|
||||
@@ -342,225 +316,6 @@ def _create_operation(
|
||||
)
|
||||
|
||||
|
||||
def _create_movie_operation(
|
||||
video_file: VideoFile,
|
||||
identity: MovieIdentity,
|
||||
config: Config
|
||||
) -> FileOperation:
|
||||
"""Create operation for a movie file.
|
||||
|
||||
Args:
|
||||
video_file: The movie file
|
||||
identity: Parsed movie identity
|
||||
config: Configuration with templates
|
||||
|
||||
Returns:
|
||||
FileOperation for organizing the movie
|
||||
"""
|
||||
# Explicitly blocked by manual review workflow.
|
||||
if identity.review_status == "rejected":
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Movie rejected during manual review",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# If movie needs review (no year or low-confidence enrichment), generate no-op
|
||||
if identity.needs_review or identity.year is None:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Movie needs manual review (no year found)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
safe_title = sanitize_path_component(identity.title, fallback="untitled")
|
||||
|
||||
# Apply movie directory template
|
||||
target_dir = config.movie_template.format(
|
||||
title=safe_title,
|
||||
year=identity.year
|
||||
)
|
||||
|
||||
# Get file extension
|
||||
ext = video_file.path.suffix
|
||||
|
||||
# Apply movie filename template
|
||||
target_filename = config.movie_filename_template.format(
|
||||
title=safe_title,
|
||||
year=identity.year,
|
||||
ext=ext
|
||||
)
|
||||
|
||||
# Construct full destination path
|
||||
destination = config.library_root / target_dir / target_filename
|
||||
if not is_within_root(destination, config.library_root):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=f"Unsafe destination outside library root: {destination}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Check if source and destination are the same
|
||||
if video_file.path.resolve() == destination.resolve():
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="File already at target location",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Determine operation type (move or rename)
|
||||
if video_file.path.parent == destination.parent:
|
||||
operation_type = "rename"
|
||||
else:
|
||||
operation_type = "move"
|
||||
|
||||
# Check for conflicts - destination file already exists
|
||||
has_conflict = destination.exists()
|
||||
conflict_reason = None
|
||||
if has_conflict:
|
||||
conflict_reason = f"Destination file already exists: {destination}"
|
||||
|
||||
return FileOperation(
|
||||
operation_type=operation_type,
|
||||
source_path=video_file.path,
|
||||
destination_path=destination,
|
||||
reason=f"Organize movie: {identity.title} ({identity.year})",
|
||||
has_conflict=has_conflict,
|
||||
conflict_reason=conflict_reason
|
||||
)
|
||||
|
||||
|
||||
def _create_series_operation(
|
||||
video_file: VideoFile,
|
||||
identity: SeriesIdentity,
|
||||
config: Config
|
||||
) -> FileOperation:
|
||||
"""Create operation for a series file.
|
||||
|
||||
Args:
|
||||
video_file: The series file
|
||||
identity: Parsed series identity
|
||||
config: Configuration with templates
|
||||
|
||||
Returns:
|
||||
FileOperation for organizing the series episode
|
||||
"""
|
||||
# Explicitly blocked by manual review workflow.
|
||||
if identity.review_status == "rejected":
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Series rejected during manual review",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# If series needs review (no season or no episodes), generate no-op
|
||||
if identity.needs_review or identity.season is None or len(identity.episodes) == 0:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Series needs manual review (no season/episode found)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
if identity.season > config.plan_max_season:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=NO_OP_REASON_SEASON_OUT_OF_RANGE,
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
if any(ep > config.plan_max_episode for ep in identity.episodes):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=NO_OP_REASON_EPISODE_OUT_OF_RANGE,
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
safe_title = sanitize_path_component(identity.title, fallback="untitled")
|
||||
|
||||
# Apply series directory template
|
||||
target_dir = config.series_template.format(
|
||||
title=safe_title,
|
||||
season=identity.season
|
||||
)
|
||||
|
||||
# Get file extension
|
||||
ext = video_file.path.suffix
|
||||
|
||||
# Apply series filename template
|
||||
# For multi-episode files, use the first episode number
|
||||
target_filename = config.series_filename_template.format(
|
||||
season=identity.season,
|
||||
episode=identity.episodes[0],
|
||||
ext=ext
|
||||
)
|
||||
|
||||
# Construct full destination path
|
||||
destination = config.library_root / target_dir / target_filename
|
||||
if not is_within_root(destination, config.library_root):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=f"Unsafe destination outside library root: {destination}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Check if source and destination are the same
|
||||
if video_file.path.resolve() == destination.resolve():
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="File already at target location",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Determine operation type (move or rename)
|
||||
if video_file.path.parent == destination.parent:
|
||||
operation_type = "rename"
|
||||
else:
|
||||
operation_type = "move"
|
||||
|
||||
# Check for conflicts - destination file already exists
|
||||
has_conflict = destination.exists()
|
||||
conflict_reason = None
|
||||
if has_conflict:
|
||||
conflict_reason = f"Destination file already exists: {destination}"
|
||||
|
||||
return FileOperation(
|
||||
operation_type=operation_type,
|
||||
source_path=video_file.path,
|
||||
destination_path=destination,
|
||||
reason=f"Organize series: {identity.title} S{identity.season:02d}E{identity.episodes[0]:02d}",
|
||||
has_conflict=has_conflict,
|
||||
conflict_reason=conflict_reason
|
||||
)
|
||||
|
||||
|
||||
def _stamp_review_context_on_operations(
|
||||
operations: list[FileOperation],
|
||||
identities: list[tuple],
|
||||
@@ -597,7 +352,7 @@ def _stamp_review_context_on_operations(
|
||||
}
|
||||
for s in sidecars
|
||||
]
|
||||
stamped.append(replace(op, review_context=ctx))
|
||||
stamped.append(op.model_copy(update={"review_context": ctx}))
|
||||
continue
|
||||
stamped.append(op)
|
||||
return stamped
|
||||
@@ -688,23 +443,6 @@ def _generate_human_summary(
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _select_duplicate_quarantine_reason(
|
||||
strategy: str,
|
||||
identities: list[Union[MovieIdentity, SeriesIdentity]],
|
||||
) -> str:
|
||||
"""Choose a user-facing reason string for duplicate quarantine."""
|
||||
if strategy == "by_quality":
|
||||
return QUARANTINE_REASON_DUPLICATE_BY_QUALITY
|
||||
if strategy == "by_reputation_quality_time":
|
||||
return QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME
|
||||
if strategy == "by_reputation":
|
||||
rep_values = [i.reputation_score for i in identities if i.reputation_score is not None]
|
||||
if len(rep_values) <= 1:
|
||||
return QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY
|
||||
return QUARANTINE_REASON_DUPLICATE
|
||||
return QUARANTINE_REASON_DUPLICATE
|
||||
|
||||
|
||||
def _build_quarantine_recommendation_lines(operations: list[FileOperation], limit: int = 20) -> list[str]:
|
||||
"""Build human-readable quarantine recommendations with reasons."""
|
||||
quarantines = [op for op in operations if op.operation_type == "quarantine"]
|
||||
|
||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RequestRateLimiter:
|
||||
"""Thread-safe rate limiter for API requests.
|
||||
@@ -31,8 +32,7 @@ class RequestRateLimiter:
|
||||
self._last_request_time = time.monotonic()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResult:
|
||||
class ProviderResult(BaseModel):
|
||||
"""Normalized provider output used by enrichment pipeline."""
|
||||
|
||||
provider: str
|
||||
@@ -44,7 +44,7 @@ class ProviderResult:
|
||||
reputation_votes: Optional[int] = None
|
||||
reputation_source: Optional[str] = None
|
||||
match_score: Optional[float] = None
|
||||
raw_metadata: dict[str, str] = field(default_factory=dict)
|
||||
raw_metadata: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EnrichmentProvider(Protocol):
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from vlm.plan_review import save_review_csv
|
||||
from vlm.review_display import (
|
||||
build_csv_rows,
|
||||
@@ -36,17 +37,18 @@ def _change_label(row: dict[str, str]) -> str:
|
||||
return src
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewTUIContext:
|
||||
class ReviewTUIContext(BaseModel):
|
||||
"""Inputs for the plan review TUI."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
rows: list[dict[str, str]]
|
||||
counters: dict[str, int]
|
||||
library_root: Path
|
||||
output_csv: Path
|
||||
plan_input: Path
|
||||
summary_text: str
|
||||
path_to_quality: dict[str, dict] = field(default_factory=dict)
|
||||
path_to_quality: dict[str, dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
try:
|
||||
|
||||
+2
-74
@@ -1,7 +1,7 @@
|
||||
"""Inventory scanner for discovering and cataloging video files.
|
||||
|
||||
This module implements the core scanning functionality for the Video Library Manager,
|
||||
including file discovery via `find`, metadata extraction, and categorization based on
|
||||
including file discovery, metadata extraction, and categorization based on
|
||||
directory structure.
|
||||
"""
|
||||
|
||||
@@ -143,79 +143,7 @@ def scan_library(
|
||||
|
||||
|
||||
def _discover_video_paths(root: Path, video_extensions: list[str]) -> list[Path]:
|
||||
"""Discover matching video files under root.
|
||||
|
||||
Uses the system `find` command for traversal speed and falls back to Python
|
||||
recursion if `find` is unavailable.
|
||||
"""
|
||||
try:
|
||||
return _discover_video_paths_with_find(root, video_extensions)
|
||||
except FileNotFoundError:
|
||||
logger.warning("`find` command not available - falling back to Python recursion")
|
||||
return _discover_video_paths_recursive(root, video_extensions)
|
||||
|
||||
|
||||
def _log_find_nonzero_exit(returncode: int, stderr_text: str, discovered_count: int) -> None:
|
||||
"""Log the explicit contract for non-zero `find` exits.
|
||||
|
||||
Contract: if `find` emits partial stdout before failing, keep those paths and
|
||||
continue with a warning. If no paths were emitted, return an empty result and
|
||||
log that scan discovery was incomplete.
|
||||
"""
|
||||
stderr_suffix = f": {stderr_text}" if stderr_text else ""
|
||||
if discovered_count > 0:
|
||||
logger.warning(
|
||||
"find exited with code %s; using %s partial scan result(s)%s",
|
||||
returncode,
|
||||
discovered_count,
|
||||
stderr_suffix,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"find exited with code %s and produced no scan results%s",
|
||||
returncode,
|
||||
stderr_suffix,
|
||||
)
|
||||
|
||||
|
||||
def _discover_video_paths_with_find(root: Path, video_extensions: list[str]) -> list[Path]:
|
||||
"""Discover matching video files using the system `find` command."""
|
||||
normalized_extensions = [ext.lower() for ext in video_extensions if ext]
|
||||
if not normalized_extensions:
|
||||
return []
|
||||
|
||||
command: list[str] = ["find", str(root), "-type", "f", "("]
|
||||
for index, extension in enumerate(normalized_extensions):
|
||||
if index > 0:
|
||||
command.append("-o")
|
||||
command.extend(["-iname", f"*{extension}"])
|
||||
command.extend([")", "-print0"])
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
discovered_paths: list[Path] = []
|
||||
for path_bytes in stdout.split(b"\0"):
|
||||
if not path_bytes:
|
||||
continue
|
||||
file_path = Path(os.fsdecode(path_bytes))
|
||||
if _is_hidden_path(file_path, root):
|
||||
continue
|
||||
discovered_paths.append(file_path)
|
||||
|
||||
if process.returncode != 0:
|
||||
stderr_text = stderr.decode(errors="replace").strip()
|
||||
_log_find_nonzero_exit(process.returncode, stderr_text, len(discovered_paths))
|
||||
|
||||
return discovered_paths
|
||||
|
||||
|
||||
def _discover_video_paths_recursive(root: Path, video_extensions: list[str]) -> list[Path]:
|
||||
"""Fallback discovery using Python directory traversal."""
|
||||
"""Discover matching video files under root using os.scandir recursion."""
|
||||
discovered_paths: list[Path] = []
|
||||
for file_path in _scan_directory_recursive(root, video_extensions):
|
||||
if _is_hidden_path(file_path, root):
|
||||
|
||||
+319
-277
@@ -10,214 +10,269 @@ from vlm.analysis import analyze_series_completeness, compare_quality, detect_du
|
||||
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:
|
||||
"""Test series completeness analysis functionality."""
|
||||
|
||||
|
||||
def test_detect_single_gap(self):
|
||||
"""Test detection of a single missing episode."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [4], 0.9, False, "Show.Name.S01E04.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[2],
|
||||
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)
|
||||
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].series_title == "Show Name"
|
||||
assert result[0].season == 1
|
||||
assert result[0].episodes_found == [1, 2, 4, 5]
|
||||
assert result[0].episodes_missing == [3]
|
||||
|
||||
|
||||
def test_detect_multiple_gaps(self):
|
||||
"""Test detection of multiple missing episodes."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [7], 0.9, False, "Show.Name.S01E07.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[3],
|
||||
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)
|
||||
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_found == [1, 3, 5, 7]
|
||||
assert result[0].episodes_missing == [2, 4, 6]
|
||||
|
||||
|
||||
def test_no_gaps_returns_empty(self):
|
||||
"""Test that complete seasons are not included in results."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.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)
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_multi_season_independence(self):
|
||||
"""Test that gap detection for one season doesn't affect others."""
|
||||
episodes = [
|
||||
# Season 1 - has gap at episode 2
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
_series("Show Name", season=1, episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", season=1, episodes=[3],
|
||||
original_filename="Show.Name.S01E03.mkv"),
|
||||
# Season 2 - complete
|
||||
SeriesIdentity("Show Name", 2, [1], 0.9, False, "Show.Name.S02E01.mkv"),
|
||||
SeriesIdentity("Show Name", 2, [2], 0.9, False, "Show.Name.S02E02.mkv"),
|
||||
_series("Show Name", season=2, episodes=[1],
|
||||
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
|
||||
SeriesIdentity("Show Name", 3, [4], 0.9, False, "Show.Name.S03E04.mkv"),
|
||||
SeriesIdentity("Show Name", 3, [6], 0.9, False, "Show.Name.S03E06.mkv"),
|
||||
_series("Show Name", season=3, episodes=[4],
|
||||
original_filename="Show.Name.S03E04.mkv"),
|
||||
_series("Show Name", season=3, episodes=[6],
|
||||
original_filename="Show.Name.S03E06.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
# Should have 2 results (seasons 1 and 3 with gaps)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
# Find season 1 result
|
||||
season1 = next(r for r in result if r.season == 1)
|
||||
assert season1.episodes_found == [1, 3]
|
||||
assert season1.episodes_missing == [2]
|
||||
|
||||
|
||||
# Find season 3 result
|
||||
season3 = next(r for r in result if r.season == 3)
|
||||
assert season3.episodes_found == [4, 6]
|
||||
assert season3.episodes_missing == [5]
|
||||
|
||||
|
||||
def test_multi_episode_files(self):
|
||||
"""Test handling of multi-episode files."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1, 2], 0.9, False, "Show.Name.S01E01-E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [4], 0.9, False, "Show.Name.S01E04.mkv"),
|
||||
_series("Show Name", episodes=[1, 2],
|
||||
original_filename="Show.Name.S01E01-E02.mkv"),
|
||||
_series("Show Name", episodes=[4],
|
||||
original_filename="Show.Name.S01E04.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_found == [1, 2, 4]
|
||||
assert result[0].episodes_missing == [3]
|
||||
|
||||
|
||||
def test_different_series_separate_analysis(self):
|
||||
"""Test that different series are analyzed separately."""
|
||||
episodes = [
|
||||
# Series A - has gap
|
||||
SeriesIdentity("Series A", 1, [1], 0.9, False, "Series.A.S01E01.mkv"),
|
||||
SeriesIdentity("Series A", 1, [3], 0.9, False, "Series.A.S01E03.mkv"),
|
||||
_series("Series A", episodes=[1],
|
||||
original_filename="Series.A.S01E01.mkv"),
|
||||
_series("Series A", episodes=[3],
|
||||
original_filename="Series.A.S01E03.mkv"),
|
||||
# Series B - complete
|
||||
SeriesIdentity("Series B", 1, [1], 0.9, False, "Series.B.S01E01.mkv"),
|
||||
SeriesIdentity("Series B", 1, [2], 0.9, False, "Series.B.S01E02.mkv"),
|
||||
_series("Series B", episodes=[1],
|
||||
original_filename="Series.B.S01E01.mkv"),
|
||||
_series("Series B", episodes=[2],
|
||||
original_filename="Series.B.S01E02.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
# Only Series A should be in results
|
||||
assert len(result) == 1
|
||||
assert result[0].series_title == "Series A"
|
||||
assert result[0].episodes_missing == [2]
|
||||
|
||||
|
||||
def test_skip_episodes_without_season(self):
|
||||
"""Test that episodes with season=None are excluded from analysis."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", None, [1], 0.3, True, "Show.Name.Episode.1.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.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)
|
||||
|
||||
|
||||
# Should only analyze season 1, which is complete
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_skip_episodes_with_empty_episode_list(self):
|
||||
"""Test that episodes with empty episode list are excluded from analysis."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.S01.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.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)
|
||||
|
||||
|
||||
# Should detect gap at episode 2
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_missing == [2]
|
||||
|
||||
|
||||
def test_non_sequential_start(self):
|
||||
"""Test gap detection when episodes don't start at 1."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [6], 0.9, False, "Show.Name.S01E06.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [8], 0.9, False, "Show.Name.S01E08.mkv"),
|
||||
_series("Show Name", episodes=[5],
|
||||
original_filename="Show.Name.S01E05.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)
|
||||
|
||||
|
||||
# Should detect gap at episode 7 in range [5, 8]
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_found == [5, 6, 8]
|
||||
assert result[0].episodes_missing == [7]
|
||||
|
||||
|
||||
def test_empty_input(self):
|
||||
"""Test handling of empty episode list."""
|
||||
result = analyze_series_completeness([])
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_single_episode_no_gap(self):
|
||||
"""Test that a single episode has no gaps."""
|
||||
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)
|
||||
|
||||
|
||||
# Single episode has no gaps
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
|
||||
class TestDuplicateDetection:
|
||||
"""Test duplicate detection functionality."""
|
||||
|
||||
|
||||
def test_detect_movie_duplicates(self):
|
||||
"""Test detection of duplicate movies with identical title and year."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.720p.mkv"),
|
||||
MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.1080p.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.720p.mkv"),
|
||||
_movie("Inception", 2010),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
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"
|
||||
),
|
||||
_video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264"),
|
||||
_video("The.Matrix.1999.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h264"),
|
||||
_video("Inception.2010.mkv", 1_500_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should find one duplicate group (The Matrix)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0].identity, MovieIdentity)
|
||||
@@ -225,43 +280,32 @@ class TestDuplicateDetection:
|
||||
assert result[0].identity.year == 1999
|
||||
assert len(result[0].files) == 2
|
||||
assert len(result[0].quality_comparison) == 2
|
||||
|
||||
|
||||
def test_detect_series_duplicates(self):
|
||||
"""Test detection of duplicate series episodes."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.1080p.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.720p.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [2], 0.9, False, "Breaking.Bad.S01E02.mkv"),
|
||||
_series("Breaking Bad", episodes=[1],
|
||||
original_filename="Breaking.Bad.S01E01.1080p.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 = [
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.1080p.mkv"),
|
||||
"Breaking.Bad.S01E01.1080p.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series",
|
||||
resolution="1920x1080"
|
||||
),
|
||||
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"
|
||||
),
|
||||
_video("Breaking.Bad.S01E01.1080p.mkv", 1_500_000_000,
|
||||
"series", modified_timestamp=now,
|
||||
resolution="1920x1080"),
|
||||
_video("Breaking.Bad.S01E01.720p.mkv", 800_000_000,
|
||||
"series", modified_timestamp=now,
|
||||
resolution="1280x720"),
|
||||
_video("Breaking.Bad.S01E02.mkv", 1_200_000_000,
|
||||
"series", modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should find one duplicate group (S01E01)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0].identity, SeriesIdentity)
|
||||
@@ -269,110 +313,123 @@ class TestDuplicateDetection:
|
||||
assert result[0].identity.season == 1
|
||||
assert 1 in result[0].identity.episodes
|
||||
assert len(result[0].files) == 2
|
||||
|
||||
|
||||
def test_no_duplicates(self):
|
||||
"""Test that unique files are not flagged as duplicates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("Movie A", 2020, 0.9, False, "Movie.A.2020.mkv"),
|
||||
MovieIdentity("Movie B", 2021, 0.9, False, "Movie.B.2021.mkv"),
|
||||
_movie("Movie A", 2020),
|
||||
_movie("Movie B", 2021),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie.A.2020.mkv"), "Movie.A.2020.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Movie.B.2021.mkv"), "Movie.B.2021.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Movie.A.2020.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Movie.B.2021.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_skip_movies_without_year(self):
|
||||
"""Test that movies without year are excluded from duplicate detection."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("Unknown Movie", None, 0.3, True, "Unknown.Movie.mkv"),
|
||||
MovieIdentity("Unknown Movie", None, 0.3, True, "Unknown.Movie.2.mkv"),
|
||||
_movie("Unknown Movie", year=None, confidence=0.3,
|
||||
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 = [
|
||||
VideoFile(Path("/movies/Unknown.Movie.mkv"), "Unknown.Movie.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Unknown.Movie.2.mkv"), "Unknown.Movie.2.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Unknown.Movie.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Unknown.Movie.2.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should not detect duplicates for files needing review
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_skip_series_without_season(self):
|
||||
"""Test that series without season are excluded from duplicate detection."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Unknown Show", None, [1], 0.3, True, "Unknown.Show.E01.mkv"),
|
||||
SeriesIdentity("Unknown Show", None, [1], 0.3, True, "Unknown.Show.Episode.1.mkv"),
|
||||
_series("Unknown Show", season=None, confidence=0.3,
|
||||
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 = [
|
||||
VideoFile(Path("/series/Unknown.Show.E01.mkv"), "Unknown.Show.E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Unknown.Show.Episode.1.mkv"), "Unknown.Show.Episode.1.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
_video("Unknown.Show.E01.mkv", 1_000_000_000, "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)))
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_skip_series_with_empty_episodes(self):
|
||||
"""Test that series with empty episode list are excluded."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.S01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.Season.1.mkv"),
|
||||
_series("Show Name", episodes=[], confidence=0.3,
|
||||
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 = [
|
||||
VideoFile(Path("/series/Show.Name.S01.mkv"), "Show.Name.S01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.Name.Season.1.mkv"), "Show.Name.Season.1.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
_video("Show.Name.S01.mkv", 1_000_000_000, "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)))
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_quality_comparison_includes_all_metadata(self):
|
||||
"""Test that quality comparison includes all available metadata."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("Test Movie", 2020, 0.9, False, "Test.Movie.2020.1080p.mkv"),
|
||||
MovieIdentity("Test Movie", 2020, 0.9, False, "Test.Movie.2020.720p.mkv"),
|
||||
_movie("Test Movie", 2020,
|
||||
original_filename="Test.Movie.2020.1080p.mkv"),
|
||||
_movie("Test Movie", 2020,
|
||||
original_filename="Test.Movie.2020.720p.mkv"),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Test.Movie.2020.1080p.mkv"),
|
||||
"Test.Movie.2020.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
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
|
||||
),
|
||||
_video("Test.Movie.2020.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=5000),
|
||||
_video("Test.Movie.2020.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=2500),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
assert len(result) == 1
|
||||
comparison = result[0].quality_comparison
|
||||
assert len(comparison) == 2
|
||||
|
||||
|
||||
# Check first file comparison data
|
||||
assert comparison[0]['filename'] == "Test.Movie.2020.1080p.mkv"
|
||||
assert comparison[0]['size_bytes'] == 2000000000
|
||||
@@ -380,96 +437,98 @@ class TestDuplicateDetection:
|
||||
assert comparison[0]['codec'] == "h264"
|
||||
assert comparison[0]['duration_seconds'] == 7200.0
|
||||
assert comparison[0]['bitrate_kbps'] == 5000
|
||||
|
||||
|
||||
# Check second file comparison data
|
||||
assert comparison[1]['filename'] == "Test.Movie.2020.720p.mkv"
|
||||
assert comparison[1]['size_bytes'] == 1000000000
|
||||
assert comparison[1]['resolution'] == "1280x720"
|
||||
|
||||
|
||||
def test_multi_episode_file_duplicates(self):
|
||||
"""Test duplicate detection for multi-episode files."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Show", 1, [1, 2], 0.9, False, "Show.S01E01-E02.mkv"),
|
||||
SeriesIdentity("Show", 1, [1], 0.9, False, "Show.S01E01.mkv"),
|
||||
SeriesIdentity("Show", 1, [2], 0.9, False, "Show.S01E02.mkv"),
|
||||
_series("Show", episodes=[1, 2],
|
||||
original_filename="Show.S01E01-E02.mkv"),
|
||||
_series("Show", episodes=[1],
|
||||
original_filename="Show.S01E01.mkv"),
|
||||
_series("Show", episodes=[2],
|
||||
original_filename="Show.S01E02.mkv"),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/series/Show.S01E01-E02.mkv"), "Show.S01E01-E02.mkv", 2000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
_video("Show.S01E01-E02.mkv", 2_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_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)))
|
||||
|
||||
|
||||
# Should find duplicates for both E01 and E02
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
def test_different_years_not_duplicates(self):
|
||||
"""Test that same title with different years are not duplicates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("The Thing", 1982, 0.9, False, "The.Thing.1982.mkv"),
|
||||
MovieIdentity("The Thing", 2011, 0.9, False, "The.Thing.2011.mkv"),
|
||||
_movie("The Thing", 1982),
|
||||
_movie("The Thing", 2011),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/movies/The.Thing.1982.mkv"), "The.Thing.1982.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/The.Thing.2011.mkv"), "The.Thing.2011.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("The.Thing.1982.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("The.Thing.2011.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_different_seasons_not_duplicates(self):
|
||||
"""Test that same series/episode in different seasons are not duplicates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Show", 1, [1], 0.9, False, "Show.S01E01.mkv"),
|
||||
SeriesIdentity("Show", 2, [1], 0.9, False, "Show.S02E01.mkv"),
|
||||
_series("Show", season=1,
|
||||
original_filename="Show.S01E01.mkv"),
|
||||
_series("Show", season=2,
|
||||
original_filename="Show.S02E01.mkv"),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S02E01.mkv"), "Show.S02E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S02E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestQualityComparison:
|
||||
"""Test quality comparison functionality."""
|
||||
|
||||
|
||||
def test_compare_quality_with_all_metadata(self):
|
||||
"""Test quality comparison with all metadata available."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file1.mkv"),
|
||||
"file1.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
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
|
||||
),
|
||||
_video("file1.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=5000),
|
||||
_video("file2.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h265", duration_seconds=7200.0,
|
||||
bitrate_kbps=2500),
|
||||
]
|
||||
|
||||
|
||||
result = compare_quality(files)
|
||||
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]['filename'] == "file1.mkv"
|
||||
assert result[0]['size_bytes'] == 2000000000
|
||||
@@ -477,36 +536,24 @@ class TestQualityComparison:
|
||||
assert result[0]['codec'] == "h264"
|
||||
assert result[0]['duration_seconds'] == 7200.0
|
||||
assert result[0]['bitrate_kbps'] == 5000
|
||||
|
||||
|
||||
assert result[1]['filename'] == "file2.mkv"
|
||||
assert result[1]['size_bytes'] == 1000000000
|
||||
assert result[1]['resolution'] == "1280x720"
|
||||
assert result[1]['codec'] == "h265"
|
||||
|
||||
|
||||
def test_compare_quality_with_partial_metadata(self):
|
||||
"""Test quality comparison when some metadata is missing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file1.mkv"),
|
||||
"file1.mkv",
|
||||
2000000000,
|
||||
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
|
||||
),
|
||||
_video("file1.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080"),
|
||||
_video("file2.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = compare_quality(files)
|
||||
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]['filename'] == "file1.mkv"
|
||||
assert result[0]['size_bytes'] == 2000000000
|
||||
@@ -514,33 +561,28 @@ class TestQualityComparison:
|
||||
assert 'codec' not in result[0]
|
||||
assert 'duration_seconds' not in result[0]
|
||||
assert 'bitrate_kbps' not in result[0]
|
||||
|
||||
|
||||
assert result[1]['filename'] == "file2.mkv"
|
||||
assert result[1]['size_bytes'] == 1000000000
|
||||
assert 'resolution' not in result[1]
|
||||
assert 'codec' not in result[1]
|
||||
|
||||
|
||||
def test_compare_quality_empty_list(self):
|
||||
"""Test quality comparison with empty file list."""
|
||||
result = compare_quality([])
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_compare_quality_single_file(self):
|
||||
"""Test quality comparison with single file."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file.mkv"),
|
||||
"file.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
_video("file.mkv", 1_500_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264"),
|
||||
]
|
||||
|
||||
|
||||
result = compare_quality(files)
|
||||
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]['filename'] == "file.mkv"
|
||||
assert result[0]['size_bytes'] == 1500000000
|
||||
|
||||
+162
-129
@@ -18,6 +18,42 @@ from vlm.reports import (
|
||||
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
|
||||
|
||||
@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(
|
||||
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' '
|
||||
)))
|
||||
|
||||
|
||||
if season is None:
|
||||
season = draw(st.integers(min_value=1, max_value=20))
|
||||
|
||||
|
||||
# Generate 1-3 episode numbers
|
||||
episode_count = draw(st.integers(min_value=1, max_value=3))
|
||||
episodes = draw(st.lists(
|
||||
@@ -39,12 +75,13 @@ def series_identity_strategy(draw, title=None, season=None):
|
||||
max_size=episode_count,
|
||||
unique=True
|
||||
))
|
||||
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
@@ -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(
|
||||
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' '
|
||||
)))
|
||||
|
||||
|
||||
if year is None:
|
||||
year = draw(st.integers(min_value=1900, max_value=2030))
|
||||
|
||||
|
||||
confidence = draw(st.floats(min_value=0.5, max_value=1.0))
|
||||
needs_review = False
|
||||
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
|
||||
@@ -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(
|
||||
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters='.-_'
|
||||
))) + ".mkv"
|
||||
|
||||
path = Path(f"/{category}/{filename}")
|
||||
|
||||
size_bytes = draw(st.integers(min_value=1000000, max_value=10000000000))
|
||||
modified_timestamp = datetime.now(timezone.utc)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Optional metadata
|
||||
has_metadata = draw(st.booleans())
|
||||
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"]))
|
||||
duration_seconds = draw(st.floats(min_value=300, max_value=10800))
|
||||
bitrate_kbps = draw(st.integers(min_value=500, max_value=20000))
|
||||
return VideoFile(path, filename, size_bytes, modified_timestamp, category,
|
||||
resolution, codec, duration_seconds, bitrate_kbps)
|
||||
return _video(
|
||||
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:
|
||||
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
|
||||
@@ -109,38 +152,39 @@ def video_file_strategy(draw, filename=None, category="movie"):
|
||||
def test_property_10_gap_detection(title, season, episodes_data):
|
||||
"""Property 10: For any set of episodes within season, analysis SHALL detect
|
||||
missing episode numbers in range [min, max].
|
||||
|
||||
|
||||
Validates: Requirements 4.1, 4.2
|
||||
"""
|
||||
# Sort episodes and ensure there's at least one gap
|
||||
sorted_episodes = sorted(episodes_data)
|
||||
|
||||
|
||||
# Create episodes, intentionally removing one to create a gap
|
||||
if len(sorted_episodes) >= 3:
|
||||
# Remove a middle episode to guarantee a gap
|
||||
gap_index = len(sorted_episodes) // 2
|
||||
removed_episode = sorted_episodes[gap_index]
|
||||
episodes_with_gap = sorted_episodes[:gap_index] + sorted_episodes[gap_index + 1:]
|
||||
|
||||
|
||||
# Create SeriesIdentity objects
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
# Analyze completeness
|
||||
result = analyze_series_completeness(episode_identities)
|
||||
|
||||
|
||||
# Should detect the gap
|
||||
if len(result) > 0:
|
||||
assert result[0].series_title == title
|
||||
assert result[0].season == season
|
||||
|
||||
|
||||
# The missing episode should be in the detected gaps
|
||||
min_ep = min(episodes_with_gap)
|
||||
max_ep = max(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 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):
|
||||
"""Property 11: For any series with multiple seasons, gap detection of one
|
||||
season SHALL not affect others.
|
||||
|
||||
|
||||
Validates: Requirements 4.4
|
||||
"""
|
||||
# Create episodes for season 1 with a gap
|
||||
@@ -170,29 +214,31 @@ def test_property_11_multi_season_independence(title, season1_episodes, season2_
|
||||
else:
|
||||
s1_with_gap = s1_sorted
|
||||
s1_missing = None
|
||||
|
||||
|
||||
# Create episodes for season 2 (complete, no gaps)
|
||||
s2_sorted = sorted(season2_episodes)
|
||||
s2_complete = list(range(min(s2_sorted), max(s2_sorted) + 1))
|
||||
|
||||
|
||||
# Create SeriesIdentity objects
|
||||
episode_identities = []
|
||||
for ep in s1_with_gap:
|
||||
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:
|
||||
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
|
||||
result = analyze_series_completeness(episode_identities)
|
||||
|
||||
|
||||
# Season 2 should not appear in results (it's complete)
|
||||
season2_results = [r for r in result if r.season == 2]
|
||||
assert len(season2_results) == 0
|
||||
|
||||
|
||||
# Season 1 should appear if there's a gap
|
||||
if s1_missing is not None:
|
||||
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):
|
||||
"""Property 12: For any set of movies with identical normalized titles and years,
|
||||
all SHALL be grouped as duplicates.
|
||||
|
||||
|
||||
Validates: Requirements 5.1
|
||||
"""
|
||||
# Create multiple movie identities with same title and year
|
||||
identities = []
|
||||
files = []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(duplicate_count):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
|
||||
identities.append(MovieIdentity(title, year, 0.9, False, filename))
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
identities.append(_movie(title, year, original_filename=filename))
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
))
|
||||
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should find exactly one duplicate group
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# The group should contain all files
|
||||
assert len(result[0].files) == duplicate_count
|
||||
|
||||
|
||||
# Identity should match
|
||||
assert result[0].identity.title == title
|
||||
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):
|
||||
"""Property 13: For any set of series files with identical normalized titles,
|
||||
seasons, and episodes, all SHALL be grouped as duplicates.
|
||||
|
||||
|
||||
Validates: Requirements 5.2
|
||||
"""
|
||||
# Create multiple series identities with same title, season, and episode
|
||||
identities = []
|
||||
files = []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(duplicate_count):
|
||||
filename = f"{title.replace(' ', '.')}.S{season:02d}E{episode:02d}.{i}.mkv"
|
||||
identities.append(SeriesIdentity(title, season, [episode], 0.9, False, filename))
|
||||
files.append(VideoFile(
|
||||
Path(f"/series/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
identities.append(
|
||||
_series(title, season, [episode], original_filename=filename)
|
||||
)
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000, "series",
|
||||
modified_timestamp=now, path=Path(f"/series/{filename}"),
|
||||
))
|
||||
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should find exactly one duplicate group
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# The group should contain all files
|
||||
assert len(result[0].files) == duplicate_count
|
||||
|
||||
|
||||
# Identity should match
|
||||
assert result[0].identity.title == title
|
||||
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):
|
||||
"""Property 14: For any duplicate group, comparison data SHALL include
|
||||
available metadata for each file.
|
||||
|
||||
|
||||
Validates: Requirements 5.3
|
||||
"""
|
||||
# Create movie identities and files with varying metadata
|
||||
identities = []
|
||||
files = []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(file_count):
|
||||
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
|
||||
if i % 2 == 0:
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
resolution="1920x1080", codec="h264",
|
||||
duration_seconds=7200.0, bitrate_kbps=5000,
|
||||
))
|
||||
else:
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
))
|
||||
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should have quality comparison data
|
||||
assert len(result) == 1
|
||||
assert len(result[0].quality_comparison) == file_count
|
||||
|
||||
|
||||
# Each comparison entry should have at least filename and size
|
||||
for comparison in result[0].quality_comparison:
|
||||
assert 'filename' in comparison
|
||||
assert 'size_bytes' in comparison
|
||||
assert 'path' in comparison
|
||||
|
||||
|
||||
# Files with metadata should have those fields
|
||||
if comparison['filename'].endswith('.0.mkv') or comparison['filename'].endswith('.2.mkv'):
|
||||
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):
|
||||
"""Property 42: For any set of analyzed series, completeness report SHALL
|
||||
include all series with detected gaps.
|
||||
|
||||
|
||||
Validates: Requirements 11.2
|
||||
"""
|
||||
# Create series with gaps
|
||||
analysis_results = []
|
||||
|
||||
|
||||
for i in range(series_count):
|
||||
title = f"Series {i}"
|
||||
season = 1
|
||||
episodes_found = [1, 2, 4, 5] # Gap at episode 3
|
||||
episodes_missing = [3]
|
||||
|
||||
|
||||
analysis_results.append(SeasonCompleteness(
|
||||
series_title=title,
|
||||
season=season,
|
||||
episodes_found=episodes_found,
|
||||
episodes_missing=episodes_missing
|
||||
season=1,
|
||||
episodes_found=[1, 2, 4, 5], # Gap at episode 3
|
||||
episodes_missing=[3]
|
||||
))
|
||||
|
||||
|
||||
# Generate report
|
||||
library_root = Path("/test/library")
|
||||
report = generate_completeness_report(analysis_results, format, library_root)
|
||||
|
||||
|
||||
# Report should include all series
|
||||
for i in range(series_count):
|
||||
assert f"Series {i}" in report
|
||||
|
||||
|
||||
# Report should include metadata
|
||||
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):
|
||||
"""Property 43: For any set of detected duplicates, duplicate report SHALL
|
||||
group files by identity with comparison data.
|
||||
|
||||
|
||||
Validates: Requirements 11.3
|
||||
"""
|
||||
# Create duplicate groups
|
||||
duplicate_groups = []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(duplicate_count):
|
||||
title = f"Movie {i}"
|
||||
year = 2020 + i
|
||||
|
||||
|
||||
# Create 2 files for each duplicate group
|
||||
files = []
|
||||
quality_comparison = []
|
||||
|
||||
|
||||
for j in range(2):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{j}.mkv"
|
||||
file = VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + j * 500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
file = _video(
|
||||
filename, 1_000_000_000 + j * 500_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
resolution="1920x1080" if j == 0 else "1280x720",
|
||||
codec="h264"
|
||||
codec="h264",
|
||||
)
|
||||
files.append(file)
|
||||
quality_comparison.append({
|
||||
@@ -443,21 +475,24 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
|
||||
'resolution': file.resolution,
|
||||
'codec': file.codec
|
||||
})
|
||||
|
||||
identity = MovieIdentity(title, year, 0.9, False, files[0].filename)
|
||||
duplicate_groups.append(DuplicateGroup(identity, files, quality_comparison))
|
||||
|
||||
|
||||
identity = _movie(title, year, original_filename=files[0].filename)
|
||||
duplicate_groups.append(DuplicateGroup(
|
||||
identity=identity, files=files,
|
||||
quality_comparison=quality_comparison,
|
||||
))
|
||||
|
||||
# Generate report
|
||||
library_root = Path("/test/library")
|
||||
report = generate_duplicate_report(duplicate_groups, format, library_root)
|
||||
|
||||
|
||||
# Report should include all duplicate groups
|
||||
for i in range(duplicate_count):
|
||||
assert f"Movie {i}" in report
|
||||
|
||||
|
||||
# Report should include comparison data (file sizes, resolutions)
|
||||
assert "1920x1080" in report or "resolution" in report.lower()
|
||||
|
||||
|
||||
# Report should include metadata
|
||||
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):
|
||||
"""Property 44: For any scanned library, summary report SHALL contain
|
||||
accurate counts and sizes.
|
||||
|
||||
|
||||
Validates: Requirements 11.4
|
||||
"""
|
||||
# Create video files
|
||||
files = []
|
||||
total_size = 0
|
||||
category_counts = {}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(file_count):
|
||||
category = categories[i % len(categories)]
|
||||
size = 1000000000 + i * 100000000
|
||||
size = 1_000_000_000 + i * 100_000_000
|
||||
filename = f"file_{i}.mkv"
|
||||
|
||||
files.append(VideoFile(
|
||||
Path(f"/{category}/{filename}"),
|
||||
filename,
|
||||
size,
|
||||
datetime.now(timezone.utc),
|
||||
category
|
||||
|
||||
files.append(_video(
|
||||
filename, size, category,
|
||||
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
|
||||
))
|
||||
|
||||
|
||||
total_size += size
|
||||
category_counts[category] = category_counts.get(category, 0) + 1
|
||||
|
||||
|
||||
# Generate summary report
|
||||
library_root = Path("/test/library")
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
|
||||
# Report should include total file count
|
||||
assert f"Total Files: {file_count}" in report
|
||||
|
||||
|
||||
# Report should include category breakdown
|
||||
for category, count in category_counts.items():
|
||||
assert category.capitalize() in report
|
||||
assert f"Files: {count}" in report
|
||||
|
||||
|
||||
# Report should include metadata
|
||||
assert str(library_root) in report
|
||||
|
||||
+188
-234
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vlm.config import Config, create_default_config, load_config, validate_config
|
||||
|
||||
@@ -334,277 +335,235 @@ class TestCreateDefaultConfig:
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
"""Test validate_config function."""
|
||||
|
||||
"""Test validation — with Pydantic, invalid values raise ValidationError at construction."""
|
||||
|
||||
def test_validate_valid_config(self):
|
||||
"""Test validating a valid configuration."""
|
||||
config = Config(library_root=Path("/mnt/nas/videos"))
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert errors == []
|
||||
|
||||
assert validate_config(config) == []
|
||||
|
||||
def test_validate_empty_library_root(self):
|
||||
"""Test validating config with empty library_root."""
|
||||
config = Config(library_root=Path(""))
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("library_root" in err for err in errors)
|
||||
|
||||
with pytest.raises(ValidationError, match="library_root"):
|
||||
Config(library_root=Path(""))
|
||||
|
||||
def test_validate_empty_video_extensions(self):
|
||||
"""Test validating config with empty video_extensions."""
|
||||
config = Config(
|
||||
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)
|
||||
|
||||
with pytest.raises(ValidationError, match="video_extensions"):
|
||||
Config(library_root=Path("/mnt/nas/videos"), video_extensions=[])
|
||||
|
||||
def test_validate_invalid_video_extension_format(self):
|
||||
"""Test validating config with invalid video extension format."""
|
||||
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(
|
||||
with pytest.raises(ValidationError, match="must start with"):
|
||||
Config(
|
||||
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):
|
||||
"""Test validating config with absolute quarantine_dir."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
quarantine_dir="/absolute/path"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("must be relative" in err for err in errors)
|
||||
|
||||
with pytest.raises(ValidationError, match="must be relative"):
|
||||
Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
quarantine_dir="/absolute/path",
|
||||
)
|
||||
|
||||
def test_validate_empty_quarantine_dir(self):
|
||||
"""Test validating config with empty quarantine_dir."""
|
||||
config = Config(
|
||||
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)
|
||||
with pytest.raises(ValidationError, match="quarantine_dir"):
|
||||
Config(library_root=Path("/mnt/nas/videos"), quarantine_dir="")
|
||||
|
||||
def test_validate_workspace_dir_type(self):
|
||||
"""workspace_dir must be a Path object."""
|
||||
def test_workspace_dir_coerces_from_string(self):
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
workspace_dir="artifacts", # type: ignore[arg-type]
|
||||
workspace_dir="artifacts",
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("workspace_dir must be a Path object" in err for err in errors)
|
||||
|
||||
assert config.workspace_dir == Path("artifacts")
|
||||
|
||||
def test_validate_multiple_errors(self):
|
||||
"""Test validating config with multiple errors."""
|
||||
config = Config(
|
||||
library_root=Path(""),
|
||||
video_extensions=[],
|
||||
movie_template="",
|
||||
log_level="INVALID"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
# Should have multiple errors
|
||||
assert len(errors) >= 4
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Config(
|
||||
library_root=Path(""),
|
||||
video_extensions=[],
|
||||
movie_template="",
|
||||
log_level="INVALID",
|
||||
)
|
||||
assert len(exc_info.value.errors()) >= 4
|
||||
|
||||
def test_validate_duplicate_keep_reputation_quality_time(self):
|
||||
"""Test validating config with by_reputation_quality_time strategy."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
duplicate_keep="by_reputation_quality_time"
|
||||
duplicate_keep="by_reputation_quality_time",
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == []
|
||||
assert validate_config(config) == []
|
||||
|
||||
def test_validate_empty_categories(self):
|
||||
"""Test validating config with empty categories."""
|
||||
config = Config(library_root=Path("/test"), categories={})
|
||||
errors = validate_config(config)
|
||||
assert any("categories" in e and "empty" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="categories"):
|
||||
Config(library_root=Path("/test"), categories={})
|
||||
|
||||
def test_validate_missing_required_category(self):
|
||||
"""Test validating config with missing required categories."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={"movie": ["movie"]} # Missing series, anime
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("series" in e or "anime" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="categories"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={"movie": ["movie"]},
|
||||
)
|
||||
|
||||
def test_validate_duplicate_directory_names(self):
|
||||
"""Test validating config with duplicate directory names."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "videos"],
|
||||
"series": ["series", "videos"], # Duplicate
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("Duplicate" in e and "videos" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="Duplicate.*videos"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "videos"],
|
||||
"series": ["series", "videos"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_case_insensitive_duplicates(self):
|
||||
"""Test validating config with case-insensitive duplicates."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["Movie"],
|
||||
"series": ["movie"], # Case-insensitive duplicate
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("Duplicate" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="Duplicate"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["Movie"],
|
||||
"series": ["movie"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_valid_custom_categories(self):
|
||||
"""Test validating config with valid custom categories."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series", "tv"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == []
|
||||
assert validate_config(config) == []
|
||||
|
||||
def test_validate_categories_not_dict(self):
|
||||
"""Test validating config with categories not a dict."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories=["movie", "series"] # Wrong type
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must be a dictionary" in e for e in errors)
|
||||
with pytest.raises(ValidationError):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories=["movie", "series"],
|
||||
)
|
||||
|
||||
def test_validate_category_list_not_list(self):
|
||||
"""Test validating config with category value not a list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": "movie", # Should be a list
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must be a list" in e for e in errors)
|
||||
with pytest.raises(ValidationError):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": "movie",
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_empty_category_list(self):
|
||||
"""Test validating config with empty category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": [], # Empty list
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("cannot be empty" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="cannot be empty"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": [],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_rejects_unsupported_enrichment_provider(self):
|
||||
"""Test validating config with unsupported enrichment provider."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
enrichment_providers=["tmdb", "douban"],
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("unsupported providers" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="unsupported providers"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
enrichment_providers=["tmdb", "douban"],
|
||||
)
|
||||
|
||||
def test_validate_category_list_with_non_string(self):
|
||||
"""Test validating config with non-string in category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", 123], # Non-string
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must contain strings" in e for e in errors)
|
||||
with pytest.raises(ValidationError):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", 123],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_category_list_with_empty_string(self):
|
||||
"""Test validating config with empty string in category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", ""], # Empty string
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("empty directory name" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="empty directory name"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", ""],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_invalid_plan_thresholds(self):
|
||||
"""Plan season/episode thresholds must be positive integers."""
|
||||
config = Config(
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
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"),
|
||||
plan_max_season=0,
|
||||
plan_max_episode=-1,
|
||||
video_extensions=[],
|
||||
movie_template="movie/{title} ({year})/",
|
||||
series_template="series/{title}/Season {season:02d}/",
|
||||
movie_filename_template="{title} ({year}){ext}",
|
||||
series_filename_template="S{season:02d}E{episode:02d}{ext}",
|
||||
log_level="INFO",
|
||||
quarantine_dir=".quarantine",
|
||||
workspace_dir=Path("artifacts"),
|
||||
categories={"movie": ["movie"], "series": ["series"], "anime": ["anime"]},
|
||||
enrichment_enabled=True,
|
||||
enrichment_incremental=True,
|
||||
enrichment_refresh_mode="manual",
|
||||
enrichment_providers=["tmdb"],
|
||||
enrichment_cache_db=Path.home() / ".vlm" / "enrichment_cache.db",
|
||||
enrichment_max_concurrency=6,
|
||||
enrichment_min_match_score=0.75,
|
||||
translation_mode="bidirectional",
|
||||
translation_fallback_machine=True,
|
||||
tmdb_api_key=None,
|
||||
tmdb_bearer_token=None,
|
||||
tmdb_language="zh-CN",
|
||||
tmdb_region=None,
|
||||
tmdb_include_adult=False,
|
||||
openai_api_key=None,
|
||||
reputation_min_votes=50,
|
||||
reputation_low_score_threshold=6.0,
|
||||
reputation_policy="flag_for_review",
|
||||
naming_title_format="{title_zh} {title_en}",
|
||||
duplicate_keep="by_reputation",
|
||||
plan_max_season=15,
|
||||
plan_max_episode=100,
|
||||
plan_include_sample_files=False,
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("plan_max_season" in e for e in errors)
|
||||
assert any("plan_max_episode" in e for e in errors)
|
||||
assert any("video_extensions" in e for e in errors)
|
||||
|
||||
|
||||
class TestConfigIntegration:
|
||||
@@ -654,26 +613,21 @@ class TestConfigIntegration:
|
||||
assert loaded_config.library_root == default_config.library_root
|
||||
|
||||
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"
|
||||
|
||||
# Create config with some invalid values
|
||||
|
||||
config_data = {
|
||||
'library_root': '/mnt/nas/videos',
|
||||
'video_extensions': ['mp4', '.mkv'], # First one missing dot
|
||||
'log_level': 'INVALID'
|
||||
}
|
||||
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
# Load config
|
||||
config = load_config(config_file)
|
||||
|
||||
# Validate
|
||||
errors = validate_config(config)
|
||||
|
||||
# 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)
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
load_config(config_file)
|
||||
|
||||
messages = [e["msg"] for e in exc_info.value.errors()]
|
||||
assert any("must start with" in m for m in messages)
|
||||
assert any("log_level" in m for m in messages)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
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():
|
||||
config = Config(library_root=Path("/test"), enrichment_max_concurrency=0)
|
||||
errors = validate_config(config)
|
||||
assert any("enrichment_max_concurrency must be >= 1" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="enrichment_max_concurrency"):
|
||||
Config(library_root=Path("/test"), enrichment_max_concurrency=0)
|
||||
|
||||
@@ -38,9 +38,10 @@ def test_enrich_incremental_cache_hit(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = DummyProvider()
|
||||
monkeypatch.setattr(
|
||||
@@ -104,11 +105,12 @@ def test_enrich_flags_low_reputation_for_review(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
reputation_low_score_threshold=6.0,
|
||||
reputation_min_votes=50,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = LowScoreProvider()
|
||||
monkeypatch.setattr(
|
||||
@@ -144,9 +146,10 @@ def test_enrich_refresh_all_bypasses_cache(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = DummyProvider()
|
||||
monkeypatch.setattr(
|
||||
@@ -184,8 +187,9 @@ def test_build_providers_rejects_unknown_provider(tmp_path):
|
||||
"""Unknown providers should fail fast with a clear error."""
|
||||
config = Config(
|
||||
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"):
|
||||
_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(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = FlakyProvider()
|
||||
monkeypatch.setattr(
|
||||
@@ -361,10 +366,11 @@ def test_enrich_respects_max_concurrency_for_uncached_records(tmp_path, monkeypa
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
enrichment_max_concurrency=4,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
identities = {
|
||||
"metadata": {},
|
||||
|
||||
+275
-266
@@ -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:
|
||||
"""Test inventory report generation."""
|
||||
|
||||
|
||||
def test_generate_csv_report(self):
|
||||
"""Test generating CSV format inventory report."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Movie1.mkv"),
|
||||
"Movie1.mkv",
|
||||
2000000000,
|
||||
datetime(2023, 1, 15, 10, 30, 0),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
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"
|
||||
),
|
||||
_video("Movie1.mkv", 2_000_000_000,
|
||||
modified_timestamp=datetime(2023, 1, 15, 10, 30, 0),
|
||||
resolution="1920x1080", codec="h264",
|
||||
duration_seconds=7200.0, bitrate_kbps=5000),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=datetime(2023, 2, 20, 14, 45, 0),
|
||||
resolution="1280x720", codec="h265"),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
|
||||
# Check metadata comments
|
||||
assert "# Generated:" in report
|
||||
assert f"# Library Root: {library_root}" in report
|
||||
|
||||
|
||||
# Parse CSV
|
||||
lines = report.strip().split('\n')
|
||||
# Skip comment lines
|
||||
csv_lines = [line for line in lines if not line.startswith('#')]
|
||||
csv_reader = csv.DictReader(csv_lines)
|
||||
rows = list(csv_reader)
|
||||
|
||||
|
||||
# Check we have 2 data rows
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
# Check first file
|
||||
assert rows[0]['filename'] == 'Movie1.mkv'
|
||||
assert rows[0]['size_bytes'] == '2000000000'
|
||||
@@ -77,7 +99,7 @@ class TestInventoryReport:
|
||||
assert rows[0]['codec'] == 'h264'
|
||||
assert rows[0]['duration_seconds'] == '7200.0'
|
||||
assert rows[0]['bitrate_kbps'] == '5000'
|
||||
|
||||
|
||||
# Check second file
|
||||
assert rows[1]['filename'] == 'Show.S01E01.mkv'
|
||||
assert rows[1]['category'] == 'series'
|
||||
@@ -86,44 +108,33 @@ class TestInventoryReport:
|
||||
# Optional fields not present should be empty
|
||||
assert rows[1]['duration_seconds'] == ''
|
||||
assert rows[1]['bitrate_kbps'] == ''
|
||||
|
||||
|
||||
def test_generate_json_report(self):
|
||||
"""Test generating JSON format inventory report."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Movie1.mkv"),
|
||||
"Movie1.mkv",
|
||||
2000000000,
|
||||
datetime(2023, 1, 15, 10, 30, 0),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/anime/Anime1.mkv"),
|
||||
"Anime1.mkv",
|
||||
800000000,
|
||||
datetime(2023, 3, 10, 8, 15, 0),
|
||||
"anime"
|
||||
),
|
||||
_video("Movie1.mkv", 2_000_000_000,
|
||||
modified_timestamp=datetime(2023, 1, 15, 10, 30, 0),
|
||||
resolution="1920x1080", codec="h264"),
|
||||
_video("Anime1.mkv", 800_000_000, "anime",
|
||||
modified_timestamp=datetime(2023, 3, 10, 8, 15, 0)),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "json", library_root)
|
||||
|
||||
|
||||
# Parse JSON
|
||||
data = json.loads(report)
|
||||
|
||||
|
||||
# Check metadata
|
||||
assert "metadata" in data
|
||||
assert "generated" in data["metadata"]
|
||||
assert data["metadata"]["library_root"] == str(library_root)
|
||||
assert data["metadata"]["file_count"] == 2
|
||||
|
||||
|
||||
# Check files
|
||||
assert "files" in data
|
||||
assert len(data["files"]) == 2
|
||||
|
||||
|
||||
# Check first file
|
||||
file1 = data["files"][0]
|
||||
assert file1["filename"] == "Movie1.mkv"
|
||||
@@ -131,7 +142,7 @@ class TestInventoryReport:
|
||||
assert file1["category"] == "movie"
|
||||
assert file1["resolution"] == "1920x1080"
|
||||
assert file1["codec"] == "h264"
|
||||
|
||||
|
||||
# Check second file
|
||||
file2 = data["files"][1]
|
||||
assert file2["filename"] == "Anime1.mkv"
|
||||
@@ -139,89 +150,79 @@ class TestInventoryReport:
|
||||
# Optional fields should be null
|
||||
assert file2["resolution"] is None
|
||||
assert file2["codec"] is None
|
||||
|
||||
|
||||
def test_generate_csv_report_empty(self):
|
||||
"""Test generating CSV report with no files."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
|
||||
# Should have metadata and header
|
||||
assert "# Generated:" in report
|
||||
assert "path,filename,size_bytes" in report
|
||||
|
||||
|
||||
# Parse CSV
|
||||
lines = report.strip().split('\n')
|
||||
csv_lines = [line for line in lines if not line.startswith('#')]
|
||||
csv_reader = csv.DictReader(csv_lines)
|
||||
rows = list(csv_reader)
|
||||
|
||||
|
||||
# No data rows
|
||||
assert len(rows) == 0
|
||||
|
||||
|
||||
def test_generate_json_report_empty(self):
|
||||
"""Test generating JSON report with no files."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "json", library_root)
|
||||
|
||||
|
||||
data = json.loads(report)
|
||||
assert data["metadata"]["file_count"] == 0
|
||||
assert len(data["files"]) == 0
|
||||
|
||||
|
||||
def test_invalid_format_raises_error(self):
|
||||
"""Test that invalid format raises ValueError."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid format"):
|
||||
generate_inventory_report(files, "xml", library_root)
|
||||
|
||||
|
||||
def test_csv_schema_columns(self):
|
||||
"""Test that CSV has all required columns in correct order."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
)
|
||||
_video("test.mkv", 1000,
|
||||
modified_timestamp=datetime.now(timezone.utc)),
|
||||
]
|
||||
library_root = Path("/test")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
|
||||
# Parse CSV header
|
||||
lines = report.strip().split('\n')
|
||||
csv_lines = [line for line in lines if not line.startswith('#')]
|
||||
header = csv_lines[0].strip() # Strip to remove any line ending characters
|
||||
|
||||
|
||||
# Check column order
|
||||
expected_columns = [
|
||||
'path', 'filename', 'size_bytes', 'modified_timestamp', 'category',
|
||||
'resolution', 'codec', 'duration_seconds', 'bitrate_kbps'
|
||||
]
|
||||
assert header == ','.join(expected_columns)
|
||||
|
||||
|
||||
def test_timestamp_formatting(self):
|
||||
"""Test that timestamps are formatted as ISO 8601."""
|
||||
naive_local = datetime(2023, 6, 15, 14, 30, 45)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
naive_local,
|
||||
"movie"
|
||||
)
|
||||
_video("test.mkv", 1000,
|
||||
modified_timestamp=naive_local),
|
||||
]
|
||||
library_root = Path("/test")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
|
||||
# Check timestamp format
|
||||
expected_utc = naive_local.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
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")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
naive_local,
|
||||
"movie"
|
||||
)
|
||||
_video("test.mkv", 1000,
|
||||
modified_timestamp=naive_local),
|
||||
]
|
||||
report = generate_inventory_report(files, "json", Path("/test"))
|
||||
data = json.loads(report)
|
||||
@@ -259,104 +255,128 @@ class TestInventoryReport:
|
||||
|
||||
class TestCompletenessReport:
|
||||
"""Test completeness report generation."""
|
||||
|
||||
|
||||
def test_generate_text_report_with_gaps(self):
|
||||
"""Test generating text format completeness report with gaps."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Breaking Bad", 1, [1, 2, 4, 5], [3]),
|
||||
SeasonCompleteness("Breaking Bad", 2, [1, 3, 5], [2, 4]),
|
||||
SeasonCompleteness("The Wire", 1, [1, 2, 4], [3]),
|
||||
SeasonCompleteness(
|
||||
series_title="Breaking Bad", season=1,
|
||||
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")
|
||||
|
||||
|
||||
report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
|
||||
# Check report structure
|
||||
assert "SERIES COMPLETENESS REPORT" in report
|
||||
assert "Generated:" in report
|
||||
assert str(library_root) in report
|
||||
assert "Series with gaps: 3" in report
|
||||
|
||||
|
||||
# Check series content
|
||||
assert "Breaking Bad" in report
|
||||
assert "The Wire" in report
|
||||
assert "Season 01:" in report
|
||||
assert "Season 02:" in report
|
||||
|
||||
|
||||
# Check episode information
|
||||
assert "Episodes found:" in report
|
||||
assert "Episodes missing:" in report
|
||||
|
||||
|
||||
def test_generate_json_report_with_gaps(self):
|
||||
"""Test generating JSON format completeness report with gaps."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Breaking Bad", 1, [1, 2, 4, 5], [3]),
|
||||
SeasonCompleteness("The Wire", 1, [1, 2, 4], [3]),
|
||||
SeasonCompleteness(
|
||||
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")
|
||||
|
||||
|
||||
report = generate_completeness_report(analysis, "json", library_root)
|
||||
|
||||
|
||||
# Parse JSON
|
||||
data = json.loads(report)
|
||||
|
||||
|
||||
# Check metadata
|
||||
assert "metadata" in data
|
||||
assert "generated" in data["metadata"]
|
||||
assert data["metadata"]["library_root"] == str(library_root)
|
||||
assert data["metadata"]["series_count"] == 2
|
||||
|
||||
|
||||
# Check series data
|
||||
assert "series" in data
|
||||
assert len(data["series"]) == 2
|
||||
|
||||
|
||||
# Check Breaking Bad
|
||||
breaking_bad = next(s for s in data["series"] if s["title"] == "Breaking Bad")
|
||||
assert len(breaking_bad["seasons"]) == 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_missing"] == [3]
|
||||
|
||||
|
||||
def test_generate_text_report_empty(self):
|
||||
"""Test generating text report with no gaps."""
|
||||
analysis = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
|
||||
assert "SERIES COMPLETENESS REPORT" in report
|
||||
assert "No series with episode gaps detected." in report
|
||||
|
||||
|
||||
def test_generate_json_report_empty(self):
|
||||
"""Test generating JSON report with no gaps."""
|
||||
analysis = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_completeness_report(analysis, "json", library_root)
|
||||
|
||||
|
||||
data = json.loads(report)
|
||||
assert data["metadata"]["series_count"] == 0
|
||||
assert len(data["series"]) == 0
|
||||
|
||||
|
||||
def test_invalid_format_raises_error(self):
|
||||
"""Test that invalid format raises ValueError."""
|
||||
analysis = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid format"):
|
||||
generate_completeness_report(analysis, "xml", library_root)
|
||||
|
||||
|
||||
def test_multiple_seasons_same_series(self):
|
||||
"""Test report with multiple seasons of same series."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Show Name", 1, [1, 3], [2]),
|
||||
SeasonCompleteness("Show Name", 2, [1, 2, 4], [3]),
|
||||
SeasonCompleteness("Show Name", 3, [5, 7], [6]),
|
||||
SeasonCompleteness(
|
||||
series_title="Show Name", season=1,
|
||||
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")
|
||||
|
||||
|
||||
report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
|
||||
# Should group all seasons under same series
|
||||
assert report.count("Show Name") == 1 # Series title appears once
|
||||
assert "Season 01:" in report
|
||||
@@ -366,36 +386,23 @@ class TestCompletenessReport:
|
||||
|
||||
class TestDuplicateReport:
|
||||
"""Test duplicate report generation."""
|
||||
|
||||
|
||||
def test_generate_text_report_with_duplicates(self):
|
||||
"""Test generating text format duplicate report."""
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
identity = _movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.1080p.mkv")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
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"
|
||||
),
|
||||
_video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=5000),
|
||||
_video("The.Matrix.1999.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h264"),
|
||||
]
|
||||
|
||||
|
||||
quality_comparison = [
|
||||
{
|
||||
'filename': 'The.Matrix.1999.1080p.mkv',
|
||||
@@ -414,184 +421,175 @@ class TestDuplicateReport:
|
||||
'codec': 'h264'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identities[0], files, quality_comparison)
|
||||
DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
|
||||
# Check report structure
|
||||
assert "DUPLICATE FILES REPORT" in report
|
||||
assert "Generated:" in report
|
||||
assert str(library_root) in report
|
||||
assert "Duplicate groups: 1" in report
|
||||
|
||||
|
||||
# Check duplicate group content
|
||||
assert "The Matrix (1999)" in report
|
||||
assert "Files: 2" in report
|
||||
|
||||
|
||||
# Check file details
|
||||
assert "The.Matrix.1999.1080p.mkv" in report
|
||||
assert "The.Matrix.1999.720p.mkv" in report
|
||||
assert "1920x1080" in report
|
||||
assert "1280x720" in report
|
||||
assert "h264" in report
|
||||
|
||||
|
||||
def test_generate_json_report_with_duplicates(self):
|
||||
"""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 = [
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.1080p.mkv"),
|
||||
"Inception.2010.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.720p.mkv"),
|
||||
"Inception.2010.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
),
|
||||
_video("Inception.2010.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Inception.2010.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
quality_comparison = [
|
||||
{'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}
|
||||
]
|
||||
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity, files, quality_comparison)
|
||||
DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "json", library_root)
|
||||
|
||||
|
||||
# Parse JSON
|
||||
data = json.loads(report)
|
||||
|
||||
|
||||
# Check metadata
|
||||
assert "metadata" in data
|
||||
assert data["metadata"]["duplicate_groups"] == 1
|
||||
assert data["metadata"]["library_root"] == str(library_root)
|
||||
|
||||
|
||||
# Check duplicates
|
||||
assert "duplicates" in data
|
||||
assert len(data["duplicates"]) == 1
|
||||
|
||||
|
||||
dup = data["duplicates"][0]
|
||||
assert dup["identity"]["type"] == "movie"
|
||||
assert dup["identity"]["title"] == "Inception"
|
||||
assert dup["identity"]["year"] == 2010
|
||||
assert dup["file_count"] == 2
|
||||
assert len(dup["files"]) == 2
|
||||
|
||||
|
||||
def test_generate_text_report_series_duplicates(self):
|
||||
"""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 = [
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.1080p.mkv"),
|
||||
"Breaking.Bad.S01E01.1080p.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.720p.mkv"),
|
||||
"Breaking.Bad.S01E01.720p.mkv",
|
||||
800000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
),
|
||||
_video("Breaking.Bad.S01E01.1080p.mkv", 1_500_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Breaking.Bad.S01E01.720p.mkv", 800_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
quality_comparison = [
|
||||
{'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}
|
||||
]
|
||||
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity, files, quality_comparison)
|
||||
DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
|
||||
# Check series format
|
||||
assert "Breaking Bad - S01E1" in report
|
||||
assert "Files: 2" in report
|
||||
|
||||
|
||||
def test_generate_text_report_empty(self):
|
||||
"""Test generating text report with no duplicates."""
|
||||
duplicates = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
|
||||
assert "DUPLICATE FILES REPORT" in report
|
||||
assert "No duplicate files detected." in report
|
||||
|
||||
|
||||
def test_generate_json_report_empty(self):
|
||||
"""Test generating JSON report with no duplicates."""
|
||||
duplicates = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "json", library_root)
|
||||
|
||||
|
||||
data = json.loads(report)
|
||||
assert data["metadata"]["duplicate_groups"] == 0
|
||||
assert len(data["duplicates"]) == 0
|
||||
|
||||
|
||||
def test_invalid_format_raises_error(self):
|
||||
"""Test that invalid format raises ValueError."""
|
||||
duplicates = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid format"):
|
||||
generate_duplicate_report(duplicates, "csv", library_root)
|
||||
|
||||
|
||||
def test_sorted_by_file_size(self):
|
||||
"""Test that duplicate groups are sorted by largest file size."""
|
||||
now = datetime.now(timezone.utc)
|
||||
# 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 = [
|
||||
VideoFile(Path("/movies/Small.Movie.1.mkv"), "Small.Movie.1.mkv", 500000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Small.Movie.2.mkv"), "Small.Movie.2.mkv", 600000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Small.Movie.1.mkv", 500_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Small.Movie.2.mkv", 600_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
quality1 = [
|
||||
{'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}
|
||||
]
|
||||
|
||||
identity2 = MovieIdentity("Large Movie", 2021, 0.9, False, "Large.Movie.mkv")
|
||||
|
||||
identity2 = _movie("Large Movie", 2021,
|
||||
original_filename="Large.Movie.mkv")
|
||||
files2 = [
|
||||
VideoFile(Path("/movies/Large.Movie.1.mkv"), "Large.Movie.1.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Large.Movie.2.mkv"), "Large.Movie.2.mkv", 1800000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Large.Movie.1.mkv", 2_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Large.Movie.2.mkv", 1_800_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
quality2 = [
|
||||
{'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}
|
||||
]
|
||||
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity1, files1, quality1),
|
||||
DuplicateGroup(identity2, files2, quality2)
|
||||
DuplicateGroup(identity=identity1, files=files1, quality_comparison=quality1),
|
||||
DuplicateGroup(identity=identity2, files=files2, quality_comparison=quality2)
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
|
||||
# Large Movie should appear before Small Movie
|
||||
large_pos = report.find("Large Movie")
|
||||
small_pos = report.find("Small Movie")
|
||||
@@ -599,20 +597,21 @@ class TestDuplicateReport:
|
||||
|
||||
def test_sorted_by_quality_size_when_file_sizes_missing(self):
|
||||
"""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 = [
|
||||
VideoFile(Path("/movies/Tiny.1.mkv"), "Tiny.1.mkv", 0, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Tiny.2.mkv"), "Tiny.2.mkv", 0, datetime.now(timezone.utc), "movie"),
|
||||
_video("Tiny.1.mkv", 0, modified_timestamp=now),
|
||||
_video("Tiny.2.mkv", 0, modified_timestamp=now),
|
||||
]
|
||||
quality1 = [
|
||||
{"filename": "Tiny.1.mkv", "path": "/movies/Tiny.1.mkv", "size_bytes": 600000000},
|
||||
{"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 = [
|
||||
VideoFile(Path("/movies/Huge.1.mkv"), "Huge.1.mkv", 0, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Huge.2.mkv"), "Huge.2.mkv", 0, datetime.now(timezone.utc), "movie"),
|
||||
_video("Huge.1.mkv", 0, modified_timestamp=now),
|
||||
_video("Huge.2.mkv", 0, modified_timestamp=now),
|
||||
]
|
||||
quality2 = [
|
||||
{"filename": "Huge.1.mkv", "path": "/movies/Huge.1.mkv", "size_bytes": 3000000000},
|
||||
@@ -620,8 +619,8 @@ class TestDuplicateReport:
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity1, files1, quality1),
|
||||
DuplicateGroup(identity2, files2, quality2),
|
||||
DuplicateGroup(identity=identity1, files=files1, quality_comparison=quality1),
|
||||
DuplicateGroup(identity=identity2, files=files2, quality_comparison=quality2),
|
||||
]
|
||||
report = generate_duplicate_report(duplicates, "text", Path("/mnt/nas/videos"))
|
||||
assert report.find("Huge") < report.find("Tiny")
|
||||
@@ -629,63 +628,73 @@ class TestDuplicateReport:
|
||||
|
||||
class TestSummaryReport:
|
||||
"""Test summary report generation."""
|
||||
|
||||
|
||||
def test_generate_summary_report(self):
|
||||
"""Test generating summary report with various files."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(timezone.utc), "anime"),
|
||||
VideoFile(Path("/other/Random.mkv"), "Random.mkv", 500000000, datetime.now(timezone.utc), "other"),
|
||||
_video("Movie1.mkv", 2_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Movie2.mkv", 1_500_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_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),
|
||||
_video("Random.mkv", 500_000_000, "other",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
|
||||
# Check report structure
|
||||
assert "LIBRARY SUMMARY REPORT" in report
|
||||
assert "Generated:" in report
|
||||
assert str(library_root) in report
|
||||
|
||||
|
||||
# Check totals
|
||||
assert "Total Files: 6" in report
|
||||
assert "Total Size:" in report
|
||||
|
||||
|
||||
# Check category breakdown
|
||||
assert "Category Breakdown:" in report
|
||||
assert "Movie:" in report
|
||||
assert "Series:" in report
|
||||
assert "Anime:" in report
|
||||
assert "Other:" in report
|
||||
|
||||
|
||||
# Check category counts
|
||||
assert "Files: 2" in report # Movies
|
||||
|
||||
|
||||
def test_generate_summary_report_empty(self):
|
||||
"""Test generating summary report with no files."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
|
||||
assert "LIBRARY SUMMARY REPORT" in report
|
||||
assert "Total Files: 0" in report
|
||||
assert "Total Size: 0.00 B" in report
|
||||
|
||||
|
||||
def test_generate_summary_report_single_category(self):
|
||||
"""Test generating summary report with files in single category."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Movie1.mkv", 1_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Movie2.mkv", 2_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
|
||||
assert "Total Files: 2" in report
|
||||
assert "Movie:" in report
|
||||
assert "Files: 2" in report
|
||||
@@ -693,64 +702,64 @@ class TestSummaryReport:
|
||||
|
||||
class TestFormatHelpers:
|
||||
"""Test formatting helper functions."""
|
||||
|
||||
|
||||
def test_format_episode_list_single(self):
|
||||
"""Test formatting single episode."""
|
||||
assert _format_episode_list([5]) == "5"
|
||||
|
||||
|
||||
def test_format_episode_list_range(self):
|
||||
"""Test formatting consecutive episode range."""
|
||||
assert _format_episode_list([1, 2, 3, 4, 5]) == "1-5"
|
||||
|
||||
|
||||
def test_format_episode_list_mixed(self):
|
||||
"""Test formatting mixed ranges and singles."""
|
||||
assert _format_episode_list([1, 2, 3, 5, 6, 8]) == "1-3, 5-6, 8"
|
||||
|
||||
|
||||
def test_format_episode_list_non_sequential(self):
|
||||
"""Test formatting non-sequential episodes."""
|
||||
assert _format_episode_list([1, 3, 5, 7]) == "1, 3, 5, 7"
|
||||
|
||||
|
||||
def test_format_episode_list_empty(self):
|
||||
"""Test formatting empty episode list."""
|
||||
assert _format_episode_list([]) == "none"
|
||||
|
||||
|
||||
def test_format_episode_list_unsorted(self):
|
||||
"""Test formatting unsorted episode list."""
|
||||
assert _format_episode_list([5, 1, 3, 2, 4]) == "1-5"
|
||||
|
||||
|
||||
def test_format_size_bytes(self):
|
||||
"""Test formatting bytes."""
|
||||
assert _format_size(512) == "512.00 B"
|
||||
|
||||
|
||||
def test_format_size_kilobytes(self):
|
||||
"""Test formatting kilobytes."""
|
||||
assert _format_size(1024) == "1.00 KB"
|
||||
assert _format_size(2048) == "2.00 KB"
|
||||
|
||||
|
||||
def test_format_size_megabytes(self):
|
||||
"""Test formatting megabytes."""
|
||||
assert _format_size(1048576) == "1.00 MB"
|
||||
assert _format_size(5242880) == "5.00 MB"
|
||||
|
||||
|
||||
def test_format_size_gigabytes(self):
|
||||
"""Test formatting gigabytes."""
|
||||
assert _format_size(1073741824) == "1.00 GB"
|
||||
assert _format_size(2147483648) == "2.00 GB"
|
||||
|
||||
|
||||
def test_format_size_terabytes(self):
|
||||
"""Test formatting terabytes."""
|
||||
assert _format_size(1099511627776) == "1.00 TB"
|
||||
|
||||
|
||||
def test_format_duration_seconds(self):
|
||||
"""Test formatting seconds only."""
|
||||
assert _format_duration(30) == "30s"
|
||||
assert _format_duration(0) == "0s"
|
||||
|
||||
|
||||
def test_format_duration_minutes(self):
|
||||
"""Test formatting minutes and seconds."""
|
||||
assert _format_duration(90) == "1m 30s"
|
||||
assert _format_duration(120) == "2m"
|
||||
|
||||
|
||||
def test_format_duration_hours(self):
|
||||
"""Test formatting hours, minutes, and seconds."""
|
||||
assert _format_duration(3665) == "1h 1m 5s"
|
||||
|
||||
@@ -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:
|
||||
"""Test report generation integrated with analysis engine."""
|
||||
|
||||
|
||||
def test_completeness_workflow(self):
|
||||
"""Test complete workflow from series analysis to completeness report."""
|
||||
# Create test episodes with gaps
|
||||
episodes = [
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [2], 0.9, False, "Breaking.Bad.S01E02.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [4], 0.9, False, "Breaking.Bad.S01E04.mkv"),
|
||||
SeriesIdentity("The Wire", 1, [1], 0.9, False, "The.Wire.S01E01.mkv"),
|
||||
SeriesIdentity("The Wire", 1, [3], 0.9, False, "The.Wire.S01E03.mkv"),
|
||||
_series("Breaking Bad", episodes=[1],
|
||||
original_filename="Breaking.Bad.S01E01.mkv"),
|
||||
_series("Breaking Bad", episodes=[2],
|
||||
original_filename="Breaking.Bad.S01E02.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
|
||||
analysis = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
# Generate text report
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
text_report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
|
||||
# Verify report contains expected information
|
||||
assert "Breaking Bad" in text_report
|
||||
assert "The Wire" in text_report
|
||||
assert "Episodes missing:" in text_report
|
||||
|
||||
|
||||
# Generate JSON report
|
||||
json_report = generate_completeness_report(analysis, "json", library_root)
|
||||
data = json.loads(json_report)
|
||||
|
||||
|
||||
# Verify JSON structure
|
||||
assert data["metadata"]["series_count"] == 2
|
||||
assert len(data["series"]) == 2
|
||||
|
||||
|
||||
def test_duplicate_workflow(self):
|
||||
"""Test complete workflow from duplicate detection to duplicate report."""
|
||||
# Create test identities and files
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.720p.mkv"),
|
||||
MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.1080p.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.720p.mkv"),
|
||||
_movie("Inception", 2010),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
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"
|
||||
),
|
||||
_video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080", codec="h264"),
|
||||
_video("The.Matrix.1999.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720", codec="h264"),
|
||||
_video("Inception.2010.mkv", 1_500_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
# Detect duplicates
|
||||
duplicates = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Generate text report
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
text_report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
|
||||
# Verify report contains expected information
|
||||
assert "The Matrix (1999)" in text_report
|
||||
assert "1920x1080" in text_report
|
||||
assert "1280x720" in text_report
|
||||
|
||||
|
||||
# Generate JSON report
|
||||
json_report = generate_duplicate_report(duplicates, "json", library_root)
|
||||
data = json.loads(json_report)
|
||||
|
||||
|
||||
# Verify JSON structure
|
||||
assert data["metadata"]["duplicate_groups"] == 1
|
||||
assert len(data["duplicates"]) == 1
|
||||
assert data["duplicates"][0]["file_count"] == 2
|
||||
|
||||
|
||||
def test_summary_workflow(self):
|
||||
"""Test summary report generation with mixed file types."""
|
||||
# Create test files
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(timezone.utc), "anime"),
|
||||
_video("Movie1.mkv", 2_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Movie2.mkv", 1_500_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_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
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
|
||||
# Verify report contains expected information
|
||||
assert "Total Files: 5" in report
|
||||
assert "Movie:" in report
|
||||
assert "Series:" in report
|
||||
assert "Anime:" in report
|
||||
assert "Category Breakdown:" in report
|
||||
|
||||
|
||||
def test_all_reports_include_metadata(self):
|
||||
"""Test that all reports include generation timestamp and library root."""
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
# Test completeness report
|
||||
completeness_report = generate_completeness_report([], "text", library_root)
|
||||
assert "Generated:" in completeness_report
|
||||
assert str(library_root) in completeness_report
|
||||
|
||||
|
||||
# Test duplicate report
|
||||
duplicate_report = generate_duplicate_report([], "text", library_root)
|
||||
assert "Generated:" in duplicate_report
|
||||
assert str(library_root) in duplicate_report
|
||||
|
||||
|
||||
# Test summary report
|
||||
summary_report = generate_summary_report([], library_root)
|
||||
assert "Generated:" in summary_report
|
||||
|
||||
+8
-78
@@ -110,8 +110,8 @@ class TestScanLibrary:
|
||||
filenames = {vf.filename for vf in result}
|
||||
assert filenames == {"video.mp4", "video.mkv"}
|
||||
|
||||
def test_scan_uses_find_output_and_filters_hidden_paths(self, tmp_path):
|
||||
"""Test scan_library filters hidden paths from find output."""
|
||||
def test_scan_filters_hidden_paths(self, tmp_path):
|
||||
"""Test scan_library filters hidden paths from discovery."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
hidden_dir = tmp_path / ".hidden"
|
||||
movie_dir.mkdir()
|
||||
@@ -122,82 +122,12 @@ class TestScanLibrary:
|
||||
visible_file.touch()
|
||||
hidden_file.touch()
|
||||
|
||||
fake_stdout = f"{visible_file}\0{hidden_file}\0".encode()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (fake_stdout, b"")
|
||||
process.returncode = 0
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config, include_video_metadata=False)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == visible_file
|
||||
|
||||
def test_scan_keeps_partial_find_results_when_find_exits_nonzero(self, tmp_path):
|
||||
"""Non-zero find exits should keep partial stdout and log the contract."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
visible_file = movie_dir / "visible.mp4"
|
||||
visible_file.touch()
|
||||
|
||||
fake_stdout = f"{visible_file}\0".encode()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen, patch("vlm.scanner.logger.warning") as mock_warning:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (fake_stdout, b"Permission denied")
|
||||
process.returncode = 1
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
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):
|
||||
"""Test scanning records file metadata correctly."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
@@ -356,7 +286,7 @@ class TestScanLibrary:
|
||||
with patch("vlm.scanner._discover_video_paths", return_value=fake_paths), patch(
|
||||
"vlm.scanner._create_video_file",
|
||||
side_effect=_fake_create,
|
||||
):
|
||||
), patch("shutil.which", return_value="/usr/bin/ffprobe"):
|
||||
result = scan_library(tmp_path, config, include_video_metadata=True)
|
||||
|
||||
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(
|
||||
returncode=0,
|
||||
stdout=json.dumps(mock_output),
|
||||
stderr=""
|
||||
)
|
||||
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
@@ -823,7 +753,7 @@ class TestExtractMetadata:
|
||||
metadata_cache = {str(vf.path): vf for vf in cached_entries}
|
||||
|
||||
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)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
@@ -2,6 +2,15 @@ version = 1
|
||||
revision = 3
|
||||
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]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
@@ -146,7 +155,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pygments"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "uc-micro-py"
|
||||
version = "2.0.0"
|
||||
@@ -499,6 +651,7 @@ version = "0.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
|
||||
@@ -517,6 +670,7 @@ tui = [
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.1.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-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
|
||||
Reference in New Issue
Block a user