- Updated `duplicate_resolve.py` to introduce a new strategy for keeping files based on quality, considering resolution, source, codec, and size. - Enhanced `planner.py` to utilize the new quality-based strategy during plan generation, updating quarantine reasons accordingly. - Modified `README.md` to document the new `plan.duplicate_keep` options, including `by_quality`, and provided detailed descriptions of each strategy. - Added unit tests in `test_duplicate_resolve.py` to validate the new quality-based resolution logic. - Updated `analysis.json` and `plan.json` with new timestamps and IDs to reflect recent changes. These updates improve the Video Library Manager's ability to handle duplicate files more effectively, ensuring users retain the highest quality versions.
338 lines
14 KiB
Python
338 lines
14 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"
|
|
|
|
|
|
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")
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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}
|
|
|
|
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")
|
|
|
|
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",
|
|
"first_seen",
|
|
"manual",
|
|
"by_quality",
|
|
):
|
|
errors.append(
|
|
f"duplicate_keep must be one of 'by_reputation', 'first_seen', 'manual', 'by_quality', got: {config.duplicate_keep!r}"
|
|
)
|
|
|
|
return errors
|