From fecd8389e9271131687713d66215971915646ef5 Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 11 Feb 2026 21:39:39 +0100 Subject: [PATCH] cache manager --- Dockers/self-hosted-iptv/iptv/cache.py | 180 +++++++++++++++++++ Dockers/self-hosted-iptv/iptv/epg_builder.py | 14 +- Dockers/self-hosted-iptv/iptv/schedule.py | 38 ++-- Dockers/self-hosted-iptv/iptv/server.py | 19 +- Dockers/self-hosted-iptv/main.py | 5 +- 5 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 Dockers/self-hosted-iptv/iptv/cache.py diff --git a/Dockers/self-hosted-iptv/iptv/cache.py b/Dockers/self-hosted-iptv/iptv/cache.py new file mode 100644 index 0000000..89a37ba --- /dev/null +++ b/Dockers/self-hosted-iptv/iptv/cache.py @@ -0,0 +1,180 @@ +"""Disk cache for EPG and playlists, invalidated when channels.json changes.""" + +import hashlib +import json +import logging +from pathlib import Path + +from .config import CHANNELS_FILE, DATA_ROOT + +CACHE_DIR = DATA_ROOT / "cache" + +logger = logging.getLogger(__name__) + + +def get_channels_hash(channels_file: Path = CHANNELS_FILE) -> str | None: + """ + Compute SHA256 hash of channels.json content. + Returns None if file does not exist. + """ + if not channels_file.exists(): + return None + try: + with open(channels_file, "rb") as f: + content = f.read() + return hashlib.sha256(content).hexdigest() + except (OSError, json.JSONDecodeError) as e: + logger.warning("Could not hash channels file: %s", e) + return None + + +class CacheManager: + """ + Manages disk cache for EPG and playlists. + Cache is valid only while channels.json content stays the same. + """ + + def __init__( + self, + channels_file: Path = CHANNELS_FILE, + cache_dir: Path = CACHE_DIR, + schedule_builder=None, + ): + self._channels_file = channels_file + self._cache_dir = cache_dir + self._schedule_builder = schedule_builder + + def _ensure_cache_dir(self) -> bool: + """Ensure cache dir exists. Returns False if creation fails.""" + try: + self._cache_dir.mkdir(parents=True, exist_ok=True) + return True + except OSError as e: + logger.debug("Could not create cache dir %s: %s", self._cache_dir, e) + return False + + def _hash_file(self) -> Path: + return self._cache_dir / "channels_hash.txt" + + def _epg_cache_file(self) -> Path: + return self._cache_dir / "epg.xml" + + def _get_stored_hash(self) -> str | None: + hf = self._hash_file() + if not hf.exists(): + return None + try: + return hf.read_text().strip() or None + except OSError: + return None + + def _write_hash(self, h: str) -> None: + self._hash_file().write_text(h) + + def invalidate_if_needed(self) -> bool: + """ + If channels.json has changed, clear all caches and return True. + Otherwise return False. + """ + current = get_channels_hash(self._channels_file) + stored = self._get_stored_hash() + + if current is None: + return False + + if stored != current: + logger.info("Channels changed (hash %s -> %s), invalidating cache", stored, current) + self._clear_cache() + if self._schedule_builder and hasattr(self._schedule_builder, "invalidate_cache"): + self._schedule_builder.invalidate_cache() + return True + return False + + def _clear_cache(self) -> None: + """Remove all cached files and hash.""" + if not self._cache_dir.exists(): + return + for f in self._cache_dir.iterdir(): + try: + f.unlink() + except OSError as e: + logger.warning("Could not remove cache file %s: %s", f, e) + hf = self._hash_file() + if hf.exists(): + try: + hf.unlink() + except OSError: + pass + + def _ensure_hash_stored(self, current_hash: str) -> None: + """Store current hash after a successful cache write.""" + stored = self._get_stored_hash() + if stored != current_hash: + self._write_hash(current_hash) + + def get_epg(self) -> str | None: + """Return cached EPG XML if valid, else None.""" + self.invalidate_if_needed() + current = get_channels_hash(self._channels_file) + if current is None: + return None + epg_path = self._epg_cache_file() + if not epg_path.exists(): + return None + stored = self._get_stored_hash() + if stored != current: + return None + try: + return epg_path.read_text(encoding="utf-8") + except OSError as e: + logger.warning("Could not read EPG cache: %s", e) + return None + + def set_epg(self, xml: str) -> None: + """Save EPG XML to cache and update stored hash.""" + current = get_channels_hash(self._channels_file) + if current is None or not self._ensure_cache_dir(): + return + try: + self._epg_cache_file().write_text(xml, encoding="utf-8") + self._ensure_hash_stored(current) + except OSError as e: + logger.warning("Could not write EPG cache: %s", e) + + def _playlist_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"playlist_{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. + path_str is the path for stream?path= (e.g. movies/foo or series/bar). + """ + self.invalidate_if_needed() + current = get_channels_hash(self._channels_file) + if current is None: + return None + path = self._playlist_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")) + return [tuple(item) for item in data] + except (OSError, json.JSONDecodeError, TypeError) as e: + logger.warning("Could not read playlist cache for %s: %s", channel_id, e) + return None + + def set_playlist(self, channel_id: str, data: list[tuple[str, str]]) -> None: + """Save playlist data to cache.""" + current = get_channels_hash(self._channels_file) + if current is None or not self._ensure_cache_dir(): + return + path = self._playlist_cache_path(channel_id) + try: + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + self._ensure_hash_stored(current) + except OSError as e: + logger.warning("Could not write playlist cache for %s: %s", channel_id, e) diff --git a/Dockers/self-hosted-iptv/iptv/epg_builder.py b/Dockers/self-hosted-iptv/iptv/epg_builder.py index d2aa9e0..e8e7d7e 100644 --- a/Dockers/self-hosted-iptv/iptv/epg_builder.py +++ b/Dockers/self-hosted-iptv/iptv/epg_builder.py @@ -48,16 +48,23 @@ def schedule_epoch_utc() -> datetime: class EPGBuilder: """Builds XMLTV EPG for all channels.""" - def __init__(self, schedule_builder, tmdb_client, channel_repo): + def __init__(self, schedule_builder, tmdb_client, channel_repo, cache_manager=None): self._schedule = schedule_builder self._tmdb = tmdb_client self._channels = channel_repo + self._cache = cache_manager def build_xml(self) -> str: """ Build XMLTV EPG for all channels. Schedule repeats from midnight UTC. Programmes generated for EPG_TIMESPAN_DAYS. """ + if self._cache: + cached = self._cache.get_epg() + if cached is not None: + logger.debug("EPG served from cache") + return cached + epg_start = time.monotonic() channels = self._channels.get_all() if not channels: @@ -181,7 +188,10 @@ class EPGBuilder: ET.indent(root, space=" ") epg_elapsed = time.monotonic() - epg_start logger.info("EPG build finished in %.1fs (%s programmes total)", epg_elapsed, total_programmes) - return ( + xml = ( '\n' + ET.tostring(root, encoding="unicode", default_namespace=None) ) + if self._cache: + self._cache.set_epg(xml) + return xml diff --git a/Dockers/self-hosted-iptv/iptv/schedule.py b/Dockers/self-hosted-iptv/iptv/schedule.py index a847dd2..f4b9bc6 100644 --- a/Dockers/self-hosted-iptv/iptv/schedule.py +++ b/Dockers/self-hosted-iptv/iptv/schedule.py @@ -76,23 +76,28 @@ def build_channel_path_list(path_entries: list) -> tuple[list[Path], list[dict]] return [p for p, _ in pairs], [c for _, c in pairs] +def _path_to_stream_path(p: Path) -> str: + """Convert absolute path to stream path (for stream?path=).""" + try: + if MOVIES_ROOT in p.parents: + rel = p.relative_to(MOVIES_ROOT) + return "movies/" + str(rel).replace("\\", "/") + rel = p.relative_to(SERIES_ROOT) + return "series/" + str(rel).replace("\\", "/") + except ValueError: + return "movies/" + p.name + + def build_playlist(path_entries: list, base_url: str) -> list[tuple[str, str]]: """Build playlist as list of (display_name, stream_url).""" + data = build_playlist_data(path_entries) + return [(name, f"{base_url}/stream?path={path_str}") for name, path_str in data] + + +def build_playlist_data(path_entries: list) -> list[tuple[str, str]]: + """Build playlist data as list of (display_name, path_str) for caching.""" paths, _ = build_channel_path_list(path_entries) - playlist = [] - for p in paths: - try: - if MOVIES_ROOT in p.parents: - rel = p.relative_to(MOVIES_ROOT) - path_str = "movies/" + str(rel).replace("\\", "/") - else: - rel = p.relative_to(SERIES_ROOT) - path_str = "series/" + str(rel).replace("\\", "/") - except ValueError: - path_str = "movies/" + p.name - stream_url = f"{base_url}/stream?path={path_str}" - playlist.append((p.stem, stream_url)) - return playlist + return [(p.stem, _path_to_stream_path(p)) for p in paths] def get_duration(path: Path) -> float: @@ -128,6 +133,11 @@ class ScheduleBuilder: self._ttl_sec = SCHEDULE_CACHE_TTL_SEC self._channel_repo = None # injected + def invalidate_cache(self) -> None: + """Clear in-memory schedule cache (e.g. when channels.json changes).""" + self._cache.clear() + logger.debug("Schedule cache invalidated") + def set_channel_repo(self, repo) -> None: """Set the channel repository for schedule building.""" self._channel_repo = repo diff --git a/Dockers/self-hosted-iptv/iptv/server.py b/Dockers/self-hosted-iptv/iptv/server.py index d17cb03..7d1928a 100644 --- a/Dockers/self-hosted-iptv/iptv/server.py +++ b/Dockers/self-hosted-iptv/iptv/server.py @@ -4,7 +4,7 @@ import mimetypes from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import parse_qs, urlparse -from .schedule import build_playlist +from .schedule import build_playlist_data def get_base_url(handler: BaseHTTPRequestHandler) -> str: @@ -21,6 +21,7 @@ class IPTVHandler(BaseHTTPRequestHandler): self._channel_repo = getattr(server, "channel_repo", None) self._schedule_builder = getattr(server, "schedule_builder", None) self._epg_builder = getattr(server, "epg_builder", None) + self._cache_manager = getattr(server, "cache_manager", None) super().__init__(request, client_address, server) def log_message(self, format, *args): @@ -98,7 +99,19 @@ class IPTVHandler(BaseHTTPRequestHandler): return path_keys = channel.get("paths", []) base = get_base_url(self) - playlist = build_playlist(path_keys, base) + + playlist_data = None + if self._cache_manager: + playlist_data = self._cache_manager.get_playlist(channel_id) + + if playlist_data is not None: + playlist = [(name, f"{base}/stream?path={path_str}") for name, path_str in playlist_data] + else: + playlist_data = build_playlist_data(path_keys) + playlist = [(name, f"{base}/stream?path={path_str}") for name, path_str in playlist_data] + if self._cache_manager: + self._cache_manager.set_playlist(channel_id, playlist_data) + lines = ["#EXTM3U"] for name, url in playlist: lines.append(f"#EXTINF:-1,{name}") @@ -176,10 +189,12 @@ def create_server( epg_builder, host: str = "0.0.0.0", port: int = 8080, + cache_manager=None, ) -> HTTPServer: """Create HTTP server with injected dependencies.""" server = HTTPServer((host, port), IPTVHandler) server.channel_repo = channel_repo server.schedule_builder = schedule_builder server.epg_builder = epg_builder + server.cache_manager = cache_manager return server diff --git a/Dockers/self-hosted-iptv/main.py b/Dockers/self-hosted-iptv/main.py index 598f8cb..2f474df 100644 --- a/Dockers/self-hosted-iptv/main.py +++ b/Dockers/self-hosted-iptv/main.py @@ -8,6 +8,7 @@ import logging import os from iptv.config import CHANNELS_FILE, EPG_TIMESPAN_DAYS, SCHEDULE_CACHE_TTL_SEC, init_epg_timespan +from iptv.cache import CacheManager from iptv.channel import ChannelRepository from iptv.schedule import ScheduleBuilder from iptv.tmdb_client import TMDBClient @@ -37,7 +38,8 @@ def main(): schedule_builder = ScheduleBuilder() schedule_builder.set_channel_repo(channel_repo) tmdb_client = TMDBClient() - epg_builder = EPGBuilder(schedule_builder, tmdb_client, channel_repo) + cache_manager = CacheManager(schedule_builder=schedule_builder) + epg_builder = EPGBuilder(schedule_builder, tmdb_client, channel_repo, cache_manager) port = 8080 server = create_server( @@ -46,6 +48,7 @@ def main(): epg_builder=epg_builder, host="0.0.0.0", port=port, + cache_manager=cache_manager, ) print(f"Serving at http://0.0.0.0:{port}/ (playlist: http://:{port}/playlist.m3u)") server.serve_forever()