From 45dce0647965ad77daee12957ab690eea0364b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Lavaud-Wernert?= Date: Thu, 13 Jun 2024 14:28:29 +0200 Subject: [PATCH] Add Orange auth and rework plugin architecture --- .github/workflows/ci.yml | 12 +- .gitignore | 3 +- .pylintrc | 11 - addon.xml | 7 +- pyproject.toml | 27 ++ requirements.txt | 4 +- resources/addon.py | 83 +++--- .../resource.language.en_gb/strings.po | 24 +- .../resource.language.fr_fr/strings.po | 24 +- resources/lib/__init__.py | 3 +- resources/lib/channelmanager.py | 32 +++ resources/lib/generators/__init__.py | 4 - resources/lib/generators/epg_generator.py | 85 ------ .../lib/generators/playlist_generator.py | 39 --- resources/lib/iptvmanager.py | 41 +-- resources/lib/provider_templates/__init__.py | 3 - resources/lib/provider_templates/orange.py | 158 ----------- resources/lib/providers/__init__.py | 34 ++- resources/lib/providers/cache_provider.py | 50 ++++ resources/lib/providers/fr/__init__.py | 8 +- resources/lib/providers/fr/orange.py | 64 ++--- resources/lib/providers/fr/orange_caraibe.py | 15 - resources/lib/providers/fr/orange_reunion.py | 38 --- resources/lib/providers/provider_interface.py | 24 +- resources/lib/providers/provider_wrapper.py | 42 --- resources/lib/utils.py | 95 ------- resources/lib/utils/__init__.py | 1 + resources/lib/utils/orange.py | 267 ++++++++++++++++++ resources/lib/utils/request.py | 27 ++ resources/lib/utils/xbmc.py | 60 ++++ resources/service.py | 43 --- resources/settings.xml | 11 +- 32 files changed, 593 insertions(+), 746 deletions(-) delete mode 100644 .pylintrc create mode 100644 pyproject.toml create mode 100644 resources/lib/channelmanager.py delete mode 100644 resources/lib/generators/__init__.py delete mode 100644 resources/lib/generators/epg_generator.py delete mode 100644 resources/lib/generators/playlist_generator.py delete mode 100644 resources/lib/provider_templates/__init__.py delete mode 100644 resources/lib/provider_templates/orange.py create mode 100644 resources/lib/providers/cache_provider.py delete mode 100644 resources/lib/providers/fr/orange_caraibe.py delete mode 100644 resources/lib/providers/fr/orange_reunion.py delete mode 100644 resources/lib/providers/provider_wrapper.py delete mode 100644 resources/lib/utils.py create mode 100644 resources/lib/utils/__init__.py create mode 100644 resources/lib/utils/orange.py create mode 100644 resources/lib/utils/request.py create mode 100644 resources/lib/utils/xbmc.py delete mode 100644 resources/service.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7335381..01c9c78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,22 +15,22 @@ jobs: strategy: fail-fast: false matrix: - kodi-version: [ matrix ] - python-version: [ 3.9 ] + kodi-version: [ nexus ] + python-version: [ 3.8 ] steps: - name: Check out ${{ github.sha }} from repository ${{ github.repository }} - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: pip install -r ./requirements.txt - - name: Run pylint - run: pylint ./resources + - name: Run ruff + run: ruff check . - name: Run kodi-addon-checker uses: xbmc/action-kodi-addon-checker@v1.2 diff --git a/.gitignore b/.gitignore index 8f5c8b8..5e88cd6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ +.ruff_cache .vscode *.py[cod] -__pycache__ \ No newline at end of file +__pycache__ diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 18c36ef..0000000 --- a/.pylintrc +++ /dev/null @@ -1,11 +0,0 @@ -[MASTER] -init-hook="from pylint.config import find_pylintrc; import os, sys; sys.path.append(os.path.dirname(find_pylintrc()))" - -[BASIC] -min-public-methods=1 - -[FORMAT] -max-line-length=140 - -[MESSAGES CONTROL] -disable=fixme diff --git a/addon.xml b/addon.xml index 73381bb..424136a 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -8,7 +8,6 @@ video - Watch TV channels provided by your Orange subscription from Kodi! This addon brings to Kodi all the TV channels included in your Orange subscription. Easy install via IPTV Manager. @@ -19,8 +18,8 @@ all MIT https://forum.kodi.tv/showthread.php?tid=360391 - https://github.com/BreizhReloaded/plugin.video.orange.fr - breizhreloaded@outlook.com + https://github.com/f-lawe/plugin.video.orange.fr + francois@lavaud.family resources/media/icon.png resources/media/fanart.jpg diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bf642a9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +requires-python = ">=3.8" + +[tool.ruff] +line-length = 120 +target-version = "py38" + +[tool.ruff.format] +docstring-code-format = false +docstring-code-line-length = "dynamic" +indent-style = "space" +line-ending = "auto" +quote-style = "double" +skip-magic-trailing-comma = false + +[tool.ruff.lint] +fixable = ["ALL"] +ignore = ["D203", "D213"] +select = [ + "E", # pycodestyle + "F", # Pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "I", # isort + "D", # pydocstyle +] diff --git a/requirements.txt b/requirements.txt index d10ee85..99cfcb6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -kodistubs==19.* -pylint==2.14.* +kodistubs==21.* +ruff==0.3.* diff --git a/resources/addon.py b/resources/addon.py index 83c68fe..f77e132 100644 --- a/resources/addon.py +++ b/resources/addon.py @@ -1,58 +1,47 @@ -# -*- coding: utf-8 -*- -"""Addon entry point""" -import routing # pylint: disable=import-error -import inputstreamhelper # pylint: disable=import-error -import xbmcgui -import xbmcplugin +"""Addon entry point.""" + +import sys +import xbmc +import xbmcplugin +from lib.channelmanager import ChannelManager from lib.iptvmanager import IPTVManager -from lib.providers import get_provider -from lib.utils import localize, log, LogLevel, ok_dialog +from lib.utils.xbmc import log, ok_dialog +from routing import Plugin -plugin = routing.Plugin() +router = Plugin() -@plugin.route('/') + +@router.route("/") def index(): - """Addon index""" - ok_dialog(localize(30902)) + """Display a welcome message.""" + log("Hello from plugin.video.orange.fr", xbmc.LOGINFO) + ok_dialog("Hello from plugin.video.orange.fr") + -@plugin.route('/channel/') +@router.route("/channels/") def channel(channel_id: str): - """Load stream for the required channel id""" - log(f'Loading channel {channel_id}', LogLevel.INFO) - - stream = get_provider().get_stream_info(channel_id) - if not stream: - ok_dialog(localize(30900)) - return - - is_helper = inputstreamhelper.Helper(stream['manifest_type'], drm=stream['drm']) - if not is_helper.check_inputstream(): - ok_dialog(localize(30901)) - return - - listitem = xbmcgui.ListItem(path=stream['path']) - listitem.setMimeType(stream['mime_type']) - listitem.setProperty('inputstream', 'inputstream.adaptive') - listitem.setProperty('inputstream.adaptive.manifest_type', stream['manifest_type']) - listitem.setProperty('inputstream.adaptive.manifest_update_parameter', 'full') - listitem.setProperty('inputstream.adaptive.license_type', stream['license_type']) - listitem.setProperty('inputstream.adaptive.license_key', stream['license_key']) - xbmcplugin.setResolvedUrl(plugin.handle, True, listitem=listitem) - -@plugin.route('/iptv/channels') + """Load stream for the required channel id.""" + log(f"Loading channel {channel_id}", xbmc.LOGINFO) + xbmcplugin.setResolvedUrl(router.handle, True, listitem=ChannelManager().load_channel_listitem(channel_id)) + + +@router.route("/iptv/channels") def iptv_channels(): - """Return JSON-STREAMS formatted data for all live channels""" - log('Loading channels for IPTV Manager', LogLevel.INFO) - port = int(plugin.args.get('port')[0]) - IPTVManager(port, get_provider()).send_channels() + """Return JSON-STREAMS formatted data for all live channels.""" + log("Loading channels for IPTV Manager", xbmc.LOGINFO) + port = int(router.args.get("port")[0]) + IPTVManager(port).send_channels() -@plugin.route('/iptv/epg') + +@router.route("/iptv/epg") def iptv_epg(): - """Return JSON-EPG formatted data for all live channel EPG data""" - log('Loading EPG for IPTV Manager', LogLevel.INFO) - port = int(plugin.args.get('port')[0]) - IPTVManager(port, get_provider()).send_epg() + """Return JSON-EPG formatted data for all live channel EPG data.""" + log("Loading EPG for IPTV Manager") + port = int(router.args.get("port")[0]) + IPTVManager(port).send_epg() + -if __name__ == '__main__': - plugin.run() +if __name__ == "__main__": + log(sys.version, xbmc.LOGINFO) + router.run() diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 1ac7c99..a34ada0 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -19,37 +19,21 @@ msgid "TV Integration" msgstr "" msgctxt "#30101" -msgid "Enable IPTV Manager integration" +msgid "Install IPTV Manager…" msgstr "" msgctxt "#30102" -msgid "Help 30102" -msgstr "" +msgid "Help 30104" +msgstr " msgctxt "#30103" -msgid "Install IPTV Manager…" +msgid "Go to IPTV Manager settings…" msgstr "" msgctxt "#30104" msgid "Help 30104" msgstr "" -msgctxt "#30105" -msgid "Go to IPTV Manager settings…" -msgstr "" - -msgctxt "#30106" -msgid "Help 30106" -msgstr "" - -msgctxt "#30111" -msgid "Enable basic integration" -msgstr "" - -msgctxt "#30112" -msgid "Help 30112" -msgstr "" - # Provider settings (from 30200 to 30299) msgctxt "#30200" diff --git a/resources/language/resource.language.fr_fr/strings.po b/resources/language/resource.language.fr_fr/strings.po index 060f388..7484523 100644 --- a/resources/language/resource.language.fr_fr/strings.po +++ b/resources/language/resource.language.fr_fr/strings.po @@ -19,35 +19,19 @@ msgid "TV Integration" msgstr "Intégration TV" msgctxt "#30101" -msgid "Enable IPTV Manager integration" -msgstr "Activer l'intégration avec IPTV Manager" - -msgctxt "#30102" -msgid "Help 30102" -msgstr "" - -msgctxt "#30103" msgid "Install IPTV Manager…" msgstr "Installer IPTV Manager…" -msgctxt "#30104" +msgctxt "#30102" msgid "Help 30104" msgstr "" -msgctxt "#30105" +msgctxt "#30103" msgid "Go to IPTV Manager settings…" msgstr "Ouvrir les paramètres de IPTV Manager…" -msgctxt "#30106" -msgid "Help 30106" -msgstr "" - -msgctxt "#30111" -msgid "Enable basic integration" -msgstr "Activer l'intégration basique" - -msgctxt "#30112" -msgid "Help 30112" +msgctxt "#30104" +msgid "Help 30104" msgstr "" # Provider settings (from 30200 to 30299) diff --git a/resources/lib/__init__.py b/resources/lib/__init__.py index d7bd267..6e03199 100644 --- a/resources/lib/__init__.py +++ b/resources/lib/__init__.py @@ -1,2 +1 @@ -# -*- coding: utf-8 -*- -# pylint: disable=missing-module-docstring +# noqa: D104 diff --git a/resources/lib/channelmanager.py b/resources/lib/channelmanager.py new file mode 100644 index 0000000..2390c45 --- /dev/null +++ b/resources/lib/channelmanager.py @@ -0,0 +1,32 @@ +"""Channel stream loader.""" + +import inputstreamhelper +import xbmcgui + +from lib.providers import get_provider +from lib.utils.xbmc import localize, ok_dialog + + +class ChannelManager: + """.""" + + def load_channel_listitem(self, channel_id: str): + """.""" + stream = get_provider().get_stream_info(channel_id) + if not stream: + ok_dialog(localize(30900)) + return + + is_helper = inputstreamhelper.Helper(stream["manifest_type"], drm=stream["drm"]) + if not is_helper.check_inputstream(): + ok_dialog(localize(30901)) + return + + listitem = xbmcgui.ListItem(path=stream["path"]) + listitem.setMimeType(stream["mime_type"]) + listitem.setContentLookup(False) + listitem.setProperty("inputstream", "inputstream.adaptive") + listitem.setProperty("inputstream.adaptive.license_type", stream["license_type"]) + listitem.setProperty("inputstream.adaptive.license_key", stream["license_key"]) + + return listitem diff --git a/resources/lib/generators/__init__.py b/resources/lib/generators/__init__.py deleted file mode 100644 index 3388efc..0000000 --- a/resources/lib/generators/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -# pylint: disable=missing-module-docstring -from .epg_generator import EPGGenerator -from .playlist_generator import PlaylistGenerator diff --git a/resources/lib/generators/epg_generator.py b/resources/lib/generators/epg_generator.py deleted file mode 100644 index 10783b9..0000000 --- a/resources/lib/generators/epg_generator.py +++ /dev/null @@ -1,85 +0,0 @@ -# -*- coding: utf-8 -*- -"""Generate XMLTV file based on the provided channels and programs""" -from datetime import datetime -from xml.dom import minidom - -from lib.providers import ProviderInterface - -class EPGGenerator: - """This class provides tools to generate an XMLTV file based on the given channel and program information""" - - def __init__(self, provider: ProviderInterface) -> None: - implementation = minidom.getDOMImplementation('') - doctype = implementation.createDocumentType('tv', None, 'xmltv.dtd') - self.document = implementation.createDocument(None, 'tv', doctype) - self.document.documentElement.setAttribute('source-info-url', 'https://mediation-tv.orange.fr') - self.document.documentElement.setAttribute('source-data-url', 'https://mediation-tv.orange.fr') - self.provider = provider - self._load_streams() - self._load_epg() - - def _load_streams(self) -> None: - """Add channels to the XML document""" - for stream in self.provider.get_streams(): - channel_element = self.document.createElement('channel') - channel_element.setAttribute('id', stream['id']) - - display_name_element = self.document.createElement('display-name') - display_name_element.appendChild(self.document.createTextNode(stream['name'])) - channel_element.appendChild(display_name_element) - - icon_element = self.document.createElement('icon') - icon_element.setAttribute('src', stream['logo']) - channel_element.appendChild(icon_element) - - self.document.documentElement.appendChild(channel_element) - - def _load_epg(self) -> None: - """Add programs to the XML document""" - for channel_id, programs in self.provider.get_epg().items(): - for program in programs: - program_element = self.document.createElement('programme') - program_element.setAttribute( - 'start', - datetime.fromisoformat(program['start']).strftime('%Y%m%d%H%M%S %z') - ) - program_element.setAttribute( - 'stop', - datetime.fromisoformat(program['stop']).strftime('%Y%m%d%H%M%S %z') - ) - program_element.setAttribute('channel', channel_id) - - title_element = self.document.createElement('title') - title_element.appendChild(self.document.createTextNode(program['title'])) - program_element.appendChild(title_element) - - if program['subtitle'] is not None: - sub_title_element = self.document.createElement('sub-title') - sub_title_element.appendChild(self.document.createTextNode(program['subtitle'])) - program_element.appendChild(sub_title_element) - - desc_element = self.document.createElement('desc') - desc_element.setAttribute('lang', 'fr') - desc_element.appendChild(self.document.createTextNode(program['description'])) - program_element.appendChild(desc_element) - - category_element = self.document.createElement('category') - category_element.appendChild(self.document.createTextNode(program['genre'])) - program_element.appendChild(category_element) - - icon_element = self.document.createElement('icon') - icon_element.setAttribute('src', program['image']) - program_element.appendChild(icon_element) - - if program['episode'] is not None: - episode_num_element = self.document.createElement('episode-num') - episode_num_element.setAttribute('system', 'onscreen') - episode_num_element.appendChild(self.document.createTextNode(program['episode'])) - program_element.appendChild(episode_num_element) - - self.document.documentElement.appendChild(program_element) - - def write(self, filepath) -> None: - """Write the loaded channels and programs into XMLTV file""" - with open(filepath, 'wb') as file: - file.write(self.document.toprettyxml(indent=' ', encoding='utf-8')) diff --git a/resources/lib/generators/playlist_generator.py b/resources/lib/generators/playlist_generator.py deleted file mode 100644 index b86074a..0000000 --- a/resources/lib/generators/playlist_generator.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -"""Generate playlist based on available channels""" -from lib.providers import ProviderInterface - -class PlaylistGenerator: - """This class provides tools to generate a playlist based on the given channel information""" - - def __init__(self, provider: ProviderInterface): - self.entries = ['#EXTM3U tvg-shift=0', ''] - self.provider = provider - self._load_streams() - - # pylint: disable=line-too-long - def _load_streams(self): - """Load streams from provider""" - - stream_template = [ - '## {name}', - '#EXTINF:-1 tvg-name="{name}" tvg-id="{id}" tvg-logo="{logo}" tvg-chno="{chno}" group-title="Orange TV France;{group}",{name}', - '{stream}', - '' - ] - - for stream in self.provider.get_streams(): - self.entries.append(stream_template[0].format(name=stream['name'])) - self.entries.append(stream_template[1].format( - name=stream['name'], - id=stream['id'], - logo=stream['logo'], - chno=stream['preset'], - group=','.join(stream['group']) - )) - self.entries.append(stream_template[2].format(stream=stream['stream'])) - self.entries.append(stream_template[3]) - - def write(self, filepath: str): - """Write the loaded channels into M3U8 file""" - with open(filepath, 'wb') as file: - file.writelines(f'{entry}\n'.encode('utf-8') for entry in self.entries) diff --git a/resources/lib/iptvmanager.py b/resources/lib/iptvmanager.py index c1a5ca4..7bf8c39 100644 --- a/resources/lib/iptvmanager.py +++ b/resources/lib/iptvmanager.py @@ -1,39 +1,40 @@ -# -*- coding: utf-8 -*- -"""IPTV Manager Integration module""" +"""IPTV Manager Integration module.""" + import json import socket +from typing import Any, Callable + +from lib.providers import get_provider -from lib.providers import ProviderInterface class IPTVManager: - """IPTV Manager interface""" + """Interface to IPTV Manager.""" - def __init__(self, port: int, provider: ProviderInterface): - """Initialize IPTV Manager object""" + def __init__(self, port: int): + """Initialize IPTV Manager object.""" self.port = port - self.provider = provider + self.provider = get_provider() - # pylint: disable=no-self-argument - def via_socket(func): - """Send the output of the wrapped function to socket""" + def via_socket(func: Callable[[Any], Any]): + """Send the output of the wrapped function to socket.""" - def send(self): - """Decorator to send over a socket""" + def send(self) -> None: + """Decorate to send over a socket.""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect(('127.0.0.1', self.port)) + sock.connect(("127.0.0.1", self.port)) try: - sock.sendall(json.dumps(func(self)).encode()) # pylint: disable=not-callable + sock.sendall(json.dumps(func(self)).encode()) finally: sock.close() return send @via_socket - def send_channels(self) -> dict: - """Return JSON-STREAMS formatted python datastructure to IPTV Manager""" - return { 'version': 1, 'streams': self.provider.get_streams() } + def send_channels(self): + """Return JSON-STREAMS formatted python datastructure to IPTV Manager.""" + return dict(version=1, streams=self.provider.get_streams()) @via_socket - def send_epg(self) -> dict: - """Return JSON-EPG formatted python data structure to IPTV Manager""" - return { 'version': 1, 'epg': self.provider.get_epg() } + def send_epg(self): + """Return JSON-EPG formatted python data structure to IPTV Manager.""" + return dict(version=1, epg=self.provider.get_epg()) diff --git a/resources/lib/provider_templates/__init__.py b/resources/lib/provider_templates/__init__.py deleted file mode 100644 index 74197bc..0000000 --- a/resources/lib/provider_templates/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- -# pylint: disable=missing-module-docstring -from .orange import OrangeTemplate diff --git a/resources/lib/provider_templates/orange.py b/resources/lib/provider_templates/orange.py deleted file mode 100644 index 2484b5e..0000000 --- a/resources/lib/provider_templates/orange.py +++ /dev/null @@ -1,158 +0,0 @@ -# -*- coding: utf-8 -*- -"""Orange Template""" -from dataclasses import dataclass -from datetime import date, datetime, timedelta -import json -from urllib.error import HTTPError -from urllib.parse import urlparse -from urllib.request import Request, urlopen - -from lib.providers.provider_interface import ProviderInterface -from lib.utils import get_drm, get_global_setting, log, LogLevel, random_ua - -@dataclass -class OrangeTemplate(ProviderInterface): - """This template helps creating providers based on the Orange architecture""" - chunks_per_day: int = 2 - - def __init__( - self, - endpoint_stream_info: str, - endpoint_streams: str, - endpoint_programs: str, - groups: dict = None - ) -> None: - self.endpoint_stream_info = endpoint_stream_info - self.endpoint_streams = endpoint_streams - self.endpoint_programs = endpoint_programs - self.groups = groups - - def get_stream_info(self, channel_id: int) -> dict: - req = Request(self.endpoint_stream_info.format(channel_id=channel_id), headers={ - 'User-Agent': random_ua(), - 'Host': urlparse(self.endpoint_stream_info).netloc - }) - - try: - with urlopen(req) as res: - stream_info = json.loads(res.read()) - except HTTPError as error: - if error.code == 403: - return False - - drm = get_drm() - license_server_url = None - for system in stream_info.get('protectionData'): - if system.get('keySystem') == drm.value: - license_server_url = system.get('laUrl') - - headers = f'Content-Type=&User-Agent={random_ua()}&Host={urlparse(license_server_url).netloc}' - post_data = 'R{SSM}' - response = '' - - stream_info = { - 'path': stream_info['url'], - 'mime_type': 'application/xml+dash', - 'manifest_type': 'mpd', - 'drm': drm.name.lower(), - 'license_type': drm.value, - 'license_key': f'{license_server_url}|{headers}|{post_data}|{response}' - } - - log(stream_info, LogLevel.DEBUG) - return stream_info - - def get_streams(self) -> list: - req = Request(self.endpoint_streams, headers={ - 'User-Agent': random_ua(), - 'Host': urlparse(self.endpoint_streams).netloc - }) - - with urlopen(req) as res: - channels = json.loads(res.read()) - - streams = [] - - for channel in channels: - channel_id: str = channel['id'] - streams.append({ - 'id': channel_id, - 'name': channel['name'], - 'preset': channel['zappingNumber'], - 'logo': channel['logos']['square'].replace('%2F/', '%2F') if 'square' in channel['logos'] else None, - 'stream': f'plugin://plugin.video.orange.fr/channel/{channel_id}', - 'group': [group_name for group_name in self.groups if int(channel['id']) in self.groups[group_name]] - }) - - return streams - - def get_epg(self) -> dict: - start_day = datetime.timestamp( - datetime.combine( - date.today() - timedelta(days=int(get_global_setting('epg.pastdaystodisplay'))), - datetime.min.time() - ) - ) - - days_to_display = int(get_global_setting('epg.futuredaystodisplay')) \ - + int(get_global_setting('epg.pastdaystodisplay')) - - chunk_duration = 24 * 60 * 60 / self.chunks_per_day - programs = [] - - for chunk in range(0, days_to_display * self.chunks_per_day): - programs.extend(self._get_programs( - period_start=(start_day + chunk_duration * chunk) * 1000, - period_end=(start_day + chunk_duration * (chunk + 1)) * 1000 - )) - - epg = {} - - for program in programs: - if not program['channelId'] in epg: - epg[program['channelId']] = [] - - if program['programType'] != 'EPISODE': - title = program['title'] - subtitle = None - episode = None - else: - title = program['season']['serie']['title'] - subtitle = program['title'] - season_number = program['season']['number'] - episode_number = program['episodeNumber'] if 'episodeNumber' in program else None - episode = f'S{season_number}E{episode_number}' - - image = None - if isinstance(program['covers'], list): - for cover in program['covers']: - if cover['format'] == 'RATIO_16_9': - image = program['covers'][0]['url'] - - epg[program['channelId']].append({ - 'start': datetime.fromtimestamp(program['diffusionDate']).astimezone().replace(microsecond=0).isoformat(), - 'stop': (datetime.fromtimestamp(program['diffusionDate'] + program['duration']).astimezone()).isoformat(), - 'title': title, - 'subtitle': subtitle, - 'episode': episode, - 'description': program['synopsis'], - 'genre': program['genre'] if program['genreDetailed'] is None else program['genreDetailed'], - 'image': image - }) - - return epg - - def _get_programs(self, period_start: int = None, period_end: int = None) -> list: - """Returns the programs for today (default) or the specified period""" - try: - period = f'{int(period_start)},{int(period_end)}' - except ValueError: - period = 'today' - - req = Request(self.endpoint_programs.format(period=period), headers={ - 'User-Agent': random_ua(), - 'Host': urlparse(self.endpoint_programs).netloc - }) - - with urlopen(req) as res: - return json.loads(res.read()) diff --git a/resources/lib/providers/__init__.py b/resources/lib/providers/__init__.py index 243134b..2bcdde5 100644 --- a/resources/lib/providers/__init__.py +++ b/resources/lib/providers/__init__.py @@ -1,27 +1,25 @@ -# -*- coding: utf-8 -*- -"""List all available providers and return the provider selected by the user""" -from lib.utils import get_addon_setting, log, LogLevel +"""List all available providers and return the provider selected by the user.""" -from .provider_interface import ProviderInterface -from .provider_wrapper import ProviderWrapper -from .fr import OrangeFranceProvider, OrangeCaraibeProvider, OrangeReunionProvider +import xbmc + +from lib.utils.xbmc import get_addon_setting, log -_PROVIDERS = { - 'France.Orange': OrangeFranceProvider, - 'France.Orange Caraïbe': OrangeCaraibeProvider, - 'France.Orange Réunion': OrangeReunionProvider -} +from .cache_provider import CacheProvider +from .fr import OrangeFranceProvider +from .provider_interface import ProviderInterface -name: str = get_addon_setting('provider.name') -country: str = get_addon_setting('provider.country') +_PROVIDERS = {"France.Orange": OrangeFranceProvider} -_KEY = f'{country}.{name}' +_PROVIDER_NAME: str = get_addon_setting("provider.name") +_PROVIDER_COUNTRY: str = get_addon_setting("provider.country") +_PROVIDER_KEY = f"{_PROVIDER_COUNTRY}.{_PROVIDER_NAME}" -_PROVIDER = _PROVIDERS[_KEY]() if _PROVIDERS.get(_KEY) is not None else None +_PROVIDER = _PROVIDERS[_PROVIDER_KEY]() if _PROVIDERS.get(_PROVIDER_KEY) is not None else None if not _PROVIDER: - log(f'Cannot instanciate provider: {_KEY}', LogLevel.ERROR) + log(f"Cannot instanciate provider: {_PROVIDER_KEY}", xbmc.LOGERROR) + def get_provider() -> ProviderInterface: - """Return the selected provider""" - return ProviderWrapper(_PROVIDER) + """Return the selected provider.""" + return CacheProvider(_PROVIDER) diff --git a/resources/lib/providers/cache_provider.py b/resources/lib/providers/cache_provider.py new file mode 100644 index 0000000..7fdf002 --- /dev/null +++ b/resources/lib/providers/cache_provider.py @@ -0,0 +1,50 @@ +"""Cache provider.""" + +import json +import os +from urllib.error import URLError + +import xbmc +import xbmcvfs + +from lib.utils.xbmc import get_addon_info, log + +from .provider_interface import ProviderInterface + + +class CacheProvider(ProviderInterface): + """Provider wrapper bringing cache capabilities on top of supplied provider.""" + + cache_folder = os.path.join(xbmcvfs.translatePath(get_addon_info("profile")), "cache") + + def __init__(self, provider: ProviderInterface) -> None: + """Initialize CacheProvider with TV provider.""" + self.provider = provider + + log(f"Cache folder: {self.cache_folder}", xbmc.LOGDEBUG) + + if not os.path.exists(self.cache_folder): + os.makedirs(self.cache_folder) + + def get_stream_info(self, channel_id: int) -> dict: + """Get stream information (MPD address, Widewine key) for the specified id. Required keys: path, mime_type, manifest_type, drm, license_type, license_key.""" # noqa: E501 + return self.provider.get_stream_info(channel_id) + + def get_streams(self) -> list: + """Retrieve all the available channels and the the associated information (name, logo, preset, etc.) following JSON-STREAMS format.""" # noqa: E501 + streams = [] + + try: + streams = self.provider.get_streams() + with open(os.path.join(self.cache_folder, "streams.json"), "wb") as file: + file.write(json.dumps(streams).encode("utf-8")) + except URLError: + log("Can't reach server: load streams from cache", xbmc.LOGWARNING) + with open(os.path.join(self.cache_folder, "streams.json"), encoding="utf-8") as file: + streams = json.loads("".join(file.readlines())) + + return streams + + def get_epg(self) -> dict: + """Return EPG data for the specified period following JSON-EPG format.""" + return self.provider.get_epg() diff --git a/resources/lib/providers/fr/__init__.py b/resources/lib/providers/fr/__init__.py index db15907..93aafa4 100644 --- a/resources/lib/providers/fr/__init__.py +++ b/resources/lib/providers/fr/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# pylint: disable=missing-module-docstring -from .orange import OrangeFranceProvider -from .orange_caraibe import OrangeCaraibeProvider -from .orange_reunion import OrangeReunionProvider +""".""" + +from .orange import OrangeFranceProvider as OrangeFranceProvider diff --git a/resources/lib/providers/fr/orange.py b/resources/lib/providers/fr/orange.py index aad2e21..b888de3 100644 --- a/resources/lib/providers/fr/orange.py +++ b/resources/lib/providers/fr/orange.py @@ -1,46 +1,22 @@ -# -*- coding: utf-8 -*- -"""Orange France""" -from lib.provider_templates import OrangeTemplate +"""Orange France.""" -class OrangeFranceProvider(OrangeTemplate): - """Orange France provider""" +from lib.providers.provider_interface import ProviderInterface +from lib.utils.orange import get_epg, get_stream_info, get_streams - # pylint: disable=line-too-long - def __init__(self) -> None: - super().__init__( - endpoint_stream_info = 'https://mediation-tv.orange.fr/all/live/v3/applications/PC/users/me/channels/{channel_id}/stream?terminalModel=WEB_PC', - endpoint_streams = 'https://mediation-tv.orange.fr/all/live/v3/applications/PC/channels?mco=OFR', - endpoint_programs = 'https://rp-ott-mediation-tv.woopic.com/api-gw/live/v3/applications/PC/programs?period={period}&mco=OFR', - groups = { - 'TNT': \ - [192, 4, 80, 34, 47, 118, 111, 445, 119, 195, 446, 444, 234, 78, 481, 226, 458, 482, 3163, 1404, 1401, 1403, 1402, 1400, 1399, 112, 2111], - 'Généralistes': \ - [205, 191, 145, 115, 225], - 'Premium': \ - [1290, 1304, 1335, 730, 733, 732, 734], - 'Cinéma': \ - [185, 1562, 2072, 10, 282, 284, 283, 401, 285, 287, 1190], - 'Divertissement': \ - [128, 1960, 5, 121, 2441, 2752, 87, 1167, 54, 2326, 2334, 49, 1408, 1832], - 'Jeunesse': \ - [2803, 321, 928, 924, 229, 32, 888, 473, 2065, 1746, 58, 299, 300, 36, 344, 197, 293], - 'Découverte': \ - [90112, 1072, 12, 2037, 38, 7, 88, 451, 829, 63, 508, 719, 147, 662, 402], - 'Jeunes': \ - [563, 2942, 2353, 2442, 6, 2040, 1585, 2171, 2781], - 'Musique': \ - [90150, 605, 2006, 1989, 453, 90159, 265, 90161, 90162, 90165, 2958, 125, 907, 1353], - 'Sport': \ - [64, 2837, 1336, 1337, 1338, 1339, 1340, 1341, 1342, 15, 1166], - 'Jeux': \ - [1061], - 'Société': \ - [1996, 531, 90216, 57, 110, 90221], - 'Information française': \ - [992, 90226, 1073, 140, 90230, 90231], - 'Information internationnale': \ - [671, 90233, 53, 51, 410, 19, 525, 90239, 90240, 90241, 90242, 781, 830, 90246], - 'France 3 Régions': \ - [655, 249, 304, 649, 647, 636, 634, 306, 641, 308, 642, 637, 646, 650, 638, 640, 651, 644, 313, 635, 645, 639, 643, 648] - } - ) + +class OrangeFranceProvider(ProviderInterface): + """Orange France provider.""" + + groups = {} + + def get_stream_info(self, channel_id: str) -> dict: + """Get stream information (MPD address, Widewine key) for the specified id. Required keys: path, mime_type, manifest_type, drm, license_type, license_key.""" # noqa: E501 + return get_stream_info(channel_id, "OFR") + + def get_streams(self) -> list: + """Retrieve all the available channels and the the associated information (name, logo, preset, etc.) following JSON-STREAMS format.""" # noqa: E501 + return get_streams(self.groups, "OFR") + + def get_epg(self) -> dict: + """Return EPG data for the specified period following JSON-EPG format.""" + return get_epg(2, "OFR") diff --git a/resources/lib/providers/fr/orange_caraibe.py b/resources/lib/providers/fr/orange_caraibe.py deleted file mode 100644 index 2bbfa14..0000000 --- a/resources/lib/providers/fr/orange_caraibe.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -"""Orange Caraïbe""" -from lib.provider_templates import OrangeTemplate - -class OrangeCaraibeProvider(OrangeTemplate): - """Orange Caraïbe provider""" - - # pylint: disable=line-too-long - def __init__(self) -> None: - super().__init__( - endpoint_stream_info = 'https://mediation-tv.orange.fr/all/live/v3/applications/PC/users/me/channels/{channel_id}/stream?terminalModel=WEB_PC', - endpoint_streams = 'https://mediation-tv.orange.fr/all/live/v3/applications/PC/channels?mco=OCA', - endpoint_programs = 'https://mediation-tv.orange.fr/all/live/v3/applications/PC/programs?period={period}&mco=OCA', - groups = {} - ) diff --git a/resources/lib/providers/fr/orange_reunion.py b/resources/lib/providers/fr/orange_reunion.py deleted file mode 100644 index 9bfc326..0000000 --- a/resources/lib/providers/fr/orange_reunion.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- coding: utf-8 -*- -"""Orange Réunion""" -from lib.provider_templates import OrangeTemplate - -class OrangeReunionProvider(OrangeTemplate): - """Orange Réunion provider""" - - # pylint: disable=line-too-long - def __init__(self) -> None: - super().__init__( - endpoint_stream_info = 'https://mediation-tv.orange.fr/all/live/v3/applications/PC/users/me/channels/{channel_id}/stream?terminalModel=WEB_PC', - endpoint_streams = 'https://mediation-tv.orange.fr/all/live/v3/applications/PC/channels?mco=ORE', - endpoint_programs = 'https://mediation-tv.orange.fr/all/live/v3/applications/PC/programs?period={period}&mco=ORE', - groups = { - 'Généralistes': \ - [20245, 21079, 1080, 70005, 192, 4, 80, 47, 20118, 78], - 'Divertissement': \ - [30195, 1996, 531, 70216, 57, 70397, 70398, 70399], - 'Jeunesse': \ - [30482], - 'Découverte': \ - [111, 30445], - 'Jeunes': \ - [30444, 20119, 21404, 21403, 563], - 'Musique': \ - [20458, 21399, 70150, 605], - 'Sport': \ - [64, 2837], - 'Jeux': \ - [1061], - 'Société': \ - [1072], - 'Information française': \ - [234, 481, 226, 112, 2111, 529, 1073], - 'Information internationale': \ - [671, 53, 51, 410, 19, 525, 70239, 70240, 70241, 70242, 781, 830, 70246, 70503] - } - ) diff --git a/resources/lib/providers/provider_interface.py b/resources/lib/providers/provider_interface.py index de30289..5fadb9e 100644 --- a/resources/lib/providers/provider_interface.py +++ b/resources/lib/providers/provider_interface.py @@ -1,24 +1,14 @@ -# -*- coding: utf-8 -*- -"""PROVIDER INTERFACE""" +"""TV Provider interface.""" + class ProviderInterface: - """This interface provides methods to be implemented for each ISP""" + """Provide methods to be implemented by each ISP.""" - def get_stream_info(self, channel_id: int) -> dict: - """ - Get stream information (MPD address, Widewine key) for the specified id. - Required keys: path, mime_type, manifest_type, drm, license_type, license_key - """ + def get_stream_info(self, channel_id: str) -> dict: + """Get stream information (MPD address, Widewine key) for the specified id. Required keys: path, mime_type, manifest_type, drm, license_type, license_key.""" # noqa: E501 def get_streams(self) -> list: - """ - Retrieve all the available channels and the the associated information (name, logo, zapping number, - etc.) following JSON-STREAMS format - (https://github.com/add-ons/service.iptv.manager/wiki/JSON-STREAMS-format) - """ + """Retrieve all the available channels and the the associated information (name, logo, preset, etc.) following JSON-STREAMS format.""" # noqa: E501 def get_epg(self) -> dict: - """ - Returns EPG data for the specified period following JSON-EPG format - (https://github.com/add-ons/service.iptv.manager/wiki/JSON-EPG-format) - """ + """Return EPG data for the specified period following JSON-EPG format.""" diff --git a/resources/lib/providers/provider_wrapper.py b/resources/lib/providers/provider_wrapper.py deleted file mode 100644 index 0810660..0000000 --- a/resources/lib/providers/provider_wrapper.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -"""PROVIDER INTERFACE""" -import os -import json -from urllib.error import URLError -import xbmcvfs - -from lib.utils import get_addon_profile, log, LogLevel - -from .provider_interface import ProviderInterface - -class ProviderWrapper(ProviderInterface): - """The provider wrapper brings capabilities (like caching) on top of every registered providers""" - cache_folder = os.path.join(xbmcvfs.translatePath(get_addon_profile()), 'cache') - - def __init__(self, provider: ProviderInterface) -> None: - self.provider = provider - - if not os.path.exists(self.cache_folder): - os.makedirs(self.cache_folder) - - def get_stream_info(self, channel_id: int) -> dict: - # todo: catch error and display clean Kodi error to user - return self.provider.get_stream_info(channel_id) - - def get_streams(self) -> list: - streams = [] - - try: - streams = self.provider.get_streams() - with open(os.path.join(self.cache_folder, 'streams.json'), 'wb') as file: - file.write(json.dumps(streams).encode('utf-8')) - except URLError: - log('Can\'t reach server: load streams from cache', LogLevel.WARNING) - with open(os.path.join(self.cache_folder, 'streams.json'), 'r', encoding='utf-8') as file: - streams = json.loads(''.join(file.readlines())) - - return streams - - def get_epg(self) -> dict: - # todo: catch error and display clean Kodi error to user - return self.provider.get_epg() diff --git a/resources/lib/utils.py b/resources/lib/utils.py deleted file mode 100644 index bfa0da9..0000000 --- a/resources/lib/utils.py +++ /dev/null @@ -1,95 +0,0 @@ -# -*- coding: utf-8 -*- -"""Make the use of some Kodi functions easier""" -from enum import Enum -from json import dumps, loads -from random import randint -from string import Formatter - -import xbmc -import xbmcaddon -import xbmcgui - -_ADDON = xbmcaddon.Addon() - -_USER_AGENTS = [ - # Chrome - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36', # pylint: disable=line-too-long - 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36', - 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36', # pylint: disable=line-too-long - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36' - - # Edge - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36 Edg/88.0.705.81', # pylint: disable=line-too-long - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36 Edg/88.0.705.63' # pylint: disable=line-too-long - - # Firefox - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11.2; rv:86.0) Gecko/20100101 Firefox/86.0', - 'Mozilla/5.0 (X11; Linux i686; rv:86.0) Gecko/20100101 Firefox/86.0', - 'Mozilla/5.0 (Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0', - 'Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:86.0) Gecko/20100101 Firefox/86.0', - 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0', - 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0' -] - -class DRM(Enum): - """DRM""" - WIDEVINE = 'com.widevine.alpha' - PLAYREADY = 'com.microsoft.playready' - -class LogLevel(Enum): - """Available Kodi log levels""" - DEBUG = xbmc.LOGDEBUG - ERROR = xbmc.LOGERROR - FATAL = xbmc.LOGFATAL - INFO = xbmc.LOGINFO - NONE = xbmc.LOGNONE - WARNING = xbmc.LOGWARNING - -def get_addon_name(): - """Return the addon info name property""" - return _ADDON.getAddonInfo('name') - -def get_addon_profile(): - """Return the addon info profile property""" - return _ADDON.getAddonInfo('profile') - -def get_addon_setting(name: str) -> str: - """Return the addon setting from name""" - return _ADDON.getSetting(name) - -def get_drm() -> DRM: - """Return the DRM system available for the current platform""" - return DRM.WIDEVINE - -def get_global_setting(key): - """Get a global Kodi setting""" - cmd = { - 'id': 0, - 'jsonrpc': '2.0', - 'method': 'Settings.GetSettingValue', - 'params': { 'setting': key } - } - - return loads(xbmc.executeJSONRPC(dumps(cmd))).get('result', {}).get('value') - -def localize(string_id: int, **kwargs): - """Return the translated string from the .po language files, optionally translating variables""" - if not isinstance(string_id, int) and not string_id.isdecimal(): - return string_id - if kwargs: - return Formatter().vformat(_ADDON.getLocalizedString(string_id), (), **kwargs) - return _ADDON.getLocalizedString(string_id) - -def log(msg: str, level: LogLevel): - """Wrapper around the Kodi log function""" - xbmc.log(f'{get_addon_name()}: {msg}', level.value) - -def ok_dialog(msg: str): - """Wrapper around the Kodi dialop function, display a popup window with a button""" - xbmcgui.Dialog().ok(get_addon_name(), msg) - -def random_ua() -> str: - """Get a random user agent in the list""" - return _USER_AGENTS[randint(0, len(_USER_AGENTS) - 1)] diff --git a/resources/lib/utils/__init__.py b/resources/lib/utils/__init__.py new file mode 100644 index 0000000..6e03199 --- /dev/null +++ b/resources/lib/utils/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/resources/lib/utils/orange.py b/resources/lib/utils/orange.py new file mode 100644 index 0000000..6ef72fe --- /dev/null +++ b/resources/lib/utils/orange.py @@ -0,0 +1,267 @@ +""".""" + +import json +import re +from datetime import date, datetime, timedelta +from urllib.error import HTTPError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +import xbmc + +from lib.utils.request import get_random_ua +from lib.utils.xbmc import get_drm, get_global_setting, log + +_ENDPOINT_HOMEPAGE = "https://chaines-tv.orange.fr/" +_ENDPOINT_STREAM_INFO = "https://mediation-tv.orange.fr/all/api-gw/live/v3/auth/accountToken/applications/PC/channels/{channel_id}/stream?terminalModel=WEB_PC" +_ENDPOINT_STREAM_LOGO = "https://proxymedia.woopic.com/api/v1/images/2090%2Flogos%2Fv2%2Flogos%2F{external_id}%2F{hash}%2F{type}%2Flogo_{width}x{height}.png" +_ENDPOINT_STREAMS = "https://rp-ott-mediation-tv.woopic.com/api-gw/live/v3/applications/STB4PC/programs?groupBy=channel&epgIds=all&mco={mco}" + +_ENDPOINT_EPG = ( + "https://rp-ott-mediation-tv.woopic.com/api-gw/live/v3/applications/PC/programs?period={period}&mco={mco}" +) + +_EXTERNAL_ID_MAP = { + "livetv_canalplus": "livetv_canal_plus", + "livetv_direct8": "livetv_c8", + "livetv_nt1": "livetv_tfx", + "livetv_france4": "livetv_france_4", + "livetv_bfmtv": "livetv_bfm_tv", + "livetv_lcp": "livetv_lcp_ps", + "livetv_itelevision": "livetv_cnews", + "livetv_directstar": "livetv_cstar", + "livetv_hd1": "livetv_tf1_series_films", + "livetv_numero23": "livetv_rmc_story", + "livetv_rmcdecouverte": "livetv_rmc_decouverte", + "livetv_cherie25": "livetv_cherie_25", +} + + +def get_stream_info(channel_id: str, mco: str = "OFR") -> dict: + """Load stream info from Orange.""" + tv_token = _extract_tv_token() + log(tv_token, xbmc.LOGDEBUG) + + url = _ENDPOINT_STREAM_INFO.format(channel_id=channel_id) + headers = {**_build_headers(url), **{"tv_token": f"Bearer {tv_token}"}} + req = Request(url, headers=headers) + + try: + with urlopen(req) as res: + stream_info = json.loads(res.read()) + except HTTPError as error: + if error.code == 403: + return False + + drm = get_drm() + license_server_url = None + for system in stream_info.get("protectionData"): + if system.get("keySystem") == drm.value: + license_server_url = system.get("laUrl") + + headers = {**_build_headers(url), **{"tv_token": f"Bearer {tv_token}", "Content-Type": ""}} + h = "" + + for k, v in headers.items(): + h += f"{k}={v}&" + + headers = h[:-1] + post_data = "R{SSM}" + response = "" + + log(headers, xbmc.LOGDEBUG) + + stream_info = { + "path": stream_info["url"], + "mime_type": "application/xml+dash", + "manifest_type": "mpd", + "drm": drm.name.lower(), + "license_type": drm.value, + "license_key": f"{license_server_url}|{headers}|{post_data}|{response}", + } + + log(stream_info, xbmc.LOGDEBUG) + return stream_info + + +def get_streams(groups: dict, mco: str = "OFR") -> list: + """Load stream data from Orange and convert it to JSON-STREAMS format.""" + stream_details = _extract_stream_details() + log(f"Stream details: {stream_details}", xbmc.LOGDEBUG) + + url = _ENDPOINT_STREAMS.format(mco=mco) + headers = _build_headers(url) + req = Request(url, headers=headers) + + with urlopen(req) as res: + programs = json.loads(res.read()) + + streams = [] + log(stream_details) + + for channel_id in programs: + channel_programs = programs[channel_id] + + if len(channel_programs) == 0: + continue + + channel_program = channel_programs[0] + external_id = channel_program["externalId"].replace("_ctv", "") + channel_name = stream_details[external_id]["name"] if external_id in stream_details else external_id + channel_logo = stream_details[external_id]["logo"] if external_id in stream_details else None + + if channel_name == external_id: + log(" => " + channel_name) + + streams.append( + { + "id": channel_id, + "name": channel_name, + "preset": channel_program["channelZappingNumber"], + "logo": channel_logo, + "stream": f"plugin://plugin.video.orange.fr/channels/{channel_id}", + "group": [group_name for group_name in groups if int(channel_id) in groups[group_name]], + } + ) + + return streams + + +def get_epg(chunks_per_day: int = 2, mco: str = "OFR") -> dict: + """Load EPG data from Orange and convert it to JSON-EPG format.""" + start_day = datetime.timestamp( + datetime.combine( + date.today() - timedelta(days=int(get_global_setting("epg.pastdaystodisplay"))), datetime.min.time() + ) + ) + + days_to_display = int(get_global_setting("epg.futuredaystodisplay")) + int( + get_global_setting("epg.pastdaystodisplay") + ) + + chunk_duration = 24 * 60 * 60 / chunks_per_day + programs = [] + + for chunk in range(0, days_to_display * chunks_per_day): + programs.extend( + _get_programs( + mco, + period_start=(start_day + chunk_duration * chunk) * 1000, + period_end=(start_day + chunk_duration * (chunk + 1)) * 1000, + ) + ) + + epg = {} + + for program in programs: + if program["channelId"] not in epg: + epg[program["channelId"]] = [] + + if program["programType"] != "EPISODE": + title = program["title"] + subtitle = None + episode = None + else: + title = program["season"]["serie"]["title"] + subtitle = program["title"] + season_number = program["season"]["number"] + episode_number = program.get("episodeNumber", None) + episode = f"S{season_number}E{episode_number}" + + image = None + if isinstance(program["covers"], list): + for cover in program["covers"]: + if cover["format"] == "RATIO_16_9": + image = program["covers"][0]["url"] + + epg[program["channelId"]].append( + { + "start": datetime.fromtimestamp(program["diffusionDate"]) + .astimezone() + .replace(microsecond=0) + .isoformat(), + "stop": ( + datetime.fromtimestamp(program["diffusionDate"] + program["duration"]).astimezone() + ).isoformat(), + "title": title, + "subtitle": subtitle, + "episode": episode, + "description": program["synopsis"], + "genre": program["genre"] if program["genreDetailed"] is None else program["genreDetailed"], + "image": image, + } + ) + + return epg + + +def _get_programs(mco: str, period_start: int = None, period_end: int = None) -> list: + """Return the programs for today (default) or the specified period.""" + try: + period = f"{int(period_start)},{int(period_end)}" + except ValueError: + period = "today" + + url = _ENDPOINT_EPG.format(period=period, mco=mco) + log(f"Fetching: {url}") + req = Request(url, headers=_build_headers(url)) + + with urlopen(req) as res: + return json.loads(res.read()) + + +def _build_headers(url: str) -> dict: + """Build headers.""" + return {"User-Agent": get_random_ua(), "Host": urlparse(url).netloc} + + +def _extract_tv_token() -> str: + """Extract TV token.""" + headers = _build_headers(_ENDPOINT_HOMEPAGE) + req = Request(_ENDPOINT_HOMEPAGE, headers=headers) + + with urlopen(req) as res: + html = res.read().decode("utf-8") + + return re.search('instanceInfo:{token:"([a-zA-Z0-9-_.]+)"', html).group(1) + + +def _extract_stream_details() -> dict: + """Extract stream name and logo.""" + headers = _build_headers(_ENDPOINT_HOMEPAGE) + req = Request(_ENDPOINT_HOMEPAGE, headers=headers) + + with urlopen(req) as res: + html = res.read().decode("utf-8").replace("\u002f", "/") + + matches = re.findall('"([A-Z0-9+/ ]*[A-Z][A-Z0-9+/ ]*)","(livetv_[a-z0-9_]+)",', html) + stream_details = {match[1]: {"name": match[0], "logo": None} for match in matches} + + matches = re.findall( + 'path:"%2Flogos%2Fv2%2Flogos%2F(livetv_[a-z0-9_]+)%2F([0-9]+_[0-9]+)%2FmobileAppliDark%2Flogo_([0-9]+)x([0-9]+)\.png"', + html, + ) + + for match in matches: + if match[0] not in stream_details: + stream_details[match[0]] = {"name": match[0]} + + stream_details[match[0]]["logo"] = _ENDPOINT_STREAM_LOGO.format( + external_id=match[0], + hash=match[1], + type="mobileAppliDark", + width=match[2], + height=match[3], + ) + + stream_details = { + _format_to_new_external_id(external_id): stream_details[external_id] for external_id in stream_details + } + + return stream_details + + +def _format_to_new_external_id(external_id: str) -> str: + """Format external id to new format.""" + external_id = external_id.replace("_umts", "") + return _EXTERNAL_ID_MAP.get(external_id, external_id) diff --git a/resources/lib/utils/request.py b/resources/lib/utils/request.py new file mode 100644 index 0000000..2e8c265 --- /dev/null +++ b/resources/lib/utils/request.py @@ -0,0 +1,27 @@ +"""Request utils.""" + +from random import randint + +_USER_AGENTS = [ + # Chrome + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.3", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.3", # noqa: E501 + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.3", # noqa: E501 + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.3", + # Edge + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0", # noqa: E501 + # Firefox + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.", + "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.", + # Opera + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 OPR/108.0.0.", # noqa: E501 + # Safari + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.1", # noqa: E501 + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.1", # noqa: E501 + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.1", # noqa: E501 +] + + +def get_random_ua() -> str: + """Get a randomised user agent.""" + return _USER_AGENTS[randint(0, len(_USER_AGENTS) - 1)] diff --git a/resources/lib/utils/xbmc.py b/resources/lib/utils/xbmc.py new file mode 100644 index 0000000..152a18d --- /dev/null +++ b/resources/lib/utils/xbmc.py @@ -0,0 +1,60 @@ +"""Make the use of some Kodi functions easier.""" + +from enum import Enum +from json import dumps, loads +from string import Formatter + +import xbmc +import xbmcaddon +import xbmcgui + +ADDON = xbmcaddon.Addon() +ADDON_NAME = ADDON.getAddonInfo("name") + + +class DRM(Enum): + """List DRM providers.""" + + WIDEVINE = "com.widevine.alpha" + PLAYREADY = "com.microsoft.playready" + + +def log(msg: str, log_level: int = xbmc.LOGINFO): + """Prefix logs with addon name.""" + xbmc.log(f"{ADDON_NAME}: {msg}", log_level) + + +def get_addon_info(name: str) -> str: + """Get addon info from name.""" + return ADDON.getAddonInfo(name) + + +def get_addon_setting(name: str) -> str: + """Get addon setting from name.""" + return ADDON.getSetting(name) + + +def get_drm() -> DRM: + """Return the DRM system available for the current platform.""" + return DRM.WIDEVINE + + +def get_global_setting(name: str): + """Get global Kodi setting from name.""" + cmd = {"id": 0, "jsonrpc": "2.0", "method": "Settings.GetSettingValue", "params": {"setting": name}} + + return loads(xbmc.executeJSONRPC(dumps(cmd))).get("result", {}).get("value") + + +def localize(string_id: int, **kwargs): + """Return the translated string from the .po language files, optionally translating variables.""" + if not isinstance(string_id, int) and not string_id.isdecimal(): + return string_id + if kwargs: + return Formatter().vformat(ADDON.getLocalizedString(string_id), (), **kwargs) + return ADDON.getLocalizedString(string_id) + + +def ok_dialog(msg: str): + """Display a popup window with a button.""" + xbmcgui.Dialog().ok(ADDON_NAME, msg) diff --git a/resources/service.py b/resources/service.py deleted file mode 100644 index 49c43cd..0000000 --- a/resources/service.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -"""Update channels and programs data on startup and every hour""" -import os - -import xbmc -import xbmcvfs - -from lib.generators import EPGGenerator, PlaylistGenerator -from lib.providers import get_provider -from lib.utils import get_addon_profile, get_addon_setting, get_global_setting, log, LogLevel - -def run(): - """Run data generators""" - log('Updating data...', LogLevel.INFO) - provider = get_provider() - - filepath = os.path.join(xbmcvfs.translatePath(get_addon_profile()), 'playlist.m3u8') - log(filepath, LogLevel.DEBUG) - PlaylistGenerator(provider=provider).write(filepath=filepath) - - filepath = os.path.join(xbmcvfs.translatePath(get_addon_profile()), 'epg.xml') - log(filepath, LogLevel.DEBUG) - EPGGenerator(provider=provider).write(filepath=filepath) - - log('Channels and programs data updated', LogLevel.INFO) - -def main(): - """Service initialisation""" - log('Initialise service', LogLevel.INFO) - interval = 10 - monitor = xbmc.Monitor() - - while not monitor.abortRequested(): - if monitor.waitForAbort(interval): - break - - interval = int(get_global_setting('epg.epgupdate')) * 60 - - if get_addon_setting('basic.enabled') == 'true': - run() - -if __name__ == '__main__': - main() diff --git a/resources/settings.xml b/resources/settings.xml index b6af3db..652dd55 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -3,15 +3,12 @@ - - - - - + + - - + +