diff --git a/Dockers/self-hosted-iptv/data/channels.json.example b/Dockers/self-hosted-iptv/data/channels.json.example index 2941d81..9218110 100644 --- a/Dockers/self-hosted-iptv/data/channels.json.example +++ b/Dockers/self-hosted-iptv/data/channels.json.example @@ -1,27 +1,34 @@ { "channels": [ { - "id": "comedy", - "name": "Comedy", + "id": "nostalgia", + "name": "Nostalgie TV", "paths": [ - "movies", - "series/The Office", - { - "path": "series/W817", - "display_name": "Phineas en Ferb", - "tmdb_search": "Phineas and Ferb" - }, + "series/Mega Mindy", + "series/W817", + "series/En Daarmee Basta", { "path": "series/kika-bob", "display_name": "Kika & Bob", "tmdb_id": "97596-kika-bob" + }, + "series/Kim Possible", + { + "path": "series/Beugelbekkie", + "display_name": "Beugelbekkie", + "tmdb_id": "1763-braceface" + }, + { + "path": "series/Johnny Test", + "display_name": "Johnny Test", + "tmdb_id": "1769-johnny-test" + }, + { + "path": "series/Jimmy Neutron", + "display_name": "Jimmy Neutron", + "tmdb_id": "2129-the-adventures-of-jimmy-neutron-boy-genius" } ] - }, - { - "id": "drama", - "name": "Drama", - "paths": ["movies", "series/Breaking Bad", "series/Better Call Saul"] } ] } diff --git a/Dockers/self-hosted-iptv/iptv/cache.py b/Dockers/self-hosted-iptv/iptv/cache.py index 89a37ba..6846f53 100644 --- a/Dockers/self-hosted-iptv/iptv/cache.py +++ b/Dockers/self-hosted-iptv/iptv/cache.py @@ -5,7 +5,7 @@ import json import logging from pathlib import Path -from .config import CHANNELS_FILE, DATA_ROOT +from .config import CHANNELS_FILE, DATA_ROOT, MOVIES_ROOT, SERIES_ROOT CACHE_DIR = DATA_ROOT / "cache" @@ -145,6 +145,10 @@ class CacheManager: safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in channel_id) return self._cache_dir / f"playlist_{safe_id}.json" + def _schedule_cache_path(self, channel_id: str) -> Path: + safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in channel_id) + return self._cache_dir / f"schedule_{safe_id}.json" + def get_playlist(self, channel_id: str) -> list[tuple[str, str]] | None: """ Return cached playlist data as [(display_name, path_str), ...] if valid. @@ -178,3 +182,81 @@ class CacheManager: self._ensure_hash_stored(current) except OSError as e: logger.warning("Could not write playlist cache for %s: %s", channel_id, e) + + def get_schedule(self, channel_id: str) -> tuple[list[Path], list[float], list[dict]] | None: + """ + Return cached schedule (paths, durations, path_configs) if valid. + """ + self.invalidate_if_needed() + current = get_channels_hash(self._channels_file) + if current is None: + return None + path = self._schedule_cache_path(channel_id) + if not path.exists(): + return None + stored = self._get_stored_hash() + if stored != current: + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, TypeError) as e: + logger.warning("Could not read schedule cache for %s: %s", channel_id, e) + return None + path_strs = data.get("path_strs") + durations = data.get("durations") + path_configs = data.get("path_configs") + if not path_strs or not durations or len(path_strs) != len(durations): + return None + paths = [] + for ps in path_strs: + if not isinstance(ps, str) or ".." in ps: + return None + if ps.startswith("movies/"): + p = MOVIES_ROOT / ps[7:] + elif ps.startswith("series/"): + p = SERIES_ROOT / ps[7:] + else: + return None + if not p.exists(): + return None + paths.append(p) + if not path_configs or len(path_configs) != len(paths): + return None + return (paths, [float(d) for d in durations], path_configs) + + def set_schedule( + self, + channel_id: str, + paths: list[Path], + durations: list[float], + path_configs: list[dict], + ) -> None: + """Save schedule to cache.""" + current = get_channels_hash(self._channels_file) + if current is None or not self._ensure_cache_dir(): + return + path_strs = [] + for p in paths: + try: + if MOVIES_ROOT in p.parents: + path_strs.append("movies/" + str(p.relative_to(MOVIES_ROOT)).replace("\\", "/")) + else: + path_strs.append("series/" + str(p.relative_to(SERIES_ROOT)).replace("\\", "/")) + except ValueError: + path_strs.append("movies/" + p.name) + path = self._schedule_cache_path(channel_id) + try: + path.write_text( + json.dumps( + { + "path_strs": path_strs, + "durations": durations, + "path_configs": path_configs, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + self._ensure_hash_stored(current) + except OSError as e: + logger.warning("Could not write schedule cache for %s: %s", channel_id, e) diff --git a/Dockers/self-hosted-iptv/iptv/schedule.py b/Dockers/self-hosted-iptv/iptv/schedule.py index f4b9bc6..317464f 100644 --- a/Dockers/self-hosted-iptv/iptv/schedule.py +++ b/Dockers/self-hosted-iptv/iptv/schedule.py @@ -132,6 +132,7 @@ class ScheduleBuilder: self._cache: dict[str, tuple[list[Path], list[float], list[dict], float]] = {} self._ttl_sec = SCHEDULE_CACHE_TTL_SEC self._channel_repo = None # injected + self._cache_manager = None # injected for disk persistence def invalidate_cache(self) -> None: """Clear in-memory schedule cache (e.g. when channels.json changes).""" @@ -142,11 +143,15 @@ class ScheduleBuilder: """Set the channel repository for schedule building.""" self._channel_repo = repo + def set_cache_manager(self, cache_manager) -> None: + """Set the cache manager for disk persistence across restarts.""" + self._cache_manager = cache_manager + def get_or_build( self, channel_id: str, ) -> tuple[list[Path], list[float], list[dict]] | None: - """Return (paths, durations, path_configs) for the channel. Cached per TTL.""" + """Return (paths, durations, path_configs) for the channel. Uses memory and disk cache.""" now = time.time() if channel_id in self._cache: paths, durations, path_configs, ts = self._cache[channel_id] @@ -154,6 +159,14 @@ class ScheduleBuilder: logger.debug("schedule cache hit for channel %s (%s videos)", channel_id, len(paths)) return paths, durations, path_configs + if self._cache_manager: + cached = self._cache_manager.get_schedule(channel_id) + if cached is not None: + paths, durations, path_configs = cached + self._cache[channel_id] = (paths, durations, path_configs, now) + logger.debug("schedule disk cache hit for channel %s (%s videos)", channel_id, len(paths)) + return paths, durations, path_configs + logger.info("Building schedule for channel %s ...", channel_id) t0 = time.monotonic() if not self._channel_repo: @@ -167,5 +180,7 @@ class ScheduleBuilder: durations = [get_duration(p) for p in paths] elapsed = time.monotonic() - t0 self._cache[channel_id] = (paths, durations, path_configs, now) + if self._cache_manager: + self._cache_manager.set_schedule(channel_id, paths, durations, path_configs) logger.info("Schedule for channel %s built in %.1fs (%s videos)", channel_id, elapsed, len(paths)) return paths, durations, path_configs diff --git a/Dockers/self-hosted-iptv/main.py b/Dockers/self-hosted-iptv/main.py index 4b58341..1020f1b 100644 --- a/Dockers/self-hosted-iptv/main.py +++ b/Dockers/self-hosted-iptv/main.py @@ -40,6 +40,7 @@ def main(): schedule_builder.set_channel_repo(channel_repo) tmdb_client = TMDBClient() cache_manager = CacheManager(schedule_builder=schedule_builder) + schedule_builder.set_cache_manager(cache_manager) prefetcher = MetadataPrefetcher(schedule_builder, tmdb_client, channel_repo) prefetcher.start() epg_builder = EPGBuilder(