366 lines
15 KiB
Python
366 lines
15 KiB
Python
"""Configuration management for Video Library Manager."""
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
import yaml
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
"""Configuration for Video Library Manager."""
|
|
|
|
library_root: Path
|
|
video_extensions: list[str] = field(default_factory=lambda: [
|
|
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
|
])
|
|
movie_template: str = "movie/{title} ({year})/"
|
|
series_template: str = "series/{title}/Season {season:02d}/"
|
|
movie_filename_template: str = "{title} ({year}){ext}"
|
|
series_filename_template: str = "S{season:02d}E{episode:02d}{ext}"
|
|
log_level: str = "INFO"
|
|
quarantine_dir: str = ".quarantine"
|
|
categories: dict[str, list[str]] = field(default_factory=lambda: {
|
|
"movie": ["movie", "movies"],
|
|
"series": ["series", "tv", "shows"],
|
|
"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_max_concurrency: int = 6
|
|
enrichment_min_match_score: float = 0.75
|
|
translation_mode: str = "bidirectional"
|
|
translation_fallback_machine: bool = True
|
|
tmdb_api_key: Optional[str] = None
|
|
tmdb_bearer_token: Optional[str] = None
|
|
tmdb_language: str = "zh-CN"
|
|
tmdb_region: Optional[str] = None
|
|
tmdb_include_adult: bool = False
|
|
openai_api_key: Optional[str] = None
|
|
reputation_min_votes: int = 50
|
|
reputation_low_score_threshold: float = 6.0
|
|
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
|
|
|
|
|
|
def load_config(path: Path) -> Config:
|
|
"""Load configuration from YAML file."""
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Configuration file not found: {path}")
|
|
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f)
|
|
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", [
|
|
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
|
])
|
|
|
|
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")
|
|
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,
|
|
categories=categories,
|
|
enrichment_enabled=enrichment.get("enabled", True),
|
|
enrichment_incremental=enrichment.get("incremental", True),
|
|
enrichment_refresh_mode=enrichment.get("refresh_mode", "manual"),
|
|
enrichment_providers=enrichment.get("providers", ["tmdb"]),
|
|
enrichment_cache_db=Path(
|
|
enrichment.get("cache_db", str(Path.home() / ".vlm" / "enrichment_cache.db"))
|
|
).expanduser(),
|
|
enrichment_max_concurrency=enrichment.get("max_concurrency", 6),
|
|
enrichment_min_match_score=enrichment.get("min_match_score", 0.75),
|
|
translation_mode=translation.get("mode", "bidirectional"),
|
|
translation_fallback_machine=translation.get("fallback_machine", True),
|
|
tmdb_api_key=api_keys.get("tmdb"),
|
|
tmdb_bearer_token=api_keys.get("tmdb_bearer"),
|
|
tmdb_language=tmdb.get("language", "zh-CN"),
|
|
tmdb_region=tmdb.get("region"),
|
|
tmdb_include_adult=tmdb.get("include_adult", False),
|
|
openai_api_key=api_keys.get("openai"),
|
|
reputation_min_votes=reputation.get("min_votes", 50),
|
|
reputation_low_score_threshold=reputation.get("low_score_threshold", 6.0),
|
|
reputation_policy=reputation.get("policy", "flag_for_review"),
|
|
naming_title_format=naming.get("title_format", "{title_zh} {title_en}"),
|
|
duplicate_keep=duplicate_keep,
|
|
plan_max_season=plan_max_season,
|
|
plan_max_episode=plan_max_episode,
|
|
plan_include_sample_files=plan_include_sample_files,
|
|
)
|
|
|
|
|
|
def create_default_config(path: Path) -> Config:
|
|
"""Create a default configuration file and return the Config object."""
|
|
default_config = Config(
|
|
library_root=Path.home() / "Videos",
|
|
video_extensions=[".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"],
|
|
)
|
|
|
|
enrichment_content = {
|
|
"enabled": default_config.enrichment_enabled,
|
|
"incremental": default_config.enrichment_incremental,
|
|
"refresh_mode": default_config.enrichment_refresh_mode,
|
|
"providers": default_config.enrichment_providers,
|
|
"cache_db": str(default_config.enrichment_cache_db),
|
|
"max_concurrency": default_config.enrichment_max_concurrency,
|
|
"min_match_score": default_config.enrichment_min_match_score,
|
|
"translation": {
|
|
"mode": default_config.translation_mode,
|
|
"fallback_machine": default_config.translation_fallback_machine,
|
|
},
|
|
"api_keys": {
|
|
"tmdb": default_config.tmdb_api_key,
|
|
"tmdb_bearer": default_config.tmdb_bearer_token,
|
|
"openai": default_config.openai_api_key,
|
|
},
|
|
"tmdb": {
|
|
"language": default_config.tmdb_language,
|
|
"region": default_config.tmdb_region,
|
|
"include_adult": default_config.tmdb_include_adult,
|
|
},
|
|
"reputation": {
|
|
"min_votes": default_config.reputation_min_votes,
|
|
"low_score_threshold": default_config.reputation_low_score_threshold,
|
|
"policy": default_config.reputation_policy,
|
|
},
|
|
"naming": {
|
|
"title_format": default_config.naming_title_format,
|
|
},
|
|
}
|
|
|
|
plan_content = {
|
|
"duplicate_keep": default_config.duplicate_keep,
|
|
"max_season": default_config.plan_max_season,
|
|
"max_episode": default_config.plan_max_episode,
|
|
"include_sample_files": default_config.plan_include_sample_files,
|
|
}
|
|
|
|
yaml_content = {
|
|
"library_root": str(default_config.library_root),
|
|
"video_extensions": default_config.video_extensions,
|
|
"plan": plan_content,
|
|
"templates": {
|
|
"movie_dir": default_config.movie_template,
|
|
"series_dir": default_config.series_template,
|
|
"movie_filename": default_config.movie_filename_template,
|
|
"series_filename": default_config.series_filename_template,
|
|
},
|
|
"quarantine_dir": default_config.quarantine_dir,
|
|
"log_level": default_config.log_level,
|
|
"categories": default_config.categories,
|
|
"enrichment": enrichment_content,
|
|
# Backward-compatible alias for users who prefer `enrich`.
|
|
"enrich": enrichment_content,
|
|
}
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
yaml.dump(yaml_content, f, default_flow_style=False, sort_keys=False)
|
|
|
|
return default_config
|
|
|
|
|
|
def validate_config(config: Config) -> list[str]:
|
|
"""Validate configuration and return list of error messages."""
|
|
errors = []
|
|
|
|
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 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
|