Files
2026-09-15 13:53:58 +02:00

276 lines
9.6 KiB
Python

# streaming_providers/base/utils/mpd_cache.py
import time
from typing import Optional
from .logger import logger
from .vfs import VFS
class MPDCacheManager:
"""
Manages caching of rewritten MPD manifests with TTL support
"""
def __init__(self):
"""Initialize MPD cache manager with VFS"""
self.vfs = VFS(addon_subdir="mpd_cache")
logger.debug(f"MPD cache initialized at: {self.vfs.base_path}")
@staticmethod
def _get_cache_key(provider: str, channel_id: str) -> str:
"""Generate cache key for provider/channel"""
return f"{provider}_{channel_id}"
@staticmethod
def _get_manifest_filename(cache_key: str) -> str:
"""Get filename for cached manifest"""
return f"{cache_key}.xml"
@staticmethod
def _get_meta_filename(cache_key: str) -> str:
"""Get filename for cache metadata"""
return f"{cache_key}.meta"
def get(self, provider: str, channel_id: str, max_stale: int = 0) -> Optional[str]:
"""
Get cached MPD manifest if valid.
Args:
provider: Provider name
channel_id: Channel ID
max_stale: If > 0, still return manifest content up to this many
seconds past expiry instead of None/deleting it. The
caller is expected to only pass this when a live
refetch has already failed — a stale manifest is a
fallback of last resort, not a normal cache hit.
Default 0 preserves existing behaviour everywhere
this is called without the new argument.
Returns:
Cached MPD content if valid (or within max_stale), None otherwise
"""
cache_key = self._get_cache_key(provider, channel_id)
meta_file = self._get_meta_filename(cache_key)
manifest_file = self._get_manifest_filename(cache_key)
try:
meta = self.vfs.read_json(meta_file)
if not meta:
logger.debug(f"No cache metadata found for {cache_key}")
return None
expiry = meta.get("expiry", 0)
now = int(time.time())
if now >= expiry:
staleness = now - expiry
if max_stale and staleness <= max_stale:
manifest_content = self.vfs.read_text(manifest_file)
if manifest_content:
logger.warning(
f"Serving STALE cache for {cache_key} "
f"({staleness}s past expiry, max_stale={max_stale}s)"
)
return manifest_content
# meta exists but file missing — nothing to serve stale
logger.debug(
f"Cache expired for {cache_key} (expired {staleness}s ago)"
)
# Deliberately NOT deleting the expired files here. This get()
# call may have been made with the default max_stale=0 (a
# normal cache lookup), but a *different* caller further down
# the request chain may retry with max_stale>0 as a
# last-resort fallback after a live refetch fails. If we
# delete on every plain expiry check, that fallback caller
# always finds nothing — the fallback only ever worked when
# it happened to be the first caller to see the expired
# entry. Expired entries are reaped on their own schedule by
# clear_expired() instead, so both call patterns keep working
# regardless of ordering.
return None
manifest_content = self.vfs.read_text(manifest_file)
if manifest_content:
logger.info(f"Cache hit for {cache_key} (expires in {expiry - now}s)")
return manifest_content
else:
logger.warning(
f"Cache metadata exists but manifest file missing for {cache_key}"
)
self.vfs.delete(meta_file)
return None
except Exception as e:
logger.error(f"Error reading cache for {cache_key}: {e}")
return None
def set(
self,
provider: str,
channel_id: str,
mpd_content: str,
ttl: int,
original_url: Optional[str] = None,
) -> bool:
"""
Store MPD manifest in cache with TTL
Args:
provider: Provider name
channel_id: Channel ID
mpd_content: Rewritten MPD content
ttl: Time to live in seconds
original_url: Original manifest URL (for debugging)
Returns:
True if successfully cached, False otherwise
"""
cache_key = self._get_cache_key(provider, channel_id)
meta_file = self._get_meta_filename(cache_key)
manifest_file = self._get_manifest_filename(cache_key)
try:
# Calculate expiry timestamp
expiry = int(time.time()) + ttl
# Create metadata
meta = {
"expiry": expiry,
"ttl": ttl,
"cached_at": int(time.time()),
"provider": provider,
"channel_id": channel_id,
}
if original_url:
meta["original_url"] = original_url
# Write manifest
if not self.vfs.write_text(manifest_file, mpd_content):
logger.error(f"Failed to write manifest cache for {cache_key}")
return False
# Write metadata
if not self.vfs.write_json(meta_file, meta):
logger.error(f"Failed to write metadata cache for {cache_key}")
# Clean up manifest if metadata write failed
self.vfs.delete(manifest_file)
return False
logger.info(
f"Cached MPD for {cache_key} with TTL={ttl}s (expires at {expiry})"
)
return True
except Exception as e:
logger.error(f"Error caching MPD for {cache_key}: {e}")
return False
def delete(self, provider: str, channel_id: str) -> bool:
"""
Delete cached MPD for a channel
Args:
provider: Provider name
channel_id: Channel ID
Returns:
True if deleted or didn't exist, False on error
"""
cache_key = self._get_cache_key(provider, channel_id)
meta_file = self._get_meta_filename(cache_key)
manifest_file = self._get_manifest_filename(cache_key)
try:
self.vfs.delete(manifest_file)
self.vfs.delete(meta_file)
logger.debug(f"Deleted cache for {cache_key}")
return True
except Exception as e:
logger.error(f"Error deleting cache for {cache_key}: {e}")
return False
def clear_all(self) -> bool:
"""
Clear all cached MPD files
Returns:
True if successful, False otherwise
"""
try:
files = self.vfs.listdir()
deleted = 0
for file in files:
if file.endswith(".xml") or file.endswith(".meta"):
if self.vfs.delete(file):
deleted += 1
logger.info(f"Cleared {deleted} cached MPD files")
return True
except Exception as e:
logger.error(f"Error clearing MPD cache: {e}")
return False
def clear_expired(self) -> int:
"""
Clear all expired cached MPD files
Returns:
Number of expired entries cleared
"""
try:
files = self.vfs.listdir()
now = int(time.time())
cleared = 0
# Find all meta files
meta_files = [f for f in files if f.endswith(".meta")]
for meta_file in meta_files:
try:
meta = self.vfs.read_json(meta_file)
if meta and meta.get("expiry", 0) < now:
# Expired, delete both meta and manifest
cache_key = meta_file.replace(".meta", "")
self.vfs.delete(f"{cache_key}.xml")
self.vfs.delete(meta_file)
cleared += 1
logger.debug(f"Cleared expired cache: {cache_key}")
except Exception as e:
logger.warning(f"Error checking {meta_file}: {e}")
if cleared > 0:
logger.info(f"Cleared {cleared} expired MPD cache entries")
return cleared
except Exception as e:
logger.error(f"Error clearing expired caches: {e}")
return 0
def get_cache_info(self, provider: str, channel_id: str) -> Optional[dict]:
"""
Get cache information without reading the full manifest
Args:
provider: Provider name
channel_id: Channel ID
Returns:
Cache metadata dict or None if not cached
"""
cache_key = self._get_cache_key(provider, channel_id)
meta_file = self._get_meta_filename(cache_key)
try:
meta = self.vfs.read_json(meta_file)
if meta:
now = int(time.time())
meta["expired"] = now >= meta.get("expiry", 0)
meta["remaining_ttl"] = max(0, meta.get("expiry", 0) - now)
return meta
except Exception as e:
logger.debug(f"Error getting cache info for {cache_key}: {e}")
return None