Files

149 lines
5.5 KiB
Python

"""Configuration: TOML file merged over built-in defaults."""
from __future__ import annotations
import os
import re
import sys
from dataclasses import dataclass, field, fields
from pathlib import Path
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
class ConfigError(Exception):
pass
@dataclass
class OutputConfig:
directory: str = '.'
series_dir: str = '{series} ({year})'
season_dir: str = 'Season {season:02d}'
filename: str = '{series} - S{season:02d}E{episode:02d} - {title}'
# Roots for films and documentaries; empty: `directory` (documentaries: `movies_directory`)
movies_directory: str = ''
documentaries_directory: str = ''
movie_dir: str = '{title} ({year})'
movie_filename: str = '{title} ({year})'
# Appended to movie_filename for a documentary in several parts ("part1": Plex and Jellyfin)
part_suffix: str = ' - part{part}'
@dataclass
class VideoConfig:
max_height: int = 1080
# Preference order when several codecs exist at the same height: "hevc" (H.265), "avc" (H.264)
codecs: list[str] = field(default_factory=lambda: ['hevc', 'avc'])
@dataclass
class AudioConfig:
# Tracks to include, in order; the first one found becomes the default track.
# "original" -> original version (VO), whatever its language
# "fr", "de" -> that language (excluding audio description / "confort audio")
# "fr-ad" -> audio description, "fr-comfort" -> "confort audio" (clearer dialogue)
tracks: list[str] = field(default_factory=lambda: ['original', 'fr'])
@dataclass
class SubtitlesConfig:
# "fr" -> full subtitles, "fr-forced" -> forced (foreign lines only), "fr-acc" -> SDH
tracks: list[str] = field(default_factory=lambda: ['fr', 'fr-forced'])
# Default subtitle track: a key from `tracks`, "none", or "auto"
# ("auto": forced subs when the default audio is in the subtitle language, full subs otherwise)
default: str = 'auto'
@dataclass
class MetadataConfig:
# "tmdb": series name / year / numbering / titles from themoviedb.org; "none": Arte data only
provider: str = 'tmdb'
# TMDB API key (v3) or read access token (v4); the TMDB_API_KEY environment variable also works
api_key: str = ''
# Priority order for series name, episode titles and synopses. TMDB languages ("fr-FR",
# "en-US", "fr"...), "arte" (Arte's own titles) and "original" (the show's original language)
languages: list[str] = field(default_factory=lambda: ['fr-FR', 'arte', 'en-US'])
@property
def key(self) -> str:
return self.api_key or os.environ.get('TMDB_API_KEY', '')
@dataclass
class Config:
output: OutputConfig = field(default_factory=OutputConfig)
video: VideoConfig = field(default_factory=VideoConfig)
audio: AudioConfig = field(default_factory=AudioConfig)
subtitles: SubtitlesConfig = field(default_factory=SubtitlesConfig)
metadata: MetadataConfig = field(default_factory=MetadataConfig)
@property
def output_dir(self) -> Path:
return _expand(self.output.directory)
def movie_root(self, kind: str) -> Path:
"""Root directory for a "film" or a "documentary"."""
o = self.output
path = (o.documentaries_directory if kind == 'documentary' else '') or o.movies_directory
return _expand(path) if path else self.output_dir
def _expand(path: str) -> Path:
return Path(os.path.expandvars(path)).expanduser()
def default_config_path() -> Path:
base = os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config'
return Path(base) / 'arte-dl' / 'config.toml'
def _apply(section_obj, data: dict, section: str) -> None:
known = {f.name: f for f in fields(section_obj)}
for key, value in data.items():
if key not in known:
raise ConfigError(f'Unknown option [{section}] {key}')
default = getattr(section_obj, key)
if isinstance(default, list) and not (
isinstance(value, list) and all(isinstance(v, str) for v in value)):
raise ConfigError(f'[{section}] {key} must be a list of strings')
if isinstance(default, (str, int)) and type(value) is not type(default):
raise ConfigError(f'[{section}] {key} must be a {type(default).__name__}')
setattr(section_obj, key, value)
def load_config(path: Path | None) -> Config:
"""Load `path`, or the default location if it exists, or built-in defaults."""
cfg = Config()
if path is None:
path = default_config_path()
if not path.exists():
return cfg
try:
with open(path, 'rb') as f:
data = tomllib.load(f)
except FileNotFoundError:
raise ConfigError(f'Config file not found: {path}') from None
except tomllib.TOMLDecodeError as e:
raise ConfigError(f'{path}: {e}') from None
for section, values in data.items():
if not hasattr(cfg, section) or not isinstance(values, dict):
raise ConfigError(f'Unknown section [{section}]')
_apply(getattr(cfg, section), values, section)
_validate(cfg)
return cfg
LANGUAGE_RE = re.compile(r'^(?:[a-z]{2}(?:-[A-Z]{2})?|arte|original)$')
def _validate(cfg: Config) -> None:
if cfg.metadata.provider not in ('tmdb', 'none'):
raise ConfigError('[metadata] provider must be "tmdb" or "none"')
bad = [lang for lang in cfg.metadata.languages if not LANGUAGE_RE.match(lang)]
if bad:
raise ConfigError(f'[metadata] languages: invalid {bad} (expected "fr-FR", "fr", "arte", "original")')