From 074be7aa12570aa10439e1b463c6e250540191ca Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 19 Apr 2021 13:04:57 +0000 Subject: [PATCH] Create a dedicated class to interact with PeerTube The PeerTube class is responsible for providing methods to call easily the PeerTube REST APIs. Other changes: * the video filter is now also used when searching videos * in case of error when sending a request, the message from the response is displayed on the screen (even when listing the instances) * all the debug messages are now prefixed with the name of the add-on directly in kodi_utils: it allows an easier usage of this function anywhere in the add-on * first version of the design of the add-on added in contributing.md See merge request StCyr/plugin.video.peertube!14 for more information --- contributing.md | 51 ++++++++++ peertube.py | 185 +++++----------------------------- resources/lib/kodi_utils.py | 6 +- resources/lib/peertube.py | 192 ++++++++++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+), 161 deletions(-) create mode 100644 resources/lib/peertube.py diff --git a/contributing.md b/contributing.md index 84a2a22..b3f2f68 100644 --- a/contributing.md +++ b/contributing.md @@ -24,6 +24,57 @@ The workflow is the following: Note: more information about the pipeline is available in the [CI file](.gitlab-ci.yml). +## Design + +The add-on is based on the following python modules: + +| Name | Description | +| ------ | ------ | +| main.py | Entry point of the add-on. | +| service.py | Service responsible for downloading torrent files in the background. | +| resources/lib/addon.py | It handles the routing and the interaction between the other modules. | +| resources/lib/peertube.py | Responsible for interacting with PeerTube. | +| resources/lib/kodi_utils.py | Provides utility functions to interact easily with Kodi. | + +### main.py + +The file `peertube.py` is currently being redesigned into the `main` module. + +This module must be as short as possible (15 effective lines of code maximum) +to comply with Kodi add-on development best practices (checked by the +[Kodi add-on checker](https://github.com/xbmc/addon-check)). + +### service.py + +This module is being redesigned currently. + +This module must be as short as possible (15 effective lines of code maximum) +to comply with Kodi add-on development best practices (checked by the +[Kodi add-on checker](https://github.com/xbmc/addon-check)). + +### addon.py + +This module does not exist yet. + +### peertube.py + +This file contains: +* the class PeerTube which provides simple method to send REST APIs to a + PeerTube instance +* the function `list_instances` which lists the PeerTube instances from + joinpeertube.org. The URL of the API used by this function and the structure + of the response in case of errors is different than the other PeerTube APIs + (which are sent to a specific instance) so it made sense to have it as a + dedicated function. If more instance-related API are used in the future, a + class could be created. + +### kodi_utils.py + +This module only contains functions (no classes) as no common data between them +was identified. + +The functions must be sorted alphabetically to make the maintenance easier. + ## Coding style The code is still based on the design of the alpha version so the coding style diff --git a/peertube.py b/peertube.py index 749b165..8c7d6dd 100644 --- a/peertube.py +++ b/peertube.py @@ -19,16 +19,15 @@ except ImportError: from urlparse import parse_qsl import AddonSignals # Module exists only in Kodi - pylint: disable=import-error -import requests -from requests.compat import urljoin, urlencode +from requests.compat import urlencode import xbmc # Kodistubs for Leia is not compatible with python3 / pylint: disable=syntax-error -import xbmcaddon import xbmcgui # Kodistubs for Leia is not compatible with python3 / pylint: disable=syntax-error import xbmcplugin from resources.lib.kodi_utils import ( debug, get_property, get_setting, notif_error, notif_info, notif_warning, open_dialog, set_setting) +from resources.lib.peertube import PeerTube, list_instances class PeertubeAddon(): @@ -45,10 +44,6 @@ class PeertubeAddon(): :param plugin, plugin_id: str, int """ - # This step must be done first because the logging function requires - # the name of the add-on - self.addon_name = xbmcaddon.Addon().getAddonInfo('name') - # Save addon URL and ID self.plugin_url = plugin self.plugin_id = plugin_id @@ -60,9 +55,6 @@ class PeertubeAddon(): # Get the number of videos to show per page self.items_per_page = int(get_setting('items_per_page')) - # Get the video sort method - self.sort_method = get_setting('video_sort_method') - # Get the preferred resolution for video self.preferred_resolution = get_setting('preferred_resolution') @@ -71,14 +63,6 @@ class PeertubeAddon(): self.torrent_name = '' self.torrent_f = '' - # Get the video filter from the settings that will be used when - # browsing the videos. The value from the settings is converted into - # one of the expected values by the REST APIs ("local" or "all-local") - if 'all-local' in get_setting('video_filter'): - self.video_filter = 'all-local' - else: - self.video_filter = 'local' - # Check whether libtorrent could be imported by the service. The value # of the associated property is retrieved only once and stored in an # attribute because libtorrent is imported only once at the beginning @@ -87,51 +71,12 @@ class PeertubeAddon(): self.libtorrent_imported = \ get_property('libtorrent_imported') == 'True' - def debug(self, message): - """Log a debug message - - :param str message: Message to log (will be prefixed with the add-on - name) - """ - debug('{0}: {1}'.format(self.addon_name, message)) - - def query_peertube(self, req): - """ - Issue a PeerTube API request and return the results - :param req: str - :result data: dict - """ - - # Send the PeerTube REST API request - self.debug('Issuing request {0}'.format(req)) - response = requests.get(url=req) - data = response.json() - - # Use Request.raise_for_status() to raise an exception if the HTTP - # request returned an unsuccessful status code. - try: - response.raise_for_status() - except requests.HTTPError as e: - notif_error(title='Communication error', - message='Error when sending request {}'.format(req)) - # If the JSON contains an 'error' key, print it - error_details = data.get('error') - if error_details is not None: - self.debug('Error => "{}"'.format(data['error'])) - raise e - - # Try to get the number of elements in the response - results_found = data.get('total', None) - # If the information is available in the response, use it - if results_found is not None: - # Return when no results are found - if results_found == 0: - self.debug('No result found') - return None - else: - self.debug('Found {0} results'.format(results_found)) - - return data + # Create a PeerTube object to send requests: settings which are used + # only by this object are directly retrieved from the settings + self.peertube = PeerTube(instance=self.selected_inst, + count=self.items_per_page, + sort=get_setting('video_sort_method'), + video_filter=get_setting('video_filter')) def create_list(self, lst, data_type, start): """ @@ -227,9 +172,7 @@ class PeertubeAddon(): instance = 'https://{}'.format(instance) # Retrieve the information about the video - metadata = self.query_peertube(urljoin(instance, - '/api/v1/videos/{}' - .format(video_id))) + metadata = self.peertube.get_video(video_id) # Depending if WebTorrent is enabled or not, the files corresponding to # different resolutions available for a video may be stored in "files" @@ -240,8 +183,7 @@ class PeertubeAddon(): else: files = metadata['streamingPlaylists'][0]['files'] - self.debug( - 'Looking for the best resolution matching the user preferences') + debug('Looking for the best resolution matching the user preferences') current_res = 0 higher_res = -1 @@ -253,98 +195,33 @@ class PeertubeAddon(): if res == self.preferred_resolution: # Stop directly when we find the exact same resolution as the # user's preferred one - self.debug('Found video with preferred resolution') + debug('Found video with preferred resolution') torrent_url = f['torrentUrl'] break elif res < self.preferred_resolution and res > current_res: # Otherwise, try to find the best one just below the user's # preferred one - self.debug('Found video with good lower resolution' - '({0})'.format(f['resolution']['label'])) + debug('Found video with good lower resolution ({0})' + .format(f['resolution']['label'])) torrent_url = f['torrentUrl'] current_res = res elif (res > self.preferred_resolution and (res < higher_res or higher_res == -1)): # In the worst case, we'll take the one just above the user's # preferred one - self.debug('Saving video with higher resolution ({0})' - 'as a possible alternative' - .format(f['resolution']['label'])) + debug('Saving video with higher resolution ({0}) as a possible' + ' alternative'.format(f['resolution']['label'])) backup_url = f['torrentUrl'] higher_res = res # When we didn't find a resolution equal or lower than the user's # preferred one, use the resolution just above the preferred one if not torrent_url: - self.debug('Using video with higher resolution as alternative') + debug('Using video with higher resolution as alternative') torrent_url = backup_url return torrent_url - def build_video_rest_api_request(self, search, start): - """Build the URL of an HTTP request using the PeerTube videos REST API. - - The same function is used for browsing and searching videos. - - :param search: keywords to search - :type search: string - :param start: offset - :type start: int - :return: the URL of the request - :rtype: str - - Didn't yet find a correct way to do a search with a filter set to - local. Then if a search value is given it won't filter on local - """ - - # Common parameters of the request - params = { - 'count': self.items_per_page, - 'start': start, - 'sort': self.sort_method - } - - # Depending on the type of request (search or list videos), add - # specific parameters and define the API to use - if search is None: - # Video API does not provide "search" but provides "filter" so add - # it to the parameters - params.update({'filter': self.video_filter}) - api_url = '/api/v1/videos' - else: - # Search API does not provide "filter" but provides "search" so add - # it to the parameters - params.update({'search': search}) - api_url = '/api/v1/search/videos' - - # Build the full URL of the request (instance + API + parameters) - req = '{0}?{1}'.format(urljoin(self.selected_inst, api_url), - urlencode(params)) - - return req - - def build_browse_instances_rest_api_request(self, start): - """Build the URL of an HTTP request using the PeerTube REST API to - browse the PeerTube instances. - - :param start: offset - :type start: int - :return: the URL of the request - :rtype: str - """ - - # Create the parameters of the request - params = { - 'count': self.items_per_page, - 'start': start - } - - # Join the base URL with the REST API and the parameters - req = 'https://instances.joinpeertube.org/api/v1/instances?{0}'\ - .format(urlencode(params)) - - return req - def search_videos(self, start): """ Function to search for videos on a PeerTube instance and navigate @@ -353,20 +230,17 @@ class PeertubeAddon(): :param start: string """ - # Show a 'Search videos' dialog - search = xbmcgui.Dialog().input( + # Ask the user which keywords must be searched for + keywords = xbmcgui.Dialog().input( heading='Search videos on {}'.format(self.selected_inst), type=xbmcgui.INPUT_ALPHANUM) # Go back to main menu when user cancels - if not search: + if not keywords: return - # Create the PeerTube REST API request for searching videos - req = self.build_video_rest_api_request(search, start) - # Send the query - results = self.query_peertube(req) + results = self.peertube.search_videos(keywords, start) # Exit directly when no result is found if not results: @@ -389,11 +263,8 @@ class PeertubeAddon(): :param start: string """ - # Create the PeerTube REST API request for listing videos - req = self.build_video_rest_api_request(None, start) - # Send the query - results = self.query_peertube(req) + results = self.peertube.list_videos(start) # Create array of xmbcgui.ListItem's listing = self.create_list(results, 'videos', start) @@ -408,11 +279,8 @@ class PeertubeAddon(): :param start: str """ - # Create the PeerTube REST API request for browsing PeerTube instances - req = self.build_browse_instances_rest_api_request(start) - # Send the query - results = self.query_peertube(req) + results = list_instances(start) # Create array of xmbcgui.ListItem's listing = self.create_list(results, 'instances', start) @@ -429,8 +297,7 @@ class PeertubeAddon(): :param data: dict """ - self.debug( - 'Received metadata_downloaded signal, will start playing media') + debug('Received metadata_downloaded signal, will start playing media') self.play = 1 self.torrent_f = data['file'] @@ -448,7 +315,7 @@ class PeertubeAddon(): ' at {}'.format(self.HELP_URL)) return - self.debug('Starting torrent download ({0})'.format(torrent_url)) + debug('Starting torrent download ({0})'.format(torrent_url)) # Start a downloader thread AddonSignals.sendSignal('start_download', {'url': torrent_url}) @@ -473,7 +340,7 @@ class PeertubeAddon(): xbmc.sleep(3000) # Pass the item to the Kodi player for actual playback. - self.debug('Starting video playback ({0})'.format(torrent_url)) + debug('Starting video playback ({0})'.format(torrent_url)) play_item = xbmcgui.ListItem(path=self.torrent_f) xbmcplugin.setResolvedUrl(self.plugin_id, True, listitem=play_item) @@ -495,7 +362,7 @@ class PeertubeAddon(): message = '{0} is now the selected instance'.format(self.selected_inst) notif_info(title='Current instance changed', message=message) - self.debug(message) + debug(message) def build_kodi_url(self, parameters): """Build a Kodi URL based on the parameters. diff --git a/resources/lib/kodi_utils.py b/resources/lib/kodi_utils.py index c2e13c1..1d86aaa 100644 --- a/resources/lib/kodi_utils.py +++ b/resources/lib/kodi_utils.py @@ -15,9 +15,11 @@ import xbmcgui # Kodistubs for Leia is not compatible with python3 / pylint: dis def debug(message): """Log a message in Kodi's log with the level xbmc.LOGDEBUG - :param str message: Message to log + :param str message: Message to log prefixed with the name of the add-on + (the name is hard-coded to avoid calling xbmcaddon each time since the name + should not change) """ - xbmc.log(message, xbmc.LOGDEBUG) + xbmc.log('[PeerTube] {}'.format(message), xbmc.LOGDEBUG) def get_property(name): """Retrieve the value of a window property related to the add-on diff --git a/resources/lib/peertube.py b/resources/lib/peertube.py new file mode 100644 index 0000000..5812489 --- /dev/null +++ b/resources/lib/peertube.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +""" + PeerTube related classes and functions + + Copyright (C) 2018 Cyrille Bollu + Copyright (C) 2021 Thomas Bétous + + SPDX-License-Identifier: GPL-3.0-only + See LICENSE.txt for more information. +""" +import requests +from requests.compat import urljoin + +from resources.lib.kodi_utils import debug, get_setting, notif_error + + +class PeerTube: + """A class to interact easily with PeerTube instances using REST APIs""" + + def __init__(self, instance, sort, count, video_filter): + """Constructor + + :param str instance: URL of the PeerTube instance + :param str sort: sort method to use when listing items + :param int count: number of items to display + :param str sort: filter to apply when listing/searching videos + """ + self.instance = instance + + self.list_settings = { + "sort": sort, + "count": count + } + + # The value "video_filter" is directly retrieved from the settings so + # it must be converted into one of the expected values by the REST APIs + if 'all-local' in video_filter: + self.filter = 'all-local' + else: + self.filter = 'local' + + def _request(self, method, url, params=None, data=None): + """Call a REST API on the instance + + :param str method: REST API method (get, post, put, delete, etc.) + :param str url: URL of the REST API endpoint relative to the PeerTube + instance + :param dict params: dict of the parameters to send in the request + :param dict data: dict of the data to send with the request + :return: the response as JSON data + :rtype: dict + """ + + # Build the URL of the REST API + api_url = urljoin("{}/api/v1/".format(self.instance), url) + + # Send a request with a time-out of 5 seconds + response = requests.request(method=method, + url=api_url, + timeout=5, + params=params, + data=data) + + json = response.json() + + # Use Request.raise_for_status() to raise an exception if the HTTP + # request didn't succeed + try: + response.raise_for_status() + except requests.HTTPError as exception: + # Print in Kodi's log some information about the request + debug("Error when sending a {} request to {} with params={} and" + " data={}".format(method, url, params, data)) + + # Report the error to the user with a notification: if the response + # contains an "error" attribute, use it as error message, otherwise + # use a default message. + if "error" in json: + message = json["error"] + debug(message) + else: + message = ("No details returned by the server. Check the log" + " for more information.") + notif_error(title="Request error", message=message) + raise exception + + return json + + def _build_params(self, **kwargs): + """Build the parameters to send with a request from the common settings + + This method returns a dictionnary containing the common settings from + self.list_settings plus the arguments passed to this function. The keys + in the dictionnary will have the same name as the arguments passed to + this function. + + :return: the common settings plus other parameters + :rtype: dict + """ + # Initialize the dict from the common settings (the common settings are + # copied otherwise any modification will also impact the attribute). + params = self.list_settings.copy() + + # Add all the arguments to the dict + for param in kwargs: + params[param] = kwargs[param] + + return params + + def get_video(self, video_id): + """Get the information of a video + + :param str video_id: ID or UUID of the video + :return: the information of the video as returned by the REST API + :rtype: dict + """ + + return self._request(method="GET", url="videos/{}".format(video_id)) + + def list_videos(self, start): + """List the videos in the instance + + :param str start: index of the first video to display + :return: the list of videos as returned by the REST API + :rtype: dict + """ + # Build the parameters that will be sent in the request + params = self._build_params(filter=self.filter, start=start) + + return self._request(method="GET", url="videos", params=params) + + def search_videos(self, keywords, start): + """Search for videos on the instance and beyond. + + :param str keywords: keywords to seach for + :param str start: index of the first video to display + :return: the videos matching the keywords as returned by the REST API + :rtype: dict + """ + # Build the parameters that will be send in the request + params = self._build_params(search=keywords, + filter=self.filter, + start=start) + + return self._request(method="GET", url="search/videos", params=params) + + +def list_instances(start): + """List all the peertube instances from joinpeertube.org + + :param str start: index of the first instance to display + :return: the list of instances as returned by the REST API + :rtype: dict + """ + # URL of the REST API + api_url = "https://instances.joinpeertube.org/api/v1/instances" + # Build the parameters that will be sent in the request from the settings + params = { + "count": get_setting("items_per_page"), + "start": start + } + + # Send a request with a time-out of 5 seconds + response = requests.get(url=api_url, timeout=5, params=params) + + json = response.json() + + # Use Request.raise_for_status() to raise an exception if the HTTP + # request didn't succeed + try: + response.raise_for_status() + except requests.HTTPError as exception: + # Print in Kodi's log some information about the request + debug("Error when getting the list of instances with params={}" + .format(params)) + + # Report the error to the user with a notification: use the details of + # the error if it exists in the response, otherwise use a default + # message. + try: + # Convert the reponse to a list to get the first error whatever its + # name. Then get the second element in the sublist which contains + # the details of the error. + message = list(json["errors"].items())[0][1]["msg"] + debug(message) + except KeyError: + message = ("No details returned by the server. Check the log" + " for more information.") + notif_error(title="Request error", message=message) + raise exception + + return json