2025-12-23 16:51:49 +01:00
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# streaming_providers/base/epg_operations.py
|
|
|
|
|
|
"""
|
|
|
|
|
|
EPG-related operations.
|
|
|
|
|
|
"""
|
2026-01-06 16:41:03 +01:00
|
|
|
|
|
|
|
|
|
|
from typing import Dict, List, Optional
|
2026-06-30 12:03:17 +02:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-01-06 16:41:03 +01:00
|
|
|
|
|
2025-12-23 16:51:49 +01:00
|
|
|
|
from .epg import EPGManager
|
|
|
|
|
|
from .utils.logger import logger
|
2026-06-18 21:09:18 +02:00
|
|
|
|
from .models.epg_models import EPGEntry, EPGProgramDetails
|
2025-12-23 16:51:49 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 12:03:17 +02:00
|
|
|
|
class EPGWindowError(Exception):
|
|
|
|
|
|
"""Raised when EPG request is outside the valid time window."""
|
|
|
|
|
|
def __init__(self, message, requested_start=None, requested_end=None,
|
|
|
|
|
|
min_allowed=None, max_allowed=None, provider_window_days=None):
|
|
|
|
|
|
self.message = message
|
|
|
|
|
|
self.requested_start = requested_start
|
|
|
|
|
|
self.requested_end = requested_end
|
|
|
|
|
|
self.min_allowed = min_allowed
|
|
|
|
|
|
self.max_allowed = max_allowed
|
|
|
|
|
|
self.provider_window_days = provider_window_days
|
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-23 16:51:49 +01:00
|
|
|
|
class EPGOperations:
|
2026-04-13 12:05:26 +02:00
|
|
|
|
"""Handles all EPG-related operations.
|
|
|
|
|
|
|
|
|
|
|
|
Design note: instantiate this class ONCE (e.g. at application / route
|
|
|
|
|
|
setup time) and reuse the same instance for every request. Creating a
|
|
|
|
|
|
new instance per-request causes EPGManager, EPGCache, EPGMapping and
|
|
|
|
|
|
their VFS helpers to be re-constructed (and re-logged) on every call.
|
|
|
|
|
|
"""
|
2025-12-23 16:51:49 +01:00
|
|
|
|
|
|
|
|
|
|
def __init__(self, registry):
|
|
|
|
|
|
self.registry = registry
|
|
|
|
|
|
self.epg_manager = EPGManager()
|
|
|
|
|
|
logger.debug("EPGOperations: Initialized")
|
|
|
|
|
|
|
2026-06-15 22:28:42 +02:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Internal helpers
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _get_provider(self, provider_name: str):
|
|
|
|
|
|
"""Return the provider instance or raise ValueError."""
|
2025-12-23 16:51:49 +01:00
|
|
|
|
provider = self.registry.get_provider(provider_name)
|
|
|
|
|
|
if not provider:
|
|
|
|
|
|
raise ValueError(f"Provider '{provider_name}' not found or disabled")
|
2026-06-15 22:28:42 +02:00
|
|
|
|
return provider
|
|
|
|
|
|
|
2026-06-30 12:03:17 +02:00
|
|
|
|
def _validate_epg_window(self, provider, start_time, end_time):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Validate EPG time window against provider's supported window.
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
EPGWindowError: If the requested window is outside the valid range.
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Tuple of (start_time, end_time) validated and clamped to provider's window.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not start_time and not end_time:
|
|
|
|
|
|
# If no times provided, use provider's default (usually today)
|
|
|
|
|
|
return start_time, end_time
|
|
|
|
|
|
|
|
|
|
|
|
# Get provider's EPG window (days back, days forward)
|
|
|
|
|
|
epg_window = getattr(provider, 'epg_window', (7, 7))
|
|
|
|
|
|
days_back, days_forward = epg_window
|
|
|
|
|
|
|
|
|
|
|
|
now_utc = datetime.now(tz=timezone.utc)
|
|
|
|
|
|
min_allowed = now_utc - timedelta(days=days_back)
|
|
|
|
|
|
max_allowed = now_utc + timedelta(days=days_forward)
|
|
|
|
|
|
|
|
|
|
|
|
# Ensure times are timezone-aware
|
|
|
|
|
|
if start_time and start_time.tzinfo is None:
|
|
|
|
|
|
start_time = start_time.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
if end_time and end_time.tzinfo is None:
|
|
|
|
|
|
end_time = end_time.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
# Check if the requested window is completely outside the valid range
|
|
|
|
|
|
if start_time and end_time:
|
|
|
|
|
|
# If the entire window is outside the valid range
|
|
|
|
|
|
if end_time < min_allowed or start_time > max_allowed:
|
|
|
|
|
|
raise EPGWindowError(
|
|
|
|
|
|
message="Requested EPG time window is outside the valid range",
|
|
|
|
|
|
requested_start=start_time.isoformat(),
|
|
|
|
|
|
requested_end=end_time.isoformat(),
|
|
|
|
|
|
min_allowed=min_allowed.isoformat(),
|
|
|
|
|
|
max_allowed=max_allowed.isoformat(),
|
|
|
|
|
|
provider_window_days=days_back
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Clamp individual times if they're partially outside
|
|
|
|
|
|
if start_time:
|
|
|
|
|
|
if start_time < min_allowed:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"EPG start_time {start_time} is before the minimum allowed "
|
|
|
|
|
|
f"({min_allowed}). Clamping to {min_allowed}"
|
|
|
|
|
|
)
|
|
|
|
|
|
start_time = min_allowed
|
|
|
|
|
|
elif start_time > max_allowed:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"EPG start_time {start_time} is after the maximum allowed "
|
|
|
|
|
|
f"({max_allowed}). Clamping to {max_allowed}"
|
|
|
|
|
|
)
|
|
|
|
|
|
start_time = max_allowed
|
|
|
|
|
|
|
|
|
|
|
|
if end_time:
|
|
|
|
|
|
if end_time < min_allowed:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"EPG end_time {end_time} is before the minimum allowed "
|
|
|
|
|
|
f"({min_allowed}). Clamping to {min_allowed}"
|
|
|
|
|
|
)
|
|
|
|
|
|
end_time = min_allowed
|
|
|
|
|
|
elif end_time > max_allowed:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"EPG end_time {end_time} is after the maximum allowed "
|
|
|
|
|
|
f"({max_allowed}). Clamping to {max_allowed}"
|
|
|
|
|
|
)
|
|
|
|
|
|
end_time = max_allowed
|
|
|
|
|
|
|
|
|
|
|
|
# Ensure start_time <= end_time
|
|
|
|
|
|
if start_time and end_time and start_time > end_time:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"EPG start_time {start_time} is after end_time {end_time}. Swapping."
|
|
|
|
|
|
)
|
|
|
|
|
|
start_time, end_time = end_time, start_time
|
|
|
|
|
|
|
|
|
|
|
|
return start_time, end_time
|
|
|
|
|
|
|
2026-06-15 22:28:42 +02:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Channel EPG
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def get_channel_epg(
|
2026-06-30 12:03:17 +02:00
|
|
|
|
self,
|
|
|
|
|
|
provider_name: str,
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
start_time=None,
|
|
|
|
|
|
end_time=None,
|
|
|
|
|
|
limit: int = 100,
|
|
|
|
|
|
country: Optional[str] = None,
|
2026-06-17 16:25:07 +02:00
|
|
|
|
) -> List[EPGEntry]:
|
2026-06-30 12:03:17 +02:00
|
|
|
|
"""Get EPG data for a specific channel."""
|
2026-06-15 22:28:42 +02:00
|
|
|
|
provider = self._get_provider(provider_name)
|
2025-12-23 16:51:49 +01:00
|
|
|
|
|
|
|
|
|
|
if provider.implements_epg:
|
2026-06-30 12:03:17 +02:00
|
|
|
|
# Native providers hit a real upstream API per request — clamp/reject
|
|
|
|
|
|
# out-of-window requests so we don't overwhelm it. Generic/XMLTV-backed
|
|
|
|
|
|
# providers (epg_window == (0, 0)) read from a locally cached feed and
|
|
|
|
|
|
# don't need this throttling.
|
|
|
|
|
|
try:
|
|
|
|
|
|
start_time, end_time = self._validate_epg_window(provider, start_time, end_time)
|
|
|
|
|
|
except EPGWindowError as e:
|
|
|
|
|
|
logger.warning(f"EPG window validation failed for {provider_name}: {e.message}")
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
2025-12-23 16:51:49 +01:00
|
|
|
|
logger.debug(f"Using native EPG for '{provider_name}'")
|
2026-06-15 22:28:42 +02:00
|
|
|
|
epg_data = provider.get_epg(
|
|
|
|
|
|
channel_id,
|
|
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
end_time=end_time,
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
country=country,
|
|
|
|
|
|
)
|
2025-12-23 16:51:49 +01:00
|
|
|
|
else:
|
|
|
|
|
|
logger.debug(f"Using generic EPG for '{provider_name}'")
|
|
|
|
|
|
epg_data = self.epg_manager.get_epg(
|
|
|
|
|
|
provider_name=provider_name,
|
|
|
|
|
|
channel_id=channel_id,
|
2026-06-15 22:28:42 +02:00
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
end_time=end_time,
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
country=country,
|
2025-12-23 16:51:49 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-13 12:05:26 +02:00
|
|
|
|
if epg_data is None:
|
|
|
|
|
|
logger.warning(
|
2026-06-15 22:28:42 +02:00
|
|
|
|
f"get_epg() returned None for channel '{channel_id}' "
|
2026-04-13 12:05:26 +02:00
|
|
|
|
f"on provider '{provider_name}' — treating as empty result"
|
|
|
|
|
|
)
|
|
|
|
|
|
epg_data = []
|
|
|
|
|
|
|
2025-12-23 16:51:49 +01:00
|
|
|
|
logger.debug(f"Retrieved {len(epg_data)} EPG entries for '{channel_id}'")
|
|
|
|
|
|
return epg_data
|
|
|
|
|
|
|
2026-06-15 22:28:42 +02:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Multi-channel grid
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def get_provider_epg_grid(
|
2026-06-30 12:03:17 +02:00
|
|
|
|
self,
|
|
|
|
|
|
provider_name: str,
|
|
|
|
|
|
start_time=None,
|
|
|
|
|
|
end_time=None,
|
|
|
|
|
|
channel_ids: Optional[List[str]] = None,
|
|
|
|
|
|
country: Optional[str] = None,
|
2026-06-17 16:25:07 +02:00
|
|
|
|
) -> Dict[str, List[EPGEntry]]:
|
2026-06-30 12:03:17 +02:00
|
|
|
|
"""Get a time-windowed EPG grid across multiple channels."""
|
2026-06-15 22:28:42 +02:00
|
|
|
|
provider = self._get_provider(provider_name)
|
2025-12-23 16:51:49 +01:00
|
|
|
|
|
|
|
|
|
|
if provider.implements_epg:
|
2026-06-30 12:03:17 +02:00
|
|
|
|
try:
|
|
|
|
|
|
start_time, end_time = self._validate_epg_window(provider, start_time, end_time)
|
|
|
|
|
|
except EPGWindowError as e:
|
|
|
|
|
|
logger.warning(f"EPG window validation failed for {provider_name}: {e.message}")
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
2026-06-15 22:28:42 +02:00
|
|
|
|
logger.debug(f"Using native EPG grid for '{provider_name}'")
|
|
|
|
|
|
return provider.get_epg_grid(
|
|
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
end_time=end_time,
|
|
|
|
|
|
channel_ids=channel_ids,
|
|
|
|
|
|
country=country,
|
|
|
|
|
|
) or {}
|
|
|
|
|
|
|
2026-06-30 12:03:17 +02:00
|
|
|
|
# Fallback: generic EPG manager (XMLTV-backed, no native window throttling needed)
|
2026-06-15 22:28:42 +02:00
|
|
|
|
logger.debug(f"Using generic EPG grid fallback for '{provider_name}'")
|
|
|
|
|
|
channels = channel_ids or self.epg_manager.get_channel_ids(provider_name)
|
|
|
|
|
|
grid: Dict[str, List[Dict]] = {}
|
|
|
|
|
|
for cid in channels:
|
|
|
|
|
|
entries = self.epg_manager.get_epg(
|
|
|
|
|
|
provider_name=provider_name,
|
|
|
|
|
|
channel_id=cid,
|
|
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
end_time=end_time,
|
|
|
|
|
|
) or []
|
|
|
|
|
|
grid[cid] = entries
|
|
|
|
|
|
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
f"Grid for '{provider_name}': {len(grid)} channels, "
|
|
|
|
|
|
f"window {start_time} – {end_time}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return grid
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Program detail
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def get_program_details(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider_name: str,
|
|
|
|
|
|
program_id: str,
|
2026-06-18 21:09:18 +02:00
|
|
|
|
) -> Optional[EPGProgramDetails]:
|
2026-06-15 22:28:42 +02:00
|
|
|
|
"""Get full metadata for a single program.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_name: Registered provider identifier.
|
|
|
|
|
|
program_id: Provider-scoped program identifier.
|
|
|
|
|
|
"""
|
|
|
|
|
|
provider = self._get_provider(provider_name)
|
|
|
|
|
|
|
|
|
|
|
|
if provider.implements_epg:
|
|
|
|
|
|
logger.debug(f"Using native program detail for '{provider_name}/{program_id}'")
|
|
|
|
|
|
return provider.get_program_details(program_id)
|
|
|
|
|
|
|
|
|
|
|
|
logger.debug(f"Using generic program detail for '{provider_name}/{program_id}'")
|
2026-06-18 21:09:18 +02:00
|
|
|
|
# The generic EPGManager has no program-detail index; native providers
|
|
|
|
|
|
# must implement get_program_details() to support this endpoint.
|
|
|
|
|
|
return None
|
2026-06-15 22:28:42 +02:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# XMLTV export
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def get_provider_epg_xmltv(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider_name: str,
|
|
|
|
|
|
country: Optional[str] = None,
|
|
|
|
|
|
) -> Optional[str]:
|
|
|
|
|
|
"""Get the complete EPG feed in XMLTV format.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_name: Registered provider identifier.
|
|
|
|
|
|
country: Optional country filter forwarded to the provider.
|
|
|
|
|
|
"""
|
|
|
|
|
|
provider = self._get_provider(provider_name)
|
|
|
|
|
|
|
|
|
|
|
|
if provider.implements_epg:
|
|
|
|
|
|
return provider.get_epg_xmltv(country=country)
|
2025-12-23 16:51:49 +01:00
|
|
|
|
|
|
|
|
|
|
logger.warning(f"Provider '{provider_name}' has no XMLTV EPG")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-06-15 22:28:42 +02:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Cache / mapping utilities
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
2025-12-23 16:51:49 +01:00
|
|
|
|
def clear_epg_cache(self) -> bool:
|
|
|
|
|
|
"""Clear the generic EPG cache."""
|
|
|
|
|
|
return self.epg_manager.clear_cache()
|
|
|
|
|
|
|
|
|
|
|
|
def reload_epg_mapping(self) -> bool:
|
|
|
|
|
|
"""Reload EPG channel mapping."""
|
|
|
|
|
|
return self.epg_manager.reload_mapping()
|
|
|
|
|
|
|
|
|
|
|
|
def get_epg_cache_info(self) -> Optional[Dict]:
|
|
|
|
|
|
"""Get EPG cache information."""
|
|
|
|
|
|
return self.epg_manager.get_cache_info()
|
|
|
|
|
|
|
|
|
|
|
|
def get_epg_mapping_stats(self) -> Dict:
|
|
|
|
|
|
"""Get EPG mapping statistics."""
|
|
|
|
|
|
return self.epg_manager.get_mapping_stats()
|
|
|
|
|
|
|
|
|
|
|
|
def has_epg_mapping(self, provider_name: str, channel_id: str) -> bool:
|
2026-06-15 22:28:42 +02:00
|
|
|
|
"""Check if an EPG mapping exists for the given channel."""
|
2026-04-13 12:05:26 +02:00
|
|
|
|
return self.epg_manager.has_mapping_for_channel(provider_name, channel_id)
|