diff --git a/custom_components/xmltv_epg/api.py b/custom_components/xmltv_epg/api.py index 2e55e36..980f679 100644 --- a/custom_components/xmltv_epg/api.py +++ b/custom_components/xmltv_epg/api.py @@ -8,7 +8,7 @@ import xml.etree.ElementTree as ET -from .xmltv.model import TVGuide +from .model import TVGuide import gzip class XMLTVClientError(Exception): diff --git a/custom_components/xmltv_epg/entity.py b/custom_components/xmltv_epg/entity.py index 87e793e..fbb53e3 100644 --- a/custom_components/xmltv_epg/entity.py +++ b/custom_components/xmltv_epg/entity.py @@ -5,7 +5,7 @@ from .coordinator import XMLTVDataUpdateCoordinator -from .xmltv.model import TVGuide +from .model import TVGuide class XMLTVEntity(CoordinatorEntity): diff --git a/custom_components/xmltv_epg/model/__init__.py b/custom_components/xmltv_epg/model/__init__.py new file mode 100644 index 0000000..8417b72 --- /dev/null +++ b/custom_components/xmltv_epg/model/__init__.py @@ -0,0 +1,11 @@ +"""XMLTV Model.""" + +from .channel import TVChannel +from .program import TVProgram +from .guide import TVGuide + +__all__ = [ + 'TVChannel', + 'TVProgram', + 'TVGuide', +] diff --git a/custom_components/xmltv_epg/model/channel.py b/custom_components/xmltv_epg/model/channel.py new file mode 100644 index 0000000..c68f877 --- /dev/null +++ b/custom_components/xmltv_epg/model/channel.py @@ -0,0 +1,72 @@ +"""TV Channel Model.""" +from datetime import datetime +import xml.etree.ElementTree as ET + +from .program import TVProgram +from .helper import is_none_or_whitespace, get_child_as_text + +class TVChannel: + """TV Channel Class.""" + + TAG = 'channel' + + def __init__(self, id: str, name: str): + """Initialize TV Channel.""" + self.id = id + self.name = name + self.programs = [] + + def add_program(self, program: TVProgram): + """Add a program to channel.""" + self.programs.append(program) + + # keep programs sorted by start time + self.programs.sort(key=lambda p: p.start) + + def get_current_program(self, time: datetime) -> TVProgram: + """Get current program at given time.""" + for program in self.programs: + if program.start.timestamp() <= time.timestamp() < program.end.timestamp(): + return program + + return None + + def get_next_program(self, time: datetime) -> TVProgram: + """Get next program after given time.""" + for program in self.programs: + if program.start.timestamp() >= time.timestamp(): + return program + + return None + + @classmethod + def from_xml(cls, xml: ET.Element) -> 'TVChannel': + """Initialize TV Channel from XML Node, if possible. + + :param xml: XML Node + :return: TV Channel object, or None + + XML node format is: + + WDR Essen + + """ + + # node must be a channel + if xml.tag != cls.TAG: + return None + + # get id and display name + id = xml.attrib.get('id') + if is_none_or_whitespace(id): + return None + + name = get_child_as_text(xml, 'display-name') + if is_none_or_whitespace(name): + return None + + # remove 'XX: ' prefix from name, if present + if len(name) > 4 and name[2] == ':' and name[3] == ' ': # 'XX: ' + name = name[4:] + + return cls(id, name) diff --git a/custom_components/xmltv_epg/model/guide.py b/custom_components/xmltv_epg/model/guide.py new file mode 100644 index 0000000..f99a365 --- /dev/null +++ b/custom_components/xmltv_epg/model/guide.py @@ -0,0 +1,72 @@ +"""TV Guide Model.""" +import xml.etree.ElementTree as ET + +from .channel import TVChannel +from .program import TVProgram + +class TVGuide: + """TV Guide Class.""" + + TAG = 'tv' + + def __init__(self, generator_name: str = None, generator_url: str = None): + """Initialize TV Guide.""" + self.generator_name = generator_name + self.generator_url = generator_url + + self.channels = [] + self.programs = [] + + def get_channel(self, channel_id: str) -> TVChannel: + """Get channel by ID.""" + return next((c for c in self.channels if c.id == channel_id), None) + + @classmethod + def from_xml(cls, xml: ET.Element) -> 'TVGuide': + """Initialize TV Guide from XML Node, if possible. + + :param xml: XML Node + :return: TV Guide object, or None + + XML node format is: + + + + + """ + + # node must be a TV guide + if xml.tag != cls.TAG: + return None + + # parse generator info + generator_name = xml.attrib.get('generator-info-name') + generator_url = xml.attrib.get('generator-info-url') + + # create guide instance + guide = cls(generator_name, generator_url) + + # parse channels and programs + for child in xml: + if child.tag == TVChannel.TAG: + channel = TVChannel.from_xml(child) + if channel is not None: + # ensure no duplicate channel ids + if guide.get_channel(channel.id) is None: + guide.channels.append(channel) + else: + # ?! + continue + elif child.tag == TVProgram.TAG: + program = TVProgram.from_xml(child) + if program is not None: + guide.programs.append(program) + else: + # ?! + continue + + # cross-link programs with channels + for program in guide.programs: + program.cross_link_channel(guide.channels) + + return guide diff --git a/custom_components/xmltv_epg/model/helper.py b/custom_components/xmltv_epg/model/helper.py new file mode 100644 index 0000000..b68ef13 --- /dev/null +++ b/custom_components/xmltv_epg/model/helper.py @@ -0,0 +1,11 @@ +"""Model helper functions.""" +import xml.etree.ElementTree as ET + +def is_none_or_whitespace(s: str) -> bool: + """Check if string is None, empty, or whitespace.""" + return s is None or not isinstance(s, str) or len(s.strip()) == 0 + +def get_child_as_text(parent: ET.Element, tag: str) -> str: + """Get child node text as string, or None if not found.""" + node = parent.find(tag) + return node.text if node is not None else None diff --git a/custom_components/xmltv_epg/model/program.py b/custom_components/xmltv_epg/model/program.py new file mode 100644 index 0000000..c4520a2 --- /dev/null +++ b/custom_components/xmltv_epg/model/program.py @@ -0,0 +1,150 @@ +"""TV Program Model.""" +from datetime import datetime, timedelta +import xml.etree.ElementTree as ET + +from .helper import is_none_or_whitespace, get_child_as_text + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from .channel import TVChannel + +class TVProgram: + """TV Program Class.""" + + TAG = 'programme' + + def __init__(self, + channel_id: str, + start: datetime, + end: datetime, + title: str, + description: str, + episode: str = None, + subtitle: str = None): + """Initialize TV Program.""" + if end <= start: + raise ValueError('End time must be after start time.') + + self._channel_id = channel_id + self.start = start + self.end = end + self.title = title + self.description = description + self.episode = episode + self.subtitle = subtitle + + self.channel = None + + def cross_link_channel(self, channels: list['TVChannel']): + """Set channel for program and cross-link. + + :param channels: List of TV Channels + """ + # find channel by id + channel = next((c for c in channels if c.id == self._channel_id), None) + if channel is None: + raise ValueError(f'Channel with ID "{self._channel_id}" not found.') + + # cross-link + self.channel = channel + self.channel.add_program(self) + + @property + def duration(self) -> timedelta: + """Get program duration.""" + return self.end - self.start + + @property + def full_title(self) -> str: + """Get the full title, including episode and / or subtitle, if available. + + Examples: + (1) + Title: 'Program 1' + Episode: None + Subtitle: None + Result: 'Program 1' + + (2) + Title: 'Program 1' + Episode: 'S1 E1' + Subtitle: None + Result: 'Program 1 (S1 E1)' + + (3) + Title: 'Program 1' + Episode: 'S1 E1' + Subtitle: 'Subtitle 1' + Result: 'Program 1 - Subtitle 1 (S1 E1)' + + (4) + Title: 'Program 1' + Episode: None + Subtitle: 'Subtitle 1' + Result: 'Program 1 - Subtitle 1' + + """ + title = self.title + + if not is_none_or_whitespace(self.subtitle): + title += f' - {self.subtitle}' + + if not is_none_or_whitespace(self.episode): + title += f' ({self.episode})' + + return title + + + @classmethod + def from_xml(cls, xml: ET.Element) -> 'TVProgram': + """Initialize TV Program from XML Node, if possible. + + Cross-link is not done here, call cross_link_channel() after all programs are created. + + :param xml: XML Node + :return: TV Program object, or None + + XML node format is: + + WDR aktuell + vom 17.05.2024, 12:45 Uhr + Das Sendung bietet Nachrichten für und aus Nordrhein-Westfalen im Magazinformat. + S5 E34 + + """ + + # node must be a program + if xml.tag != cls.TAG: + return None + + # get start and end times + start = xml.attrib.get('start') + end = xml.attrib.get('stop') + if is_none_or_whitespace(start) or is_none_or_whitespace(end): + return None + + # parse start and end times + try: + start = datetime.strptime(start, '%Y%m%d%H%M%S %z') + end = datetime.strptime(end, '%Y%m%d%H%M%S %z') + except ValueError: + return None + + # get channel id + channel_id = xml.attrib.get('channel') + if is_none_or_whitespace(channel_id): + return None + + # get and validate program info + title = get_child_as_text(xml, 'title') + description = get_child_as_text(xml, 'desc') + episode = get_child_as_text(xml, 'episode-num') + subtitle = get_child_as_text(xml, 'sub-title') + + if is_none_or_whitespace(title) or is_none_or_whitespace(description): + return None + + try: + return cls(channel_id, start, end, title, description, episode, subtitle) + except ValueError: + return None diff --git a/custom_components/xmltv_epg/sensor.py b/custom_components/xmltv_epg/sensor.py index 1a5265e..874a260 100644 --- a/custom_components/xmltv_epg/sensor.py +++ b/custom_components/xmltv_epg/sensor.py @@ -12,7 +12,7 @@ from .coordinator import XMLTVDataUpdateCoordinator from .entity import XMLTVEntity -from .xmltv.model import TVGuide, TVChannel +from .model import TVGuide, TVChannel async def async_setup_entry(hass, entry, async_add_devices): """Set up the sensor platform.""" diff --git a/custom_components/xmltv_epg/xmltv/__init__.py b/custom_components/xmltv_epg/xmltv/__init__.py deleted file mode 100644 index 739dac6..0000000 --- a/custom_components/xmltv_epg/xmltv/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""XMLTV Client.""" diff --git a/custom_components/xmltv_epg/xmltv/model.py b/custom_components/xmltv_epg/xmltv/model.py deleted file mode 100644 index f99a99b..0000000 --- a/custom_components/xmltv_epg/xmltv/model.py +++ /dev/null @@ -1,287 +0,0 @@ -"""XMLTV Model Classes & Parsing.""" - -from datetime import datetime, timedelta -import xml.etree.ElementTree as ET - -def is_none_or_whitespace(s: str) -> bool: - """Check if string is None, empty, or whitespace.""" - return s is None or not isinstance(s, str) or len(s.strip()) == 0 - -def get_child_as_text(parent: ET.Element, tag: str) -> str: - """Get child node text as string, or None if not found.""" - node = parent.find(tag) - return node.text if node is not None else None - -class TVChannel: - """TV Channel Class.""" - - TAG = 'channel' - - def __init__(self, id: str, name: str): - """Initialize TV Channel.""" - self.id = id - self.name = name - self.programs = [] - - def add_program(self, program: 'TVProgram'): - """Add a program to channel.""" - self.programs.append(program) - - # keep programs sorted by start time - self.programs.sort(key=lambda p: p.start) - - def get_current_program(self, time: datetime) -> 'TVProgram': - """Get current program at given time.""" - for program in self.programs: - if program.start.timestamp() <= time.timestamp() < program.end.timestamp(): - return program - - return None - - def get_next_program(self, time: datetime) -> 'TVProgram': - """Get next program after given time.""" - for program in self.programs: - if program.start.timestamp() >= time.timestamp(): - return program - - return None - - @classmethod - def from_xml(cls, xml: ET.Element) -> 'TVChannel': - """Initialize TV Channel from XML Node, if possible. - - :param xml: XML Node - :return: TV Channel object, or None - - XML node format is: - - WDR Essen - - """ - - # node must be a channel - if xml.tag != cls.TAG: - return None - - # get id and display name - id = xml.attrib.get('id') - if is_none_or_whitespace(id): - return None - - name = get_child_as_text(xml, 'display-name') - if is_none_or_whitespace(name): - return None - - # remove 'XX: ' prefix from name, if present - if len(name) > 4 and name[2] == ':' and name[3] == ' ': # 'XX: ' - name = name[4:] - - return cls(id, name) - -class TVProgram: - """TV Program Class.""" - - TAG = 'programme' - - def __init__(self, - channel_id: str, - start: datetime, - end: datetime, - title: str, - description: str, - episode: str = None, - subtitle: str = None): - """Initialize TV Program.""" - if end <= start: - raise ValueError('End time must be after start time.') - - self._channel_id = channel_id - self.start = start - self.end = end - self.title = title - self.description = description - self.episode = episode - self.subtitle = subtitle - - self.channel = None - - def cross_link_channel(self, channels: list[TVChannel]): - """Set channel for program and cross-link. - - :param channels: List of TV Channels - """ - # find channel by id - channel = next((c for c in channels if c.id == self._channel_id), None) - if channel is None: - raise ValueError(f'Channel with ID "{self._channel_id}" not found.') - - # cross-link - self.channel = channel - self.channel.add_program(self) - - @property - def duration(self) -> timedelta: - """Get program duration.""" - return self.end - self.start - - @property - def full_title(self) -> str: - """Get the full title, including episode and / or subtitle, if available. - - Examples: - (1) - Title: 'Program 1' - Episode: None - Subtitle: None - Result: 'Program 1' - - (2) - Title: 'Program 1' - Episode: 'S1 E1' - Subtitle: None - Result: 'Program 1 (S1 E1)' - - (3) - Title: 'Program 1' - Episode: 'S1 E1' - Subtitle: 'Subtitle 1' - Result: 'Program 1 - Subtitle 1 (S1 E1)' - - (4) - Title: 'Program 1' - Episode: None - Subtitle: 'Subtitle 1' - Result: 'Program 1 - Subtitle 1' - - """ - title = self.title - - if not is_none_or_whitespace(self.subtitle): - title += f' - {self.subtitle}' - - if not is_none_or_whitespace(self.episode): - title += f' ({self.episode})' - - return title - - - @classmethod - def from_xml(cls, xml: ET.Element) -> 'TVProgram': - """Initialize TV Program from XML Node, if possible. - - Cross-link is not done here, call cross_link_channel() after all programs are created. - - :param xml: XML Node - :return: TV Program object, or None - - XML node format is: - - WDR aktuell - vom 17.05.2024, 12:45 Uhr - Das Sendung bietet Nachrichten für und aus Nordrhein-Westfalen im Magazinformat. - S5 E34 - - """ - - # node must be a program - if xml.tag != cls.TAG: - return None - - # get start and end times - start = xml.attrib.get('start') - end = xml.attrib.get('stop') - if is_none_or_whitespace(start) or is_none_or_whitespace(end): - return None - - # parse start and end times - try: - start = datetime.strptime(start, '%Y%m%d%H%M%S %z') - end = datetime.strptime(end, '%Y%m%d%H%M%S %z') - except ValueError: - return None - - # get channel id - channel_id = xml.attrib.get('channel') - if is_none_or_whitespace(channel_id): - return None - - # get and validate program info - title = get_child_as_text(xml, 'title') - description = get_child_as_text(xml, 'desc') - episode = get_child_as_text(xml, 'episode-num') - subtitle = get_child_as_text(xml, 'sub-title') - - if is_none_or_whitespace(title) or is_none_or_whitespace(description): - return None - - try: - return cls(channel_id, start, end, title, description, episode, subtitle) - except ValueError: - return None - -class TVGuide: - """TV Guide Class.""" - - TAG = 'tv' - - def __init__(self, generator_name: str = None, generator_url: str = None): - """Initialize TV Guide.""" - self.generator_name = generator_name - self.generator_url = generator_url - - self.channels = [] - self.programs = [] - - def get_channel(self, channel_id: str) -> TVChannel: - """Get channel by ID.""" - return next((c for c in self.channels if c.id == channel_id), None) - - @classmethod - def from_xml(cls, xml: ET.Element) -> 'TVGuide': - """Initialize TV Guide from XML Node, if possible. - - :param xml: XML Node - :return: TV Guide object, or None - - XML node format is: - - - - - """ - - # node must be a TV guide - if xml.tag != cls.TAG: - return None - - # parse generator info - generator_name = xml.attrib.get('generator-info-name') - generator_url = xml.attrib.get('generator-info-url') - - # create guide instance - guide = cls(generator_name, generator_url) - - # parse channels and programs - for child in xml: - if child.tag == TVChannel.TAG: - channel = TVChannel.from_xml(child) - if channel is not None: - # ensure no duplicate channel ids - if guide.get_channel(channel.id) is None: - guide.channels.append(channel) - else: - # ?! - continue - elif child.tag == TVProgram.TAG: - program = TVProgram.from_xml(child) - if program is not None: - guide.programs.append(program) - else: - # ?! - continue - - # cross-link programs with channels - for program in guide.programs: - program.cross_link_channel(guide.channels) - - return guide diff --git a/test/conftest.py b/test/conftest.py index eae91e6..9a6e0f5 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -4,10 +4,10 @@ pytest_plugins = "pytest_homeassistant_custom_component" -@pytest.fixture(autouse=True) -def auto_enable_custom_integrations(enable_custom_integrations): - """Enable loading custom integrations.""" - yield +@pytest.fixture() +def hass(hass, enable_custom_integrations): + """Return a Home Assistant instance that can load custom integrations.""" + yield hass @pytest.fixture def anyio_backend(): diff --git a/test/xmltv/__init__.py b/test/model/__init__.py similarity index 100% rename from test/xmltv/__init__.py rename to test/model/__init__.py diff --git a/test/xmltv/test_TVChannel.py b/test/model/test_TVChannel.py similarity index 96% rename from test/xmltv/test_TVChannel.py rename to test/model/test_TVChannel.py index b71ac74..3fef6f5 100644 --- a/test/xmltv/test_TVChannel.py +++ b/test/model/test_TVChannel.py @@ -3,7 +3,7 @@ from datetime import datetime import xml.etree.ElementTree as ET -from custom_components.xmltv_epg.xmltv.model import TVChannel, TVProgram +from custom_components.xmltv_epg.model import TVChannel, TVProgram def test_from_xml(): """Test TVChannel.from_xml method with valid input.""" diff --git a/test/xmltv/test_TVGuide.py b/test/model/test_TVGuide.py similarity index 94% rename from test/xmltv/test_TVGuide.py rename to test/model/test_TVGuide.py index 13e05be..9ce0122 100644 --- a/test/xmltv/test_TVGuide.py +++ b/test/model/test_TVGuide.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET -from custom_components.xmltv_epg.xmltv.model import TVGuide, TVChannel +from custom_components.xmltv_epg.model import TVGuide, TVChannel def test_from_xml(): diff --git a/test/xmltv/test_TVProgram.py b/test/model/test_TVProgram.py similarity index 98% rename from test/xmltv/test_TVProgram.py rename to test/model/test_TVProgram.py index 4064f32..7f3be04 100644 --- a/test/xmltv/test_TVProgram.py +++ b/test/model/test_TVProgram.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone import xml.etree.ElementTree as ET -from custom_components.xmltv_epg.xmltv.model import TVProgram, TVChannel +from custom_components.xmltv_epg.model import TVProgram, TVChannel def test_from_xml(): """Test TVProgram.from_xml method with valid input.""" diff --git a/test/test_config_flow.py b/test/test_config_flow.py index 9e29de0..e12be17 100644 --- a/test/test_config_flow.py +++ b/test/test_config_flow.py @@ -16,7 +16,7 @@ ) from custom_components.xmltv_epg.api import XMLTVClientCommunicationError, XMLTVClientError -from custom_components.xmltv_epg.xmltv.model import TVGuide +from custom_components.xmltv_epg.model import TVGuide XMLTV_CLIENT_DATA = TVGuide("MOCK", "http://example.com/epg.xml") diff --git a/test/test_coordinator.py b/test/test_coordinator.py index eedef13..0bfb53b 100644 --- a/test/test_coordinator.py +++ b/test/test_coordinator.py @@ -10,7 +10,7 @@ from custom_components.xmltv_epg.coordinator import XMLTVDataUpdateCoordinator from custom_components.xmltv_epg.api import XMLTVClient -from custom_components.xmltv_epg.xmltv.model import TVGuide +from custom_components.xmltv_epg.model import TVGuide TEST_NOW = datetime(2024, 5, 17, 12, 45, 0) XMLTV_CLIENT_DATA = TVGuide("MOCK", "http://example.com/epg.xml") diff --git a/test/test_init.py b/test/test_init.py index dbbb86b..d2d15a0 100644 --- a/test/test_init.py +++ b/test/test_init.py @@ -14,7 +14,7 @@ from custom_components.xmltv_epg.const import DOMAIN from custom_components.xmltv_epg.coordinator import XMLTVDataUpdateCoordinator -from custom_components.xmltv_epg.xmltv.model import TVGuide +from custom_components.xmltv_epg.model import TVGuide UPDATE_COORDINATOR_DATA = TVGuide("MOCK", "http://example.com/epg.xml") diff --git a/test/test_sensor.py b/test/test_sensor.py index 08d6418..280efa7 100644 --- a/test/test_sensor.py +++ b/test/test_sensor.py @@ -11,7 +11,7 @@ from custom_components.xmltv_epg import async_setup_entry from custom_components.xmltv_epg.const import DOMAIN, OPT_PROGRAM_LOOKAHEAD -from custom_components.xmltv_epg.xmltv.model import TVGuide, TVChannel, TVProgram +from custom_components.xmltv_epg.model import TVGuide, TVChannel, TVProgram TEST_NOW = datetime(2024, 5, 17, 12, 45, 0)