| @ -0,0 +1,40 @@ | |||||
| from dataclasses import dataclass, field | |||||
| from xml.etree.ElementTree import Element, tostring | |||||
| from dlhdhr.dlhd import DLHDChannel | |||||
| from dlhdhr.epg.zap2it import Zap2it | |||||
| from dlhdhr.epg.program import Program | |||||
| @dataclass() | |||||
| class EPG: | |||||
| zap2it: Zap2it = field(default_factory=Zap2it) | |||||
| async def get_channel_programs(self, channel: DLHDChannel) -> list[Program]: | |||||
| if channel.country_code == "us": | |||||
| return await self.zap2it.get_channel_programs(channel) | |||||
| elif channel.country_code == "uk": | |||||
| # TODO: TV24? TV Guide? | |||||
| return [] | |||||
| return [] | |||||
| async def generate_xmltv(self, channels: list[DLHDChannel]) -> bytes: | |||||
| tv = Element("tv", attrib={"generator-info-name": "dlhdhr"}) | |||||
| channels = [c for c in channels if c.xmltv_id] | |||||
| for channel in channels: | |||||
| tv.append(channel.to_xmltv()) | |||||
| for channel in channels: | |||||
| programs = await self.get_channel_programs(channel) | |||||
| # Note: The order of the elements in the <programme /> matters | |||||
| # title, desc, date, category, icon, episode-num, rating | |||||
| for program in programs: | |||||
| node = program.to_xmltv(channel) | |||||
| if node: | |||||
| tv.append(node) | |||||
| return tostring(tv) | |||||
| @ -0,0 +1,58 @@ | |||||
| import datetime | |||||
| from dataclasses import dataclass | |||||
| from xml.etree.ElementTree import Element, SubElement | |||||
| from dlhdhr.dlhd import DLHDChannel | |||||
| @dataclass(frozen=True) | |||||
| class Rating: | |||||
| system: str | |||||
| value: str | |||||
| @dataclass(frozen=True) | |||||
| class Program: | |||||
| start_time: datetime.datetime | |||||
| end_time: datetime.datetime | |||||
| title: str | |||||
| description: str | |||||
| tags: list[str] | |||||
| thumbnail: str | None | |||||
| season: int | None | |||||
| episode: int | None | |||||
| rating: Rating | None | |||||
| release_year: str | None | |||||
| def to_xmltv(self, channel: DLHDChannel) -> Element | None: | |||||
| if not channel.xmltv_id: | |||||
| return None | |||||
| start_time = self.start_time.strftime("%Y%m%d%H%M%S %z") | |||||
| end_time = self.start_time.strftime("%Y%m%d%H%M%S %z") | |||||
| programme = Element("programme", attrib={"start": start_time, "stop": end_time, "channel": channel.xmltv_id}) | |||||
| if self.title: | |||||
| SubElement(programme, "title", attrib={"lang": "en"}).text = self.title | |||||
| if self.description: | |||||
| SubElement(programme, "desc", attrib={"lang": "en"}).text = self.description | |||||
| if self.release_year: | |||||
| SubElement(programme, "date").text = self.release_year | |||||
| for tag in self.tags: | |||||
| SubElement(programme, "category", attrib={"lang": "en"}).text = tag | |||||
| if self.thumbnail: | |||||
| SubElement(programme, "icon", attrib={"src": self.thumbnail}) | |||||
| if self.season or self.episode: | |||||
| season_id = self.season or "" | |||||
| episode_id = self.episode or "" | |||||
| SubElement(programme, "episode-num", attrib={"system": "xmltv_ns"}).text = f"{season_id}.{episode_id}." | |||||
| if self.rating: | |||||
| rating = SubElement(programme, "rating", attrib={"system": self.rating.system}) | |||||
| SubElement(rating, "value").text = self.rating.value | |||||
| return programme | |||||
| @ -0,0 +1,132 @@ | |||||
| import datetime | |||||
| from dataclasses import dataclass, field | |||||
| import time | |||||
| import httpx | |||||
| from dlhdhr import config | |||||
| from dlhdhr.dlhd.channels import DLHDChannel | |||||
| from dlhdhr.epg.program import Program | |||||
| from dlhdhr.epg.program import Rating | |||||
| @dataclass() | |||||
| class Zap2it: | |||||
| _BASE_URL = "https://tvlistings.zap2it.com/api/" | |||||
| _listings: dict[str, Program] = field(default_factory=dict) | |||||
| _last_fetch: float = 0 | |||||
| def _get_client(self) -> httpx.AsyncClient: | |||||
| return httpx.AsyncClient( | |||||
| base_url=self._BASE_URL, | |||||
| timeout=2.0, | |||||
| verify=True, | |||||
| max_redirects=1, | |||||
| headers={ | |||||
| "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0", | |||||
| "Referer": "https://tvlistings.zap2it.com/?aid=gapzap", | |||||
| "Accept": "application/json", | |||||
| }, | |||||
| ) | |||||
| def _cleanup_listings(self) -> None: | |||||
| now = datetime.datetime.now(datetime.UTC) | |||||
| updated: dict[str, list[Program]] = {} | |||||
| for call_sign, programs in self._listings.items(): | |||||
| updated_programs = [p for p in programs if p.end_time > now] | |||||
| if updated_programs: | |||||
| updated[call_sign] = updated_programs | |||||
| self._listings = updated | |||||
| async def _fetch_listings(self, lineup_id: str, headend_id: str, postal_code: str) -> dict[str, list[Program]]: | |||||
| params = { | |||||
| "lineupId": lineup_id, | |||||
| "timespan": "6", | |||||
| "headendId": headend_id, | |||||
| "country": "USA", | |||||
| "timezone": "", | |||||
| "device": "X", | |||||
| "postalCode": postal_code, | |||||
| "isOverride": "true", | |||||
| "time": str(int(time.time())), | |||||
| "pref": "16,256", | |||||
| "userId": "-", | |||||
| "aid": "gapzap", | |||||
| "languagecode": "en-us", | |||||
| } | |||||
| listings: dict[str, list[Program]] = {} | |||||
| now = datetime.datetime.now(datetime.UTC) | |||||
| async with self._get_client() as client: | |||||
| res = await client.get("/grid", params=params) | |||||
| res.raise_for_status() | |||||
| data = res.json() | |||||
| for ch_data in data["channels"]: | |||||
| call_sign = ch_data["callSign"] | |||||
| programs = [] | |||||
| listings[call_sign] = programs | |||||
| for evt_data in ch_data["events"]: | |||||
| end_time = datetime.datetime.fromisoformat(evt_data["endTime"]) | |||||
| if end_time < now: | |||||
| continue | |||||
| rating = None | |||||
| if evt_data["rating"]: | |||||
| rating = Rating(system="MPAA", value=evt_data["rating"]) | |||||
| programs.append( | |||||
| Program( | |||||
| start_time=datetime.datetime.fromisoformat(evt_data["startTime"]), | |||||
| end_time=end_time, | |||||
| title=evt_data["program"]["title"], | |||||
| description=evt_data["program"]["shortDesc"], | |||||
| season=evt_data["program"]["season"], | |||||
| episode=evt_data["program"]["episode"], | |||||
| tags=evt_data["tags"], | |||||
| release_year=evt_data["program"]["releaseYear"], | |||||
| thumbnail=f"https://zap2it.tmsimg.com/assets/{evt_data['thumbnail']}.jpg?w=165", | |||||
| rating=rating, | |||||
| ) | |||||
| ) | |||||
| return listings | |||||
| async def _refresh_listings(self) -> dict[str, list[Program]]: | |||||
| self._cleanup_listings() | |||||
| now = time.time() | |||||
| if self._listings and now - self._last_fetch > config.ZAP2IT_REFRESH_DELAY: | |||||
| return self._listings | |||||
| east_coast_programs = await self._fetch_listings( | |||||
| lineup_id="USA-NY31519-DEFAULT", headend_id="NY31519", postal_code="10001" | |||||
| ) | |||||
| for call_sign, programs in east_coast_programs.items(): | |||||
| if call_sign in self._listings: | |||||
| self._listings[call_sign].extend(programs) | |||||
| else: | |||||
| self._listings[call_sign] = programs | |||||
| west_coast_programs = await self._fetch_listings( | |||||
| lineup_id="USA-CA66511-DEFAULT", headend_id="CA66511", postal_code="90001" | |||||
| ) | |||||
| for call_sign, programs in west_coast_programs.items(): | |||||
| if call_sign in self._listings: | |||||
| self._listings[call_sign].extend(programs) | |||||
| else: | |||||
| self._listings[call_sign] = programs | |||||
| return self._listings | |||||
| async def get_channel_programs(self, channel: DLHDChannel) -> list[Program]: | |||||
| if not channel.call_sign: | |||||
| return [] | |||||
| await self._refresh_listings() | |||||
| if channel.call_sign not in self._listings: | |||||
| return [] | |||||
| return self._listings[channel.call_sign] | |||||
| @ -1,61 +0,0 @@ | |||||
| from xml.etree.ElementTree import Element, SubElement, tostring | |||||
| from dlhdhr.dlhd import DLHDChannel | |||||
| from dlhdhr.zap2it import Zap2it | |||||
| async def generate_xmltv(channels: list[DLHDChannel], zap2it: Zap2it) -> bytes: | |||||
| tv = Element("tv", attrib={"generator-info-name": "dlhdhr"}) | |||||
| for channel in channels: | |||||
| if not channel.xmltv_id: | |||||
| continue | |||||
| ch_node = SubElement(tv, "channel", attrib={"id": channel.xmltv_id}) | |||||
| SubElement(ch_node, "display-name", attrib={"lang": "en"}).text = channel.name | |||||
| SubElement(ch_node, "lcn").text = channel.number | |||||
| for channel in channels: | |||||
| if not channel.call_sign: | |||||
| continue | |||||
| if not channel.xmltv_id: | |||||
| continue | |||||
| z_channel = await zap2it.get_channel(channel.call_sign) | |||||
| if not z_channel: | |||||
| continue | |||||
| # Note: The order of the elements in the <programme /> matters | |||||
| # title, desc, date, category, icon, episode-num, rating | |||||
| for event in z_channel.events: | |||||
| start_time = event.start_time.strftime("%Y%m%d%H%M%S %z") | |||||
| end_time = event.start_time.strftime("%Y%m%d%H%M%S %z") | |||||
| programme = SubElement( | |||||
| tv, "programme", attrib={"start": start_time, "stop": end_time, "channel": channel.xmltv_id} | |||||
| ) | |||||
| if event.program.title: | |||||
| SubElement(programme, "title", attrib={"lang": "en"}).text = event.program.title | |||||
| if event.program.short_desc: | |||||
| SubElement(programme, "desc", attrib={"lang": "en"}).text = event.program.short_desc | |||||
| if event.program.release_year: | |||||
| SubElement(programme, "date").text = event.program.release_year | |||||
| for tag in event.tags: | |||||
| SubElement(programme, "category", attrib={"lang": "en"}).text = tag | |||||
| if event.thumbnail: | |||||
| SubElement(programme, "icon", attrib={"src": event.thumbnail}) | |||||
| if event.program.season or event.program.episode: | |||||
| e_id = ".".join([event.program.season or "", event.program.episode or "", ""]) | |||||
| SubElement(programme, "episode-num", attrib={"system": "xmltv_ns"}).text = e_id | |||||
| if event.rating: | |||||
| rating = SubElement(programme, "rating", attrib={"system": "MPAA"}) | |||||
| SubElement(rating, "value").text = event.rating | |||||
| return tostring(tv) | |||||
| @ -1,150 +0,0 @@ | |||||
| import datetime | |||||
| from dataclasses import dataclass, field | |||||
| import time | |||||
| import httpx | |||||
| from dlhdhr import config | |||||
| class Zap2it: | |||||
| _BASE_URL = "https://tvlistings.zap2it.com/api/" | |||||
| _listings: dict[str, "Zap2it.Channel"] | |||||
| _last_fetch: float = 0 | |||||
| @dataclass | |||||
| class Program: | |||||
| title: str | |||||
| id: str | |||||
| short_desc: str | |||||
| season: str | None | |||||
| release_year: str | None | |||||
| episode: str | None | |||||
| episode_title: str | None | |||||
| series_id: str | None | |||||
| @dataclass | |||||
| class Event: | |||||
| duration: int | |||||
| start_time: datetime.datetime | |||||
| end_time: datetime.datetime | |||||
| thumbnail: str | |||||
| series_id: str | |||||
| rating: str | |||||
| tags: list[str] | |||||
| program: "Zap2it.Program" | |||||
| @dataclass | |||||
| class Channel: | |||||
| call_sign: str | |||||
| name: str | |||||
| number: str | |||||
| id: str | |||||
| thumbnail: str | |||||
| events: list["Zap2it.Event"] = field(default_factory=list) | |||||
| def __init__(self): | |||||
| self._listings = {} | |||||
| def _get_client(self) -> httpx.AsyncClient: | |||||
| return httpx.AsyncClient( | |||||
| base_url=self._BASE_URL, | |||||
| timeout=2.0, | |||||
| verify=True, | |||||
| max_redirects=1, | |||||
| headers={ | |||||
| "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0", | |||||
| "Referer": "https://tvlistings.zap2it.com/?aid=gapzap", | |||||
| "Accept": "application/json", | |||||
| }, | |||||
| ) | |||||
| def _cleanup_listings(self) -> None: | |||||
| now = datetime.datetime.now(datetime.UTC) | |||||
| updated: dict[str, "Zap2it.Channel"] = {} | |||||
| for channel in self._listings.values(): | |||||
| channel.events = [evt for evt in channel.events if evt.end_time > now] | |||||
| if channel.events: | |||||
| updated[channel.call_sign] = channel | |||||
| self._listings = updated | |||||
| async def _refresh_listings(self) -> list["Zap2it.Channel"]: | |||||
| self._cleanup_listings() | |||||
| now = time.time() | |||||
| if self._listings and now - self._last_fetch > config.ZAP2IT_REFRESH_DELAY: | |||||
| return list(self._listings.values()) | |||||
| params = { | |||||
| "lineupId": config.ZAP2IT_LINEUP_ID, | |||||
| "timespan": "6", | |||||
| "headendId": config.ZAP2IT_HEADEND_ID, | |||||
| "country": "USA", | |||||
| "timezone": "", | |||||
| "device": "X", | |||||
| "postalCode": config.ZAP2IT_POSTAL_CODE, | |||||
| "isOverride": "true", | |||||
| "time": str(int(time.time())), | |||||
| "pref": "16,256", | |||||
| "userId": "-", | |||||
| "aid": "gapzap", | |||||
| "languagecode": "en-us", | |||||
| } | |||||
| now = datetime.datetime.now(datetime.UTC) | |||||
| async with self._get_client() as client: | |||||
| res = await client.get("/grid", params=params) | |||||
| res.raise_for_status() | |||||
| data = res.json() | |||||
| for ch_data in data["channels"]: | |||||
| call_sign = ch_data["callSign"] | |||||
| if call_sign in self._listings: | |||||
| channel = self._listings[call_sign] | |||||
| else: | |||||
| thumbnail = ch_data["thumbnail"] | |||||
| if thumbnail.startswith("//"): | |||||
| thumbnail = f"https:{thumbnail}" | |||||
| channel = self.Channel( | |||||
| call_sign=call_sign, | |||||
| name=ch_data["affiliateName"], | |||||
| number=ch_data["channelNo"], | |||||
| id=ch_data["id"], | |||||
| thumbnail=thumbnail, | |||||
| ) | |||||
| self._listings[call_sign] = channel | |||||
| for evt_data in ch_data["events"]: | |||||
| end_time = datetime.datetime.fromisoformat(evt_data["endTime"]) | |||||
| if end_time < now: | |||||
| continue | |||||
| event = self.Event( | |||||
| duration=evt_data["duration"], | |||||
| rating=evt_data["rating"], | |||||
| tags=evt_data["tags"], | |||||
| thumbnail=f"https://zap2it.tmsimg.com/assets/{evt_data['thumbnail']}.jpg?w=165", | |||||
| series_id=evt_data["seriesId"], | |||||
| start_time=datetime.datetime.fromisoformat(evt_data["startTime"]), | |||||
| end_time=end_time, | |||||
| program=self.Program( | |||||
| title=evt_data["program"]["title"], | |||||
| id=evt_data["program"]["id"], | |||||
| short_desc=evt_data["program"]["shortDesc"], | |||||
| season=evt_data["program"]["season"], | |||||
| release_year=evt_data["program"]["releaseYear"], | |||||
| episode=evt_data["program"]["episode"], | |||||
| episode_title=evt_data["program"]["episodeTitle"], | |||||
| series_id=evt_data["program"]["seriesId"], | |||||
| ), | |||||
| ) | |||||
| channel.events.append(event) | |||||
| return list(self._listings.values()) | |||||
| async def get_channel(self, call_sign: str) -> Channel | None: | |||||
| await self._refresh_listings() | |||||
| return self._listings.get(call_sign) | |||||