#!/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 (ETSI EN 300 468 DVB-SI standard) """ from dataclasses import dataclass, field from datetime import datetime from enum import IntEnum from typing import List, Optional # EPG Constants from Kodi PVR specification EPG_TAG_INVALID_UID = 0 """Special broadcast ID value to indicate invalid/unset EPG event UID.""" EPG_TAG_INVALID_SERIES_EPISODE = -1 """Special value for series/episode/part numbers to indicate not applicable.""" EPG_TIMEFRAME_UNLIMITED = -1 """Special timeframe value to indicate no time restrictions.""" EPG_STRING_TOKEN_SEPARATOR = "," """Separator for multiple values in string fields (cast, directors, writers).""" class EPGEventState(IntEnum): """ EPG event states for event lifecycle callbacks. Used with EpgEventStateChange() callback in C++ PVR client. """ CREATED = 0 # Event created UPDATED = 1 # Event updated DELETED = 2 # Event deleted @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. BROADCAST ID ENCODING: ---------------------- The broadcast_id field uses a special encoding scheme that embeds provider information for catchup functionality: - Bits 0-15 (lower 16 bits): Provider hash - Bits 16-31 (upper 16 bits): Event hash (channel + start time) This allows identifying the provider from just the broadcast_id, which is critical for catchup operations where only the broadcast_id is available. Usage: # Creating entries (done by parser) broadcast_id = EPGEntry.encode_broadcast_id("rtlplus", "rtl", start_time) # In catchup handler (only broadcast_id available) provider_hash = EPGEntry.get_provider_hash(broadcast_id) # Look up provider from hash in registry # Or verify provider if EPGEntry.verify_provider(broadcast_id, "rtlplus"): # Get catchup stream from rtlplus """ # 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. Combine flags using bitwise OR: EPGFlags.IS_SERIES | EPGFlags.IS_NEW Available flags: - EPGFlags.UNDEFINED (0x00): Nothing special - EPGFlags.IS_SERIES (0x01): Part of a series - EPGFlags.IS_NEW (0x02): New episode/content - EPGFlags.IS_PREMIERE (0x04): Premiere episode - EPGFlags.IS_FINALE (0x08): Finale episode - EPGFlags.IS_LIVE (0x10): Live broadcast Example: flags = EPGFlags.IS_SERIES | EPGFlags.IS_NEW """ 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 has_flag(self, flag: int) -> bool: """ Check if a specific flag is set. Args: flag: Flag to check (use EPGFlags constants) Returns: True if flag is set Example: if entry.has_flag(EPGFlags.IS_LIVE): print("Live broadcast") """ if self.flags is None: return False return EPGFlags.has_flag(self.flags, flag) def set_flag(self, flag: int) -> None: """ Set a specific flag (adds to existing flags). Args: flag: Flag to set (use EPGFlags constants) Example: entry.set_flag(EPGFlags.IS_NEW) """ if self.flags is None: self.flags = flag else: self.flags |= flag def clear_flag(self, flag: int) -> None: """ Clear a specific flag (removes from existing flags). Args: flag: Flag to clear (use EPGFlags constants) Example: entry.clear_flag(EPGFlags.IS_NEW) """ if self.flags is not None: self.flags &= ~flag @property def is_series(self) -> bool: """Check if this entry is part of a series.""" return self.has_flag(EPGFlags.IS_SERIES) @property def is_new(self) -> bool: """Check if this entry is flagged as new.""" return self.has_flag(EPGFlags.IS_NEW) @property def is_premiere(self) -> bool: """Check if this entry is a premiere.""" return self.has_flag(EPGFlags.IS_PREMIERE) @property def is_finale(self) -> bool: """Check if this entry is a finale.""" return self.has_flag(EPGFlags.IS_FINALE) @property def is_live(self) -> bool: """Check if this entry is a live broadcast.""" return self.has_flag(EPGFlags.IS_LIVE) @staticmethod def join_string_list(items: List[str]) -> str: """ Join list of strings using EPG_STRING_TOKEN_SEPARATOR. Useful for cast, directors, writers fields when converting from lists. Args: items: List of strings to join Returns: Joined string using EPG separator Example: cast_str = EPGEntry.join_string_list(["Actor 1", "Actor 2", "Actor 3"]) # Returns: "Actor 1,Actor 2,Actor 3" """ return EPG_STRING_TOKEN_SEPARATOR.join(items) @staticmethod def split_string_list(text: str) -> List[str]: """ Split string using EPG_STRING_TOKEN_SEPARATOR. Useful for parsing cast, directors, writers fields. Args: text: String to split Returns: List of strings Example: cast = EPGEntry.split_string_list("Actor 1,Actor 2,Actor 3") # Returns: ["Actor 1", "Actor 2", "Actor 3"] """ if not text: return [] return [ item.strip() for item in text.split(EPG_STRING_TOKEN_SEPARATOR) if item.strip() ] @staticmethod def encode_broadcast_id( provider_name: str, channel_id: str, start_time: int ) -> int: """ Generate deterministic broadcast ID with encoded provider information. The ID structure allows extraction of provider hash for catchup operations: - Bits 0-15 (lower 16 bits): Provider hash (65536 possible values) - Bits 16-31 (upper 16 bits): Event hash (channel + start time) This ensures: 1. Unique IDs across different providers 2. Provider can be identified from broadcast_id alone 3. Same event on same provider always gets same ID Args: provider_name: Provider name (e.g., "rtlplus", "joyn_de") channel_id: Channel ID (e.g., "rtl", "prosieben") start_time: Unix timestamp of programme start Returns: Unique 32-bit broadcast ID with encoded provider info Example: broadcast_id = EPGEntry.encode_broadcast_id("rtlplus", "rtl", 1234567890) provider_hash = EPGEntry.get_provider_hash(broadcast_id) """ import hashlib # Generate provider hash (16 bits) provider_hash_obj = hashlib.sha256(provider_name.encode("utf-8")) provider_hash = int(provider_hash_obj.hexdigest()[:4], 16) # 16 bits # Generate event hash from channel + start time (16 bits) event_input = f"{channel_id}_{start_time}".encode("utf-8") event_hash_obj = hashlib.sha256(event_input) event_hash = int(event_hash_obj.hexdigest()[:4], 16) # 16 bits # Combine: upper 16 bits = event hash, lower 16 bits = provider hash broadcast_id = (event_hash << 16) | provider_hash # Ensure positive and non-zero return broadcast_id if broadcast_id > 0 else 1 @staticmethod def get_provider_hash(broadcast_id: int) -> int: """ Extract provider hash from encoded broadcast ID. Args: broadcast_id: Encoded broadcast ID from encode_broadcast_id() Returns: 16-bit provider hash (lower 16 bits of broadcast_id) Example: broadcast_id = EPGEntry.encode_broadcast_id("rtlplus", "rtl", 1234567890) provider_hash = EPGEntry.get_provider_hash(broadcast_id) # Use provider_hash to look up provider from registry """ return broadcast_id & 0xFFFF @staticmethod def get_event_hash(broadcast_id: int) -> int: """ Extract event hash from encoded broadcast ID. Args: broadcast_id: Encoded broadcast ID from encode_broadcast_id() Returns: 16-bit event hash (upper 16 bits of broadcast_id) """ return (broadcast_id >> 16) & 0xFFFF @staticmethod def verify_provider(broadcast_id: int, provider_name: str) -> bool: """ Verify if a broadcast ID matches a given provider. Args: broadcast_id: Encoded broadcast ID provider_name: Provider name to verify against Returns: True if broadcast_id was generated for this provider Example: if EPGEntry.verify_provider(broadcast_id, "rtlplus"): print("This EPG entry is from rtlplus") """ import hashlib # Get provider hash from broadcast_id stored_hash = EPGEntry.get_provider_hash(broadcast_id) # Calculate hash for given provider name provider_hash_obj = hashlib.sha256(provider_name.encode("utf-8")) calculated_hash = int(provider_hash_obj.hexdigest()[:4], 16) return stored_hash == calculated_hash def __post_init__(self): """Validate required fields after initialization.""" if self.broadcast_id <= EPG_TAG_INVALID_UID: raise ValueError( f"broadcast_id must be greater than EPG_TAG_INVALID_UID ({EPG_TAG_INVALID_UID})" ) 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") # Validate episode numbers if set if ( self.season_number is not None and self.season_number < EPG_TAG_INVALID_SERIES_EPISODE ): raise ValueError( f"season_number must be >= EPG_TAG_INVALID_SERIES_EPISODE ({EPG_TAG_INVALID_SERIES_EPISODE})" ) if ( self.episode_number is not None and self.episode_number < EPG_TAG_INVALID_SERIES_EPISODE ): raise ValueError( f"episode_number must be >= EPG_TAG_INVALID_SERIES_EPISODE ({EPG_TAG_INVALID_SERIES_EPISODE})" ) if ( self.episode_part_number is not None and self.episode_part_number < EPG_TAG_INVALID_SERIES_EPISODE ): raise ValueError( f"episode_part_number must be >= EPG_TAG_INVALID_SERIES_EPISODE ({EPG_TAG_INVALID_SERIES_EPISODE})" ) # Constants matching C++ EPG_TAG_FLAG values class EPGFlags: """ Bit field flags for EPG entry properties. Based on Kodi's EPG_TAG_FLAG enum. These can be combined using bitwise OR operator: flags = EPGFlags.IS_SERIES | EPGFlags.IS_NEW Example: entry = EPGEntry( ..., flags=EPGFlags.IS_SERIES | EPGFlags.IS_PREMIERE ) """ UNDEFINED = 0x00 # 0000 0000 : Nothing special to say about this entry IS_SERIES = 0x01 # 0000 0001 : This EPG entry is part of a series IS_NEW = 0x02 # 0000 0010 : This EPG entry will be flagged as new IS_PREMIERE = 0x04 # 0000 0100 : This EPG entry will be flagged as a premiere IS_FINALE = 0x08 # 0000 1000 : This EPG entry will be flagged as a finale IS_LIVE = 0x10 # 0001 0000 : This EPG entry will be flagged as live @staticmethod def has_flag(flags: int, flag: int) -> bool: """ Check if a specific flag is set. Args: flags: Combined flags value flag: Flag to check for Returns: True if flag is set Example: if EPGFlags.has_flag(entry.flags, EPGFlags.IS_LIVE): print("This is a live broadcast") """ return (flags & flag) == flag @staticmethod def combine(*flags: int) -> int: """ Combine multiple flags using bitwise OR. Args: *flags: Variable number of flag values Returns: Combined flags value Example: combined = EPGFlags.combine( EPGFlags.IS_SERIES, EPGFlags.IS_NEW, EPGFlags.IS_PREMIERE ) """ result = 0 for flag in flags: result |= flag return result @staticmethod def get_flag_names(flags: int) -> List[str]: """ Get list of flag names that are set. Args: flags: Combined flags value Returns: List of flag names Example: flags = EPGFlags.IS_SERIES | EPGFlags.IS_NEW names = EPGFlags.get_flag_names(flags) # Returns: ["IS_SERIES", "IS_NEW"] """ flag_map = { EPGFlags.IS_SERIES: "IS_SERIES", EPGFlags.IS_NEW: "IS_NEW", EPGFlags.IS_PREMIERE: "IS_PREMIERE", EPGFlags.IS_FINALE: "IS_FINALE", EPGFlags.IS_LIVE: "IS_LIVE", } result = [] for flag_value, flag_name in flag_map.items(): if flags & flag_value: result.append(flag_name) return result if result else ["UNDEFINED"] # 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 # Export all public symbols __all__ = [ # Main classes "EPGEntry", "PVREPGTag", # Legacy alias # Constants "EPG_TAG_INVALID_UID", "EPG_TAG_INVALID_SERIES_EPISODE", "EPG_TIMEFRAME_UNLIMITED", "EPG_STRING_TOKEN_SEPARATOR", # Enums and flags "EPGEventState", "EPGFlags", "EPGGenre", "EPGGenreSubtype", ]