diff --git a/plugin.video.orange.fr/CHANGELOG.md b/plugin.video.orange.fr/CHANGELOG.md index de8860668..72bf1178a 100644 --- a/plugin.video.orange.fr/CHANGELOG.md +++ b/plugin.video.orange.fr/CHANGELOG.md @@ -1,5 +1,23 @@ # 2.x +## [2.1.2](https://github.com/f-lawe/plugin.video.orange.fr/releases/tag/v2.1.2) - 2024-07-05 + +### Changed +- Better timeshift management + +## [2.1.1](https://github.com/f-lawe/plugin.video.orange.fr/releases/tag/v2.1.1) - 2024-06-23 + +### Changed +- Use [ABC](https://docs.python.org/3/library/abc.html) for class inheritance +- Move Orange util functions to decicated abstract Orange provider +- Better authenticated request management +- Move caching logic to utils + +## [2.1.0](https://github.com/f-lawe/plugin.video.orange.fr/releases/tag/v2.1.0) - 2024-06-21 + +### Added +- Catchup TV + ## [2.0.1](https://github.com/f-lawe/plugin.video.orange.fr/releases/tag/v2.0.1) - 2024-06-20 ### Changed diff --git a/plugin.video.orange.fr/README.md b/plugin.video.orange.fr/README.md index d9f46370f..a66e6196f 100644 --- a/plugin.video.orange.fr/README.md +++ b/plugin.video.orange.fr/README.md @@ -3,22 +3,27 @@ [![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_ +__→__ _[Lisez-moi](doc/README.fr.md) en français_ _This addon is not officially commissioned/supported by Orange. All product names, logos, and trademarks mentionned in this project are property of their respective owners._ ## Description 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/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). +This addon features: +- Watching TV with Kodi TV (using [IPTV Simple PVR](https://github.com/kodi-pvr/pvr.iptvsimple)) +- Watching catchup TV content +- Access to paid channels included into your subscription (for both live and catchup TV) -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. +## Installation +Orange TV France is available directly from Kodi's main repository. Integration 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. -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. +You should now be able to view all the channels grouped by category in the TV section. If 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! +If you need the latest update, you can download the latest ZIP archive from the [releases page](https://github.com/f-lawe/plugin.video.orange.fr/releases/latest) and manually 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). + ## Available providers | Country | Provider | | |:---------:|:------------------|:-| @@ -27,5 +32,5 @@ Do not forget to properly configure IPTV Manager and select your TV provider in | 🇫🇷 | Orange Réunion | thanks to [@starmate](https://github.com/starmate) and [@sae-gfc](https://github.com/sae-gfc) | ## Known limitations -- This addons does not provide any catchup functionnalities, for that you can have a look at the great [Catch-up TV & More](https://github.com/Catch-up-TV-and-More/plugin.video.catchuptvandmore/) -- Limited to 720p due to Orange limitations (they don't provide higher quality on their web service) +- Limited to 720p because Orange don't provide higher quality on their web TV +- Work only from the listed and tested areas (for now metropolitan France, French Caribbean, and Reunion island) diff --git a/plugin.video.orange.fr/addon.xml b/plugin.video.orange.fr/addon.xml index 6a1ccd353..33534a677 100644 --- a/plugin.video.orange.fr/addon.xml +++ b/plugin.video.orange.fr/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/plugin.video.orange.fr/docs/README.fr.md b/plugin.video.orange.fr/doc/README.fr.md similarity index 53% rename from plugin.video.orange.fr/docs/README.fr.md rename to plugin.video.orange.fr/doc/README.fr.md index 689198650..455f6fe1b 100644 --- a/plugin.video.orange.fr/docs/README.fr.md +++ b/plugin.video.orange.fr/doc/README.fr.md @@ -8,15 +8,20 @@ _Cet addon n'est pas officiellement supporté par Orange. Tous les produits, log ## Description Cet addon ajoute la TV d'Orange à Kodi. Toutes les chaînes inclues dans votre abonnement sont maintenant directement accessibles depuis Kodi ! -## Installation -Téléchargez l'archive de la [dernière version](https://github.com/BreizhReloaded/plugin.video.orange.fr/releases/latest) et installez-la directement depuis Kodi (Settings > Addons > Install from ZIP). Installez ensuite les dépendences depuis l'écan de l'adddon (My addons > Video addons > Orange TV France > Dependencies). +Cet addon inclue : +- la télé en direct dans Kodi TV (en utilisant [IPTV Simple PVR](https://github.com/kodi-pvr/pvr.iptvsimple)) +- les programmes en replay +- l'accès aux chaînes payantes qui font partie de votre souscription (pour le direct et le replay) -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. +## Installation +Orange TV France est disponible directement depuis le dépôt principal de Kodi. 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. 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 ! +Si vous avez besoin de la dernière version, vous pouvez toujours télécharger la dernière archive ZIP depuis [cette page](https://github.com/BreizhReloaded/plugin.video.orange.fr/releases/latest) et installez-la directement depuis Kodi (Settings > Addons > Install from ZIP). Installez ensuite les dépendences depuis l'écan de l'adddon (My addons > Video addons > Orange TV France > Dependencies). + ## Fournisseurs disponibles | Pays | Fournisseur | | |:---------:|:------------------|:-| @@ -24,6 +29,6 @@ N'oubliez pas de configurer IPTV Manager correctement et de sélectionner le bon | 🇫🇷 | Orange Caraïbe | | | 🇫🇷 | Orange Réunion | merci à [@starmate](https://github.com/starmate) et [@sae-gfc](https://github.com/sae-gfc) -## Limites connues -- Cet addon ne propose pas le replay TV. Pour cela, vous pouvez aller voir du côté du très bon [Catch-up TV & More](https://github.com/Catch-up-TV-and-More/plugin.video.catchuptvandmore/) -- Qualité vidéo limitée à 720p en raison de limitations techniques du côté d'Orange (ils ne proposent pas plus sur leur plateforme web) +## Limitations connues +- Qualité vidéo limitée à 720p, Orange ne propose pas plus sur leur plateforme web +- Ne fonctionne que depuis les zones listées et testées (pour le moment France métropolitaine, Caraïbes Françaises et La Réunion) diff --git a/plugin.video.orange.fr/resources/addon.py b/plugin.video.orange.fr/resources/addon.py index 58196a177..57cfda8bf 100644 --- a/plugin.video.orange.fr/resources/addon.py +++ b/plugin.video.orange.fr/resources/addon.py @@ -2,48 +2,11 @@ import sys +import lib.routes # noqa: F401 import xbmc -import xbmcplugin -from lib.channelmanager import ChannelManager -from lib.iptvmanager import IPTVManager -from lib.utils.xbmctools import log, ok_dialog -from routing import Plugin - -router = Plugin() - - -@router.route("/") -def index(): - """Display a welcome message.""" - log("Hello from plugin.video.orange.fr", xbmc.LOGINFO) - ok_dialog("Hello from plugin.video.orange.fr") - - -@router.route("/channels/") -def channel(channel_id: str): - """Load stream for the required channel id.""" - log(f"Loading channel {channel_id}", xbmc.LOGINFO) - listitem = ChannelManager().load_channel_listitem(channel_id) - if listitem is not None: - xbmcplugin.setResolvedUrl(router.handle, True, listitem=listitem) - - -@router.route("/iptv/channels") -def iptv_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() - - -@router.route("/iptv/epg") -def iptv_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() - +from lib.router import init_router +from lib.utils.kodi import log if __name__ == "__main__": log(sys.version, xbmc.LOGDEBUG) - router.run() + init_router() diff --git a/plugin.video.orange.fr/resources/lib/channelmanager.py b/plugin.video.orange.fr/resources/lib/channelmanager.py deleted file mode 100644 index 0feee0468..000000000 --- a/plugin.video.orange.fr/resources/lib/channelmanager.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Channel stream loader.""" - -import inputstreamhelper -import xbmcgui - -from lib.providers import get_provider -from lib.utils.xbmctools 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/plugin.video.orange.fr/resources/lib/managers/__init__.py b/plugin.video.orange.fr/resources/lib/managers/__init__.py new file mode 100644 index 000000000..3b40b4c43 --- /dev/null +++ b/plugin.video.orange.fr/resources/lib/managers/__init__.py @@ -0,0 +1,7 @@ +"""Managers.""" + +from .catchup_manager import CatchupManager +from .iptv_manager import IPTVManager +from .stream_manager import StreamManager + +__all__ = ["CatchupManager", "IPTVManager", "StreamManager"] diff --git a/plugin.video.orange.fr/resources/lib/managers/catchup_manager.py b/plugin.video.orange.fr/resources/lib/managers/catchup_manager.py new file mode 100644 index 000000000..86cf8592a --- /dev/null +++ b/plugin.video.orange.fr/resources/lib/managers/catchup_manager.py @@ -0,0 +1,54 @@ +"""Catchup TV Manager.""" + +import xbmc +import xbmcplugin + +from lib.providers import get_provider +from lib.router import router +from lib.utils.gui import create_directory_items +from lib.utils.kodi import build_addon_url + + +class CatchupManager: + """Navigate through catchup TV content.""" + + def __init__(self): + """Initialize Catchup Manager object.""" + self.provider = get_provider() + + def get_channels(self) -> list: + """Return channels available for catchup TV.""" + channels = self.provider.get_catchup_channels() + directory_items = create_directory_items(channels) + + succeeded = xbmcplugin.addDirectoryItems(router.handle, directory_items, len(directory_items)) + xbmcplugin.endOfDirectory(router.handle, succeeded) + + def get_categories(self, catchup_channel_id: str) -> list: + """Return content categories for the required channel.""" + categories = self.provider.get_catchup_categories(catchup_channel_id) + directory_items = create_directory_items(categories) + + succeeded = xbmcplugin.addDirectoryItems(router.handle, directory_items, len(directory_items)) + xbmcplugin.endOfDirectory(router.handle, succeeded) + + def get_articles(self, catchup_channel_id: str, category_id: str) -> list: + """Return content (TV show, movie, etc) for the required channel and category.""" + articles = self.provider.get_catchup_articles(catchup_channel_id, category_id) + directory_items = create_directory_items(articles) + + succeeded = xbmcplugin.addDirectoryItems(router.handle, directory_items, len(directory_items)) + xbmcplugin.endOfDirectory(router.handle, succeeded) + + def get_videos(self, catchup_channel_id: str, article_id: str) -> list: + """Return the video list for the required show.""" + videos = self.provider.get_catchup_videos(catchup_channel_id, article_id) + directory_items = create_directory_items(videos) + + succeeded = xbmcplugin.addDirectoryItems(router.handle, directory_items, len(directory_items)) + xbmcplugin.endOfDirectory(router.handle, succeeded) + + def play_video(self, video_id: str): + """Play catchup video.""" + player = xbmc.Player() + player.play(build_addon_url(f"/catchup-streams/{video_id}")) diff --git a/plugin.video.orange.fr/resources/lib/iptvmanager.py b/plugin.video.orange.fr/resources/lib/managers/iptv_manager.py similarity index 89% rename from plugin.video.orange.fr/resources/lib/iptvmanager.py rename to plugin.video.orange.fr/resources/lib/managers/iptv_manager.py index 7bf8c3934..3c09662dd 100644 --- a/plugin.video.orange.fr/resources/lib/iptvmanager.py +++ b/plugin.video.orange.fr/resources/lib/managers/iptv_manager.py @@ -5,6 +5,7 @@ from typing import Any, Callable from lib.providers import get_provider +from lib.utils.cache import use_cache class IPTVManager: @@ -30,11 +31,12 @@ def send(self) -> None: return send @via_socket - def send_channels(self): + @use_cache("streams.json") + def send_channels(self) -> dict: """Return JSON-STREAMS formatted python datastructure to IPTV Manager.""" return dict(version=1, streams=self.provider.get_streams()) @via_socket - def send_epg(self): + def send_epg(self) -> dict: """Return JSON-EPG formatted python data structure to IPTV Manager.""" return dict(version=1, epg=self.provider.get_epg()) diff --git a/plugin.video.orange.fr/resources/lib/managers/stream_manager.py b/plugin.video.orange.fr/resources/lib/managers/stream_manager.py new file mode 100644 index 000000000..f753c6ff9 --- /dev/null +++ b/plugin.video.orange.fr/resources/lib/managers/stream_manager.py @@ -0,0 +1,48 @@ +"""Video stream manager.""" + +import inputstreamhelper +import xbmcgui +import xbmcplugin + +from lib.providers import get_provider +from lib.router import router +from lib.utils.gui import create_video_item +from lib.utils.kodi import localize, ok_dialog + + +class StreamManager: + """Load streams based using active provider.""" + + def __init__(self): + """Initialize Stream Manager object.""" + self.provider = get_provider() + + def load_live_stream(self, stream_id: str) -> xbmcgui.ListItem: + """Load live TV stream.""" + stream_info = self.provider.get_live_stream_info(stream_id) + if not stream_info: + ok_dialog(localize(30900)) + return + + is_helper = inputstreamhelper.Helper(stream_info["manifest_type"], drm=stream_info["drm"]) + if not is_helper.check_inputstream(): + ok_dialog(localize(30901)) + return + + list_item = create_video_item(stream_info) + xbmcplugin.setResolvedUrl(router.handle, True, list_item) + + def load_chatchup_stream(self, stream_id: str) -> xbmcgui.ListItem: + """Load catchup TV stream.""" + stream_info = self.provider.get_catchup_stream_info(stream_id) + if not stream_info: + ok_dialog(localize(30900)) + return + + is_helper = inputstreamhelper.Helper(stream_info["manifest_type"], drm=stream_info["drm"]) + if not is_helper.check_inputstream(): + ok_dialog(localize(30901)) + return + + list_item = create_video_item(stream_info) + xbmcplugin.setResolvedUrl(router.handle, True, list_item) diff --git a/plugin.video.orange.fr/resources/lib/providers/__init__.py b/plugin.video.orange.fr/resources/lib/providers/__init__.py index bc88335be..21e088a68 100644 --- a/plugin.video.orange.fr/resources/lib/providers/__init__.py +++ b/plugin.video.orange.fr/resources/lib/providers/__init__.py @@ -2,9 +2,8 @@ import xbmc -from lib.utils.xbmctools import get_addon_setting, log +from lib.utils.kodi import get_addon_setting, log -from .cache_provider import CacheProvider from .fr import OrangeCaraibeProvider, OrangeFranceProvider, OrangeReunionProvider from .provider_interface import ProviderInterface @@ -26,4 +25,4 @@ def get_provider() -> ProviderInterface: """Return the selected provider.""" - return CacheProvider(_PROVIDER) + return _PROVIDER diff --git a/plugin.video.orange.fr/resources/lib/providers/abstract_orange_provider.py b/plugin.video.orange.fr/resources/lib/providers/abstract_orange_provider.py new file mode 100644 index 000000000..997234639 --- /dev/null +++ b/plugin.video.orange.fr/resources/lib/providers/abstract_orange_provider.py @@ -0,0 +1,294 @@ +# ruff: noqa: D102 +"""Orange provider template.""" + +import json +import re +from abc import ABC +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.providers.abstract_provider import AbstractProvider +from lib.utils.kodi import build_addon_url, get_drm, get_global_setting, log +from lib.utils.request import build_request, get_random_ua + +_PROGRAMS_ENDPOINT = "https://rp-ott-mediation-tv.woopic.com/api-gw/live/v3/applications/STB4PC/programs?period={period}&epgIds=all&mco={mco}" +_CATCHUP_CHANNELS_ENDPOINT = "https://rp-ott-mediation-tv.woopic.com/api-gw/catchup/v4/applications/PC/channels" +_CATCHUP_ARTICLES_ENDPOINT = "https://rp-ott-mediation-tv.woopic.com/api-gw/catchup/v4/applications/PC/channels/{catchup_channel_id}/categories/{category_id}" +_CATCHUP_VIDEOS_ENDPOINT = "https://rp-ott-mediation-tv.woopic.com/api-gw/catchup/v4/applications/PC/groups/{group_id}" +_CHANNELS_ENDPOINT = "https://rp-ott-mediation-tv.woopic.com/api-gw/pds/v1/live/ew?everywherePopulation=OTT_Metro" +_STREAM_ENDPOINT = "https://mediation-tv.orange.fr/all/api-gw/{stream_type}/{version}/auth/accountToken/applications/PC/{item_type}/{stream_id}/stream?terminalModel=WEB_PC" + +_STREAM_LOGO_URL = "https://proxymedia.woopic.com/api/v1/images/2090{path}" +_LIVE_HOMEPAGE_URL = "https://chaines-tv.orange.fr/" +_CATCHUP_VIDEO_URL = "https://replay.orange.fr/videos/{stream_id}" + + +class AbstractOrangeProvider(AbstractProvider, ABC): + """Abstract Orange Provider.""" + + chunks_per_day = 2 + mco = "OFR" + groups = {} + + def get_live_stream_info(self, stream_id: str) -> dict: + return self._get_stream_info("live", "v3", "channels", stream_id) + + def get_catchup_stream_info(self, stream_id: str) -> dict: + return self._get_stream_info("catchup", "v4", "videos", stream_id) + + def get_streams(self) -> list: + """Load stream data from Orange and convert it to JSON-STREAMS format.""" + req = build_request(_CHANNELS_ENDPOINT) + + with urlopen(req) as res: + channels = list(json.loads(res.read())["channels"]) + + log(f"{len(channels)} channels found", xbmc.LOGINFO) + channels.sort(key=lambda channel: channel["displayOrder"]) + + return [ + { + "id": str(channel["idEPG"]), + "name": channel["name"], + "preset": str(channel["displayOrder"]), + "logo": self._extract_logo(channel["logos"]), + "stream": build_addon_url(f"/live-streams/{channel['idEPG']}"), + "group": [group_name for group_name in self.groups if int(channel["idEPG"]) in self.groups[group_name]], + } + for channel in channels + ] + + def get_epg(self) -> 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") + ) + + programs = self._get_programs(start_day, days_to_display, self.chunks_per_day, self.mco) + 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_catchup_channels(self) -> list: + """Load available catchup channels.""" + req = build_request(_CATCHUP_CHANNELS_ENDPOINT) + + with urlopen(req) as res: + channels = list(json.loads(res.read())) + + log(f"{len(channels)} catchup channels found", xbmc.LOGINFO) + + return [ + { + "is_folder": True, + "label": str(channel["name"]).upper(), + "path": build_addon_url(f"/channels/{channel['id']}/categories"), + "art": {"thumb": channel["logos"]["ref_millenials_partner_white_logo"]}, + } + for channel in channels + ] + + def get_catchup_categories(self, catchup_channel_id: str) -> list: + """Return a list of catchup categories for the specified channel id.""" + req = build_request(_CATCHUP_CHANNELS_ENDPOINT + "/" + catchup_channel_id) + + with urlopen(req) as res: + categories = list(json.loads(res.read())["categories"]) + + return [ + { + "is_folder": True, + "label": category["name"][0].upper() + category["name"][1:], + "path": build_addon_url(f"/channels/{catchup_channel_id}/categories/{category['id']}/articles"), + } + for category in categories + ] + + def get_catchup_articles(self, catchup_channel_id: str, category_id: str) -> list: + """Return a list of catchup groups for the specified channel id and category id.""" + req = build_request( + _CATCHUP_ARTICLES_ENDPOINT.format(catchup_channel_id=catchup_channel_id, category_id=category_id) + ) + + with urlopen(req) as res: + articles = list(json.loads(res.read())["articles"]) + + return [ + { + "is_folder": True, + "label": article["title"], + "path": build_addon_url(f"/channels/{catchup_channel_id}/articles/{article['id']}/videos"), + } + for article in articles + ] + + def get_catchup_videos(self, catchup_channel_id: str, article_id: str) -> list: + """Return a list of catchup videos for the specified channel id and article id.""" + req = build_request(_CATCHUP_VIDEOS_ENDPOINT.format(group_id=article_id)) + + with urlopen(req) as res: + videos = list(json.loads(res.read())["videos"]) + + return [ + { + "is_folder": False, + "label": video["title"], + "path": build_addon_url(f"/videos/{video['id']}"), + "art": {"thumb": video["covers"]["ref_4_3"]}, + } + for video in videos + ] + + def _get_stream_info(self, stream_type: str, version: str, item_type: str, stream_id: str) -> dict: + """Load stream info from Orange.""" + auth_url = _LIVE_HOMEPAGE_URL if stream_type == "live" else _CATCHUP_VIDEO_URL.format(stream_id=stream_id) + url = _STREAM_ENDPOINT.format( + stream_type=stream_type, + version=version, + item_type=item_type, + stream_id=stream_id, + ) + req, tv_token = self._build_request(url, auth_url=auth_url) + + try: + with urlopen(req) as res: + stream_info = json.loads(res.read()) + except HTTPError as error: + log(error, xbmc.LOGERROR) + if error.code == 403 or error.code == 401: + return False + + drm = get_drm() + protectionData = ( + stream_info.get("protectionData", None) + if stream_info.get("protectionData", None) is not None + else stream_info.get("protectionDatas") + ) + license_server_url = None + + for system in protectionData: + if system.get("keySystem") == drm.value: + license_server_url = system.get("laUrl") + + headers = ( + f"User-Agent={get_random_ua()}" + + f"&Host={urlparse(url).netloc}" + + f"&tv_token=Bearer {tv_token}" + + "&Content-Type=" + ) + 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, xbmc.LOGDEBUG) + return stream_info + + def _build_request(self, url: str, additional_headers: dict = None, auth_url: str = None) -> (Request, str): + """Build HTTP request.""" + tv_token = None + + if additional_headers is None: + additional_headers = {} + + if auth_url is not None: + req = build_request(auth_url, {"User-Agent": get_random_ua()}) + + with urlopen(req) as res: + html = res.read().decode("utf-8") + + tv_token = re.search('instanceInfo:{token:"([a-zA-Z0-9-_.]+)"', html).group(1) + household_id = re.search('householdId:"([A-Z0-9]+)"', html).group(1) + + additional_headers["tv_token"] = f"Bearer {tv_token}" + additional_headers["User-Agent"] = get_random_ua() + + url += f"&terminalId=Windows10-x64-Firefox-{household_id}" + + return build_request(url, additional_headers), tv_token + + def _extract_logo(self, logos: list, definition_type: str = "mobileAppliDark") -> str: + for logo in logos: + if logo["definitionType"] == definition_type: + return _STREAM_LOGO_URL.format(path=logo["listLogos"][0]["path"]) + + return None + + def _get_programs(self, start_day: int, days_to_display: int, chunks_per_day: int = 2, mco: str = "OFR") -> list: + """Return the programs for today (default) or the specified period.""" + programs = [] + chunk_duration = 24 * 60 * 60 / chunks_per_day + + for chunk in range(0, days_to_display * chunks_per_day): + period_start = (start_day + chunk_duration * chunk) * 1000 + period_end = (start_day + chunk_duration * (chunk + 1)) * 1000 + + try: + period = f"{int(period_start)},{int(period_end)}" + except ValueError: + period = "today" + + url = _PROGRAMS_ENDPOINT.format(period=period, mco=mco) + req = build_request(url) + log(f"Fetching: {url}", xbmc.LOGINFO) + + with urlopen(req) as res: + programs.extend(json.loads(res.read())) + + return programs diff --git a/plugin.video.orange.fr/resources/lib/providers/abstract_provider.py b/plugin.video.orange.fr/resources/lib/providers/abstract_provider.py new file mode 100644 index 000000000..767def088 --- /dev/null +++ b/plugin.video.orange.fr/resources/lib/providers/abstract_provider.py @@ -0,0 +1,47 @@ +"""Abstract TV Provider.""" + +from abc import ABC, abstractmethod + + +class AbstractProvider(ABC): + """Provide methods to be implemented by each ISP.""" + + @abstractmethod + def get_live_stream_info(self, stream_id: str) -> dict: + """Get live stream information (MPD address, Widewine key) for the specified id. Returned keys: path, mime_type, manifest_type, drm, license_type, license_key.""" # noqa: E501 + pass + + @abstractmethod + def get_catchup_stream_info(self, stream_id: str) -> dict: + """Get catchup stream information (MPD address, Widewine key) for the specified id. Returned keys: path, mime_type, manifest_type, drm, license_type, license_key.""" # noqa: E501 + pass + + @abstractmethod + 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 + pass + + @abstractmethod + def get_epg(self) -> dict: + """Return EPG data for the specified period following JSON-EPG format.""" + pass + + @abstractmethod + def get_catchup_channels(self) -> list: + """Return a list of available catchup channels.""" + pass + + @abstractmethod + def get_catchup_categories(self, catchup_channel_id: str) -> list: + """Return a list of catchup categories for the specified channel id.""" + pass + + @abstractmethod + def get_catchup_articles(self, catchup_channel_id: str, category_id: str) -> list: + """Return a list of catchup articles for the specified channel id and category id.""" + pass + + @abstractmethod + def get_catchup_videos(self, catchup_channel_id: str, article_id: str) -> list: + """Return a list of catchup videos for the specified channel id and article id.""" + pass diff --git a/plugin.video.orange.fr/resources/lib/providers/cache_provider.py b/plugin.video.orange.fr/resources/lib/providers/cache_provider.py deleted file mode 100644 index ac5074123..000000000 --- a/plugin.video.orange.fr/resources/lib/providers/cache_provider.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Cache provider.""" - -import json -import os - -import xbmc -import xbmcvfs - -from lib.utils.xbmctools 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 Exception: - log("Can't load streams: using cache instead", 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/plugin.video.orange.fr/resources/lib/providers/fr/orange.py b/plugin.video.orange.fr/resources/lib/providers/fr/orange.py index 535acadb4..360c7e600 100644 --- a/plugin.video.orange.fr/resources/lib/providers/fr/orange.py +++ b/plugin.video.orange.fr/resources/lib/providers/fr/orange.py @@ -1,40 +1,32 @@ +# ruff: noqa: D102 """Orange France.""" -from lib.providers.provider_interface import ProviderInterface -from lib.utils.orange import get_epg, get_stream_info, get_streams +from lib.providers.abstract_orange_provider import AbstractOrangeProvider -class OrangeFranceProvider(ProviderInterface): +class OrangeFranceProvider(AbstractOrangeProvider): """Orange France provider.""" - 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], - } - - 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") + def __init__(self): + """Initialize Orange France provider.""" + self.mco = "OFR" + self.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], + } diff --git a/plugin.video.orange.fr/resources/lib/providers/fr/orange_caraibe.py b/plugin.video.orange.fr/resources/lib/providers/fr/orange_caraibe.py index 0ad036afa..e4565b3be 100644 --- a/plugin.video.orange.fr/resources/lib/providers/fr/orange_caraibe.py +++ b/plugin.video.orange.fr/resources/lib/providers/fr/orange_caraibe.py @@ -1,22 +1,12 @@ """Orange Caraïbe.""" -from lib.providers.provider_interface import ProviderInterface -from lib.utils.orange import get_epg, get_stream_info, get_streams +from lib.providers.abstract_orange_provider import AbstractOrangeProvider -class OrangeCaraibeProvider(ProviderInterface): +class OrangeCaraibeProvider(AbstractOrangeProvider): """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, "OCA") - - def get_epg(self) -> dict: - """Return EPG data for the specified period following JSON-EPG format.""" - return get_epg(2, "OCA") + def __init__(self): + """Initialize Orange Caraïbe provider.""" + self.mco = "OCA" + self.groups = {} diff --git a/plugin.video.orange.fr/resources/lib/providers/fr/orange_reunion.py b/plugin.video.orange.fr/resources/lib/providers/fr/orange_reunion.py index 8684b90c3..7d127d9ef 100644 --- a/plugin.video.orange.fr/resources/lib/providers/fr/orange_reunion.py +++ b/plugin.video.orange.fr/resources/lib/providers/fr/orange_reunion.py @@ -1,34 +1,25 @@ """Orange Réunion.""" -from lib.providers.provider_interface import ProviderInterface -from lib.utils.orange import get_epg, get_stream_info, get_streams +from lib.providers.abstract_orange_provider import AbstractOrangeProvider -class OrangeReunionProvider(ProviderInterface): +class OrangeReunionProvider(AbstractOrangeProvider): """Orange Réunion provider.""" - 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], - } - - 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, "ORE") - - def get_epg(self) -> dict: - """Return EPG data for the specified period following JSON-EPG format.""" - return get_epg(2, "ORE") + def __init__(self): + """Initialize Orange Réunion provider.""" + self.mco = "ORE" + self.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/plugin.video.orange.fr/resources/lib/providers/provider_interface.py b/plugin.video.orange.fr/resources/lib/providers/provider_interface.py index 5fadb9ee6..1d415652d 100644 --- a/plugin.video.orange.fr/resources/lib/providers/provider_interface.py +++ b/plugin.video.orange.fr/resources/lib/providers/provider_interface.py @@ -2,13 +2,28 @@ class ProviderInterface: - """Provide methods to be implemented by each ISP.""" - - 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 + """Provide methods to be implemented by each TV provider.""" 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 def get_epg(self) -> dict: """Return EPG data for the specified period following JSON-EPG format.""" + + def get_live_stream_info(self, stream_id: str) -> dict: + """Get live 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_catchup_stream_info(self, stream_id: str) -> dict: + """Get catchup 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_catchup_channels(self) -> list: + """Return a list of available catchup channels.""" + + def get_catchup_categories(self, catchup_channel_id: str) -> list: + """Return a list of catchup categories for the specified channel id.""" + + def get_catchup_articles(self, catchup_channel_id: str, category_id: str) -> list: + """Return a list of catchup articles for the specified channel id and category id.""" + + def get_catchup_videos(self, catchup_channel_id: str, article_id: str) -> list: + """Return a list of catchup videos for the specified channel id and article id.""" diff --git a/plugin.video.orange.fr/resources/lib/router.py b/plugin.video.orange.fr/resources/lib/router.py new file mode 100644 index 000000000..b71383037 --- /dev/null +++ b/plugin.video.orange.fr/resources/lib/router.py @@ -0,0 +1,14 @@ +"""Addon router initialization.""" + +import xbmc +from routing import Plugin as Router + +from lib.utils.kodi import log + +router = Router() + + +def init_router(): + """Init addon router.""" + router.run() + log("Addon router initialized", xbmc.LOGDEBUG) diff --git a/plugin.video.orange.fr/resources/lib/routes.py b/plugin.video.orange.fr/resources/lib/routes.py new file mode 100644 index 000000000..e74f32b53 --- /dev/null +++ b/plugin.video.orange.fr/resources/lib/routes.py @@ -0,0 +1,72 @@ +"""Addon routes.""" + +import xbmc + +from lib.managers import CatchupManager, IPTVManager, StreamManager +from lib.router import router +from lib.utils.kodi import log + + +@router.route("/") +def index(): + """Display catchup TV channels.""" + log("Display home", xbmc.LOGINFO) + CatchupManager().get_channels() + + +@router.route("/channels//categories") +def channel_categories(catchup_channel_id: str): + """Return catchup category listitems for the required channel id.""" + log(f"Loading catchup categories for channel {catchup_channel_id}", xbmc.LOGINFO) + CatchupManager().get_categories(catchup_channel_id) + + +@router.route("/channels//categories//articles") +def channel_category_articles(catchup_channel_id: str, category_id: str): + """Return catchup category article listitems.""" + log(f"Loading catchup articles for category {category_id}", xbmc.LOGINFO) + CatchupManager().get_articles(catchup_channel_id, category_id) + + +@router.route("/channels//articles//videos") +def channel_article_videos(catchup_channel_id: str, article_id: str): + """Return catchup article video listitems.""" + log(f"Loading catchup videos for article {article_id}", xbmc.LOGINFO) + CatchupManager().get_videos(catchup_channel_id, article_id) + + +@router.route("/videos/") +def video(video_id: str): + """Return catchup video listitem.""" + log(f"Loading catchup video {video_id}", xbmc.LOGINFO) + CatchupManager().play_video(video_id) + + +@router.route("/live-streams/") +def live_stream(stream_id: str): + """Load live stream for the required channel id.""" + log(f"Loading live stream {stream_id}", xbmc.LOGINFO) + StreamManager().load_live_stream(stream_id) + + +@router.route("/catchup-streams/") +def catchup_stream(stream_id: str): + """Load live stream for the required video id.""" + log(f"Loading catchup stream {stream_id}", xbmc.LOGINFO) + StreamManager().load_chatchup_stream(stream_id) + + +@router.route("/iptv/channels") +def iptv_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() + + +@router.route("/iptv/epg") +def iptv_epg(): + """Return JSON-EPG formatted data for all live channel EPG data.""" + log("Loading EPG for IPTV Manager", xbmc.LOGINFO) + port = int(router.args.get("port")[0]) + IPTVManager(port).send_epg() diff --git a/plugin.video.orange.fr/resources/lib/utils/cache.py b/plugin.video.orange.fr/resources/lib/utils/cache.py new file mode 100644 index 000000000..065fdc50a --- /dev/null +++ b/plugin.video.orange.fr/resources/lib/utils/cache.py @@ -0,0 +1,37 @@ +"""Cache utils.""" + +import json +import os +from typing import Any, Callable + +import xbmc +import xbmcvfs + +from lib.utils.kodi import get_addon_info, log + + +def use_cache(filepath: str) -> Callable[[Callable], Callable]: + """Use cached data when Exception is raised or update cache on success.""" + cache_folder = os.path.join(xbmcvfs.translatePath(get_addon_info("profile")), "cache") + + if not os.path.exists(cache_folder): + os.makedirs(cache_folder) + + def decorator(func: Callable[[Any], Any]): + def wrapper(*args: Any, **kwargs: Any) -> Any: + result = {} + + try: + result = func(*args, **kwargs) + with open(os.path.join(cache_folder, filepath), "wb") as file: + file.write(json.dumps(result).encode("utf-8")) + except Exception: + log("Can't load data: using cache instead", xbmc.LOGWARNING) + with open(os.path.join(cache_folder, filepath), encoding="utf-8") as file: + result = json.loads("".join(file.readlines())) + + return result + + return wrapper + + return decorator diff --git a/plugin.video.orange.fr/resources/lib/utils/gui.py b/plugin.video.orange.fr/resources/lib/utils/gui.py new file mode 100644 index 000000000..17a782571 --- /dev/null +++ b/plugin.video.orange.fr/resources/lib/utils/gui.py @@ -0,0 +1,35 @@ +"""Helpers for Kodi GUI.""" + +from xbmcgui import ListItem + + +def create_directory_items(data: list) -> list: + """Create a list of directory items from data.""" + items = [] + + for d in data: + list_item = ListItem(label=d["label"], path=d["path"]) + + # list_item.setLabel2("LABEL2") + # list_item.setInfo("INFO", {"INFO1": "INFO1", "INFO2": "INFO2"}) + + if "art" in d and "thumb" in d["art"]: + list_item.setArt({"thumb": d["art"]["thumb"]}) + + items.append((d["path"], list_item, bool(d["is_folder"]))) + + return items + + +def create_video_item(stream_info: dict) -> ListItem: + """Create a video item from stream data.""" + list_item = ListItem(path=stream_info["path"]) + list_item.setMimeType(stream_info["mime_type"]) + list_item.setContentLookup(False) + list_item.setProperty("inputstream", "inputstream.adaptive") + list_item.setProperty("inputstream.adaptive.play_timeshift_buffer", "true") + list_item.setProperty("inputstream.adaptive.manifest_config", '{"timeshift_bufferlimit":14400}') + list_item.setProperty("inputstream.adaptive.license_type", stream_info["license_type"]) + list_item.setProperty("inputstream.adaptive.license_key", stream_info["license_key"]) + + return list_item diff --git a/plugin.video.orange.fr/resources/lib/utils/xbmctools.py b/plugin.video.orange.fr/resources/lib/utils/kodi.py similarity index 77% rename from plugin.video.orange.fr/resources/lib/utils/xbmctools.py rename to plugin.video.orange.fr/resources/lib/utils/kodi.py index 152a18dc8..8bea6a332 100644 --- a/plugin.video.orange.fr/resources/lib/utils/xbmctools.py +++ b/plugin.video.orange.fr/resources/lib/utils/kodi.py @@ -9,7 +9,7 @@ import xbmcgui ADDON = xbmcaddon.Addon() -ADDON_NAME = ADDON.getAddonInfo("name") +ADDON_ID = ADDON.getAddonInfo("id") class DRM(Enum): @@ -19,9 +19,14 @@ class DRM(Enum): PLAYREADY = "com.microsoft.playready" -def log(msg: str, log_level: int = xbmc.LOGINFO): +def build_addon_url(path: str = "") -> str: + """Build addon URL from path.""" + return f"plugin://{ADDON_ID}{path}" + + +def log(msg: str, log_level: int = xbmc.LOGINFO) -> None: """Prefix logs with addon name.""" - xbmc.log(f"{ADDON_NAME}: {msg}", log_level) + xbmc.log(f"[{ADDON_ID}] {msg}", log_level) def get_addon_info(name: str) -> str: @@ -46,7 +51,7 @@ def get_global_setting(name: str): return loads(xbmc.executeJSONRPC(dumps(cmd))).get("result", {}).get("value") -def localize(string_id: int, **kwargs): +def localize(string_id: int, **kwargs) -> str: """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 @@ -55,6 +60,6 @@ def localize(string_id: int, **kwargs): return ADDON.getLocalizedString(string_id) -def ok_dialog(msg: str): +def ok_dialog(msg: str) -> None: """Display a popup window with a button.""" - xbmcgui.Dialog().ok(ADDON_NAME, msg) + xbmcgui.Dialog().ok(get_addon_info("name"), msg) diff --git a/plugin.video.orange.fr/resources/lib/utils/orange.py b/plugin.video.orange.fr/resources/lib/utils/orange.py deleted file mode 100644 index 44126fa2a..000000000 --- a/plugin.video.orange.fr/resources/lib/utils/orange.py +++ /dev/null @@ -1,327 +0,0 @@ -""".""" - -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, install_proxy -from lib.utils.xbmctools import get_drm, get_global_setting, log - -_EPG_ENDPOINT = "https://rp-ott-mediation-tv.woopic.com/api-gw/live/v3/applications/STB4PC/programs?period={period}&epgIds=all&mco={mco}" -_STREAM_INFO_ENDPOINT = "https://mediation-tv.orange.fr/all/api-gw/live/v3/auth/accountToken/applications/PC/channels/{channel_id}/stream?terminalModel=WEB_PC" -_STREAM_LOGO_ENDPOINT = "https://proxymedia.woopic.com/api/v1/images/2090%2Flogos%2Fv2%2Flogos%2F{external_id}%2F{hash}%2F{type}%2Flogo_{width}x{height}.png" - -_HOMEPAGE_ENDPOINTS = [ - "https://chaines-tv.orange.fr/", - "https://chaines-tv.orange.fr/ce-soir?filtres=all", - "https://chaines-tv.orange.fr/programme-tv?filtres=all", -] - -_EXTERNAL_ID_MAP = { - "canalplus": "canal_plus", - "lcp": "lcp_ps", - "france4": "france_4", - "itelevision": "cnews", - "directstar": "cstar", - "lcimobile": "lci", - "tcm": "tcmcinema", - # livetv_canal_sport - "gameone": "game_one", - "chasseetpeche": "chasse_peche", - "toute_l_histoire": "toute_histoire", - "ushuaia": "ushuaia_tv", - "natgeo": "national_geographic", - "planeteplus": "planete_plus", - "m6_music": "m6music", - "equidia": "equidia_live", - "luxe": "luxe_tv", - "deutschewelle": "deutsche_welle_english", - "france3corsevs": "f3_corse_via_stella", - "rai_tre": "raitre", -} - -_NO_PRESET_START = 1000 - - -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.LOGINFO) - - url = _STREAM_INFO_ENDPOINT.format(channel_id=channel_id) - req = _build_request(url, {"tv_token": f"Bearer {tv_token}"}) - - 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"User-Agent={get_random_ua()}" - + f"&Host={urlparse(url).netloc}" - + f"&tv_token=Bearer {tv_token}" - + "&Content-Type=" - ) - 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, 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.""" - channels = _discover_channels() - channels = _load_channel_logos(channels) - channels = _load_channel_presets(channels, mco) - log(f"{len(channels)} channels found", xbmc.LOGINFO) - - channels_without_id = { - external_id: channel for external_id, channel in list(channels.items()) if channel["id"] == "" - } - log(f"{len(channels_without_id)} channels without id", xbmc.LOGINFO) - - for external_id in channels_without_id: - log(f" => {external_id}", xbmc.LOGDEBUG) - - return [ - { - "id": channel["id"], - "name": channel["name"], - "preset": channel["preset"], - "logo": channel.get("logo", None), - "stream": "plugin://plugin.video.orange.fr/channels/{channel_id}".format(channel_id=channel["id"]), - "group": [group_name for group_name in groups if int(channel["id"]) in groups[group_name]], - } - for channel in list(channels.values()) - if channel["id"] != "" and "preset" in channel - ] - - -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") - ) - - programs = _get_programs(start_day, days_to_display, chunks_per_day, mco) - 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(start_day: int, days_to_display: int, chunks_per_day: int = 2, mco: str = "OFR") -> list: - """Return the programs for today (default) or the specified period.""" - programs = [] - chunk_duration = 24 * 60 * 60 / chunks_per_day - - for chunk in range(0, days_to_display * chunks_per_day): - period_start = (start_day + chunk_duration * chunk) * 1000 - period_end = (start_day + chunk_duration * (chunk + 1)) * 1000 - - try: - period = f"{int(period_start)},{int(period_end)}" - except ValueError: - period = "today" - - url = _EPG_ENDPOINT.format(period=period, mco=mco) - req = _build_request(url) - log(f"Fetching: {url}", xbmc.LOGINFO) - - with urlopen(req) as res: - programs.extend(json.loads(res.read())) - - return programs - - -def _build_request(url: str, additional_headers: dict = None) -> Request: - """Build request.""" - if additional_headers is None: - additional_headers = {} - - install_proxy() - - return Request(url, headers={"User-Agent": get_random_ua(), "Host": urlparse(url).netloc, **additional_headers}) - - -def _extract_tv_token() -> str: - """Extract TV token.""" - req = _build_request(_HOMEPAGE_ENDPOINTS[0]) - - with urlopen(req) as res: - html = res.read().decode("utf-8") - - return re.search('instanceInfo:{token:"([a-zA-Z0-9-_.]+)"', html).group(1) - - -def _discover_channels() -> list: - """Load available channels from homepages.""" - channels = {} - - pattern = '"([A-Z0-9+/\': ]*[A-Z][A-Z0-9+/\': ]*)","(livetv_[a-zA-Z0-9_]+)",([0-9]*)' - - for url in _HOMEPAGE_ENDPOINTS: - req = _build_request(url) - - with urlopen(req) as res: - html = codecs.decode(res.read().decode("utf-8"), "unicode-escape") - - matches = re.findall(pattern, html) - - for match in matches: - channels[match[1]] = {"name": match[0], "id": match[2]} - - return channels - - -def _load_channel_logos(channels: dict, mco: str = "OFR") -> dict: - """Load channel logos from homepage.""" - req = _build_request(_HOMEPAGE_ENDPOINTS[0]) - - with urlopen(req) as res: - html = codecs.decode(res.read().decode("utf-8"), "unicode-escape") - - matches = re.findall( - 'path:"%2Flogos%2Fv2%2Flogos%2F(livetv_[a-zA-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 channels: - continue - - channels[match[0]]["logo"] = _STREAM_LOGO_ENDPOINT.format( - external_id=match[0], - hash=match[1], - type="mobileAppliDark", - width=match[2], - height=match[3], - ) - - # Fill missing channel ids from EPG when possible - req = _build_request(_EPG_ENDPOINT.format(period="today", mco=mco) + "&groupBy=channel") - - with urlopen(req) as res: - programs_by_channel = json.loads(res.read()) - - channel_ids = { - programs[0]["externalId"]: programs[0]["channelId"] for programs in list(programs_by_channel.values()) - } - - for external_id in channels: - epg_external_id = _get_external_id(external_id) - - if epg_external_id in channel_ids and channels[external_id]["id"] == "": - log(f" => Fill missing channel id for {external_id}", xbmc.LOGDEBUG) - channels[external_id]["id"] = channel_ids[epg_external_id] - - return channels - - -def _load_channel_presets(channels: dict, mco: str = "OFR") -> dict: - """Load presets from EPG.""" - req = _build_request(_EPG_ENDPOINT.format(period="today", mco=mco) + "&groupBy=channel") - - with urlopen(req) as res: - programs_by_channel = json.loads(res.read()) - - preset = _NO_PRESET_START - - for external_id in channels: - if channels[external_id]["id"] in programs_by_channel: - program = programs_by_channel[channels[external_id]["id"]][0] - channels[external_id]["preset"] = program["channelZappingNumber"] - else: - result = re.findall("90([0-9]+)", channels[external_id]["id"]) - - if len(result) > 0: - channels[external_id]["preset"] = result[0] - else: - channels[external_id]["preset"] = preset - preset = preset + 1 - log( - "=> Force preset {preset} for {channel_name} ({channel_id})".format( - preset=preset, - channel_name=channels[external_id]["name"], - channel_id=channels[external_id]["id"], - ), - xbmc.LOGDEBUG, - ) - - return channels - - -def _get_external_id(stream_external_id: str) -> str: - """Format original external id to EPG external id.""" - 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) + "_ctv" - log(f"{stream_external_id} => {epg_external_id}".format(stream_external_id, epg_external_id), xbmc.LOGDEBUG) - return epg_external_id diff --git a/plugin.video.orange.fr/resources/lib/utils/request.py b/plugin.video.orange.fr/resources/lib/utils/request.py index feb003f65..0f61e22f4 100644 --- a/plugin.video.orange.fr/resources/lib/utils/request.py +++ b/plugin.video.orange.fr/resources/lib/utils/request.py @@ -1,10 +1,12 @@ """Request utils.""" from random import randint +from urllib.parse import urlparse +from urllib.request import Request # from socks import SOCKS5 # from sockshandler import SocksiPyHandler -from lib.utils.xbmctools import get_addon_setting +from lib.utils.kodi import get_addon_setting _USER_AGENTS = [ # Chrome @@ -31,7 +33,17 @@ def get_random_ua() -> str: return _USER_AGENTS[randint(0, len(_USER_AGENTS) - 1)] -def install_proxy(): +def build_request(url: str, additional_headers: dict = None) -> Request: + """Build HTTP request.""" + if additional_headers is None: + additional_headers = {} + + install_proxy() + + return Request(url, headers={"User-Agent": get_random_ua(), "Host": urlparse(url).netloc, **additional_headers}) + + +def install_proxy() -> None: """Install proxy server for the next requests.""" if get_addon_setting("proxy.enabled") != "true": return diff --git a/plugin.video.orange.fr/resources/settings.xml b/plugin.video.orange.fr/resources/settings.xml index bcf04a715..404d524ec 100644 --- a/plugin.video.orange.fr/resources/settings.xml +++ b/plugin.video.orange.fr/resources/settings.xml @@ -1,6 +1,7 @@ +