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
+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):
|
||||
|
||||
Reference in New Issue
Block a user