This commit is contained in:
@@ -46,6 +46,8 @@ Series are matched by folder name (e.g. `series/W817` → search "W817"); episod
|
|||||||
- EPG: `/data/cache/epg.xml` — XMLTV guide; valid only while `channels.json` is unchanged.
|
- EPG: `/data/cache/epg.xml` — XMLTV guide; valid only while `channels.json` is unchanged.
|
||||||
- Schedule: `/data/cache/schedule_<channel>.json` — per-channel video schedules.
|
- Schedule: `/data/cache/schedule_<channel>.json` — per-channel video schedules.
|
||||||
|
|
||||||
|
On startup, schedules are preloaded in the background so the first stream starts quickly. If the hash file (`channels_hash.txt`) is missing but cache files exist and are recent, the hash is restored so existing cache can be used.
|
||||||
|
|
||||||
**Stream crashes when switching episodes?** If the live stream freezes or crashes when transitioning between videos (e.g. different episodes with different resolutions), enable normalization to re-encode everything to a uniform format:
|
**Stream crashes when switching episodes?** If the live stream freezes or crashes when transitioning between videos (e.g. different episodes with different resolutions), enable normalization to re-encode everything to a uniform format:
|
||||||
- `NORMALIZE_LIVE_STREAM=1` — enables re-encoding (uses more CPU, produces a stable continuous stream)
|
- `NORMALIZE_LIVE_STREAM=1` — enables re-encoding (uses more CPU, produces a stable continuous stream)
|
||||||
- `NORMALIZE_TARGET_RES=1920x1080` — target resolution (default). Use `1280x720` for lower CPU usage.
|
- `NORMALIZE_TARGET_RES=1920x1080` — target resolution (default). Use `1280x720` for lower CPU usage.
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import CHANNELS_FILE, DATA_ROOT, MOVIES_ROOT, SERIES_ROOT
|
from .config import CHANNELS_FILE, DATA_ROOT, MOVIES_ROOT, SERIES_ROOT
|
||||||
|
|
||||||
CACHE_DIR = DATA_ROOT / "cache"
|
CACHE_DIR = DATA_ROOT / "cache"
|
||||||
|
# When hash file is missing but cache exists, bootstrap if cache is younger than this (seconds)
|
||||||
|
CACHE_BOOTSTRAP_MAX_AGE_SEC = 24 * 3600 # 24 hours (covers "restarted within a day")
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -115,6 +118,24 @@ class CacheManager:
|
|||||||
if stored != current_hash:
|
if stored != current_hash:
|
||||||
self._write_hash(current_hash)
|
self._write_hash(current_hash)
|
||||||
|
|
||||||
|
def _bootstrap_hash_if_recent(self, cache_path: Path) -> bool:
|
||||||
|
"""If hash is missing but cache file exists and is recent, write hash. Returns True if bootstrapped."""
|
||||||
|
if self._get_stored_hash() is not None:
|
||||||
|
return False
|
||||||
|
if not cache_path.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
age = time.time() - cache_path.stat().st_mtime
|
||||||
|
if age <= CACHE_BOOTSTRAP_MAX_AGE_SEC:
|
||||||
|
current = get_channels_hash(self._channels_file)
|
||||||
|
if current is not None and self._ensure_cache_dir():
|
||||||
|
self._write_hash(current)
|
||||||
|
logger.info("Cache hash bootstrapped from %s (age %.0fm)", cache_path.name, age / 60)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
def get_epg(self) -> str | None:
|
def get_epg(self) -> str | None:
|
||||||
"""Return cached EPG XML if valid, else None."""
|
"""Return cached EPG XML if valid, else None."""
|
||||||
self.invalidate_if_needed()
|
self.invalidate_if_needed()
|
||||||
@@ -127,6 +148,9 @@ class CacheManager:
|
|||||||
logger.debug("EPG cache miss: %s does not exist", epg_path)
|
logger.debug("EPG cache miss: %s does not exist", epg_path)
|
||||||
return None
|
return None
|
||||||
stored = self._get_stored_hash()
|
stored = self._get_stored_hash()
|
||||||
|
if stored is None:
|
||||||
|
self._bootstrap_hash_if_recent(epg_path)
|
||||||
|
stored = self._get_stored_hash()
|
||||||
if stored is None:
|
if stored is None:
|
||||||
logger.debug("EPG cache miss: %s does not exist (hash required)", self._hash_file())
|
logger.debug("EPG cache miss: %s does not exist (hash required)", self._hash_file())
|
||||||
return None
|
return None
|
||||||
@@ -204,6 +228,9 @@ class CacheManager:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
stored = self._get_stored_hash()
|
stored = self._get_stored_hash()
|
||||||
|
if stored is None:
|
||||||
|
self._bootstrap_hash_if_recent(path)
|
||||||
|
stored = self._get_stored_hash()
|
||||||
if stored != current:
|
if stored != current:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ with randomized order so the same show never appears twice in a row.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
from iptv.config import (
|
from iptv.config import (
|
||||||
CHANNELS_FILE,
|
CHANNELS_FILE,
|
||||||
@@ -58,9 +59,31 @@ def main():
|
|||||||
"Ensure /data is mounted read-write (not :ro).",
|
"Ensure /data is mounted read-write (not :ro).",
|
||||||
cache_manager._cache_dir,
|
cache_manager._cache_dir,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Bootstrap hash from existing cache if hash file was lost (e.g. restart)
|
||||||
|
epg_path = cache_manager._epg_cache_file()
|
||||||
|
if not cache_manager._bootstrap_hash_if_recent(epg_path):
|
||||||
|
for ch in channel_repo.get_all():
|
||||||
|
cid = ch.get("id", ch.get("name", ""))
|
||||||
|
sched_path = cache_manager._schedule_cache_path(cid)
|
||||||
|
if cache_manager._bootstrap_hash_if_recent(sched_path):
|
||||||
|
break
|
||||||
tmdb_client = TMDBClient()
|
tmdb_client = TMDBClient()
|
||||||
prefetcher = MetadataPrefetcher(schedule_builder, tmdb_client, channel_repo)
|
prefetcher = MetadataPrefetcher(schedule_builder, tmdb_client, channel_repo)
|
||||||
prefetcher.start()
|
prefetcher.start()
|
||||||
|
|
||||||
|
def _warmup_schedules():
|
||||||
|
"""Preload schedules so first stream starts quickly."""
|
||||||
|
for ch in channel_repo.get_all():
|
||||||
|
cid = ch.get("id", ch.get("name", ""))
|
||||||
|
try:
|
||||||
|
schedule_builder.get_or_build(cid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Schedule warmup %s: %s", cid, e)
|
||||||
|
|
||||||
|
threading.Thread(target=_warmup_schedules, daemon=True).start()
|
||||||
|
logger.info("Schedule warmup started in background")
|
||||||
|
|
||||||
epg_builder = EPGBuilder(
|
epg_builder = EPGBuilder(
|
||||||
schedule_builder, tmdb_client, channel_repo, cache_manager, prefetcher
|
schedule_builder, tmdb_client, channel_repo, cache_manager, prefetcher
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user