refactor: collapse Config proxy, migrate tracker to SQLite, split reconcile module
- Config: remove _ConfigAccessor and ConfigManager; use __getattr__ for lazy loading on single _Config class; re-register self as _instance in __getattr__ so reset() always clears the correct object (item 1) - upload_tracker: replace hand-rolled JSON store with sqlite3; auto-migrates existing JSON on first run; remove dead record_frigate_file function; connection re-opens when CACHE_DIR changes for test isolation (items 2, 8) - diversity: move ThreadPoolExecutor import to module level; inject optional fetch_fn parameter for testability (items 3, 6) - pyproject: consolidate 4 variant files into extras (gpu/rocm/intel/cpu); update Dockerfile to use --extra flag; delete variant pyproject/lock files; uv.lock needs regen with `uv lock` after this change (item 4) - jobs: extract _build_job helper to separate business logic from terminal I/O; auto_configure delegates dedup/selection to _build_job (item 5) - logging: convert f-string log calls to % interpolation throughout all winnow/ modules (item 7) - reconcile: new module with reconcile_frigate_mappings and enrich_asset_with_face_data extracted from executor.py (item 9) - scheduler: print next scheduled run time after startup and after each run; fix f-string logger.error call (item 10)
This commit is contained in:
+55
-68
@@ -15,43 +15,70 @@ CONFIG_FILE = Path(".immich_config.json")
|
||||
|
||||
|
||||
class _Config:
|
||||
"""Singleton configuration with uppercase attribute access for backward compatibility."""
|
||||
"""Singleton configuration with lazy loading via __getattr__.
|
||||
|
||||
Class-level attributes are annotations only (no defaults), so attribute
|
||||
access on an un-loaded instance falls through to __getattr__, which
|
||||
triggers _load() exactly once.
|
||||
"""
|
||||
|
||||
_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
|
||||
# Annotations only — no class-level defaults so __getattr__ fires on first access
|
||||
IMMICH_URL: str | None
|
||||
API_KEY: str | None
|
||||
OUTPUT_DIR: str
|
||||
YEARS_FILTER: int
|
||||
|
||||
# Quality filtering
|
||||
MIN_FACE_WIDTH: int = 90
|
||||
BLUR_THRESHOLD: float = 120.0
|
||||
MIN_CONFIDENCE: float = 0.7
|
||||
MAX_AUTO_IMAGES: int = 20
|
||||
QUALITY_REPLACEMENT: bool = True
|
||||
FRIGATE_SCORE_CEILING: float | None = None
|
||||
ENABLE_FRIGATE_SCORES: bool = True
|
||||
MIN_FACE_WIDTH: int
|
||||
BLUR_THRESHOLD: float
|
||||
MIN_CONFIDENCE: float
|
||||
MAX_AUTO_IMAGES: int
|
||||
QUALITY_REPLACEMENT: bool
|
||||
FRIGATE_SCORE_CEILING: float | None
|
||||
ENABLE_FRIGATE_SCORES: bool
|
||||
|
||||
# People filtering
|
||||
MIN_FACE_COUNT: int = 3
|
||||
MERGE_DUPLICATE_PEOPLE: bool = False
|
||||
MIN_FACE_COUNT: int
|
||||
MERGE_DUPLICATE_PEOPLE: bool
|
||||
|
||||
# Output quality
|
||||
FACE_MARGIN: float = 0.15
|
||||
USE_FULL_RESOLUTION: bool = True
|
||||
ENABLE_FACE_ALIGNMENT: bool = True
|
||||
FACE_MARGIN: float
|
||||
USE_FULL_RESOLUTION: bool
|
||||
ENABLE_FACE_ALIGNMENT: bool
|
||||
|
||||
ENABLE_CACHE: bool = True
|
||||
CACHE_DIR: str = ".if_cache"
|
||||
ENABLE_CACHE: bool
|
||||
CACHE_DIR: str
|
||||
|
||||
def __new__(cls) -> "_Config":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._load()
|
||||
# Do NOT call _load() here — keep __new__ I/O-free so that import
|
||||
# time does not trigger env/file reads.
|
||||
return cls._instance
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
"""Called only when the attribute is not found on the instance.
|
||||
|
||||
On first access to any config attribute, load all values from env/file
|
||||
and return the requested one. Re-registers self as _instance so that
|
||||
a subsequent reset() correctly finds and clears this object's attrs.
|
||||
"""
|
||||
if name.startswith("_"):
|
||||
raise AttributeError(name)
|
||||
self._load()
|
||||
# Re-register self as the singleton so reset() can clear our __dict__.
|
||||
# This handles the case where __getattr__ is called on the module-level
|
||||
# Config object after a reset() set _instance to None.
|
||||
_Config._instance = self
|
||||
# _load() sets the attribute as an instance attr; retrieve it directly
|
||||
# to avoid infinite recursion through __getattr__.
|
||||
try:
|
||||
return self.__dict__[name]
|
||||
except KeyError:
|
||||
raise AttributeError(f"_Config has no attribute {name!r}")
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load configuration from environment and config file."""
|
||||
# Load from environment (highest priority)
|
||||
@@ -83,11 +110,13 @@ class _Config:
|
||||
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}")
|
||||
logging.warning("Failed to load config file: %s", e)
|
||||
|
||||
@classmethod
|
||||
def reset(cls) -> None:
|
||||
"""Reset the singleton — mainly useful for testing or delayed env setup."""
|
||||
if cls._instance is not None:
|
||||
cls._instance.__dict__.clear()
|
||||
cls._instance = None
|
||||
|
||||
def save(self) -> None:
|
||||
@@ -106,9 +135,9 @@ class _Config:
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
logging.info(f"Configuration saved to {CONFIG_FILE}")
|
||||
logging.info("Configuration saved to %s", CONFIG_FILE)
|
||||
except OSError as e:
|
||||
logging.error(f"Failed to save config: {e}")
|
||||
logging.error("Failed to save config: %s", e)
|
||||
|
||||
def interactive_setup(self) -> None:
|
||||
"""Prompt user for missing configuration."""
|
||||
@@ -132,52 +161,10 @@ class _Config:
|
||||
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()
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
@staticmethod
|
||||
def get() -> _Config:
|
||||
return _Config()
|
||||
# Module-level singleton — lazy: no I/O until first attribute access.
|
||||
Config = _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"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user