mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-27 03:22:48 +02:00
520 lines
19 KiB
Python
520 lines
19 KiB
Python
# ============================================================================
|
|
# EPG MANAGER - Refactored to work with EPGEntry objects internally
|
|
# ============================================================================
|
|
|
|
# !/usr/bin/env python3
|
|
# streaming_providers/base/epg/epg_manager.py
|
|
"""
|
|
EPG Manager - Central coordinator for EPG operations
|
|
Orchestrates cache, mapping, and parsing components
|
|
|
|
Refactored to use EPGEntry objects internally while maintaining
|
|
backward-compatible dictionary output for external consumers.
|
|
|
|
PROVIDER ENCODING IN BROADCAST IDs:
|
|
------------------------------------
|
|
The EPGManager now encodes provider information into broadcast IDs,
|
|
enabling provider identification during catchup operations:
|
|
|
|
Example workflow:
|
|
# 1. EPG is fetched and parsed (provider is known)
|
|
manager = EPGManager()
|
|
epg_data = manager.get_epg("rtlplus", "rtl")
|
|
# broadcast_id now contains encoded "rtlplus" provider hash
|
|
|
|
# 2. Later, in catchup handler (only broadcast_id available)
|
|
def get_catchup_stream(broadcast_id):
|
|
# Identify provider from broadcast_id
|
|
provider = manager.get_provider_from_broadcast_id(broadcast_id)
|
|
# -> "rtlplus"
|
|
|
|
# Now we can call the right provider's catchup API
|
|
return provider_registry[provider].get_catchup(broadcast_id)
|
|
|
|
This solves the problem of needing provider information for catchup
|
|
when only the broadcast_id is passed from Kodi.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Union
|
|
|
|
from ..models.epg_models import EPGEntry
|
|
from ..utils.environment import get_environment_manager, is_kodi_environment
|
|
from ..utils.logger import logger
|
|
from .epg_cache import EPGCache
|
|
from .epg_mapping import EPGMapping
|
|
from .epg_parser import EPGParser
|
|
|
|
|
|
class EPGManager:
|
|
"""
|
|
Central manager for EPG operations.
|
|
Coordinates cache management, channel mapping, and EPG parsing.
|
|
|
|
Internal: Works with EPGEntry objects for type safety and validation
|
|
External: Returns dictionaries for backward compatibility with C++ frontend
|
|
|
|
All file operations are handled transparently by VFS - works in both
|
|
Kodi and standard Python environments.
|
|
"""
|
|
|
|
# Default EPG source URL (can be overridden by addon setting)
|
|
DEFAULT_EPG_URL = "https://example.com/epg.xml.gz"
|
|
|
|
def __init__(self, epg_url: Optional[str] = None):
|
|
"""
|
|
Initialize EPG manager with all components.
|
|
|
|
Args:
|
|
epg_url: EPG source URL (if None, will be determined automatically)
|
|
"""
|
|
# Initialize components - they handle paths internally
|
|
self.cache = EPGCache(vfs_subdir="epg_cache")
|
|
self.mapping = EPGMapping() # No paths needed - handles internally
|
|
self.parser = EPGParser()
|
|
|
|
# Determine EPG URL with proper precedence
|
|
self.epg_url = self._determine_epg_url(epg_url)
|
|
|
|
logger.info(f"EPGManager: Initialized with EPG URL: {self.epg_url}")
|
|
|
|
@staticmethod
|
|
def _get_epg_url_from_settings() -> Optional[str]:
|
|
"""
|
|
Get EPG URL from addon settings using SettingsManager.
|
|
|
|
Returns:
|
|
URL from settings, or None if not set
|
|
"""
|
|
try:
|
|
from ..settings.kodi_settings_bridge import KodiSettingsBridge
|
|
|
|
bridge = KodiSettingsBridge()
|
|
if bridge.is_kodi_environment():
|
|
url = bridge.addon.getSetting("epg_xml_url")
|
|
|
|
if url and url.strip():
|
|
logger.info(f"EPGManager: Using EPG URL from settings: {url}")
|
|
return url.strip()
|
|
|
|
except Exception as e:
|
|
logger.debug(f"EPGManager: Could not read EPG URL setting: {e}")
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _get_default_time_range() -> tuple:
|
|
"""
|
|
Get default time range for EPG queries (now to +12 hours).
|
|
|
|
Returns:
|
|
Tuple of (start_time, end_time) as Unix timestamps
|
|
"""
|
|
now = datetime.now()
|
|
start = int(now.timestamp())
|
|
end = int((now + timedelta(hours=12)).timestamp())
|
|
|
|
logger.debug(f"EPGManager: Using default time range: {start} to {end}")
|
|
return start, end
|
|
|
|
@staticmethod
|
|
def _to_unix_ts(value: Optional[Union[datetime, int, float]]) -> Optional[int]:
|
|
"""
|
|
Normalise a time value to a Unix timestamp (int seconds).
|
|
|
|
Accepts:
|
|
- None → None
|
|
- int / float → treated directly as Unix seconds (already a timestamp)
|
|
- datetime → converted via .timestamp()
|
|
|
|
This prevents the 'int object has no attribute timestamp' crash that
|
|
occurs when callers pass a raw Unix timestamp instead of a datetime.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (int, float)):
|
|
return int(value)
|
|
return int(value.timestamp())
|
|
|
|
def get_epg(
|
|
self,
|
|
provider_name: str,
|
|
channel_id: str,
|
|
start_time: Optional[Union[datetime, int, float]] = None,
|
|
end_time: Optional[Union[datetime, int, float]] = None,
|
|
limit: Optional[int] = None,
|
|
country: Optional[str] = None,
|
|
) -> List[Dict]:
|
|
"""
|
|
Get EPG data for a specific channel within a time range.
|
|
|
|
This is the main entry point for EPG queries.
|
|
|
|
EXTERNAL INTERFACE: Returns list of dictionaries for C++ compatibility.
|
|
|
|
Args:
|
|
provider_name: Name of provider (e.g., "rtlplus", "joyn_de")
|
|
channel_id: Channel ID within provider
|
|
start_time: Start of time range (datetime), None for now
|
|
end_time: End of time range (datetime), None for now+12h
|
|
limit: Maximum number of entries to return (None = no limit)
|
|
country: Optional country filter (forwarded to mapping lookup)
|
|
|
|
Returns:
|
|
List of EPG entries as dictionaries (empty on any error)
|
|
"""
|
|
logger.info(f"EPGManager: Getting EPG for {provider_name}/{channel_id}")
|
|
|
|
try:
|
|
# Step 1: Map to EPG channel ID
|
|
epg_channel_id = self.mapping.get_epg_channel_id(
|
|
provider_name, channel_id, country=country
|
|
)
|
|
if not epg_channel_id:
|
|
logger.warning(f"EPGManager: No EPG mapping found for {provider_name}/{channel_id}")
|
|
return []
|
|
|
|
logger.debug(f"EPGManager: Mapped to EPG channel ID: {epg_channel_id}")
|
|
|
|
# Step 2: Get or download EPG XML
|
|
xml_path = self.cache.get_or_download(self.epg_url)
|
|
if not xml_path:
|
|
logger.error("EPGManager: Failed to get EPG XML file")
|
|
return []
|
|
|
|
# Step 3: Convert start_time / end_time to Unix timestamps.
|
|
# _to_unix_ts() handles datetime objects, raw ints, and floats
|
|
# uniformly so callers can pass either form without crashing.
|
|
start_ts = self._to_unix_ts(start_time)
|
|
end_ts = self._to_unix_ts(end_time)
|
|
if start_ts is None or end_ts is None:
|
|
default_start, default_end = self._get_default_time_range()
|
|
start_ts = start_ts if start_ts is not None else default_start
|
|
end_ts = end_ts if end_ts is not None else default_end
|
|
|
|
logger.debug(f"EPGManager: Time range: {start_ts} to {end_ts}")
|
|
|
|
# Step 4: Parse EPG for channel and time range (returns EPGEntry objects)
|
|
# Pass provider_name to enable provider encoding in broadcast_id
|
|
epg_entries: List[EPGEntry] = self.parser.parse_epg_for_channel(
|
|
xml_path,
|
|
epg_channel_id,
|
|
start_ts,
|
|
end_ts,
|
|
provider_name, # Enable provider encoding
|
|
)
|
|
|
|
# Step 5: Apply limit if requested
|
|
if limit is not None and len(epg_entries) > limit:
|
|
epg_entries = epg_entries[:limit]
|
|
|
|
logger.info(
|
|
f"EPGManager: Retrieved {len(epg_entries)} EPG entries for "
|
|
f"{provider_name}/{channel_id}"
|
|
)
|
|
|
|
# Step 6: Convert EPGEntry objects to dictionaries for external consumers
|
|
return [entry.to_dict() for entry in epg_entries]
|
|
|
|
except Exception as e:
|
|
logger.error(f"EPGManager: Error getting EPG: {e}", exc_info=True)
|
|
return []
|
|
|
|
def get_epg_entries(
|
|
self,
|
|
provider_name: str,
|
|
channel_id: str,
|
|
start_time: Optional[Union[datetime, int, float]] = None,
|
|
end_time: Optional[Union[datetime, int, float]] = None,
|
|
) -> List[EPGEntry]:
|
|
"""
|
|
Get EPG data as EPGEntry objects (for internal use).
|
|
|
|
INTERNAL INTERFACE: Returns EPGEntry objects for type safety.
|
|
|
|
Args:
|
|
provider_name: Name of provider
|
|
channel_id: Channel ID within provider
|
|
start_time: Start of time range (datetime), None for now
|
|
end_time: End of time range (datetime), None for now+12h
|
|
|
|
Returns:
|
|
List of EPGEntry objects (empty on any error)
|
|
"""
|
|
logger.info(f"EPGManager: Getting EPG entries for {provider_name}/{channel_id}")
|
|
|
|
try:
|
|
epg_channel_id = self.mapping.get_epg_channel_id(provider_name, channel_id)
|
|
if not epg_channel_id:
|
|
logger.warning(f"EPGManager: No EPG mapping found for {provider_name}/{channel_id}")
|
|
return []
|
|
|
|
xml_path = self.cache.get_or_download(self.epg_url)
|
|
if not xml_path:
|
|
logger.error("EPGManager: Failed to get EPG XML file")
|
|
return []
|
|
|
|
start_ts = self._to_unix_ts(start_time)
|
|
end_ts = self._to_unix_ts(end_time)
|
|
if start_ts is None or end_ts is None:
|
|
default_start, default_end = self._get_default_time_range()
|
|
start_ts = start_ts if start_ts is not None else default_start
|
|
end_ts = end_ts if end_ts is not None else default_end
|
|
|
|
return self.parser.parse_epg_for_channel(
|
|
xml_path,
|
|
epg_channel_id,
|
|
start_ts,
|
|
end_ts,
|
|
provider_name, # Enable provider encoding
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"EPGManager: Error getting EPG entries: {e}", exc_info=True)
|
|
return []
|
|
|
|
def get_channel_ids(self, provider_name: str) -> List[str]:
|
|
"""
|
|
Return all channel IDs that have an EPG mapping for *provider_name*.
|
|
|
|
Used by EPGOperations.get_provider_epg_grid() as the fallback channel
|
|
list when the caller does not supply an explicit channel_ids filter.
|
|
|
|
Args:
|
|
provider_name: Registered provider identifier.
|
|
|
|
Returns:
|
|
List of channel IDs (may be empty if no mapping exists).
|
|
"""
|
|
provider_mapping = self.mapping.get_provider_mapping(provider_name)
|
|
if not provider_mapping:
|
|
logger.warning(f"EPGManager: No channels mapped for provider '{provider_name}'")
|
|
return []
|
|
return list(provider_mapping.keys())
|
|
|
|
def get_program_by_id(self, program_id: str) -> Optional[Dict]:
|
|
"""
|
|
Look up a single program by its broadcast/program ID.
|
|
|
|
NOTE: The generic EPG manager works on per-channel time-window scans
|
|
and does not maintain a global program index. This method is
|
|
therefore not supported on the generic path and always returns None.
|
|
Providers that implement native EPG (``provider.implements_epg``)
|
|
should override this via ``provider.get_program_details(program_id)``
|
|
in EPGOperations instead.
|
|
|
|
Args:
|
|
program_id: Provider-scoped program identifier.
|
|
|
|
Returns:
|
|
Always None for the generic path.
|
|
"""
|
|
logger.debug(
|
|
f"EPGManager.get_program_by_id: generic path does not support "
|
|
f"program ID lookup (id={program_id!r}); "
|
|
"implement get_program_details() on the provider for native support."
|
|
)
|
|
return None
|
|
|
|
def get_epg_for_provider(
|
|
self,
|
|
provider_name: str,
|
|
start_time: Optional[datetime] = None,
|
|
end_time: Optional[datetime] = None,
|
|
) -> Dict[str, List[Dict]]:
|
|
"""
|
|
Get EPG data for all channels of a provider.
|
|
|
|
EXTERNAL INTERFACE: Returns dictionaries for C++ compatibility.
|
|
|
|
Args:
|
|
provider_name: Name of provider
|
|
start_time: Start of time range (datetime)
|
|
end_time: End of time range (datetime)
|
|
|
|
Returns:
|
|
Dictionary mapping channel IDs to their EPG entries (as dicts)
|
|
"""
|
|
logger.info(f"EPGManager: Getting EPG for all channels of provider '{provider_name}'")
|
|
|
|
result = {}
|
|
|
|
# Get all mapped channels for this provider
|
|
provider_mapping = self.mapping.get_provider_mapping(provider_name)
|
|
|
|
if not provider_mapping:
|
|
logger.warning(f"EPGManager: No channels mapped for provider '{provider_name}'")
|
|
return result
|
|
|
|
# Get EPG for each channel
|
|
for channel_id in provider_mapping.keys():
|
|
epg_entries = self.get_epg(provider_name, channel_id, start_time, end_time)
|
|
if epg_entries:
|
|
result[channel_id] = epg_entries
|
|
|
|
logger.info(
|
|
f"EPGManager: Retrieved EPG for {len(result)} channels of provider '{provider_name}'"
|
|
)
|
|
|
|
return result
|
|
|
|
def clear_cache(self) -> bool:
|
|
"""
|
|
Clear EPG cache.
|
|
|
|
Returns:
|
|
True if cleared successfully
|
|
"""
|
|
logger.info("EPGManager: Clearing EPG cache")
|
|
return self.cache.clear_cache()
|
|
|
|
def reload_mapping(self) -> bool:
|
|
"""
|
|
Reload channel mapping from file.
|
|
|
|
Returns:
|
|
True if reloaded successfully
|
|
"""
|
|
logger.info("EPGManager: Reloading channel mapping")
|
|
return self.mapping.reload_mapping()
|
|
|
|
def get_cache_info(self) -> Optional[Dict]:
|
|
"""
|
|
Get information about EPG cache.
|
|
|
|
Returns:
|
|
Dictionary with cache info, or None if no cache
|
|
"""
|
|
return self.cache.get_cache_info()
|
|
|
|
def get_mapping_stats(self) -> Dict:
|
|
"""
|
|
Get statistics about channel mapping.
|
|
|
|
Returns:
|
|
Dictionary with mapping statistics
|
|
"""
|
|
return self.mapping.get_mapping_stats()
|
|
|
|
def has_mapping_for_channel(self, provider_name: str, channel_id: str) -> bool:
|
|
"""
|
|
Check if EPG mapping exists for a specific channel.
|
|
|
|
Args:
|
|
provider_name: Name of provider
|
|
channel_id: Channel ID
|
|
|
|
Returns:
|
|
True if mapping exists
|
|
"""
|
|
return self.mapping.has_mapping(provider_name, channel_id)
|
|
|
|
def get_provider_from_broadcast_id(self, broadcast_id: int) -> Optional[str]:
|
|
"""
|
|
Get provider name from an encoded broadcast ID.
|
|
Useful for catchup operations where only broadcast_id is available.
|
|
|
|
Args:
|
|
broadcast_id: Encoded broadcast ID from EPG entry
|
|
|
|
Returns:
|
|
Provider name, or None if not found
|
|
|
|
Example:
|
|
# In catchup handler
|
|
provider = manager.get_provider_from_broadcast_id(broadcast_id)
|
|
if provider:
|
|
# Get catchup stream from provider
|
|
stream_url = get_catchup_stream(provider, broadcast_id)
|
|
"""
|
|
return self.parser.get_provider_from_broadcast_id(broadcast_id)
|
|
|
|
def _determine_epg_url(self, explicit_url: Optional[str]) -> str:
|
|
"""
|
|
Determine EPG URL with proper precedence:
|
|
1. Explicit parameter
|
|
2. Kodi addon setting (if in Kodi)
|
|
3. config.json (via environment manager)
|
|
4. Environment variable ULTIMATE_EPG_URL
|
|
5. Current cached URL (from metadata)
|
|
6. Default fallback
|
|
|
|
Args:
|
|
explicit_url: Explicitly provided URL
|
|
|
|
Returns:
|
|
EPG URL as string
|
|
"""
|
|
import os
|
|
|
|
# 1. Explicit parameter (highest priority)
|
|
if explicit_url:
|
|
logger.debug(f"EPGManager: Using explicit EPG URL: {explicit_url}")
|
|
return explicit_url
|
|
|
|
# 2. Kodi addon setting (if in Kodi environment)
|
|
try:
|
|
if is_kodi_environment():
|
|
import xbmcaddon
|
|
|
|
addon = xbmcaddon.Addon()
|
|
kodi_url = addon.getSetting("epg_xml_url")
|
|
if kodi_url and kodi_url.strip() and kodi_url != "https://example.com/epg.xml.gz":
|
|
logger.info(f"EPGManager: Using EPG URL from Kodi settings: {kodi_url}")
|
|
return kodi_url.strip()
|
|
except Exception as e:
|
|
logger.debug(f"EPGManager: Could not get EPG URL from Kodi settings: {e}")
|
|
|
|
# 3. config.json via environment manager
|
|
try:
|
|
env_manager = get_environment_manager()
|
|
config_url = env_manager.get_config("epg_url")
|
|
if config_url and config_url.strip() and config_url != "https://example.com/epg.xml.gz":
|
|
logger.info(f"EPGManager: Using EPG URL from config.json: {config_url}")
|
|
return config_url.strip()
|
|
except Exception as e:
|
|
logger.debug(f"EPGManager: Could not get EPG URL from environment manager: {e}")
|
|
|
|
# 4. Environment variable
|
|
env_url = os.environ.get("ULTIMATE_EPG_URL")
|
|
if env_url and env_url.strip() and env_url != "https://example.com/epg.xml.gz":
|
|
logger.info(f"EPGManager: Using EPG URL from environment variable: {env_url}")
|
|
return env_url.strip()
|
|
|
|
# 5. Try to get the last known URL from cache metadata
|
|
try:
|
|
# Check if we have cache metadata with a valid URL
|
|
metadata = self.cache._get_metadata() if hasattr(self, "cache") else None
|
|
if metadata and "url" in metadata:
|
|
cached_url = metadata.get("url")
|
|
if (
|
|
cached_url
|
|
and cached_url.strip()
|
|
and cached_url != "https://example.com/epg.xml.gz"
|
|
):
|
|
logger.info(
|
|
f"EPGManager: Using last known URL from cache metadata: {cached_url}"
|
|
)
|
|
return cached_url.strip()
|
|
except Exception as e:
|
|
logger.debug(f"EPGManager: Could not get URL from cache metadata: {e}")
|
|
|
|
# 6. Default fallback (LAST RESORT - should rarely be used)
|
|
default_url = "https://example.com/epg.xml.gz"
|
|
logger.warning(f"EPGManager: No valid EPG URL found, using default: {default_url}")
|
|
logger.warning("Please set ULTIMATE_EPG_URL environment variable or configure in settings!")
|
|
return default_url
|
|
|
|
@staticmethod
|
|
def verify_broadcast_id_provider(broadcast_id: int, provider_name: str) -> bool:
|
|
"""
|
|
Verify if a broadcast ID belongs to a specific provider.
|
|
|
|
Args:
|
|
broadcast_id: Encoded broadcast ID
|
|
provider_name: Provider name to verify
|
|
|
|
Returns:
|
|
True if broadcast_id was generated for this provider
|
|
"""
|
|
return EPGEntry.verify_provider(broadcast_id, provider_name) |