Browse Source

Redesign the main file of the add-on

New features:
* Add the description of each video and each instance. The total number
  of local videos and users of an instance are also added to the
  description of the instance in Kodi.
* Add the total number of pages in the "Next page" item (+ fix the
  number of the current page)
* Display a notification when the download of the torrent starts (will
  help the user to know that something is going on, especially on slow
  machines)
* Support instance URL that are prefixed with "https://" in the settings

Internal changes:
* Create a smaller entry point file to match Kodi's best practices
  (main.py)
* Create a new main module (addon.py) containing only the code related
  to the add-on execution. The other lines of code were moved to the
  classes PeerTube or KodiUtils.
* KodiUtils is now a class and an instance of this class is made
  available to all the modules of the add-on to reuse easily its methods
  and attributes.
* Create helper functions in KodiUtils for creating items in Kodi UI
  easily

See merge request StCyr/plugin.video.peertube!17 for more information
merge-requests/16/merge
Thomas 2 years ago
parent
commit
7a1a4e8485
  1. 2
      addon.xml
  2. 50
      contributing.md
  3. 29
      main.py
  4. 457
      peertube.py
  5. 441
      resources/lib/addon.py
  6. 320
      resources/lib/kodi_utils.py
  7. 93
      resources/lib/peertube.py
  8. 10
      service.py

2
addon.xml

@ -5,7 +5,7 @@
<import addon="script.module.addon.signals" version="0.0.3"/>
<import addon="script.module.requests" version="2.22.0"/>
</requires>
<extension point="xbmc.python.pluginsource" library="peertube.py">
<extension point="xbmc.python.pluginsource" library="main.py">
<provides>video</provides>
</extension>
<extension point="xbmc.service" library="service.py"/>

50
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.
The functions must be sorted alphabetically to make the maintenance easier.
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.
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)

29
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)

457
peertube.py

@ -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
# {<parameter>: <value>} 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])

441
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))

320
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
: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 get_property(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(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(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(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(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(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 set_property(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(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)
class KodiUtils:
"""Utility class to call Kodi APIs"""
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")
# Prepare other attributes that will be initialized with sys.argv
self.addon_url = ""
self.addon_handle = 0
self.addon_parameters = ""
def build_kodi_url(self, parameters):
"""Build a Kodi URL based on the parameters.
This URL will be used to call the add-on with the expected parameters.
:param dict parameters: The parameters that will be encoded in the URL
"""
return "{}?{}".format(self.addon_url, urlencode(parameters))
def create_items_in_ui(self, items_info):
"""Create items in Kodi UI
: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")
list_of_items = []
for info in items_info:
# Create the ListItem object
list_item = xbmcgui.ListItem(label=info["name"])
# Add the general info of the item
list_item.setInfo("video", info["info"])
# Add the art info of the item
list_item.setArt(info["art"])
if not info["is_folder"]:
list_item.setProperty("IsPlayable", "true")
# Add to the list the tuple expected by addDirectoryItems
list_of_items.append((info["url"], list_item, info["is_folder"]))
# Create the items
xbmcplugin.addDirectoryItems(
handle=self.addon_handle,
items=list_of_items,
totalItems=len(list_of_items)
)
# Terminate the items creation
xbmcplugin.endOfDirectory(self.addon_handle)
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 <other>: 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()

93
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)
# 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"]
return self._request(method="GET", url="videos/{}".format(video_id))
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"),