209 lines
8.0 KiB
Python
209 lines
8.0 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.
|
||
|
|
|
||
|
|
Attributes:
|
||
|
|
library_root: Root directory of the video library
|
||
|
|
video_extensions: List of video file extensions to recognize
|
||
|
|
movie_template: Directory template for movies (e.g., "movie/{title} ({year})/")
|
||
|
|
series_template: Directory template for series (e.g., "series/{title}/Season {season:02d}/")
|
||
|
|
movie_filename_template: Filename template for movies (e.g., "{title} ({year}){ext}")
|
||
|
|
series_filename_template: Filename template for series (e.g., "S{season:02d}E{episode:02d}{ext}")
|
||
|
|
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||
|
|
quarantine_dir: Quarantine directory name relative to category root (e.g., ".quarantine")
|
||
|
|
"""
|
||
|
|
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"
|
||
|
|
|
||
|
|
|
||
|
|
def load_config(path: Path) -> Config:
|
||
|
|
"""Load configuration from YAML file.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
path: Path to configuration file
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Config object with loaded settings
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
FileNotFoundError: If config file doesn't exist (caller should handle by creating default)
|
||
|
|
yaml.YAMLError: If YAML syntax is invalid (caller should handle by using defaults)
|
||
|
|
"""
|
||
|
|
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 = {}
|
||
|
|
|
||
|
|
# Extract library_root (required field)
|
||
|
|
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()
|
||
|
|
|
||
|
|
# Extract optional fields with defaults
|
||
|
|
video_extensions = data.get('video_extensions', [
|
||
|
|
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||
|
|
])
|
||
|
|
|
||
|
|
# Extract templates
|
||
|
|
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}")
|
||
|
|
|
||
|
|
# Extract other settings
|
||
|
|
quarantine_dir = data.get('quarantine_dir', '.quarantine')
|
||
|
|
log_level = data.get('log_level', 'INFO')
|
||
|
|
|
||
|
|
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
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def create_default_config(path: Path) -> Config:
|
||
|
|
"""Create a default configuration file and return the Config object.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
path: Path where configuration file should be created
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Config object with default settings
|
||
|
|
"""
|
||
|
|
# Create default config object
|
||
|
|
default_config = Config(
|
||
|
|
library_root=Path.home() / "Videos",
|
||
|
|
video_extensions=[".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"],
|
||
|
|
movie_template="movie/{title} ({year})/",
|
||
|
|
series_template="series/{title}/Season {season:02d}/",
|
||
|
|
movie_filename_template="{title} ({year}){ext}",
|
||
|
|
series_filename_template="S{season:02d}E{episode:02d}{ext}",
|
||
|
|
log_level="INFO",
|
||
|
|
quarantine_dir=".quarantine"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Create YAML content
|
||
|
|
yaml_content = {
|
||
|
|
'library_root': str(default_config.library_root),
|
||
|
|
'video_extensions': default_config.video_extensions,
|
||
|
|
'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
|
||
|
|
}
|
||
|
|
|
||
|
|
# Ensure parent directory exists
|
||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
|
||
|
|
# Write configuration file
|
||
|
|
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.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
config: Configuration object to validate
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
List of error messages (empty if valid)
|
||
|
|
"""
|
||
|
|
errors = []
|
||
|
|
|
||
|
|
# Validate library_root
|
||
|
|
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")
|
||
|
|
|
||
|
|
# Validate video_extensions
|
||
|
|
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}")
|
||
|
|
|
||
|
|
# Validate templates
|
||
|
|
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")
|
||
|
|
|
||
|
|
# Validate log_level
|
||
|
|
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}")
|
||
|
|
|
||
|
|
# Validate quarantine_dir
|
||
|
|
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")
|
||
|
|
|
||
|
|
return errors
|