""" Background metadata prefetcher: periodically scans schedules for programmes missing TMDB metadata and fetches them, respecting TMDB rate limits. """ import logging import os import threading import time from pathlib import Path from .config import SERIES_ROOT, TMDB_PREFETCH_INTERVAL_SEC from .epg_builder import get_programme_name_from_path logger = logging.getLogger(__name__) class MetadataPrefetcher: """ Runs in a background thread, periodically fetching TMDB metadata for series episodes that are not yet cached locally. Respects TMDB rate limits via the shared TMDBClient. """ def __init__(self, schedule_builder, tmdb_client, channel_repo): self._schedule = schedule_builder self._tmdb = tmdb_client self._channels = channel_repo self._interval_sec = TMDB_PREFETCH_INTERVAL_SEC self._stop = threading.Event() self._thread: threading.Thread | None = None def start(self) -> None: """Start the background prefetch thread.""" if not os.environ.get("TMDB_API_KEY", "").strip(): logger.debug("TMDB_PREFETCH: skipped (no TMDB_API_KEY)") return if self._thread is not None and self._thread.is_alive(): return self._stop.clear() self._thread = threading.Thread(target=self._run_loop, daemon=True) self._thread.start() logger.info("TMDB metadata prefetcher started (interval: %.1fh)", self._interval_sec / 3600) def stop(self) -> None: """Signal the prefetcher to stop.""" self._stop.set() def _run_loop(self) -> None: """Background loop: run first scan immediately, then wait and repeat.""" while not self._stop.is_set(): self._run_scan() if self._stop.is_set(): break self._stop.wait(timeout=min(60, self._interval_sec)) if self._stop.is_set(): break self._stop.wait(timeout=max(0, self._interval_sec - 60)) def _run_scan(self, scheduled: set | None = None) -> None: """ Prefetch TMDB metadata for series episodes that are not already scheduled (not in the current EPG) and not in the TMDB cache. This builds the cache so the next EPG build will be faster. TMDBClient rate limiting applies to API calls. """ channels = self._channels.get_all() if not channels: return scheduled = scheduled or set() seen: set[tuple[Path, str | None, str | None, int | None]] = set() fetched = 0 for ch in channels: cid = ch.get("id", ch.get("name", "")) try: schedule = self._schedule.get_or_build(cid) except Exception as e: logger.debug("Prefetch channel %s: %s", cid, e) continue if not schedule: continue paths, _, path_configs = schedule for i, path in enumerate(paths): if SERIES_ROOT not in path.parents: continue cfg = path_configs[i] if i < len(path_configs) else {} key = ( path, cfg.get("display_name"), cfg.get("tmdb_search"), cfg.get("tmdb_id"), ) if key in seen: continue seen.add(key) if key in scheduled: continue programme_name = get_programme_name_from_path(path) if self._tmdb.has_episode_metadata_cached( programme_name, path, tmdb_search=cfg.get("tmdb_search"), tmdb_id=cfg.get("tmdb_id"), series_root=SERIES_ROOT, ): continue self._tmdb.get_episode_metadata( programme_name, path, display_name=cfg.get("display_name"), tmdb_search=cfg.get("tmdb_search"), tmdb_id=cfg.get("tmdb_id"), series_root=SERIES_ROOT, ) fetched += 1 if fetched: logger.info( "TMDB prefetch: fetched metadata for %s unscheduled episodes (of %s total)", fetched, len(seen), ) def run_once(self, scheduled: set | None = None) -> None: """ Run a single prefetch scan immediately (e.g. after EPG build). scheduled: set of (path, display_name, tmdb_search, tmdb_id) for programmes already in the current EPG; only fetch for episodes NOT in this set, so the next scheduling process will be faster. """ if not os.environ.get("TMDB_API_KEY", "").strip(): return threading.Thread(target=self._run_scan, args=(scheduled or set(),), daemon=True).start()