From 8637a77e4bebefb69e34b91d5b0d40fa21212e0c 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 | 15 +- .gitignore | 3 +- .pylintrc | 11 - README.md | 10 +- addon.xml | 9 +- docs/README.fr.md | 8 +- 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 | 10 +- resources/lib/providers/fr/orange.py | 103 +++---- resources/lib/providers/fr/orange_caraibe.py | 39 ++- resources/lib/providers/fr/orange_reunion.py | 62 ++--- 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 | 257 ++++++++++++++++++ resources/lib/utils/request.py | 27 ++ resources/lib/utils/xbmc.py | 60 ++++ resources/service.py | 43 --- resources/settings.xml | 11 +- 34 files changed, 682 insertions(+), 759 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/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..a243754 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,26 +15,27 @@ 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 with: - rewrite-for-matrix: true + is-pr: true + rewrite-for-matrix: false kodi-version: ${{ matrix.kodi-version }} addon-id: ${{ github.event.repository.name }} 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/README.md b/README.md index bd9fe6a..d91d2d6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Orange TV France for Kodi -[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/BreizhReloaded/plugin.video.orange.fr)](https://github.com/BreizhReloaded/plugin.video.orange.fr/releases) -[![Kodi version](https://img.shields.io/badge/kodi%20version-v19-blue)](https://kodi.tv/) -[![GitHub](https://img.shields.io/github/license/BreizhReloaded/plugin.video.orange.fr)](https://github.com/BreizhReloaded/plugin.video.orange.fr/blob/master/LICENSE) +[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/f-lawe/plugin.video.orange.fr)](https://github.com/f-lawe/plugin.video.orange.fr/releases) +[![Kodi version](https://img.shields.io/badge/kodi%20version-v21-blue)](https://kodi.tv/) +[![GitHub](https://img.shields.io/github/license/f-lawe/plugin.video.orange.fr)](https://github.com/f-lawe/plugin.video.orange.fr/blob/master/LICENSE) __→__ _[Lisez-moi](docs/README.fr.md) en français_ @@ -11,12 +11,10 @@ _This addon is not officially commissioned/supported by Orange. All product name This addon brings Orange TV to Kodi. All channels included in your registration are now directly available from Kodi! ## Installation -Download the latest ZIP archive from the [releases page](https://github.com/BreizhReloaded/plugin.video.orange.fr/releases/latest) and install it into Kodi (Settings > Addons > Install from ZIP). Then install the dependencies from the addon screen (My addons > Video addons > Orange TV France > Dependencies). +Download the latest ZIP archive from the [releases page](https://github.com/f-lawe/plugin.video.orange.fr/releases/latest) and install it into Kodi (Settings > Addons > Install from ZIP). Then install the dependencies from the addon screen (My addons > Video addons > Orange TV France > Dependencies). Integration to Kodi TV is handled via [IPTV Manager](https://github.com/add-ons/service.iptv.manager). You can install and activate it from the addon settings. Once set up on your system, channels and TV guide are loaded automatically. -_NB: you can also choose the basic integration, in that case you need to manually pass to [IPTV Simple](https://github.com/kodi-pvr/pvr.iptvsimple) the .m3u8 and .xml files located within the data directory._ - You should now be able to view all the channels grouped by category in the TV section. If that's not already installed, Kodi will ask you to install the decrypting tool when accessing a channel for the first time. Do not forget to properly configure IPTV Manager and select your TV provider in the settings! diff --git a/addon.xml b/addon.xml index 73381bb..93c25da 100644 --- a/addon.xml +++ b/addon.xml @@ -1,14 +1,13 @@ - + - + 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/docs/README.fr.md b/docs/README.fr.md index c78aff2..2977ef1 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -1,7 +1,7 @@ # Orange TV France pour Kodi -[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/BreizhReloaded/plugin.video.orange.fr)](https://github.com/BreizhReloaded/plugin.video.orange.fr/releases) -[![Kodi version](https://img.shields.io/badge/kodi%20version-v19-blue)](https://kodi.tv/) -[![GitHub](https://img.shields.io/github/license/BreizhReloaded/plugin.video.orange.fr)](https://github.com/BreizhReloaded/plugin.video.orange.fr/blob/master/LICENSE) +[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/f-lawe/plugin.video.orange.fr)](https://github.com/f-lawe/plugin.video.orange.fr/releases) +[![Kodi version](https://img.shields.io/badge/kodi%20version-v21-blue)](https://kodi.tv/) +[![GitHub](https://img.shields.io/github/license/f-lawe/plugin.video.orange.fr)](https://github.com/f-lawe/plugin.video.orange.fr/blob/master/LICENSE) _Cet addon n'est pas officiellement supporté par Orange. Tous les produits, logos et marques déposées mentionnés dans ce projet sont la propriété de leurs propriétaires respectifs._ @@ -13,8 +13,6 @@ Téléchargez l'archive de la [dernière version](https://github.com/BreizhReloa L'intégration à la télévision sur Kodi se fait via [IPTV Manager](https://github.com/add-ons/service.iptv.manager). Vous pouvez l'installer et l'activer directement depuis les réglages de l'addon. Une fois configuré, les chaînes et le programme TV se chargent automatiquement. -_NB: vous pouvez également choisir l'intégration basique, dans ce cas vous devez passer manuellement à [IPTV Simple](https://github.com/kodi-pvr/pvr.iptvsimple) les fichiers .m3u8 et .xml situés dans le dossier data._ - Vous devriez maintenant voir toutes les chaînes regroupées par categories dans la section TV. S'il n'est pas déjà installé, Kodi vous demandera d'installer l'outil de décryptage quand vous regarderez une chaîne pour la première fois. N'oubliez pas de configurer IPTV Manager correctement et de sélectionner le bon fournisseur dans les paramètres ! 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..b39efd2 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.LOGDEBUG) + 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..e4b45ef 100644 --- a/resources/lib/providers/__init__.py +++ b/resources/lib/providers/__init__.py @@ -1,27 +1,29 @@ -# -*- 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.""" +import xbmc + +from lib.utils.xbmc import get_addon_setting, log + +from .cache_provider import CacheProvider +from .fr import OrangeCaraibeProvider, OrangeFranceProvider, OrangeReunionProvider from .provider_interface import ProviderInterface -from .provider_wrapper import ProviderWrapper -from .fr import OrangeFranceProvider, OrangeCaraibeProvider, OrangeReunionProvider _PROVIDERS = { - 'France.Orange': OrangeFranceProvider, - 'France.Orange Caraïbe': OrangeCaraibeProvider, - 'France.Orange Réunion': OrangeReunionProvider + "France.Orange": OrangeFranceProvider, + "France.Orange Caraïbe": OrangeCaraibeProvider, + "France.Orange Réunion": OrangeReunionProvider, } -name: str = get_addon_setting('provider.name') -country: str = get_addon_setting('provider.country') +_PROVIDER_NAME: str = get_addon_setting("provider.name") +_PROVIDER_COUNTRY: str = get_addon_setting("provider.country") +_PROVIDER_KEY = f"{_PROVIDER_COUNTRY}.{_PROVIDER_NAME}" -_KEY = f'{country}.{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..4974d3e 100644 --- a/resources/lib/providers/fr/__init__.py +++ b/resources/lib/providers/fr/__init__.py @@ -1,5 +1,5 @@ -# -*- 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 +from .orange_caraibe import OrangeCaraibeProvider as OrangeCaraibeProvider +from .orange_reunion import OrangeReunionProvider as OrangeReunionProvider diff --git a/resources/lib/providers/fr/orange.py b/resources/lib/providers/fr/orange.py index aad2e21..7ffa027 100644 --- a/resources/lib/providers/fr/orange.py +++ b/resources/lib/providers/fr/orange.py @@ -1,46 +1,57 @@ -# -*- coding: utf-8 -*- -"""Orange France""" -from lib.provider_templates import OrangeTemplate - -class OrangeFranceProvider(OrangeTemplate): - """Orange France 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=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] - } - ) +"""Orange France.""" + +from lib.providers.provider_interface import ProviderInterface +from lib.utils.orange import get_epg, get_stream_info, get_streams + +EXTERNAL_ID_MAP = { + "canalplus": "canal_plus", + "direct8": "c8", + "nt1": "tfx", + "france4": "france_4", + "bfmtv": "bfm_tv", + "lcp": "lcp_ps", + "itelevision": "cnews", + "directstar": "cstar", + "hd1": "tf1_series_films", + "equipe21": "la_chaine_l_equipe", + "numero23": "rmc_story", + "rmcdecouverte": "rmc_decouverte", + "cherie25": "cherie_25", + "lcimobile": "lci", + "tv5": "tv5monde", + "beinsport1": "beinsports1", + "beinsport2": "beinsports_2", + "beinsportsmax3": "beinsports_3", + "orange_cine_max": "ocsmax", + "orange_cine_choc": "ocschoc", + "orange_cine_geants": "ocs_geants", + "paramount": "paramount_channel", + "paramountdec": "paramount_channel_decale", + "tcm": "tcmcinema", + "cineplus_premier": "cine_premier", + "cineplus_frisson": "cine_frisson", + "cineplus_emotion": "cine_emotion", + "cineplus_famiz": "cine_famiz", + "cineplus_club": "cine_club", + "cineplus_classic": "cine_classic", + "warner_tv": "warnertv", + "tlc_wbd": "tlc", +} + + +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, EXTERNAL_ID_MAP, "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 index 2bbfa14..78103f2 100644 --- a/resources/lib/providers/fr/orange_caraibe.py +++ b/resources/lib/providers/fr/orange_caraibe.py @@ -1,15 +1,24 @@ -# -*- 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 = {} - ) +"""Orange Caraïbe.""" + +from lib.providers.provider_interface import ProviderInterface +from lib.utils.orange import get_epg, get_stream_info, get_streams + +from .orange import EXTERNAL_ID_MAP + + +class OrangeCaraibeProvider(ProviderInterface): + """Orange Caraïbe 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, "OCA") + + 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, EXTERNAL_ID_MAP, "OCA") + + def get_epg(self) -> dict: + """Return EPG data for the specified period following JSON-EPG format.""" + return get_epg(2, "OCA") diff --git a/resources/lib/providers/fr/orange_reunion.py b/resources/lib/providers/fr/orange_reunion.py index 9bfc326..7733917 100644 --- a/resources/lib/providers/fr/orange_reunion.py +++ b/resources/lib/providers/fr/orange_reunion.py @@ -1,38 +1,24 @@ -# -*- 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] - } - ) +"""Orange Réunion.""" + +from lib.providers.provider_interface import ProviderInterface +from lib.utils.orange import get_epg, get_stream_info, get_streams + +from .orange import EXTERNAL_ID_MAP + + +class OrangeReunionProvider(ProviderInterface): + """Orange Réunion 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, "ORE") + + 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, EXTERNAL_ID_MAP, "ORE") + + def get_epg(self) -> dict: + """Return EPG data for the specified period following JSON-EPG format.""" + return get_epg(2, "ORE") 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..5046e96 --- /dev/null +++ b/resources/lib/utils/orange.py @@ -0,0 +1,257 @@ +""".""" + +import codecs +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}" +) + + +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, external_id_map: dict, mco: str = "OFR") -> list: + """Load stream data from Orange and convert it to JSON-STREAMS format.""" + stream_details = _extract_stream_details(external_id_map) + 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 = [] + + 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(external_id_map: dict) -> dict: + """Extract stream name and logo.""" + headers = _build_headers(_ENDPOINT_HOMEPAGE) + req = Request(_ENDPOINT_HOMEPAGE, headers=headers) + + with urlopen(req) as res: + html = codecs.decode(res.read().decode("utf-8"), "unicode-escape") + + matches = re.findall('"([A-Z0-9+/\': ]*[A-Z][A-Z0-9+/\': ]*)","(livetv_[a-zA-Z0-9_]+)",', html) + stream_details = {match[1]: {"name": match[0], "logo": None} for match in matches} + + log(stream_details, xbmc.LOGDEBUG) + + 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 = { + _get_epg_external_id(external_id_map, external_id): stream_details[external_id] + for external_id in stream_details + } + + return stream_details + + +def _get_epg_external_id(external_id_map: dict, stream_external_id: str) -> str: + """Format external id to new format.""" + epg_external_id = stream_external_id.lower().replace("_umts", "").replace("livetv_", "") + epg_external_id = "livetv_" + external_id_map.get(epg_external_id, epg_external_id) + log(f"{stream_external_id} => {epg_external_id}".format(stream_external_id, epg_external_id), xbmc.LOGDEBUG) + return epg_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 @@ - - - - - + + - - + +