Files
projects/Dockers/self-hosted-iptv/iptv/cache.py
T
Bram fecd8389e9
Build and Push Docker Images / build-and-push (push) Successful in 16s
cache manager
2026-02-11 21:39:39 +01:00

181 lines
6.1 KiB
Python

"""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)