This commit is contained in:
@@ -1,27 +1,34 @@
|
|||||||
{
|
{
|
||||||
"channels": [
|
"channels": [
|
||||||
{
|
{
|
||||||
"id": "comedy",
|
"id": "nostalgia",
|
||||||
"name": "Comedy",
|
"name": "Nostalgie TV",
|
||||||
"paths": [
|
"paths": [
|
||||||
"movies",
|
"series/Mega Mindy",
|
||||||
"series/The Office",
|
"series/W817",
|
||||||
{
|
"series/En Daarmee Basta",
|
||||||
"path": "series/W817",
|
|
||||||
"display_name": "Phineas en Ferb",
|
|
||||||
"tmdb_search": "Phineas and Ferb"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "series/kika-bob",
|
"path": "series/kika-bob",
|
||||||
"display_name": "Kika & Bob",
|
"display_name": "Kika & Bob",
|
||||||
"tmdb_id": "97596-kika-bob"
|
"tmdb_id": "97596-kika-bob"
|
||||||
}
|
},
|
||||||
]
|
"series/Kim Possible",
|
||||||
|
{
|
||||||
|
"path": "series/Beugelbekkie",
|
||||||
|
"display_name": "Beugelbekkie",
|
||||||
|
"tmdb_id": "1763-braceface"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "drama",
|
"path": "series/Johnny Test",
|
||||||
"name": "Drama",
|
"display_name": "Johnny Test",
|
||||||
"paths": ["movies", "series/Breaking Bad", "series/Better Call Saul"]
|
"tmdb_id": "1769-johnny-test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "series/Jimmy Neutron",
|
||||||
|
"display_name": "Jimmy Neutron",
|
||||||
|
"tmdb_id": "2129-the-adventures-of-jimmy-neutron-boy-genius"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
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"
|
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)
|
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"
|
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:
|
def get_playlist(self, channel_id: str) -> list[tuple[str, str]] | None:
|
||||||
"""
|
"""
|
||||||
Return cached playlist data as [(display_name, path_str), ...] if valid.
|
Return cached playlist data as [(display_name, path_str), ...] if valid.
|
||||||
@@ -178,3 +182,81 @@ class CacheManager:
|
|||||||
self._ensure_hash_stored(current)
|
self._ensure_hash_stored(current)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("Could not write playlist cache for %s: %s", channel_id, 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)
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ class ScheduleBuilder:
|
|||||||
self._cache: dict[str, tuple[list[Path], list[float], list[dict], float]] = {}
|
self._cache: dict[str, tuple[list[Path], list[float], list[dict], float]] = {}
|
||||||
self._ttl_sec = SCHEDULE_CACHE_TTL_SEC
|
self._ttl_sec = SCHEDULE_CACHE_TTL_SEC
|
||||||
self._channel_repo = None # injected
|
self._channel_repo = None # injected
|
||||||
|
self._cache_manager = None # injected for disk persistence
|
||||||
|
|
||||||
def invalidate_cache(self) -> None:
|
def invalidate_cache(self) -> None:
|
||||||
"""Clear in-memory schedule cache (e.g. when channels.json changes)."""
|
"""Clear in-memory schedule cache (e.g. when channels.json changes)."""
|
||||||
@@ -142,11 +143,15 @@ class ScheduleBuilder:
|
|||||||
"""Set the channel repository for schedule building."""
|
"""Set the channel repository for schedule building."""
|
||||||
self._channel_repo = repo
|
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(
|
def get_or_build(
|
||||||
self,
|
self,
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
) -> tuple[list[Path], list[float], list[dict]] | None:
|
) -> 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()
|
now = time.time()
|
||||||
if channel_id in self._cache:
|
if channel_id in self._cache:
|
||||||
paths, durations, path_configs, ts = self._cache[channel_id]
|
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))
|
logger.debug("schedule cache hit for channel %s (%s videos)", channel_id, len(paths))
|
||||||
return paths, durations, path_configs
|
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)
|
logger.info("Building schedule for channel %s ...", channel_id)
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
if not self._channel_repo:
|
if not self._channel_repo:
|
||||||
@@ -167,5 +180,7 @@ class ScheduleBuilder:
|
|||||||
durations = [get_duration(p) for p in paths]
|
durations = [get_duration(p) for p in paths]
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
self._cache[channel_id] = (paths, durations, path_configs, now)
|
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))
|
logger.info("Schedule for channel %s built in %.1fs (%s videos)", channel_id, elapsed, len(paths))
|
||||||
return paths, durations, path_configs
|
return paths, durations, path_configs
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ def main():
|
|||||||
schedule_builder.set_channel_repo(channel_repo)
|
schedule_builder.set_channel_repo(channel_repo)
|
||||||
tmdb_client = TMDBClient()
|
tmdb_client = TMDBClient()
|
||||||
cache_manager = CacheManager(schedule_builder=schedule_builder)
|
cache_manager = CacheManager(schedule_builder=schedule_builder)
|
||||||
|
schedule_builder.set_cache_manager(cache_manager)
|
||||||
prefetcher = MetadataPrefetcher(schedule_builder, tmdb_client, channel_repo)
|
prefetcher = MetadataPrefetcher(schedule_builder, tmdb_client, channel_repo)
|
||||||
prefetcher.start()
|
prefetcher.start()
|
||||||
epg_builder = EPGBuilder(
|
epg_builder = EPGBuilder(
|
||||||
|
|||||||
Reference in New Issue
Block a user