This commit is contained in:
@@ -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)
|
||||||
@@ -48,16 +48,23 @@ def schedule_epoch_utc() -> datetime:
|
|||||||
class EPGBuilder:
|
class EPGBuilder:
|
||||||
"""Builds XMLTV EPG for all channels."""
|
"""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._schedule = schedule_builder
|
||||||
self._tmdb = tmdb_client
|
self._tmdb = tmdb_client
|
||||||
self._channels = channel_repo
|
self._channels = channel_repo
|
||||||
|
self._cache = cache_manager
|
||||||
|
|
||||||
def build_xml(self) -> str:
|
def build_xml(self) -> str:
|
||||||
"""
|
"""
|
||||||
Build XMLTV EPG for all channels. Schedule repeats from midnight UTC.
|
Build XMLTV EPG for all channels. Schedule repeats from midnight UTC.
|
||||||
Programmes generated for EPG_TIMESPAN_DAYS.
|
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()
|
epg_start = time.monotonic()
|
||||||
channels = self._channels.get_all()
|
channels = self._channels.get_all()
|
||||||
if not channels:
|
if not channels:
|
||||||
@@ -181,7 +188,10 @@ class EPGBuilder:
|
|||||||
ET.indent(root, space=" ")
|
ET.indent(root, space=" ")
|
||||||
epg_elapsed = time.monotonic() - epg_start
|
epg_elapsed = time.monotonic() - epg_start
|
||||||
logger.info("EPG build finished in %.1fs (%s programmes total)", epg_elapsed, total_programmes)
|
logger.info("EPG build finished in %.1fs (%s programmes total)", epg_elapsed, total_programmes)
|
||||||
return (
|
xml = (
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
+ ET.tostring(root, encoding="unicode", default_namespace=None)
|
+ ET.tostring(root, encoding="unicode", default_namespace=None)
|
||||||
)
|
)
|
||||||
|
if self._cache:
|
||||||
|
self._cache.set_epg(xml)
|
||||||
|
return xml
|
||||||
|
|||||||
@@ -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]
|
return [p for p, _ in pairs], [c for _, c in pairs]
|
||||||
|
|
||||||
|
|
||||||
def build_playlist(path_entries: list, base_url: str) -> list[tuple[str, str]]:
|
def _path_to_stream_path(p: Path) -> str:
|
||||||
"""Build playlist as list of (display_name, stream_url)."""
|
"""Convert absolute path to stream path (for stream?path=)."""
|
||||||
paths, _ = build_channel_path_list(path_entries)
|
|
||||||
playlist = []
|
|
||||||
for p in paths:
|
|
||||||
try:
|
try:
|
||||||
if MOVIES_ROOT in p.parents:
|
if MOVIES_ROOT in p.parents:
|
||||||
rel = p.relative_to(MOVIES_ROOT)
|
rel = p.relative_to(MOVIES_ROOT)
|
||||||
path_str = "movies/" + str(rel).replace("\\", "/")
|
return "movies/" + str(rel).replace("\\", "/")
|
||||||
else:
|
|
||||||
rel = p.relative_to(SERIES_ROOT)
|
rel = p.relative_to(SERIES_ROOT)
|
||||||
path_str = "series/" + str(rel).replace("\\", "/")
|
return "series/" + str(rel).replace("\\", "/")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
path_str = "movies/" + p.name
|
return "movies/" + p.name
|
||||||
stream_url = f"{base_url}/stream?path={path_str}"
|
|
||||||
playlist.append((p.stem, stream_url))
|
|
||||||
return playlist
|
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)
|
||||||
|
return [(p.stem, _path_to_stream_path(p)) for p in paths]
|
||||||
|
|
||||||
|
|
||||||
def get_duration(path: Path) -> float:
|
def get_duration(path: Path) -> float:
|
||||||
@@ -128,6 +133,11 @@ class ScheduleBuilder:
|
|||||||
self._ttl_sec = SCHEDULE_CACHE_TTL_SEC
|
self._ttl_sec = SCHEDULE_CACHE_TTL_SEC
|
||||||
self._channel_repo = None # injected
|
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:
|
def set_channel_repo(self, repo) -> None:
|
||||||
"""Set the channel repository for schedule building."""
|
"""Set the channel repository for schedule building."""
|
||||||
self._channel_repo = repo
|
self._channel_repo = repo
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import mimetypes
|
|||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from urllib.parse import parse_qs, urlparse
|
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:
|
def get_base_url(handler: BaseHTTPRequestHandler) -> str:
|
||||||
@@ -21,6 +21,7 @@ class IPTVHandler(BaseHTTPRequestHandler):
|
|||||||
self._channel_repo = getattr(server, "channel_repo", None)
|
self._channel_repo = getattr(server, "channel_repo", None)
|
||||||
self._schedule_builder = getattr(server, "schedule_builder", None)
|
self._schedule_builder = getattr(server, "schedule_builder", None)
|
||||||
self._epg_builder = getattr(server, "epg_builder", None)
|
self._epg_builder = getattr(server, "epg_builder", None)
|
||||||
|
self._cache_manager = getattr(server, "cache_manager", None)
|
||||||
super().__init__(request, client_address, server)
|
super().__init__(request, client_address, server)
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
def log_message(self, format, *args):
|
||||||
@@ -98,7 +99,19 @@ class IPTVHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
path_keys = channel.get("paths", [])
|
path_keys = channel.get("paths", [])
|
||||||
base = get_base_url(self)
|
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"]
|
lines = ["#EXTM3U"]
|
||||||
for name, url in playlist:
|
for name, url in playlist:
|
||||||
lines.append(f"#EXTINF:-1,{name}")
|
lines.append(f"#EXTINF:-1,{name}")
|
||||||
@@ -176,10 +189,12 @@ def create_server(
|
|||||||
epg_builder,
|
epg_builder,
|
||||||
host: str = "0.0.0.0",
|
host: str = "0.0.0.0",
|
||||||
port: int = 8080,
|
port: int = 8080,
|
||||||
|
cache_manager=None,
|
||||||
) -> HTTPServer:
|
) -> HTTPServer:
|
||||||
"""Create HTTP server with injected dependencies."""
|
"""Create HTTP server with injected dependencies."""
|
||||||
server = HTTPServer((host, port), IPTVHandler)
|
server = HTTPServer((host, port), IPTVHandler)
|
||||||
server.channel_repo = channel_repo
|
server.channel_repo = channel_repo
|
||||||
server.schedule_builder = schedule_builder
|
server.schedule_builder = schedule_builder
|
||||||
server.epg_builder = epg_builder
|
server.epg_builder = epg_builder
|
||||||
|
server.cache_manager = cache_manager
|
||||||
return server
|
return server
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from iptv.config import CHANNELS_FILE, EPG_TIMESPAN_DAYS, SCHEDULE_CACHE_TTL_SEC, init_epg_timespan
|
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.channel import ChannelRepository
|
||||||
from iptv.schedule import ScheduleBuilder
|
from iptv.schedule import ScheduleBuilder
|
||||||
from iptv.tmdb_client import TMDBClient
|
from iptv.tmdb_client import TMDBClient
|
||||||
@@ -37,7 +38,8 @@ def main():
|
|||||||
schedule_builder = ScheduleBuilder()
|
schedule_builder = ScheduleBuilder()
|
||||||
schedule_builder.set_channel_repo(channel_repo)
|
schedule_builder.set_channel_repo(channel_repo)
|
||||||
tmdb_client = TMDBClient()
|
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
|
port = 8080
|
||||||
server = create_server(
|
server = create_server(
|
||||||
@@ -46,6 +48,7 @@ def main():
|
|||||||
epg_builder=epg_builder,
|
epg_builder=epg_builder,
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=port,
|
port=port,
|
||||||
|
cache_manager=cache_manager,
|
||||||
)
|
)
|
||||||
print(f"Serving at http://0.0.0.0:{port}/ (playlist: http://<host>:{port}/playlist.m3u)")
|
print(f"Serving at http://0.0.0.0:{port}/ (playlist: http://<host>:{port}/playlist.m3u)")
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|||||||
Reference in New Issue
Block a user