mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-08 08:52:14 +02:00
Improve EPG
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
# ============================================================================
|
||||
# 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.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
@@ -10,6 +17,7 @@ from typing import List, Dict, Optional
|
||||
from .epg_cache import EPGCache
|
||||
from .epg_mapping import EPGMapping
|
||||
from .epg_parser import EPGParser
|
||||
from ..models.epg_models import EPGEntry
|
||||
from ..utils.logger import logger
|
||||
|
||||
|
||||
@@ -18,6 +26,9 @@ 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.
|
||||
"""
|
||||
@@ -66,7 +77,8 @@ class EPGManager:
|
||||
|
||||
return None
|
||||
|
||||
def _get_default_time_range(self) -> tuple:
|
||||
@staticmethod
|
||||
def _get_default_time_range() -> tuple:
|
||||
"""
|
||||
Get default time range for EPG queries (now to +12 hours).
|
||||
|
||||
@@ -92,6 +104,8 @@ class EPGManager:
|
||||
|
||||
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
|
||||
@@ -131,8 +145,8 @@ class EPGManager:
|
||||
|
||||
logger.debug(f"EPGManager: Time range: {start_ts} to {end_ts}")
|
||||
|
||||
# Step 4: Parse EPG for channel and time range
|
||||
epg_entries = self.parser.parse_epg_for_channel(
|
||||
# Step 4: Parse EPG for channel and time range (returns EPGEntry objects)
|
||||
epg_entries: List[EPGEntry] = self.parser.parse_epg_for_channel(
|
||||
xml_path,
|
||||
epg_channel_id,
|
||||
start_ts,
|
||||
@@ -144,12 +158,68 @@ class EPGManager:
|
||||
f"{provider_name}/{channel_id}"
|
||||
)
|
||||
|
||||
return epg_entries
|
||||
# Step 5: 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[datetime] = None,
|
||||
end_time: Optional[datetime] = 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 []
|
||||
|
||||
if start_time is None or end_time is None:
|
||||
default_start, default_end = self._get_default_time_range()
|
||||
start_ts = int(start_time.timestamp()) if start_time else default_start
|
||||
end_ts = int(end_time.timestamp()) if end_time else default_end
|
||||
else:
|
||||
start_ts = int(start_time.timestamp())
|
||||
end_ts = int(end_time.timestamp())
|
||||
|
||||
return self.parser.parse_epg_for_channel(
|
||||
xml_path,
|
||||
epg_channel_id,
|
||||
start_ts,
|
||||
end_ts
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"EPGManager: Error getting EPG entries: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def get_epg_for_provider(
|
||||
self,
|
||||
provider_name: str,
|
||||
@@ -159,13 +229,15 @@ class EPGManager:
|
||||
"""
|
||||
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
|
||||
Dictionary mapping channel IDs to their EPG entries (as dicts)
|
||||
"""
|
||||
logger.info(f"EPGManager: Getting EPG for all channels of provider '{provider_name}'")
|
||||
|
||||
|
||||
@@ -9,14 +9,22 @@ import xml.etree.ElementTree as ET
|
||||
import gzip
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional, Tuple, Any
|
||||
from typing import List, Optional, Tuple, Any, Dict
|
||||
from ..models.epg_models import EPGEntry
|
||||
from ..models import epg_models
|
||||
from ..utils.logger import logger
|
||||
|
||||
# Import constants with proper naming
|
||||
EPGGenre = epg_models.EPGGenre
|
||||
EPGFlags = epg_models.EPGFlags
|
||||
|
||||
|
||||
class EPGParser:
|
||||
"""
|
||||
Parses XMLTV EPG data and converts to Kodi PVR format.
|
||||
Uses streaming parse for performance with large EPG files.
|
||||
|
||||
Now returns EPGEntry objects internally for validation and type safety.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@@ -181,16 +189,57 @@ class EPGParser:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def parse_programme(programme_elem: ET.Element, epg_channel_id: str) -> Optional[Dict]:
|
||||
def _map_genre_to_type(genre_str: str) -> int:
|
||||
"""
|
||||
Parse a single XMLTV programme element to Kodi EPG format.
|
||||
Map genre string to numeric genre type based on DVB-SI standard.
|
||||
|
||||
Args:
|
||||
genre_str: Genre string from XMLTV (lowercase)
|
||||
|
||||
Returns:
|
||||
Numeric genre type from EPGGenre (DVB-SI ETSI EN 300 468)
|
||||
"""
|
||||
if 'movie' in genre_str or 'film' in genre_str or 'drama' in genre_str:
|
||||
return EPGGenre.MOVIEDRAMA
|
||||
elif 'news' in genre_str:
|
||||
return EPGGenre.NEWSCURRENTAFFAIRS
|
||||
elif 'show' in genre_str or 'series' in genre_str or 'serie' in genre_str:
|
||||
return EPGGenre.SHOW
|
||||
elif 'sports' in genre_str or 'sport' in genre_str:
|
||||
return EPGGenre.SPORTS
|
||||
elif 'children' in genre_str or 'kids' in genre_str or 'cartoon' in genre_str or 'youth' in genre_str:
|
||||
return EPGGenre.CHILDRENYOUTH
|
||||
elif 'documentary' in genre_str or 'dokumentation' in genre_str:
|
||||
# Documentary can be either News/Current Affairs or Educational/Science
|
||||
# We'll use News/Current Affairs as it's more common
|
||||
return EPGGenre.NEWSCURRENTAFFAIRS
|
||||
elif 'music' in genre_str or 'ballet' in genre_str or 'dance' in genre_str:
|
||||
return EPGGenre.MUSICBALLETDANCE
|
||||
elif 'arts' in genre_str or 'culture' in genre_str or 'art' in genre_str:
|
||||
return EPGGenre.ARTSCULTURE
|
||||
elif 'educational' in genre_str or 'education' in genre_str or 'science' in genre_str:
|
||||
return EPGGenre.EDUCATIONALSCIENCE
|
||||
elif 'social' in genre_str or 'political' in genre_str or 'politics' in genre_str or 'economic' in genre_str:
|
||||
return EPGGenre.SOCIALPOLITICALECONOMICS
|
||||
elif 'leisure' in genre_str or 'hobbies' in genre_str or 'hobby' in genre_str:
|
||||
return EPGGenre.LEISUREHOBBIES
|
||||
elif 'comedy' in genre_str:
|
||||
# Comedy is a subtype of Movie/Drama
|
||||
return EPGGenre.MOVIEDRAMA
|
||||
else:
|
||||
return EPGGenre.UNDEFINED
|
||||
|
||||
@staticmethod
|
||||
def parse_programme(programme_elem: ET.Element, epg_channel_id: str) -> Optional[EPGEntry]:
|
||||
"""
|
||||
Parse a single XMLTV programme element to EPGEntry object.
|
||||
|
||||
Args:
|
||||
programme_elem: XML programme element
|
||||
epg_channel_id: EPG channel ID for validation
|
||||
|
||||
Returns:
|
||||
Dictionary with EPG data, or None if required fields missing
|
||||
EPGEntry object, or None if required fields missing or validation fails
|
||||
"""
|
||||
# Verify channel matches
|
||||
channel = programme_elem.get('channel', '')
|
||||
@@ -215,12 +264,12 @@ class EPGParser:
|
||||
# Generate broadcast ID
|
||||
broadcast_id = EPGParser.generate_broadcast_id(epg_channel_id, start_time)
|
||||
|
||||
# Build base EPG entry with required fields for C++ frontend
|
||||
epg_entry: Dict[str, Any] = {
|
||||
# Build EPGEntry with required fields
|
||||
entry_kwargs: Dict[str, Any] = {
|
||||
'broadcast_id': broadcast_id,
|
||||
'title': title.strip(),
|
||||
'start': start_time, # C++ expects 'start' key
|
||||
'end': end_time, # C++ expects 'end' key
|
||||
'start': start_time,
|
||||
'end': end_time,
|
||||
}
|
||||
|
||||
# Parse optional fields
|
||||
@@ -228,86 +277,59 @@ class EPGParser:
|
||||
# Sub-title (episode name)
|
||||
subtitle_elem = programme_elem.find('sub-title')
|
||||
if subtitle_elem is not None and subtitle_elem.text:
|
||||
epg_entry['episode_name'] = subtitle_elem.text.strip()
|
||||
entry_kwargs['episode_name'] = subtitle_elem.text.strip()
|
||||
|
||||
# Description (plot)
|
||||
desc_elem = programme_elem.find('desc')
|
||||
if desc_elem is not None and desc_elem.text:
|
||||
plot = desc_elem.text.strip()
|
||||
epg_entry['description'] = plot # C++ expects 'description' for full plot
|
||||
entry_kwargs['description'] = plot
|
||||
|
||||
# Use first sentence or first 100 chars as plot_outline
|
||||
outline = plot.split('.')[0] if '.' in plot else plot[:100]
|
||||
epg_entry['plot_outline'] = outline.strip()
|
||||
entry_kwargs['plot_outline'] = outline.strip()
|
||||
|
||||
# Credits
|
||||
credits_elem = programme_elem.find('credits')
|
||||
if credits_elem is not None:
|
||||
# Director - C++ expects 'directors' as array
|
||||
# Director
|
||||
director_elem = credits_elem.find('director')
|
||||
if director_elem is not None and director_elem.text:
|
||||
director = director_elem.text.strip()
|
||||
epg_entry['directors'] = [director]
|
||||
entry_kwargs['directors'] = [director_elem.text.strip()]
|
||||
|
||||
# Actors - C++ expects 'cast' as array
|
||||
# Actors
|
||||
actors = credits_elem.findall('actor')
|
||||
if actors:
|
||||
cast_list = [actor.text.strip() for actor in actors if actor.text]
|
||||
if cast_list:
|
||||
epg_entry['cast'] = cast_list
|
||||
entry_kwargs['cast'] = cast_list
|
||||
|
||||
# Writer - C++ expects 'writers' as array
|
||||
# Writer
|
||||
writer_elem = credits_elem.find('writer')
|
||||
if writer_elem is not None and writer_elem.text:
|
||||
writer = writer_elem.text.strip()
|
||||
epg_entry['writers'] = [writer]
|
||||
entry_kwargs['writers'] = [writer_elem.text.strip()]
|
||||
|
||||
# Year/Date
|
||||
date_elem = programme_elem.find('date')
|
||||
if date_elem is not None and date_elem.text:
|
||||
try:
|
||||
year = int(date_elem.text.strip()[:4])
|
||||
epg_entry['year'] = year
|
||||
entry_kwargs['year'] = year
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Category (genre) - Convert to numeric genre type for C++
|
||||
# Category (genre)
|
||||
category_elem = programme_elem.find('category')
|
||||
if category_elem is not None and category_elem.text:
|
||||
genre_str = category_elem.text.strip().lower()
|
||||
entry_kwargs['genre'] = EPGParser._map_genre_to_type(genre_str)
|
||||
|
||||
# Convert to numeric genre type (Kodi expects 0-...)
|
||||
genre_type = 0 # Unknown
|
||||
|
||||
if 'movie' in genre_str or 'film' in genre_str:
|
||||
genre_type = 1 # Movie
|
||||
elif 'news' in genre_str:
|
||||
genre_type = 2 # News
|
||||
elif 'show' in genre_str or 'series' in genre_str or 'serie' in genre_str:
|
||||
genre_type = 3 # TV Show
|
||||
elif 'sports' in genre_str or 'sport' in genre_str:
|
||||
genre_type = 4 # Sports
|
||||
elif 'children' in genre_str or 'kids' in genre_str or 'cartoon' in genre_str:
|
||||
genre_type = 5 # Children's
|
||||
elif 'documentary' in genre_str or 'dokumentation' in genre_str:
|
||||
genre_type = 6 # Documentary
|
||||
elif 'music' in genre_str:
|
||||
genre_type = 7 # Music
|
||||
elif 'comedy' in genre_str:
|
||||
genre_type = 8 # Comedy
|
||||
elif 'drama' in genre_str:
|
||||
genre_type = 9 # Drama
|
||||
elif 'educational' in genre_str or 'education' in genre_str:
|
||||
genre_type = 10 # Educational
|
||||
|
||||
epg_entry['genre'] = genre_type
|
||||
|
||||
# Icon - C++ expects 'icon'
|
||||
# Icon
|
||||
icon_elem = programme_elem.find('icon')
|
||||
if icon_elem is not None:
|
||||
icon_src = icon_elem.get('src', '')
|
||||
if icon_src:
|
||||
epg_entry['icon'] = icon_src
|
||||
entry_kwargs['icon'] = icon_src
|
||||
|
||||
# Episode numbering
|
||||
episode_nums = programme_elem.findall('episode-num')
|
||||
@@ -327,18 +349,18 @@ class EPGParser:
|
||||
break
|
||||
|
||||
if series_num is not None:
|
||||
epg_entry['season_number'] = series_num # C++ expects 'season_number'
|
||||
entry_kwargs['season_number'] = series_num
|
||||
if episode_num is not None:
|
||||
epg_entry['episode_number'] = episode_num
|
||||
entry_kwargs['episode_number'] = episode_num
|
||||
if part_num is not None:
|
||||
epg_entry['episode_part_number'] = part_num
|
||||
entry_kwargs['episode_part_number'] = part_num
|
||||
|
||||
# Star rating
|
||||
star_rating_elem = programme_elem.find('star-rating')
|
||||
if star_rating_elem is not None:
|
||||
rating = EPGParser.parse_star_rating(star_rating_elem)
|
||||
if rating is not None:
|
||||
epg_entry['star_rating'] = rating
|
||||
entry_kwargs['star_rating'] = rating
|
||||
|
||||
# Parental rating
|
||||
rating_elems = programme_elem.findall('rating')
|
||||
@@ -350,7 +372,7 @@ class EPGParser:
|
||||
import re
|
||||
match = re.search(r'\d+', value_elem.text)
|
||||
if match:
|
||||
epg_entry['parental_rating'] = int(match.group())
|
||||
entry_kwargs['parental_rating'] = int(match.group())
|
||||
break
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
@@ -362,9 +384,14 @@ class EPGParser:
|
||||
if first_aired_str:
|
||||
first_aired = EPGParser.parse_xmltv_time(first_aired_str)
|
||||
if first_aired:
|
||||
epg_entry['first_aired'] = first_aired
|
||||
entry_kwargs['first_aired'] = first_aired
|
||||
|
||||
return epg_entry
|
||||
# Create EPGEntry object with validation
|
||||
try:
|
||||
return EPGEntry(**entry_kwargs)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid EPG entry: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def open_xml_file(file_path: str):
|
||||
@@ -390,7 +417,7 @@ class EPGParser:
|
||||
epg_channel_id: str,
|
||||
start_time: Optional[int] = None,
|
||||
end_time: Optional[int] = None
|
||||
) -> List[Dict]:
|
||||
) -> List[EPGEntry]:
|
||||
"""
|
||||
Parse EPG data for a specific channel within a time range.
|
||||
Uses streaming parse for performance.
|
||||
@@ -402,11 +429,11 @@ class EPGParser:
|
||||
end_time: End of time range (Unix timestamp), None for no upper bound
|
||||
|
||||
Returns:
|
||||
List of EPG entries as dictionaries
|
||||
List of EPGEntry objects
|
||||
"""
|
||||
logger.info(f"Parsing EPG for channel '{epg_channel_id}' from {xml_path}")
|
||||
|
||||
programmes = []
|
||||
programmes: List[EPGEntry] = []
|
||||
|
||||
try:
|
||||
with EPGParser.open_xml_file(xml_path) as xml_file:
|
||||
@@ -421,17 +448,13 @@ class EPGParser:
|
||||
epg_entry = EPGParser.parse_programme(elem, epg_channel_id)
|
||||
|
||||
if epg_entry:
|
||||
# Filter by time range
|
||||
prog_start = epg_entry['start'] # Changed from 'start_time'
|
||||
prog_end = epg_entry['end'] # Changed from 'end_time'
|
||||
|
||||
# Include if programme overlaps with requested range
|
||||
if start_time is not None and prog_end < start_time:
|
||||
# Filter by time range using EPGEntry methods
|
||||
if start_time is not None and epg_entry.end < start_time:
|
||||
# Programme ends before requested range
|
||||
elem.clear()
|
||||
continue
|
||||
|
||||
if end_time is not None and prog_start > end_time:
|
||||
if end_time is not None and epg_entry.start > end_time:
|
||||
# Programme starts after requested range
|
||||
# Since XMLTV is chronological, we can stop here
|
||||
elem.clear()
|
||||
@@ -450,4 +473,4 @@ class EPGParser:
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing EPG file: {e}")
|
||||
return []
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3
|
||||
# streaming_providers/base/models/epg_models.py
|
||||
"""
|
||||
EPG Models - Data classes for Electronic Program Guide entries
|
||||
Based on Kodi PVR EPG Tag specification
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class EPGEntry:
|
||||
"""
|
||||
EPG Entry model for Kodi PVR Backend.
|
||||
|
||||
Represents a single program/event in the Electronic Program Guide.
|
||||
Based on Kodi's PVREPGTag C++ class specification.
|
||||
|
||||
This class matches the dictionary format used by epg_parser.py and
|
||||
expected by the C++ PVR frontend.
|
||||
"""
|
||||
|
||||
# Required fields
|
||||
broadcast_id: int
|
||||
"""Unique identifier for this broadcast event. Must be unique per channel."""
|
||||
|
||||
title: str
|
||||
"""Program title."""
|
||||
|
||||
start: int
|
||||
"""Start time as Unix timestamp (seconds since epoch)."""
|
||||
|
||||
end: int
|
||||
"""End time as Unix timestamp (seconds since epoch)."""
|
||||
|
||||
# Optional fields - Program Information
|
||||
description: Optional[str] = None
|
||||
"""Full program description/plot. C++ expects 'description' key."""
|
||||
|
||||
plot_outline: Optional[str] = None
|
||||
"""Short plot outline (first sentence or ~100 chars of description)."""
|
||||
|
||||
episode_name: Optional[str] = None
|
||||
"""Episode title/name (XMLTV sub-title)."""
|
||||
|
||||
original_title: Optional[str] = None
|
||||
"""Original title if different from main title."""
|
||||
|
||||
# Optional fields - Media Metadata
|
||||
year: Optional[int] = None
|
||||
"""Production year."""
|
||||
|
||||
icon: Optional[str] = None
|
||||
"""URL to program icon/poster image. C++ expects 'icon' key."""
|
||||
|
||||
# Optional fields - People (C++ expects arrays)
|
||||
cast: Optional[List[str]] = None
|
||||
"""List of actor names. C++ expects array format."""
|
||||
|
||||
directors: Optional[List[str]] = None
|
||||
"""List of director names. C++ expects 'directors' array."""
|
||||
|
||||
writers: Optional[List[str]] = None
|
||||
"""List of writer names. C++ expects 'writers' array."""
|
||||
|
||||
# Optional fields - Genre/Category
|
||||
genre: Optional[int] = None
|
||||
"""
|
||||
Numeric genre type based on DVB-SI standard (ETSI EN 300 468).
|
||||
Use EPGGenre constants (e.g., EPGGenre.MOVIEDRAMA = 0x10).
|
||||
Set to EPGGenre.USE_STRING (0xF0) to use genre_description instead.
|
||||
"""
|
||||
|
||||
genre_sub_type: Optional[int] = None
|
||||
"""
|
||||
Numeric genre subtype based on DVB-SI standard.
|
||||
Use EPGGenreSubtype nested classes (e.g., EPGGenreSubtype.Sports.FOOTBALL_SOCCER).
|
||||
Must be used in combination with appropriate genre type.
|
||||
"""
|
||||
|
||||
genre_description: Optional[str] = None
|
||||
"""
|
||||
Text description of genre.
|
||||
Used when genre=EPGGenre.USE_STRING or for custom genres not in DVB-SI standard.
|
||||
"""
|
||||
|
||||
# Optional fields - Episode Information
|
||||
season_number: Optional[int] = None
|
||||
"""Season/series number (1-based). C++ expects 'season_number' key."""
|
||||
|
||||
episode_number: Optional[int] = None
|
||||
"""Episode number within season (1-based)."""
|
||||
|
||||
episode_part_number: Optional[int] = None
|
||||
"""Part number for multi-part episodes (1-based)."""
|
||||
|
||||
# Optional fields - Ratings
|
||||
star_rating: Optional[int] = None
|
||||
"""Star rating on 0-10 scale."""
|
||||
|
||||
parental_rating: Optional[int] = None
|
||||
"""Parental rating code/age restriction."""
|
||||
|
||||
parental_rating_code: Optional[str] = None
|
||||
"""Text parental rating code (e.g., 'TV-PG', 'FSK 12')."""
|
||||
|
||||
# Optional fields - Additional Metadata
|
||||
first_aired: Optional[int] = None
|
||||
"""Original air date as Unix timestamp."""
|
||||
|
||||
imdb_number: Optional[str] = None
|
||||
"""IMDB identifier."""
|
||||
|
||||
series_link: Optional[str] = None
|
||||
"""Link to series information."""
|
||||
|
||||
flags: Optional[int] = None
|
||||
"""
|
||||
Bit field flags for EPG entry properties.
|
||||
Common flags: IS_SERIES=0x01, IS_NEW=0x02, IS_PREMIERE=0x04,
|
||||
IS_FINALE=0x08, IS_LIVE=0x10
|
||||
"""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""
|
||||
Convert EPGEntry to dictionary format expected by C++ frontend.
|
||||
Only includes non-None values to minimize data transfer.
|
||||
|
||||
Returns:
|
||||
Dictionary with EPG data
|
||||
"""
|
||||
result = {
|
||||
'broadcast_id': self.broadcast_id,
|
||||
'title': self.title,
|
||||
'start': self.start,
|
||||
'end': self.end,
|
||||
}
|
||||
|
||||
# Add optional fields only if they have values
|
||||
optional_fields = [
|
||||
'description', 'plot_outline', 'episode_name', 'original_title',
|
||||
'year', 'icon', 'cast', 'directors', 'writers', 'genre',
|
||||
'genre_description', 'season_number', 'episode_number',
|
||||
'episode_part_number', 'star_rating', 'parental_rating',
|
||||
'parental_rating_code', 'first_aired', 'imdb_number',
|
||||
'series_link', 'flags'
|
||||
]
|
||||
|
||||
for field_name in optional_fields:
|
||||
value = getattr(self, field_name)
|
||||
if value is not None:
|
||||
result[field_name] = value
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> 'EPGEntry':
|
||||
"""
|
||||
Create EPGEntry from dictionary (e.g., from epg_parser output).
|
||||
|
||||
Args:
|
||||
data: Dictionary with EPG data
|
||||
|
||||
Returns:
|
||||
EPGEntry instance
|
||||
"""
|
||||
from dataclasses import fields
|
||||
|
||||
# Extract only fields that exist in EPGEntry
|
||||
valid_fields = {f.name for f in fields(cls)}
|
||||
|
||||
filtered_data = {
|
||||
k: v for k, v in data.items()
|
||||
if k in valid_fields
|
||||
}
|
||||
|
||||
return cls(**filtered_data)
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> int:
|
||||
"""Calculate program duration in seconds."""
|
||||
return self.end - self.start
|
||||
|
||||
@property
|
||||
def start_datetime(self) -> datetime:
|
||||
"""Get start time as datetime object."""
|
||||
return datetime.fromtimestamp(self.start)
|
||||
|
||||
@property
|
||||
def end_datetime(self) -> datetime:
|
||||
"""Get end time as datetime object."""
|
||||
return datetime.fromtimestamp(self.end)
|
||||
|
||||
def is_currently_airing(self, reference_time: Optional[int] = None) -> bool:
|
||||
"""
|
||||
Check if program is currently airing.
|
||||
|
||||
Args:
|
||||
reference_time: Unix timestamp to check against (None = now)
|
||||
|
||||
Returns:
|
||||
True if program is airing at reference_time
|
||||
"""
|
||||
if reference_time is None:
|
||||
reference_time = int(datetime.now().timestamp())
|
||||
|
||||
return self.start <= reference_time < self.end
|
||||
|
||||
def overlaps_with(self, start_time: int, end_time: int) -> bool:
|
||||
"""
|
||||
Check if this program overlaps with a given time range.
|
||||
|
||||
Args:
|
||||
start_time: Start of time range (Unix timestamp)
|
||||
end_time: End of time range (Unix timestamp)
|
||||
|
||||
Returns:
|
||||
True if there is any overlap
|
||||
"""
|
||||
# Program overlaps if it doesn't end before range starts
|
||||
# and doesn't start after range ends
|
||||
return self.end > start_time and self.start < end_time
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate required fields after initialization."""
|
||||
if self.broadcast_id <= 0:
|
||||
raise ValueError("broadcast_id must be positive")
|
||||
|
||||
if not self.title or not self.title.strip():
|
||||
raise ValueError("title is required and cannot be empty")
|
||||
|
||||
if self.start <= 0:
|
||||
raise ValueError("start time must be a valid Unix timestamp")
|
||||
|
||||
if self.end <= self.start:
|
||||
raise ValueError("end time must be after start time")
|
||||
|
||||
|
||||
# Constants matching C++ EPG_TAG_FLAG values
|
||||
class EPGFlags:
|
||||
"""Bit flags for EPG entry properties."""
|
||||
IS_SERIES = 0x01
|
||||
IS_NEW = 0x02
|
||||
IS_PREMIERE = 0x04
|
||||
IS_FINALE = 0x08
|
||||
IS_LIVE = 0x10
|
||||
|
||||
|
||||
# Genre type constants based on ETSI EN 300 468 V1.14.1 (DVB-SI EIT content descriptor)
|
||||
# These match Kodi's EPG_EVENT_CONTENTMASK values
|
||||
class EPGGenre:
|
||||
"""
|
||||
EPG genre type codes based on DVB-SI standard (ETSI EN 300 468).
|
||||
These are the main content masks - use with genre_type field.
|
||||
"""
|
||||
# Main genre types (content masks)
|
||||
UNDEFINED = 0x00
|
||||
MOVIEDRAMA = 0x10
|
||||
NEWSCURRENTAFFAIRS = 0x20
|
||||
SHOW = 0x30
|
||||
SPORTS = 0x40
|
||||
CHILDRENYOUTH = 0x50
|
||||
MUSICBALLETDANCE = 0x60
|
||||
ARTSCULTURE = 0x70
|
||||
SOCIALPOLITICALECONOMICS = 0x80
|
||||
EDUCATIONALSCIENCE = 0x90
|
||||
LEISUREHOBBIES = 0xA0
|
||||
SPECIAL = 0xB0
|
||||
USERDEFINED = 0xF0
|
||||
|
||||
# Special Kodi value to indicate genre is provided as string
|
||||
USE_STRING = 0xF0 # Same as USERDEFINED, signals use of genre_description
|
||||
|
||||
|
||||
class EPGGenreSubtype:
|
||||
"""
|
||||
EPG genre subtype codes based on DVB-SI standard (ETSI EN 300 468).
|
||||
These are used with genre_sub_type field in combination with main genre_type.
|
||||
"""
|
||||
|
||||
# Movie/Drama subtypes (use with EPGGenre.MOVIEDRAMA)
|
||||
class MovieDrama:
|
||||
GENERAL = 0x00
|
||||
DETECTIVE_THRILLER = 0x01
|
||||
ADVENTURE_WESTERN_WAR = 0x02
|
||||
SCIENCEFICTION_FANTASY_HORROR = 0x03
|
||||
COMEDY = 0x04
|
||||
SOAP_MELODRAMA_FOLKLORIC = 0x05
|
||||
ROMANCE = 0x06
|
||||
SERIOUS_CLASSICAL_RELIGIOUS_HISTORICAL = 0x07
|
||||
ADULT = 0x08
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
# News/Current Affairs subtypes (use with EPGGenre.NEWSCURRENTAFFAIRS)
|
||||
class NewsCurrentAffairs:
|
||||
GENERAL = 0x00
|
||||
WEATHER = 0x01
|
||||
MAGAZINE = 0x02
|
||||
DOCUMENTARY = 0x03
|
||||
DISCUSSION_INTERVIEW_DEBATE = 0x04
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
# Show/Game Show subtypes (use with EPGGenre.SHOW)
|
||||
class Show:
|
||||
GENERAL = 0x00
|
||||
GAMESHOW_QUIZ_CONTEST = 0x01
|
||||
VARIETY_SHOW = 0x02
|
||||
TALK_SHOW = 0x03
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
# Sports subtypes (use with EPGGenre.SPORTS)
|
||||
class Sports:
|
||||
GENERAL = 0x00
|
||||
OLYMPICGAMES_WORLDCUP = 0x01
|
||||
SPORTS_MAGAZINES = 0x02
|
||||
FOOTBALL_SOCCER = 0x03
|
||||
TENNIS_SQUASH = 0x04
|
||||
TEAMSPORTS = 0x05
|
||||
ATHLETICS = 0x06
|
||||
MOTORSPORT = 0x07
|
||||
WATERSPORT = 0x08
|
||||
WINTERSPORTS = 0x09
|
||||
EQUESTRIAN = 0x0A
|
||||
MARTIALSPORTS = 0x0B
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
# Children/Youth subtypes (use with EPGGenre.CHILDRENYOUTH)
|
||||
class ChildrenYouth:
|
||||
GENERAL = 0x00
|
||||
PRESCHOOL_CHILDREN = 0x01
|
||||
ENTERTAIN_6TO14 = 0x02
|
||||
ENTERTAIN_10TO16 = 0x03
|
||||
INFORMATIONAL_EDUCATIONAL_SCHOOL = 0x04
|
||||
CARTOONS_PUPPETS = 0x05
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
# Music/Ballet/Dance subtypes (use with EPGGenre.MUSICBALLETDANCE)
|
||||
class MusicBalletDance:
|
||||
GENERAL = 0x00
|
||||
ROCKPOP = 0x01
|
||||
SERIOUSMUSIC_CLASSICALMUSIC = 0x02
|
||||
FOLK_TRADITIONAL_MUSIC = 0x03
|
||||
JAZZ = 0x04
|
||||
MUSICAL_OPERA = 0x05
|
||||
BALLET = 0x06
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
# Arts/Culture subtypes (use with EPGGenre.ARTSCULTURE)
|
||||
class ArtsCulture:
|
||||
GENERAL = 0x00
|
||||
PERFORMINGARTS = 0x01
|
||||
FINEARTS = 0x02
|
||||
RELIGION = 0x03
|
||||
POPULARCULTURE_TRADITIONALARTS = 0x04
|
||||
LITERATURE = 0x05
|
||||
FILM_CINEMA = 0x06
|
||||
EXPERIMENTALFILM_VIDEO = 0x07
|
||||
BROADCASTING_PRESS = 0x08
|
||||
NEWMEDIA = 0x09
|
||||
ARTS_CULTUREMAGAZINES = 0x0A
|
||||
FASHION = 0x0B
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
# Social/Political/Economics subtypes (use with EPGGenre.SOCIALPOLITICALECONOMICS)
|
||||
class SocialPoliticalEconomics:
|
||||
GENERAL = 0x00
|
||||
MAGAZINES_REPORTS_DOCUMENTARY = 0x01
|
||||
ECONOMICS_SOCIALADVISORY = 0x02
|
||||
REMARKABLEPEOPLE = 0x03
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
# Educational/Science subtypes (use with EPGGenre.EDUCATIONALSCIENCE)
|
||||
class EducationalScience:
|
||||
GENERAL = 0x00
|
||||
NATURE_ANIMALS_ENVIRONMENT = 0x01
|
||||
TECHNOLOGY_NATURALSCIENCES = 0x02
|
||||
MEDICINE_PHYSIOLOGY_PSYCHOLOGY = 0x03
|
||||
FOREIGNCOUNTRIES_EXPEDITIONS = 0x04
|
||||
SOCIAL_SPIRITUALSCIENCES = 0x05
|
||||
FURTHEREDUCATION = 0x06
|
||||
LANGUAGES = 0x07
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
# Leisure/Hobbies subtypes (use with EPGGenre.LEISUREHOBBIES)
|
||||
class LeisureHobbies:
|
||||
GENERAL = 0x00
|
||||
TOURISM_TRAVEL = 0x01
|
||||
HANDICRAFT = 0x02
|
||||
MOTORING = 0x03
|
||||
FITNESSANDHEALTH = 0x04
|
||||
COOKING = 0x05
|
||||
ADVERTISEMENT_SHOPPING = 0x06
|
||||
GARDENING = 0x07
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
# Special Characteristics subtypes (use with EPGGenre.SPECIAL)
|
||||
class Special:
|
||||
GENERAL = 0x00
|
||||
BLACKANDWHITE = 0x01
|
||||
UNPUBLISHED = 0x02
|
||||
LIVEBROADCAST = 0x03
|
||||
PLANOSTEREOSCOPIC = 0x04
|
||||
LOCALORREGIONAL = 0x05
|
||||
USERDEFINED = 0x0F
|
||||
|
||||
|
||||
# Legacy alias for backwards compatibility with existing code
|
||||
PVREPGTag = EPGEntry
|
||||
Reference in New Issue
Block a user