Files
script.service.ultimate/lib/streaming_providers/base/models/epg_models.py
T
2026-07-11 00:53:29 +02:00

1186 lines
38 KiB
Python

#!/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, fields, replace
from datetime import datetime
from enum import IntEnum
from typing import List, Optional, Union, Dict, Any
# 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)."""
def _coerce_timestamp(value: Union[int, str, None], field_name: str) -> Optional[int]:
"""
Coerce a raw timestamp value (int, numeric string, or ISO-8601 string)
into a Unix timestamp (int seconds).
This is the single source of truth for timestamp parsing used by
EPGEntry.__post_init__. Upstream providers are inconsistent about
whether they send epoch ints, epoch strings, or ISO-8601 datetimes,
so all three forms are accepted here.
Args:
value: Raw timestamp value to coerce.
field_name: Name of the field being coerced, used only for error
messages (e.g. "start", "end").
Returns:
Unix timestamp as int, or None if value was None.
Raises:
ValueError: If value is a non-empty string/other type that cannot
be parsed as either a numeric epoch or an ISO-8601 datetime.
"""
if value is None:
return None
# Already an int (bools are ints too, but that's not a realistic
# input here so no special-casing).
if isinstance(value, int):
return value
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return None
# Numeric epoch string, e.g. "1750000000" or "-1"
if stripped.lstrip("-").isdigit():
return int(stripped)
# ISO-8601 datetime string, e.g. "2026-06-17T16:28:00Z"
try:
dt = datetime.fromisoformat(stripped.replace("Z", "+00:00"))
return int(dt.timestamp())
except (ValueError, AttributeError) as e:
raise ValueError(
f"{field_name} could not be parsed as a timestamp: "
f"{value!r} ({e})"
) from e
raise ValueError(
f"{field_name} must be an int, numeric string, or ISO-8601 string, "
f"got {type(value)}: {value!r}"
)
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)."""
program_id: Optional[str] = None
# 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."""
producers: Optional[List[str]] = None
"""
List of producer names. Note: unlike cast/directors/writers, Kodi's
PVREPGTag C++ interface has no dedicated producer slot, so this field
is not rendered by the Kodi frontend today. It is still captured here
for JSON/API consumers and providers (e.g. MoveTV's channel-based EPG
endpoint) that return producer data as part of a single one-shot
fetch with no separate enrichment pass to fall back on.
"""
# 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.
"""
genres: Optional[List[str]] = None
"""
List of text genre/category labels (e.g. ["Komedija", "Drama", "Romansa"]),
distinct from the single numeric DVB-SI `genre` field and from the single
`genre_description` string above. Named to match EPGProgramDetails.genres
so the two line up if this ever gets pulled into a shared merge field.
"""
# 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 (e.g. 'tt1234567').
May be unset on grid-import entries and only populated later once a
provider detail fetch (EPGProgramDetails.imdb_number) is merged in —
it is not guaranteed to be present in the initial schedule import.
"""
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 for frontend consumption.
This format is used by:
- Kodi PVR frontend (C++)
- Web UI / API endpoints
- Other frontend clients
Note: The dict includes both broadcast_id (for Kodi) and program_id
(for provider-specific operations). Frontends that only need the
integer ID can ignore program_id.
"""
result = {
"broadcast_id": self.broadcast_id,
"title": self.title,
"start": self.start,
"end": self.end,
}
# Include program_id if available (multi-platform support)
if self.program_id is not None:
result["program_id"] = self.program_id
# Add all optional fields
optional_fields = [
"description",
"plot_outline",
"episode_name",
"original_title",
"year",
"icon",
"cast",
"directors",
"writers",
"producers",
"genre",
"genre_description",
"genres",
"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
"""
# 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)
def merge_details(self, details: "EPGProgramDetails") -> "EPGEntry":
"""
Overlay shared EPGContent fields from a fetched EPGProgramDetails
onto this entry. Convenience wrapper around the module-level
merge_content() function - see its docstring for exact semantics
(non-None overlay only, returns a new EPGEntry).
Example:
entry = entry.merge_details(details)
"""
return merge_content(self, details)
@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."""
# --- COERCE TIMESTAMPS FIRST ---
start_coerced = _coerce_timestamp(self.start, "start")
end_coerced = _coerce_timestamp(self.end, "end")
if start_coerced is None:
raise ValueError("start is required and cannot be None")
if end_coerced is None:
raise ValueError("end is required and cannot be None")
self.start = start_coerced
self.end = end_coerced
# --- END COERCION ---
# Now validate
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})"
)
@dataclass(frozen=True)
class PersonData:
"""Enriched person data with image and roles."""
id: str
name: str
image: Optional[str] = None
roles: Optional[List[str]] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {"id": self.id, "name": self.name}
if self.image:
result["image"] = self.image
if self.roles:
result["roles"] = self.roles
return result
@dataclass(frozen=True)
class EPGProgramDetails:
"""
Enrichment metadata for a single programme fetched from a provider's
program-detail endpoint.
"""
program_id: str
description: Optional[str] = None
episode_name: Optional[str] = None
year: Optional[int] = None
icon: Optional[str] = None
# String-based credits (backwards compatible for Kodi/PVR)
cast: Optional[List[str]] = None
directors: Optional[List[str]] = None
writers: Optional[List[str]] = None
producers: Optional[List[str]] = None
presenter: Optional[List[str]] = None
composers: Optional[List[str]] = None
contributors: Optional[List[str]] = None
# Enriched person data (for modern web UIs)
cast_details: Optional[List[PersonData]] = None
directors_details: Optional[List[PersonData]] = None
writers_details: Optional[List[PersonData]] = None
producers_details: Optional[List[PersonData]] = None
presenter_details: Optional[List[PersonData]] = None
# Extended media metadata
backdrop: Optional[str] = None
poster: Optional[str] = None
# External identifiers
imdb_number: Optional[str] = None
"""
IMDB identifier (e.g. 'tt1234567'), when returned by the provider's
detail endpoint. Named to match EPGEntry.imdb_number so the two can be
merged directly without a field-name translation step.
"""
provider_vod_id: Optional[str] = None
"""
Provider-specific identifier valid for that provider's own VOD/catchup
service (distinct from imdb_number and from the general-purpose
program_id). Not a shared/EPGEntry concept — this is provider playback
plumbing, only meaningful in combination with the provider it came from.
"""
series_id: Optional[str] = None
"""
Provider-specific series identifier (e.g. 'HRT1-SH4506209'), distinct
from program_id which is episode-scoped (e.g.
'HRT1-SH4506209-S4E236'). Not part of EPGContent — EPGEntry has no
matching field. Useful as a grouping key if detail fetches are ever
batched/cached per-series or per-season rather than per-episode.
"""
# Additional metadata
genres: Optional[List[str]] = None
parental_rating: Optional[int] = None
release_date: Optional[int] = None
duration: Optional[int] = None
season_number: Optional[int] = None
episode_number: Optional[int] = None
country_of_origin: Optional[List[str]] = None
"""
Country/countries of origin as returned by the provider's detail
endpoint (e.g. ["Österreich"], ["USA"]). Not part of EPGContent —
EPGEntry has no matching field, same treatment as presenter/composers.
"""
trailer: Optional[List[str]] = None
"""
Trailer URL(s), when the provider's detail endpoint returns them.
Not part of EPGContent — no matching EPGEntry field.
"""
def to_dict(self) -> dict:
"""Serialise to a plain dict, omitting None values."""
result: dict = {"program_id": self.program_id}
simple_fields = (
"description", "episode_name", "year", "icon",
"cast", "directors", "writers", "producers",
"presenter", "composers", "contributors",
"backdrop", "poster", "imdb_number", "provider_vod_id",
"series_id",
"genres", "parental_rating",
"release_date", "duration",
"season_number", "episode_number",
"country_of_origin", "trailer",
)
for field in simple_fields:
value = getattr(self, field)
if value is not None:
result[field] = value
detail_fields = (
"cast_details", "directors_details", "writers_details",
"producers_details", "presenter_details"
)
for field in detail_fields:
value = getattr(self, field)
if value is not None:
result[field] = [person.to_dict() for person in value]
return result
@dataclass(frozen=True)
class EPGContent:
"""
Single source of truth for the fields that are shared in meaning
between EPGEntry (schedule/grid data) and EPGProgramDetails
(enrichment data fetched from a provider's detail endpoint).
This is deliberately NOT used via class inheritance. EPGEntry is
mutable (it mutates `flags` via set_flag/clear_flag) while
EPGProgramDetails is frozen, and dataclasses do not allow mixing
frozen and non-frozen classes across an inheritance chain in either
direction. EPGEntry also has required positional fields
(broadcast_id, title, start, end) which, combined with a base
class's defaulted fields, would hit dataclasses' "non-default
argument follows default argument" ordering error.
Instead, EPGContent is used as:
1. A single declared list of "shared" field names, walked by
merge_content() below - adding a new shared field means adding
it here AND to whichever of EPGEntry/EPGProgramDetails don't
already have it, rather than hand-copying merge logic in three
separate places.
2. A documentation anchor: a same-named field on EPGEntry and
EPGProgramDetails is expected to carry the same meaning and be
safe to overlay via merge_content().
Field types/defaults here must stay in sync with the matching
fields on EPGEntry and EPGProgramDetails.
"""
description: Optional[str] = None
episode_name: Optional[str] = None
year: Optional[int] = None
icon: Optional[str] = None
cast: Optional[List[str]] = None
directors: Optional[List[str]] = None
writers: Optional[List[str]] = None
season_number: Optional[int] = None
episode_number: Optional[int] = None
parental_rating: Optional[int] = None
imdb_number: Optional[str] = None
def merge_content(entry: "EPGEntry", details: "EPGProgramDetails") -> "EPGEntry":
"""
Overlay the shared EPGContent fields from a fetched EPGProgramDetails
onto an existing EPGEntry, returning a NEW EPGEntry.
Only non-None values from `details` are applied, so a detail fetch
that didn't return a given field (e.g. no imdb_number available for
this title) will not clobber a value already present on `entry`.
Fields that exist on EPGProgramDetails but NOT in EPGContent (e.g.
provider_vod_id, genres, backdrop/poster, presenter/composers/
contributors, the *_details enriched-person fields) are intentionally
NOT copied here - EPGEntry has no matching field for them. Callers
that need that richer data should keep the EPGProgramDetails instance
itself rather than expecting it to appear on the merged EPGEntry.
Note: EPGEntry.producers is the one exception - it exists directly on
EPGEntry (not via EPGContent/merge) for providers like MoveTV that
return producer data in a single one-shot grid fetch with no separate
detail-fetch step to enrich later. It is set at parse time, not
merged in here.
A new EPGEntry is returned (rather than mutating in place) because
EPGEntry is not frozen, but merge_content should behave predictably
even if a caller holds another reference to the original entry.
Args:
entry: The existing schedule entry (e.g. from a grid import).
details: Freshly-fetched detail-endpoint enrichment data.
Returns:
A new EPGEntry with EPGContent-shared fields overlaid from
details wherever details provided a non-None value.
Example:
# Two-step: grid import now, detail fetch later
entry = merge_content(entry, details)
# Or via the EPGEntry convenience method:
entry = entry.merge_details(details)
Raises:
ValueError: If both entry.program_id and details.program_id are
set but differ, since applying details for a different
programme onto this entry would silently mix data - the
same class of bug as the Magenta2/ThePlatform field-mapping
issue, just at the merge step instead of the parse step.
"""
if (
entry.program_id is not None
and details.program_id is not None
and entry.program_id != details.program_id
):
raise ValueError(
f"program_id mismatch: entry has {entry.program_id!r}, "
f"details has {details.program_id!r} - refusing to merge "
f"details for a different programme onto this entry"
)
updates = {
f.name: getattr(details, f.name)
for f in fields(EPGContent)
if getattr(details, f.name) is not None
}
return replace(entry, **updates)
# 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",
"EPGProgramDetails",
"EPGContent",
"PersonData",
"PVREPGTag", # Legacy alias
# Functions
"merge_content",
# Constants
"EPG_TAG_INVALID_UID",
"EPG_TAG_INVALID_SERIES_EPISODE",
"EPG_TIMEFRAME_UNLIMITED",
"EPG_STRING_TOKEN_SEPARATOR",
# Enums and flags
"EPGEventState",
"EPGFlags",
"EPGGenre",
"EPGGenreSubtype",
]