# -*- 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])