Files
winnow/if_curator/config.py
T

163 lines
5.3 KiB
Python

"""Configuration management for if-curator."""
import json
import logging
import os
from pathlib import Path
from typing import ClassVar
from dotenv import load_dotenv
from rich.prompt import Prompt
load_dotenv()
CONFIG_FILE = Path(".immich_config.json")
class _Config:
"""Singleton configuration with uppercase attribute access for backward compatibility."""
_instance: ClassVar["_Config | None"] = None
# Configuration values
IMMICH_URL: str | None = None
API_KEY: str | None = None
OUTPUT_DIR: str = "./frigate_train"
YEARS_FILTER: int = 10
# Quality filtering
MIN_FACE_WIDTH: int = 50
BLUR_THRESHOLD: float = 100.0
MIN_CONFIDENCE: float = 0.7
MAX_AUTO_IMAGES: int = 80
# People filtering
MIN_FACE_COUNT: int = 0
# Output quality
FACE_MARGIN: float = 0.15
USE_FULL_RESOLUTION: bool = True
ENABLE_FACE_ALIGNMENT: bool = True
# Caching (opt-in to avoid unexpected files)
ENABLE_CACHE: bool = False
CACHE_DIR: str = ".if_cache"
def __new__(cls) -> "_Config":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._load()
return cls._instance
def _load(self) -> None:
"""Load configuration from environment and config file."""
# Load from environment (highest priority)
self.IMMICH_URL = os.getenv("IMMICH_URL")
self.API_KEY = os.getenv("API_KEY")
self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train")
self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10"))
self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "50"))
self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "0"))
self.ENABLE_CACHE = os.getenv("ENABLE_CACHE", "false").lower() in ("true", "1", "yes")
self.CACHE_DIR = os.getenv("CACHE_DIR", ".if_cache")
# Fall back to config file for missing values
if CONFIG_FILE.exists():
try:
data = json.loads(CONFIG_FILE.read_text())
self.IMMICH_URL = self.IMMICH_URL or data.get("IMMICH_URL")
self.API_KEY = self.API_KEY or data.get("API_KEY")
if not os.getenv("OUTPUT_DIR"):
self.OUTPUT_DIR = data.get("OUTPUT_DIR", self.OUTPUT_DIR)
except (json.JSONDecodeError, OSError) as e:
logging.warning(f"Failed to load config file: {e}")
@classmethod
def reset(cls) -> None:
"""Reset the singleton — mainly useful for testing or delayed env setup."""
cls._instance = None
def save(self) -> None:
"""Persist configuration to file."""
try:
CONFIG_FILE.write_text(
json.dumps(
{
"IMMICH_URL": self.IMMICH_URL,
"API_KEY": self.API_KEY,
"OUTPUT_DIR": self.OUTPUT_DIR,
},
indent=2,
)
)
logging.info(f"Configuration saved to {CONFIG_FILE}")
except OSError as e:
logging.error(f"Failed to save config: {e}")
def interactive_setup(self) -> None:
"""Prompt user for missing configuration."""
from rich.console import Console
console = Console()
if not self.IMMICH_URL:
console.print("[yellow]Immich URL not found.[/yellow]")
self.IMMICH_URL = Prompt.ask("Enter Immich URL (e.g. http://192.168.1.5:2283)")
self.save()
if not self.API_KEY:
console.print("[yellow]Immich API Key not found.[/yellow]")
self.API_KEY = Prompt.ask("Enter Immich API Key", password=True)
self.save()
def validate(self) -> None:
"""Raise ValueError if required config is missing."""
if not self.IMMICH_URL or not self.API_KEY:
raise ValueError("Missing Immich URL or API Key.")
# Singleton instance — use a lazy property pattern to avoid import-time side effects
# when env vars aren't yet set. Call Config.instance() or just access attributes on
# the module-level `Config` (which delegates to the singleton).
class _ConfigAccessor:
"""Lazy accessor that defers singleton creation until first attribute access.
This avoids reading .env and config files at import time, so environment
variables set after importing the module are properly picked up.
"""
def __getattr__(self, name: str):
return getattr(_Config(), name)
def __setattr__(self, name: str, value):
if name.startswith("_"):
super().__setattr__(name, value)
else:
setattr(_Config(), name, value)
def reset(self) -> None:
"""Reset the underlying singleton."""
_Config.reset()
def interactive_setup(self) -> None:
"""Delegate to the singleton."""
_Config().interactive_setup()
def validate(self) -> None:
"""Delegate to the singleton."""
_Config().validate()
def save(self) -> None:
"""Delegate to the singleton."""
_Config().save()
Config = _ConfigAccessor()
ConfigManager = type("ConfigManager", (), {"get": staticmethod(lambda: _Config())})
def get_headers() -> dict[str, str]:
"""Return HTTP headers for Immich API requests."""
return {"x-api-key": Config.API_KEY or "", "Accept": "application/json"}