diff --git a/addon.xml b/addon.xml index c8fb8f1..5ace20c 100644 --- a/addon.xml +++ b/addon.xml @@ -5,7 +5,7 @@ - + video diff --git a/contributing.md b/contributing.md index 36b996d..0a65f2e 100644 --- a/contributing.md +++ b/contributing.md @@ -21,24 +21,29 @@ The workflow is the following: 1. if the pipeline passed, the merge request may be merged by one of the maintainers. Note that the preferred option is to squash commits. -Note: more information about the pipeline is available in the +More information about the pipeline is available in the [CI file](.gitlab-ci.yml). ## Design +Basically the add-on is composed of: +* a service which will download the torrent videos in the background +* classes which will retrieved information and play videos from a PeerTube + instance + 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/addon.py | 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. +The file `peertube.py` is the entry point of the add-on. 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 @@ -46,7 +51,13 @@ to comply with Kodi add-on development best practices (checked by the ### service.py -This module is being redesigned currently. +Note: the design of this module is still based on the alpha version. + +It contains 2 classes: +* PeertubeService: code of the service which is run by Kodi. It will + instantiate `PeertubeDownloader` when the signal to start a download is + received from `addon.py` +* PeertubeDownloader: downloads torrent in an independent thread 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 @@ -54,12 +65,14 @@ to comply with Kodi add-on development best practices (checked by the ### addon.py -This module does not exist yet. +This module contains the class `PeerTubeAddon` which is the main class of the +add-on. It is responsible for calling the other modules and classes to provide +the features of the add-on. ### peertube.py This file contains: -* the class PeerTube which provides simple method to send REST APIs to a +* 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 @@ -70,10 +83,25 @@ This file contains: ### kodi_utils.py -This module only contains functions (no classes) as no common data between them -was identified. +This module contains the class `KodiUtils` which provides utility methods +to the other modules so that the Kodi APIs can be called easily. It imports the +xbmc file and the other modules should not import any xmbc file. -The functions must be sorted alphabetically to make the maintenance easier. +A global instance of the class `KodiUtils` which is called `kodi` is defined in +this file so that it can be reused easily anywhere in the add-on by simply +importing this module. + +Some important features provided by this module: +* The methods `get_property` and `set_property` allows to manage data which + will remain available when the current call of the add-on ends. It can also + be used to share information between the service and the rest of the add-on. +* There are some helper functions which make the creation of items in Kodi UI + easier. + `generate_item_info` creates a dict with the required information to create + an item: it allows to define only the parameters that are useful for a given + items and the method will use a correct value for the other parameters. + Then `create_items_in_ui` is called with the information generated by + `generate_item_info` to actually create the items in the UI. ## Coding style @@ -106,7 +134,7 @@ These steps should be followed only by maintainers. the release process like: - a bump of the add-on version in `addon.xml` (note that the version numbering must follow the [semantic versioning](https://semver.org/)) - - the update of the changelog in the `news` tag in `addon.xml` (using + - the update of the change log in the `news` tag in `addon.xml` (using Markdown syntax since it will be re-used automatically in the release notes) 3. Merge the merge request (maintainers only) diff --git a/main.py b/main.py new file mode 100644 index 0000000..f9d7c5d --- /dev/null +++ b/main.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +""" + Entry point of the add-on + + 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 sys + +from resources.lib.addon import PeerTubeAddon +from resources.lib.kodi_utils import kodi + +def main(argv): + """First function called by the add-on + + This function is created to be able to test the code in this module easily. + """ + # Update the kodi object with the system arguments of this call + kodi.update_call_info(argv) + # Initialize the main class of the add-on + addon = PeerTubeAddon() + # Call the router function to execute the requested action + addon.router(kodi.get_run_parameters()) + +if __name__ == "__main__": + main(sys.argv) diff --git a/peertube.py b/peertube.py deleted file mode 100644 index 8c2a148..0000000 --- a/peertube.py +++ /dev/null @@ -1,457 +0,0 @@ -# -*- coding: utf-8 -*- -""" - A Kodi add-on to play video hosted on the PeerTube service - (http://joinpeertube.org/) - - 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 sys - -try: - # Python 3.x - from urllib.parse import parse_qsl -except ImportError: - # Python 2.x - from urlparse import parse_qsl - -import AddonSignals # Module exists only in Kodi - pylint: disable=import-error -from requests.compat import urlencode -import xbmc # Kodistubs for Leia is not compatible with python3 / pylint: disable=syntax-error -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(): - """ - Main class of the addon - """ - - # URL of the page which explains how to install libtorrent - HELP_URL = "https://link.infini.fr/libtorrent-peertube-kodi" - - def __init__(self, plugin, plugin_id): - """ - Initialisation of the PeertubeAddon class - :param plugin, plugin_id: str, int - """ - - # Save addon URL and ID - self.plugin_url = plugin - self.plugin_id = plugin_id - - # Select preferred instance by default - self.selected_inst ="https://{}"\ - .format(get_setting("preferred_instance")) - - # Get the number of videos to show per page - self.items_per_page = int(get_setting("items_per_page")) - - # Get the preferred resolution for video - self.preferred_resolution = get_setting("preferred_resolution") - - # Nothing to play at initialisation - self.play = 0 - self.torrent_name = "" - self.torrent_f = "" - - # 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 - # of the service (we assume it is not possible to start the add-on - # before the service) - self.libtorrent_imported = \ - get_property("libtorrent_imported") == "True" - - # 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): - """ - Create an array of xmbcgui.ListItem's from the lst parameter - :param lst, data_type, start: dict, str, str - :result listing: array - """ - # Create a list for our items. - listing = [] - for data in lst["data"]: - - # Create a list item with a text label - list_item = xbmcgui.ListItem(label=data["name"]) - - if data_type == "videos": - # Add thumbnail - list_item.setArt({ - "thumb": "{0}/{1}".format(self.selected_inst, - data["thumbnailPath"])}) - - # Set a fanart image for the list item. - # list_item.setProperty("fanart_image", data["thumb"]) - - # Compute media info from item's metadata - info = {"title": data["name"], - "playcount": data["views"], - "plotoutline": data["description"], - "duration": data["duration"] - } - - # For videos, add a rating based on likes and dislikes - if data["likes"] > 0 or data["dislikes"] > 0: - info["rating"] = data["likes"] / ( - data["likes"] + data["dislikes"]) - - # Set additional info for the list item. - list_item.setInfo("video", info) - - # Videos are playable - list_item.setProperty("IsPlayable", "true") - - # Build the Kodi URL to play the associated video only with the - # id of the video. The instance is omitted because the - # currently selected instance will be used automatically. - url = self.build_kodi_url({ - "action": "play_video", - "id": data["uuid"] - }) - - elif data_type == "instances": - # TODO: Add a context menu to select instance as preferred - # Instances are not playable - list_item.setProperty("IsPlayable", "false") - - # Set URL to select this instance - url = self.build_kodi_url({ - "action": "select_instance", - "url": data["host"] - }) - - # Add our item to the listing as a 3-element tuple. - listing.append((url, list_item, False)) - - # Add a "Next page" button when there are more data to show - start = int(start) + self.items_per_page - if lst["total"] > start: - list_item = xbmcgui.ListItem(label="Next page ({0})" - .format(start/self.items_per_page)) - url = self.build_kodi_url({ - "action": "browse_{0}".format(data_type), - "start": start}) - listing.append((url, list_item, True)) - - return listing - - def get_video_url(self, video_id, instance=None): - """Find the URL of the video with the best possible quality matching - user's preferences. - - :param video_id: ID of the torrent linked to the video - :type video_id: str - :param instance: PeerTube instance hosting the video (optional) - :type instance: str - """ - - # If no instance was provided, use the selected one (internal call) - if instance is None: - instance = self.selected_inst - else: - # If an instance was provided (external call), ensure the URL is - # prefixed with HTTPS - if not instance.startswith("https://"): - instance = "https://{}".format(instance) - - # Retrieve the information about the video - 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" - # or "streamingPlaylists[].files". Note that "files" will always exist - # in the response but may be empty. - if len(metadata["files"]) != 0: - files = metadata["files"] - else: - files = metadata["streamingPlaylists"][0]["files"] - - debug("Looking for the best resolution matching the user preferences") - - current_res = 0 - higher_res = -1 - torrent_url = "" - - for f in files: - # Get the resolution - res = f["resolution"]["id"] - if res == self.preferred_resolution: - # Stop directly when we find the exact same resolution as the - # user's preferred one - 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 - 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 - 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: - debug("Using video with higher resolution as alternative") - torrent_url = backup_url - - return torrent_url - - def search_videos(self, start): - """ - Function to search for videos on a PeerTube instance and navigate - in the results - - :param start: string - """ - - # 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 keywords: - return - - # Send the query - results = self.peertube.search_videos(keywords, start) - - # Exit directly when no result is found - if not results: - notif_warning(title="No videos found", - message="No videos found matching the query.") - return - - # Create array of xmbcgui.ListItem's - listing = self.create_list(results, "videos", start) - - # Add our listing to Kodi. - xbmcplugin.addDirectoryItems(self.plugin_id, listing, len(listing)) - xbmcplugin.endOfDirectory(self.plugin_id) - - def browse_videos(self, start): - """ - Function to navigate through all the video published by a PeerTube - instance - - :param start: string - """ - - # Send the query - results = self.peertube.list_videos(start) - - # Create array of xmbcgui.ListItem's - listing = self.create_list(results, "videos", start) - - # Add our listing to Kodi. - xbmcplugin.addDirectoryItems(self.plugin_id, listing, len(listing)) - xbmcplugin.endOfDirectory(self.plugin_id) - - def browse_instances(self, start): - """ - Function to navigate through all PeerTube instances - :param start: str - """ - - # Send the query - results = list_instances(start) - - # Create array of xmbcgui.ListItem's - listing = self.create_list(results, "instances", start) - - # Add our listing to Kodi. - xbmcplugin.addDirectoryItems(self.plugin_id, listing, len(listing)) - xbmcplugin.endOfDirectory(self.plugin_id) - - def play_video_continue(self, data): - """ - Callback function to let the play_video function resume when the - PeertubeDownloader has downloaded all the torrent's metadata - - :param data: dict - """ - - debug("Received metadata_downloaded signal, will start playing media") - self.play = 1 - self.torrent_f = data["file"] - - def play_video(self, torrent_url): - """ - Start the torrent's download and play it while being downloaded - :param torrent_url: str - """ - # If libtorrent could not be imported, display a message and do not try - # download nor play the video as it will fail. - if not self.libtorrent_imported: - open_dialog(title="Error: libtorrent could not be imported", - message="PeerTube cannot play videos without" - " libtorrent.\nPlease follow the instructions" - " at {}".format(self.HELP_URL)) - return - - debug("Starting torrent download ({0})".format(torrent_url)) - - # Start a downloader thread - AddonSignals.sendSignal("start_download", {"url": torrent_url}) - - # Wait until the PeerTubeDownloader has downloaded all the torrent's - # metadata - AddonSignals.registerSlot("plugin.video.peertube", - "metadata_downloaded", - self.play_video_continue) - timeout = 0 - while self.play == 0 and timeout < 10: - xbmc.sleep(1000) - timeout += 1 - - # Abort in case of timeout - if timeout == 10: - notif_error(title="Download timeout", - message="Timeout fetching {}".format(torrent_url)) - return - else: - # Wait a little before starting playing the torrent - xbmc.sleep(3000) - - # Pass the item to the Kodi player for actual playback. - 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) - - def select_instance(self, instance): - """ - Change currently selected instance to "instance" parameter - :param instance: str - """ - - # Update the object attribute even though it is not used currently but - # it may be useful in case reuselanguageinvoker is enabled. - self.selected_inst = "https://{}".format(instance) - - # Update the preferred instance in the settings so that this choice is - # reused on the next run and the next call of the add-on - set_setting("preferred_instance", instance) - - # Notify the user and log the event - message = "{0} is now the selected instance".format(self.selected_inst) - notif_info(title="Current instance changed", - message=message) - debug(message) - - def build_kodi_url(self, parameters): - """Build a Kodi URL based on the parameters. - - :param parameters: dict containing all the parameters that will be - encoded in the URL - """ - - return "{0}?{1}".format(self.plugin_url, urlencode(parameters)) - - def main_menu(self): - """ - Addon's main menu - """ - - # Create a list for our items. - listing = [] - - # 1st menu entry - list_item = xbmcgui.ListItem(label="Browse selected instance") - url = self.build_kodi_url({"action": "browse_videos", "start": 0}) - listing.append((url, list_item, True)) - - # 2nd menu entry - list_item = xbmcgui.ListItem(label="Search on selected instance") - url = self.build_kodi_url({"action": "search_videos", "start": 0}) - listing.append((url, list_item, True)) - - # 3rd menu entry - list_item = xbmcgui.ListItem(label="Select other instance") - url = self.build_kodi_url({"action": "browse_instances", "start": 0}) - listing.append((url, list_item, True)) - - # Add our listing to Kodi. - xbmcplugin.addDirectoryItems(self.plugin_id, listing, len(listing)) - - # Finish creating a virtual folder. - xbmcplugin.endOfDirectory(self.plugin_id) - - def router(self, paramstring): - """ - Router function that calls other functions - depending on the provided paramstring - :param paramstring: dict - """ - - # Parse a URL-encoded paramstring to the dictionary of - # {: } elements - params = dict(parse_qsl(paramstring[1:])) - - # Check the parameters passed to the plugin - if params: - action = params["action"] - if action == "browse_videos": - # Browse videos on selected instance - self.browse_videos(params["start"]) - elif action == "search_videos": - # Search for videos on selected instance - self.search_videos(params["start"]) - elif action == "browse_instances": - # Browse peerTube instances - self.browse_instances(params["start"]) - elif action == "play_video": - # This action comes with the id of the video to play as - # parameter. The instance may also be in the parameters. Use - # these parameters to retrieve the complete URL (containing the - # resolution). - url = self.get_video_url(instance=params.get("instance"), - video_id=params.get("id")) - # Play the video using the URL - self.play_video(url) - elif action == "select_instance": - self.select_instance(params["url"]) - else: - # Display the addon's main menu when the plugin is called from - # Kodi UI without any parameters - self.main_menu() - - # Display a warning if libtorrent could not be imported - if not self.libtorrent_imported: - open_dialog(title="Error: libtorrent could not be imported", - message="You can still browse and search videos" - " but you will not be able to play them.\n" - "Please follow the instructions at {}" - .format(self.HELP_URL)) - -if __name__ == "__main__": - - # Initialise addon - addon = PeertubeAddon(sys.argv[0], int(sys.argv[1])) - # Call the router function and pass the plugin call parameters to it. - addon.router(sys.argv[2]) diff --git a/resources/lib/addon.py b/resources/lib/addon.py new file mode 100644 index 0000000..3b23eb5 --- /dev/null +++ b/resources/lib/addon.py @@ -0,0 +1,441 @@ +# -*- coding: utf-8 -*- +""" + Main class used by the add-on + + 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 AddonSignals # Module exists only in Kodi - pylint: disable=import-error + +from resources.lib.kodi_utils import kodi +from resources.lib.peertube import PeerTube, list_instances + + +class PeerTubeAddon(): + """ + Main class used by the add-on + """ + + # URL of the page which explains how to install libtorrent + HELP_URL = "https://link.infini.fr/libtorrent-peertube-kodi" + + def __init__(self): + """Initialize parameters and create a PeerTube instance""" + + # Get the number of items to show per page + self.items_per_page = int(kodi.get_setting("items_per_page")) + + # Get the preferred resolution for video + self.preferred_resolution = \ + int(kodi.get_setting("preferred_resolution")) + + # Nothing to play at initialisation + self.play = False + self.torrent_name = "" + self.torrent_file = "" + + # 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 + # of the service (we assume it is not possible to start the add-on + # before the service) + self.libtorrent_imported = \ + kodi.get_property("libtorrent_imported") == "True" + + # 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=kodi.get_setting("preferred_instance"), + count=self.items_per_page, + sort=kodi.get_setting("video_sort_method"), + video_filter=kodi.get_setting("video_filter")) + + def _browse_videos(self, start): + """Display the list of all the videos published on a PeerTube instance + + :param int start: index of the first video to display (pagination) + """ + + # Use the API to get the list of the videos + results = self.peertube.list_videos(start) + + # Extract the information of each video from the API response + list_of_videos = self._create_list_of_videos(results, start) + + # Create the associated items in Kodi + kodi.create_items_in_ui(list_of_videos) + + def _browse_instances(self, start): + """ + Function to navigate through all the PeerTube instances + + :param int start: index of the first instance to display (pagination) + """ + + # Use the API to get the list of the instances + results = list_instances(start) + + # Extract the information of each instance from the API response + list_of_instances = self._create_list_of_instances(results, start) + + # Create the associated items in Kodi + kodi.create_items_in_ui(list_of_instances) + + def _create_list_of_instances(self, response, start): + """Generator of instance items to be added in Kodi UI + + :param dict response: data returned by joinpeertube + :param int start: index of the first item to display (pagination) + :return: yield the information of each item + :rtype: dict + """ + + for data in response["data"]: + + # The description of each instance in Kodi will be composed of: + # * the description of the instance (from joinpeertube.org) + # * the number of local videos hosted on this instance + # * the number of users on this instance + description = "{}\n\n----------\nNumber of local videos: {}\n"\ + "Number of users: {}".format( + data["shortDescription"].encode("utf-8"), + data["totalLocalVideos"], + data["totalUsers"]) + # The value of "totalLocalVideos" and "totalUsers" are int so they + # don't need to be encoded. + + instance_info = kodi.generate_item_info( + name=data["name"], + url=kodi.build_kodi_url({ + "action": "select_instance", + "url": data["host"] + } + ), + is_folder=True, + plot=description + ) + + yield instance_info + else: + # Add a "Next page" button when there are more items to show + next_page_item = self._create_next_page_item( + total=int(response["total"]), + current_index=start, + url=kodi.build_kodi_url( + { + "action": "browse_instances", + "start": start + self.items_per_page + } + ) + ) + + if next_page_item: + yield next_page_item + + def _create_list_of_videos(self, response, start): + """Generator of video items to be added in Kodi UI + + :param dict response: data returned by PeerTube + :param int start: index of the first item to display (pagination) + :return: yield the information of each item + :rtype: dict + """ + + for data in response["data"]: + + video_info = kodi.generate_item_info( + name=data["name"], + url=kodi.build_kodi_url( + { + "action": "play_video", + "id": data["uuid"] + } + ), + is_folder=False, + plot=data["description"], + duration=data["duration"], + thumbnail="{0}/{1}".format(self.peertube.instance, + data["thumbnailPath"]), + aired=data["publishedAt"] + ) + + yield video_info + else: + # Add a "Next page" button when there are more items to show + next_page_item = self._create_next_page_item( + total=int(response["total"]), + current_index=start, + url=kodi.build_kodi_url( + { + "action": "browse_videos", + "start": start + self.items_per_page + } + ) + ) + + if next_page_item: + yield next_page_item + + def _create_next_page_item(self, total, current_index, url): + """Return the info required to create an item to go to the next page + + :param int total: total number of elements + :param int current_index: index of the first element currently used + :param str url: URL to reach when the "Next page" item is run + :return: yield the info to create a "Next page" item in Kodi UI if + there are more items to show + :rtype: dict + """ + next_index = current_index + self.items_per_page + if total > next_index: + next_page = (next_index / self.items_per_page) + 1 + total_pages = (total / self.items_per_page) + 1 + + next_page_item = kodi.generate_item_info( + name="Next page ({}/{})".format(next_page, total_pages), + url=url + ) + + return next_page_item + + def _get_video_url(self, video_id, instance=None): + """Find the URL of the video with the best possible quality matching + user's preferences. + + :param str video_id: ID of the torrent linked with the video + :param str instance: PeerTube instance hosting the video (optional) + :return: URL of the video containing the resolution + :rtype: str + """ + # Retrieve the information about the video including the different + # resolutions available + video_files = self.peertube.get_video_urls(video_id, instance=instance) + + # Find the best resolution matching user's preferences + current_resolution = 0 + higher_resolution = -1 + url = "" + for video in video_files: + # Get the resolution + resolution = video["resolution"] + if resolution == self.preferred_resolution: + # Stop directly when we find the exact same resolution as the + # user's preferred one + kodi.debug("Found video with preferred resolution ({})" + .format(self.preferred_resolution)) + url = video["url"] + break + elif (resolution < self.preferred_resolution + and resolution > current_resolution): + # Otherwise, try to find the best one just below the user's + # preferred one + kodi.debug("Found video with good lower resolution ({})" + .format(resolution)) + url = video["url"] + current_resolution = resolution + elif (resolution > self.preferred_resolution and + (resolution < higher_resolution or + higher_resolution == -1)): + # In the worst case, we'll take the one just above the user's + # preferred one + kodi.debug("Saving video with higher resolution ({}) as a" + " possible alternative".format(resolution)) + backup_url = video["url"] + higher_resolution = resolution + else: + kodi.debug("Ignoring the resolution '{}'".format(resolution)) + + # 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 url: + kodi.debug("Using video with higher resolution as alternative ({})" + .format(higher_resolution)) + url = backup_url + + return url + + def _home_page(self): + """Display the items of the home page of the add-on""" + + home_page_items = [ + kodi.generate_item_info( + name="Browse videos on the selected instance", + url=kodi.build_kodi_url({"action": "browse_videos","start": 0}) + ), + kodi.generate_item_info( + name="Search videos on the selected instance", + url=kodi.build_kodi_url({"action": "search_videos","start": 0}) + ), + kodi.generate_item_info( + name="Browse videos on the selected instance", + url=kodi.build_kodi_url({ + "action": "browse_instances", + "start": 0 + } + ) + ) + ] + + kodi.create_items_in_ui(home_page_items) + + def _search_videos(self, start): + """ + Function to search for videos on a PeerTube instance + + :param str start: index of the first video to display (pagination) + """ + + # Ask the user which keywords must be searched for + keywords = kodi.open_input_box( + title="Search videos on {}".format(self.peertube.instance)) + + # Go back to the home page when the user cancels or didn't enter any + # string + if not keywords: + return + + # Use the API to search for videos + results = self.peertube.search_videos(keywords, start) + + # Exit directly when no result is found + if not results: + kodi.notif_warning( + title="No videos found", + message="No videos found matching the keywords.") + return + + # Extract the information of each video from the API response + list_of_videos = self._create_list_of_videos(results, start) + + # Create the associated items in Kodi + kodi.create_items_in_ui(list_of_videos) + + def _play_video(self, torrent_url): + """ + Start the torrent's download and play it while being downloaded + + :param str torrent_url: URL of the torrent file to download and play + """ + # If libtorrent could not be imported, display a message and do not try + # download nor play the video as it will fail. + if not self.libtorrent_imported: + kodi.open_dialog( + title="Error: libtorrent could not be imported", + message="PeerTube cannot play videos without libtorrent\n" + "Please follow the instructions at {}" + .format(self.HELP_URL)) + return + + kodi.debug("Starting torrent download ({})".format(torrent_url)) + kodi.notif_info(title="Download started", + message="The video will be played soon.") + + # Start a downloader thread + AddonSignals.sendSignal("start_download", {"url": torrent_url}) + + # Wait until the PeerTubeDownloader has downloaded all the torrent's + # metadata + AddonSignals.registerSlot(kodi.addon_id, + "metadata_downloaded", + self._play_video_continue) + timeout = 0 + while not self.play and timeout < 10: + kodi.sleep(1000) + timeout += 1 + + # Abort in case of timeout + if timeout == 10: + kodi.notif_error( + title="Download timeout", + message="Timeout fetching {}".format(torrent_url)) + return + else: + # Wait a little before starting playing the torrent + kodi.sleep(3000) + + # Pass the item to the Kodi player for actual playback. + kodi.debug("Starting video playback ({})".format(self.torrent_file)) + kodi.play(self.torrent_file) + + def _play_video_continue(self, data): + """ + Callback function to let the _play_video method resume when the + PeertubeDownloader has downloaded all the torrent's metadata + + :param data: dict of information sent from PeertubeDownloader + """ + + kodi.debug( + "Received metadata_downloaded signal, will start playing media") + self.play = True + self.torrent_file = data["file"] + + def _select_instance(self, instance): + """ + Change currently selected instance to "instance" parameter + + :param str instance: URL of the new instance + """ + + # Update the PeerTube object attribute even though it is not used + # currently (because the value will be retrieved from the settings on + # the next run of the add-on but it may be useful in case + # reuselanguageinvoker is enabled) + self.peertube.set_instance(instance) + + # Update the preferred instance in the settings so that this choice is + # reused on the next runs and the next calls of the add-on + kodi.set_setting("preferred_instance", instance) + + # Notify the user and log the event + message = \ + "{} is now the selected instance".format(self.peertube.instance) + kodi.notif_info(title="Current instance changed", message=message) + kodi.debug(message) + + def router(self, params): + """Route the add-on to the requested actions + + :param dict params: Parameters the add-on was called with + """ + + # Check the parameters passed to the plugin + if params: + action = params["action"] + if action == "browse_videos": + # Browse videos on the selected instance + self._browse_videos(int(params["start"])) + elif action == "search_videos": + # Search for videos on the selected instance + self._search_videos(int(params["start"])) + elif action == "browse_instances": + # Browse PeerTube instances + self._browse_instances(int(params["start"])) + elif action == "play_video": + # This action comes with the id of the video to play as + # parameter. The instance may also be in the parameters. Use + # these parameters to retrieve the complete URL (containing the + # resolution). + url = self._get_video_url(instance=params.get("instance"), + video_id=params.get("id")) + # Play the video using the URL + self._play_video(url) + elif action == "select_instance": + # Set the selected instance as the preferred instance + self._select_instance(params["url"]) + else: + # Display the addon's main menu when the plugin is called from + # Kodi UI without any parameters + self._home_page() + + # Display a warning if libtorrent could not be imported + if not self.libtorrent_imported: + kodi.open_dialog( + title="Error: libtorrent could not be imported", + message="You can still browse and search videos but you" + " will not be able to play them.\n" + "Please follow the instructions at {}" + .format(self.HELP_URL)) diff --git a/resources/lib/kodi_utils.py b/resources/lib/kodi_utils.py index b0b53b0..1b7e602 100644 --- a/resources/lib/kodi_utils.py +++ b/resources/lib/kodi_utils.py @@ -7,90 +7,246 @@ SPDX-License-Identifier: GPL-3.0-only See LICENSE.txt for more information. """ +try: + # Python 3.x + from urllib.parse import parse_qsl +except ImportError: + # Python 2.x + from urlparse import parse_qsl + +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 -def debug(message): - """Log a message in Kodi's log with the level xbmc.LOGDEBUG +class KodiUtils: + """Utility class to call Kodi APIs""" - :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("[PeerTube] {}".format(message), xbmc.LOGDEBUG) + def __init__(self): + """Initialize the object with information about the add-on""" + self.addon_name = xbmcaddon.Addon().getAddonInfo("name") + self.addon_id = xbmcaddon.Addon().getAddonInfo("id") -def get_property(name): - """Retrieve the value of a window property related to the add-on + # Prepare other attributes that will be initialized with sys.argv + self.addon_url = "" + self.addon_handle = 0 + self.addon_parameters = "" - :param str name: Name of the property which value will be retrieved (the - actual name of the property is prefixed with "peertube_") - :return: Value of the window property - :rtype: str - """ - return xbmcgui.Window(10000).getProperty("peertube_{}".format(name)) + def build_kodi_url(self, parameters): + """Build a Kodi URL based on the parameters. -def get_setting(setting_name): - """Retrieve the value of a setting + This URL will be used to call the add-on with the expected parameters. - :param str setting_name: Name of the setting - :return: Value of the setting named setting_name - :rtype: str - """ - return xbmcaddon.Addon().getSetting(setting_name) + :param dict parameters: The parameters that will be encoded in the URL + """ -def notif_error(title, message): - """Display a notification with the error icon + return "{}?{}".format(self.addon_url, urlencode(parameters)) - :param str title: Title of the notification - :param str message: Message of the notification - """ - xbmcgui.Dialog().notification(heading=title, - message=message, - icon=xbmcgui.NOTIFICATION_ERROR) + def create_items_in_ui(self, items_info): + """Create items in Kodi UI -def notif_info(title, message): - """Display a notification with the info icon + :param list items_info: A list of dict containing all the required + information to create the items (i.e. the return value of the method + generate_item_info) + """ + # Tell Kodi to use the "video" viewtypes + xbmcplugin.setContent(handle=self.addon_handle, content="videos") - :param str title: Title of the notification - :param str message: Message of the notification - """ - xbmcgui.Dialog().notification(heading=title, - message=message, - icon=xbmcgui.NOTIFICATION_INFO) + list_of_items = [] -def notif_warning(title, message): - """Display a notification with the warning icon + for info in items_info: + # Create the ListItem object + list_item = xbmcgui.ListItem(label=info["name"]) - :param str title: Title of the notification - :param str message: Message of the notification - """ - xbmcgui.Dialog().notification(heading=title, - message=message, - icon=xbmcgui.NOTIFICATION_WARNING) + # Add the general info of the item + list_item.setInfo("video", info["info"]) -def open_dialog(title, message): - """Open a dialog box with an "OK" button + # Add the art info of the item + list_item.setArt(info["art"]) - :param str title: Title of the box - :param str message: Message in the box - """ - xbmcgui.Dialog().ok(heading=title, line1=message) + if not info["is_folder"]: + list_item.setProperty("IsPlayable", "true") -def set_property(name, value): - """Modify the value of a window property related to the add-on + # Add to the list the tuple expected by addDirectoryItems + list_of_items.append((info["url"], list_item, info["is_folder"])) - :param str name: Name of the property which value will be modified (the - actual name of the property is prefixed with "peertube_") - :param str value: New value of the property - """ - xbmcgui.Window(10000).setProperty("peertube_{}".format(name), value) + # Create the items + xbmcplugin.addDirectoryItems( + handle=self.addon_handle, + items=list_of_items, + totalItems=len(list_of_items) + ) -def set_setting(setting_name, setting_value): - """Modify the value of a setting + # Terminate the items creation + xbmcplugin.endOfDirectory(self.addon_handle) - :param str setting_name: Name of the setting - :param str setting_value: New value of the setting - """ - xbmcaddon.Addon().setSetting(setting_name, setting_value) + def debug(self, message, prefix=None): + """Log a message in Kodi's log with the level xbmc.LOGDEBUG + + The message will be prefixed with the prefix passed as argument or with + the name of the add-on. + + :param str message: Message to log + :param str prefix: String to prefix the message with + """ + if not prefix: + prefix = self.addon_name + + xbmc.log("[{}] {}".format(prefix, message), xbmc.LOGDEBUG) + + def generate_item_info(self, name, url, is_folder=True, thumbnail="", + aired="", duration=0, plot="",): + """Return all the information required to create an item in Kodi UI + + This function makes the creation of an item easier: it allows to pass + to the function only the known information about an item, and it will + return a dict with all the keys expected by create_items_in_ui + correctly initialized (including the ones that were not passed). + + :param str name: Name of the item + :param str url: URL to reach when the item is used + :param bool is_folder: Whether the item is a folder or is playable + :param : The other parameters are the ones expected by + ListItem.setInfo() and ListItem.setArt() + :return: Information required to create the item in Kodi UI + :rtype: dict + """ + return { + "name": name, + "url": url, + "is_folder": is_folder, + "art": { + "thumb": thumbnail, + }, + "info": { + "aired": aired, + "duration": duration, + "plot": plot, + "title": name + } + } + + def get_run_parameters(self): + """Return the parameter the add-on was called with + + The parameters are read in the method "update_call_info" + + :return: The extracted parameters + :rtype: dict + """ + # The first character ("?") is skipped + return dict(parse_qsl(self.addon_parameters[1:])) + + def get_property(self, name): + """Retrieve the value of a window property related to the add-on + + :param str name: Name of the property which value will be retrieved (the + actual name of the property is prefixed with "peertube_") + :return: Value of the window property + :rtype: str + """ + return xbmcgui.Window(10000).getProperty("peertube_{}".format(name)) + + def get_setting(self, setting_name): + """Retrieve the value of a setting + + :param str setting_name: Name of the setting + :return: Value of the setting named setting_name + :rtype: str + """ + return xbmcaddon.Addon().getSetting(setting_name) + + def notif_error(self, title, message): + """Display a notification with the error icon + + :param str title: Title of the notification + :param str message: Message of the notification + """ + xbmcgui.Dialog().notification(heading=title, + message=message, + icon=xbmcgui.NOTIFICATION_ERROR) + + def notif_info(self, title, message): + """Display a notification with the info icon + + :param str title: Title of the notification + :param str message: Message of the notification + """ + xbmcgui.Dialog().notification(heading=title, + message=message, + icon=xbmcgui.NOTIFICATION_INFO) + + def notif_warning(self, title, message): + """Display a notification with the warning icon + + :param str title: Title of the notification + :param str message: Message of the notification + """ + xbmcgui.Dialog().notification(heading=title, + message=message, + icon=xbmcgui.NOTIFICATION_WARNING) + + def open_dialog(self, title, message): + """Open a dialog box with an "OK" button + + :param str title: Title of the box + :param str message: Message in the box + """ + xbmcgui.Dialog().ok(heading=title, line1=message) + + def open_input_box(self, title): + """Open a box for the user to input alphanumeric data + + :param str title: Title of the box + :return: Entered data or an empty string + :rtype: str + """ + return xbmcgui.Dialog().input(heading=title, + type=xbmcgui.INPUT_ALPHANUM) + + def play(self, url): + """Play the media behind the URL + + :param str url: URL of the media to play + """ + xbmcplugin.setResolvedUrl(handle=self.addon_handle, + succeeded=True, + listitem=xbmcgui.ListItem(path=url)) + + def set_property(self, name, value): + """Modify the value of a window property related to the add-on + + :param str name: Name of the property which value will be modified (the + actual name of the property is prefixed with "peertube_") + :param str value: New value of the property + """ + xbmcgui.Window(10000).setProperty("peertube_{}".format(name), value) + + def set_setting(self, setting_name, setting_value): + """Modify the value of a setting + + :param str setting_name: Name of the setting + :param str setting_value: New value of the setting + """ + xbmcaddon.Addon().setSetting(setting_name, setting_value) + + def sleep(self, time_us): + """Sleep for some micro seconds + + :param int time_us: Sleep time in micro seconds + """ + xbmc.sleep(time_us) + + def update_call_info(self, argv): + """Update the attributes related to the current call of the add-on + + :param list argv: System arguments + """ + self.addon_url = argv[0] + self.addon_handle = int(argv[1]) + self.addon_parameters = argv[2] + +kodi = KodiUtils() \ No newline at end of file diff --git a/resources/lib/peertube.py b/resources/lib/peertube.py index 60e5a44..ebf9413 100644 --- a/resources/lib/peertube.py +++ b/resources/lib/peertube.py @@ -11,7 +11,7 @@ import requests from requests.compat import urljoin -from resources.lib.kodi_utils import debug, get_setting, notif_error +from resources.lib.kodi_utils import kodi class PeerTube: @@ -23,9 +23,9 @@ class PeerTube: :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 + :param str video_filter: filter to apply when listing/searching videos """ - self.instance = instance + self.set_instance(instance) self.list_settings = { "sort": sort, @@ -39,7 +39,7 @@ class PeerTube: else: self.filter = "local" - def _request(self, method, url, params=None, data=None): + def _request(self, method, url, params=None, data=None, instance=None): """Call a REST API on the instance :param str method: REST API method (get, post, put, delete, etc.) @@ -47,12 +47,22 @@ class 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 + :param str instance: URL of the instance hosting the video. The + configured instance will be used if empty. :return: the response as JSON data :rtype: dict """ + # If no instance was provided, use the one from the settings (which was + # used when instantianting this object) + if instance is None: + instance = self.instance + else: + # If an instance was provided ensure the URL is prefixed with HTTPS + if not instance.startswith("https://"): + instance = "https://{}".format(instance) # Build the URL of the REST API - api_url = urljoin("{}/api/v1/".format(self.instance), url) + api_url = urljoin("{}/api/v1/".format(instance), url) # Send a request with a time-out of 5 seconds response = requests.request(method=method, @@ -69,19 +79,19 @@ class PeerTube: 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)) + kodi.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) + kodi.debug(message) else: message = ("No details returned by the server. Check the log" " for more information.") - notif_error(title="Request error", message=message) + kodi.notif_error(title="Request error", message=message) raise exception return json @@ -107,20 +117,43 @@ class PeerTube: return params - def get_video(self, video_id): - """Get the information of a video + def get_video_urls(self, video_id, instance=None): + """Return the URLs of a video + + PeerTube creates 1 URL for each resolution of a video so this method + returns a list of URL/resolution pairs. :param str video_id: ID or UUID of the video - :return: the information of the video as returned by the REST API - :rtype: dict + :param str instance: URL of the instance hosting the video. The + configured instance will be used if empty. + :return: pair(s) of URL/resolution + :rtype: generator """ + # Get the information about the video + metadata = self._request(method="GET", + url="videos/{}".format(video_id), + instance=instance) - return self._request(method="GET", url="videos/{}".format(video_id)) + # Depending if WebTorrent is enabled or not, the files corresponding to + # different resolutions available for a video may be stored in "files" + # or "streamingPlaylists[].files". Note that "files" will always exist + # in the response but may be empty. + if len(metadata["files"]) != 0: + files = metadata["files"] + else: + files = metadata["streamingPlaylists"][0]["files"] + + for file in files: + yield { + "resolution": int(file["resolution"]["id"]), + "url": file["torrentUrl"], + "is_live": False + } def list_videos(self, start): """List the videos in the instance - :param str start: index of the first video to display + :param int start: index of the first video to display :return: the list of videos as returned by the REST API :rtype: dict """ @@ -133,7 +166,7 @@ class PeerTube: """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 + :param int start: index of the first video to display :return: the videos matching the keywords as returned by the REST API :rtype: dict """ @@ -144,11 +177,27 @@ class PeerTube: return self._request(method="GET", url="search/videos", params=params) + def set_instance(self, instance): + """Set the URL of the current instance with the right format + + The URL of the instance may not be prefixed with HTTPS, for instance: + * in the settings the URL does not use this prefix to allow the user to + change it easily + * the URL from the list of instances is not prefixed + This method is used to ensure the URL is correctly prefixed with HTTPS + + :param str instance: URL of the instance + """ + if not instance.startswith("https://"): + instance = "https://{}".format(instance) + + self.instance = instance + def list_instances(start): """List all the peertube instances from joinpeertube.org - :param str start: index of the first instance to display + :param int start: index of the first instance to display :return: the list of instances as returned by the REST API :rtype: dict """ @@ -156,7 +205,7 @@ def list_instances(start): 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"), + "count": kodi.get_setting("items_per_page"), "start": start } @@ -171,8 +220,8 @@ def list_instances(start): 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)) + kodi.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 @@ -182,11 +231,11 @@ def list_instances(start): # 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) + kodi.debug(message) except KeyError: message = ("No details returned by the server. Check the log" " for more information.") - notif_error(title="Request error", message=message) + kodi.notif_error(title="Request error", message=message) raise exception return json diff --git a/service.py b/service.py index 888d6df..4eb4a1e 100644 --- a/service.py +++ b/service.py @@ -14,7 +14,7 @@ from threading import Thread import xbmc # Kodistubs for Leia is not compatible with python3 / pylint: disable=syntax-error import xbmcvfs # Kodistubs for Leia is not compatible with python3 / pylint: disable=syntax-error -from resources.lib.kodi_utils import debug, set_property +from resources.lib.kodi_utils import kodi class PeertubeDownloader(Thread): """ @@ -39,7 +39,7 @@ class PeertubeDownloader(Thread): :param str message: Message to log (will be prefixed with the name of the class) """ - debug("PeertubeDownloader: {}".format(message)) + kodi.debug(message=message, prefix="PeertubeDownloader") def run(self): """ @@ -97,7 +97,7 @@ class PeertubeService(): :param str message: Message to log (will be prefixed with the name of the class) """ - debug("PeertubeService: {}".format(message)) + kodi.debug(message=message, prefix="PeertubeService") def download_torrent(self, data): """ @@ -122,7 +122,7 @@ class PeertubeService(): # Launch the download_torrent callback function when the # "start_download" signal is received - AddonSignals.registerSlot("plugin.video.peertube", + AddonSignals.registerSlot(kodi.addon_id, "start_download", self.download_torrent) @@ -151,7 +151,7 @@ if __name__ == "__main__": # Save whether libtorrent could be imported as a window property so that # this information can be retrieved by the add-on - set_property("libtorrent_imported", str(LIBTORRENT_IMPORTED)) + kodi.set_property("libtorrent_imported", str(LIBTORRENT_IMPORTED)) # Start the service service.run()