Create a dedicated class to interact with PeerTube

The PeerTube class is responsible for providing methods to call easily
the PeerTube REST APIs.

Other changes:
* the video filter is now also used when searching videos
* in case of error when sending a request, the message from the response
  is displayed on the screen (even when listing the instances)
* all the debug messages are now prefixed with the name of the add-on
  directly in kodi_utils: it allows an easier usage of this function
  anywhere in the add-on
* first version of the design of the add-on added in contributing.md

See merge request StCyr/plugin.video.peertube!14 for more information
This commit is contained in:
Thomas 2021-04-19 13:04:57 +00:00
parent 142df05350
commit 074be7aa12
4 changed files with 273 additions and 161 deletions

View File

@ -24,6 +24,57 @@ The workflow is the following:
Note: more information about the pipeline is available in the
[CI file](.gitlab-ci.yml).
## Design
The add-on is based on the following python modules:
| Name | Description |
| ------ | ------ |
| main.py | Entry point of the add-on. |
| service.py | Service responsible for downloading torrent files in the background. |
| resources/lib/addon.py | It handles the routing and the interaction between the other modules. |
| resources/lib/peertube.py | Responsible for interacting with PeerTube. |
| resources/lib/kodi_utils.py | Provides utility functions to interact easily with Kodi. |
### main.py
The file `peertube.py` is currently being redesigned into the `main` module.
This module must be as short as possible (15 effective lines of code maximum)
to comply with Kodi add-on development best practices (checked by the
[Kodi add-on checker](https://github.com/xbmc/addon-check)).
### service.py
This module is being redesigned currently.
This module must be as short as possible (15 effective lines of code maximum)
to comply with Kodi add-on development best practices (checked by the
[Kodi add-on checker](https://github.com/xbmc/addon-check)).
### addon.py
This module does not exist yet.
### peertube.py
This file contains:
* the class PeerTube which provides simple method to send REST APIs to a
PeerTube instance
* the function `list_instances` which lists the PeerTube instances from
joinpeertube.org. The URL of the API used by this function and the structure
of the response in case of errors is different than the other PeerTube APIs
(which are sent to a specific instance) so it made sense to have it as a
dedicated function. If more instance-related API are used in the future, a
class could be created.
### kodi_utils.py
This module only contains functions (no classes) as no common data between them
was identified.
The functions must be sorted alphabetically to make the maintenance easier.
## Coding style
The code is still based on the design of the alpha version so the coding style

View File

@ -19,16 +19,15 @@ except ImportError:
from urlparse import parse_qsl
import AddonSignals # Module exists only in Kodi - pylint: disable=import-error
import requests
from requests.compat import urljoin, urlencode
from requests.compat import urlencode
import xbmc # Kodistubs for Leia is not compatible with python3 / pylint: disable=syntax-error
import xbmcaddon
import xbmcgui # Kodistubs for Leia is not compatible with python3 / pylint: disable=syntax-error
import xbmcplugin
from resources.lib.kodi_utils import (
debug, get_property, get_setting, notif_error, notif_info, notif_warning,
open_dialog, set_setting)
from resources.lib.peertube import PeerTube, list_instances
class PeertubeAddon():
@ -45,10 +44,6 @@ class PeertubeAddon():
:param plugin, plugin_id: str, int
"""
# This step must be done first because the logging function requires
# the name of the add-on
self.addon_name = xbmcaddon.Addon().getAddonInfo('name')
# Save addon URL and ID
self.plugin_url = plugin
self.plugin_id = plugin_id
@ -60,9 +55,6 @@ class PeertubeAddon():
# Get the number of videos to show per page
self.items_per_page = int(get_setting('items_per_page'))
# Get the video sort method
self.sort_method = get_setting('video_sort_method')
# Get the preferred resolution for video
self.preferred_resolution = get_setting('preferred_resolution')
@ -71,14 +63,6 @@ class PeertubeAddon():
self.torrent_name = ''
self.torrent_f = ''
# Get the video filter from the settings that will be used when
# browsing the videos. The value from the settings is converted into
# one of the expected values by the REST APIs ("local" or "all-local")
if 'all-local' in get_setting('video_filter'):
self.video_filter = 'all-local'
else:
self.video_filter = 'local'
# Check whether libtorrent could be imported by the service. The value
# of the associated property is retrieved only once and stored in an
# attribute because libtorrent is imported only once at the beginning
@ -87,51 +71,12 @@ class PeertubeAddon():
self.libtorrent_imported = \
get_property('libtorrent_imported') == 'True'
def debug(self, message):
"""Log a debug message
:param str message: Message to log (will be prefixed with the add-on
name)
"""
debug('{0}: {1}'.format(self.addon_name, message))
def query_peertube(self, req):
"""
Issue a PeerTube API request and return the results
:param req: str
:result data: dict
"""
# Send the PeerTube REST API request
self.debug('Issuing request {0}'.format(req))
response = requests.get(url=req)
data = response.json()
# Use Request.raise_for_status() to raise an exception if the HTTP
# request returned an unsuccessful status code.
try:
response.raise_for_status()
except requests.HTTPError as e:
notif_error(title='Communication error',
message='Error when sending request {}'.format(req))
# If the JSON contains an 'error' key, print it
error_details = data.get('error')
if error_details is not None:
self.debug('Error => "{}"'.format(data['error']))
raise e
# Try to get the number of elements in the response
results_found = data.get('total', None)
# If the information is available in the response, use it
if results_found is not None:
# Return when no results are found
if results_found == 0:
self.debug('No result found')
return None
else:
self.debug('Found {0} results'.format(results_found))
return data
# Create a PeerTube object to send requests: settings which are used
# only by this object are directly retrieved from the settings
self.peertube = PeerTube(instance=self.selected_inst,
count=self.items_per_page,
sort=get_setting('video_sort_method'),
video_filter=get_setting('video_filter'))
def create_list(self, lst, data_type, start):
"""
@ -227,9 +172,7 @@ class PeertubeAddon():
instance = 'https://{}'.format(instance)
# Retrieve the information about the video
metadata = self.query_peertube(urljoin(instance,
'/api/v1/videos/{}'
.format(video_id)))
metadata = self.peertube.get_video(video_id)
# Depending if WebTorrent is enabled or not, the files corresponding to
# different resolutions available for a video may be stored in "files"
@ -240,8 +183,7 @@ class PeertubeAddon():
else:
files = metadata['streamingPlaylists'][0]['files']
self.debug(
'Looking for the best resolution matching the user preferences')
debug('Looking for the best resolution matching the user preferences')
current_res = 0
higher_res = -1
@ -253,98 +195,33 @@ class PeertubeAddon():
if res == self.preferred_resolution:
# Stop directly when we find the exact same resolution as the
# user's preferred one
self.debug('Found video with preferred resolution')
debug('Found video with preferred resolution')
torrent_url = f['torrentUrl']
break
elif res < self.preferred_resolution and res > current_res:
# Otherwise, try to find the best one just below the user's
# preferred one
self.debug('Found video with good lower resolution'
'({0})'.format(f['resolution']['label']))
debug('Found video with good lower resolution ({0})'
.format(f['resolution']['label']))
torrent_url = f['torrentUrl']
current_res = res
elif (res > self.preferred_resolution
and (res < higher_res or higher_res == -1)):
# In the worst case, we'll take the one just above the user's
# preferred one
self.debug('Saving video with higher resolution ({0})'
'as a possible alternative'
.format(f['resolution']['label']))
debug('Saving video with higher resolution ({0}) as a possible'
' alternative'.format(f['resolution']['label']))
backup_url = f['torrentUrl']
higher_res = res
# When we didn't find a resolution equal or lower than the user's
# preferred one, use the resolution just above the preferred one
if not torrent_url:
self.debug('Using video with higher resolution as alternative')
debug('Using video with higher resolution as alternative')
torrent_url = backup_url
return torrent_url
def build_video_rest_api_request(self, search, start):
"""Build the URL of an HTTP request using the PeerTube videos REST API.
The same function is used for browsing and searching videos.
:param search: keywords to search
:type search: string
:param start: offset
:type start: int
:return: the URL of the request
:rtype: str
Didn't yet find a correct way to do a search with a filter set to
local. Then if a search value is given it won't filter on local
"""
# Common parameters of the request
params = {
'count': self.items_per_page,
'start': start,
'sort': self.sort_method
}
# Depending on the type of request (search or list videos), add
# specific parameters and define the API to use
if search is None:
# Video API does not provide "search" but provides "filter" so add
# it to the parameters
params.update({'filter': self.video_filter})
api_url = '/api/v1/videos'
else:
# Search API does not provide "filter" but provides "search" so add
# it to the parameters
params.update({'search': search})
api_url = '/api/v1/search/videos'
# Build the full URL of the request (instance + API + parameters)
req = '{0}?{1}'.format(urljoin(self.selected_inst, api_url),
urlencode(params))
return req
def build_browse_instances_rest_api_request(self, start):
"""Build the URL of an HTTP request using the PeerTube REST API to
browse the PeerTube instances.
:param start: offset
:type start: int
:return: the URL of the request
:rtype: str
"""
# Create the parameters of the request
params = {
'count': self.items_per_page,
'start': start
}
# Join the base URL with the REST API and the parameters
req = 'https://instances.joinpeertube.org/api/v1/instances?{0}'\
.format(urlencode(params))
return req
def search_videos(self, start):
"""
Function to search for videos on a PeerTube instance and navigate
@ -353,20 +230,17 @@ class PeertubeAddon():
:param start: string
"""
# Show a 'Search videos' dialog
search = xbmcgui.Dialog().input(
# Ask the user which keywords must be searched for
keywords = xbmcgui.Dialog().input(
heading='Search videos on {}'.format(self.selected_inst),
type=xbmcgui.INPUT_ALPHANUM)
# Go back to main menu when user cancels
if not search:
if not keywords:
return
# Create the PeerTube REST API request for searching videos
req = self.build_video_rest_api_request(search, start)
# Send the query
results = self.query_peertube(req)
results = self.peertube.search_videos(keywords, start)
# Exit directly when no result is found
if not results:
@ -389,11 +263,8 @@ class PeertubeAddon():
:param start: string
"""
# Create the PeerTube REST API request for listing videos
req = self.build_video_rest_api_request(None, start)
# Send the query
results = self.query_peertube(req)
results = self.peertube.list_videos(start)
# Create array of xmbcgui.ListItem's
listing = self.create_list(results, 'videos', start)
@ -408,11 +279,8 @@ class PeertubeAddon():
:param start: str
"""
# Create the PeerTube REST API request for browsing PeerTube instances
req = self.build_browse_instances_rest_api_request(start)
# Send the query
results = self.query_peertube(req)
results = list_instances(start)
# Create array of xmbcgui.ListItem's
listing = self.create_list(results, 'instances', start)
@ -429,8 +297,7 @@ class PeertubeAddon():
:param data: dict
"""
self.debug(
'Received metadata_downloaded signal, will start playing media')
debug('Received metadata_downloaded signal, will start playing media')
self.play = 1
self.torrent_f = data['file']
@ -448,7 +315,7 @@ class PeertubeAddon():
' at {}'.format(self.HELP_URL))
return
self.debug('Starting torrent download ({0})'.format(torrent_url))
debug('Starting torrent download ({0})'.format(torrent_url))
# Start a downloader thread
AddonSignals.sendSignal('start_download', {'url': torrent_url})
@ -473,7 +340,7 @@ class PeertubeAddon():
xbmc.sleep(3000)
# Pass the item to the Kodi player for actual playback.
self.debug('Starting video playback ({0})'.format(torrent_url))
debug('Starting video playback ({0})'.format(torrent_url))
play_item = xbmcgui.ListItem(path=self.torrent_f)
xbmcplugin.setResolvedUrl(self.plugin_id, True, listitem=play_item)
@ -495,7 +362,7 @@ class PeertubeAddon():
message = '{0} is now the selected instance'.format(self.selected_inst)
notif_info(title='Current instance changed',
message=message)
self.debug(message)
debug(message)
def build_kodi_url(self, parameters):
"""Build a Kodi URL based on the parameters.

View File

@ -15,9 +15,11 @@ import xbmcgui # Kodistubs for Leia is not compatible with python3 / pylint: dis
def debug(message):
"""Log a message in Kodi's log with the level xbmc.LOGDEBUG
:param str message: Message to log
:param str message: Message to log prefixed with the name of the add-on
(the name is hard-coded to avoid calling xbmcaddon each time since the name
should not change)
"""
xbmc.log(message, xbmc.LOGDEBUG)
xbmc.log('[PeerTube] {}'.format(message), xbmc.LOGDEBUG)
def get_property(name):
"""Retrieve the value of a window property related to the add-on

192
resources/lib/peertube.py Normal file
View File

@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
"""
PeerTube related classes and functions
Copyright (C) 2018 Cyrille Bollu
Copyright (C) 2021 Thomas Bétous
SPDX-License-Identifier: GPL-3.0-only
See LICENSE.txt for more information.
"""
import requests
from requests.compat import urljoin
from resources.lib.kodi_utils import debug, get_setting, notif_error
class PeerTube:
"""A class to interact easily with PeerTube instances using REST APIs"""
def __init__(self, instance, sort, count, video_filter):
"""Constructor
:param str instance: URL of the PeerTube instance
:param str sort: sort method to use when listing items
:param int count: number of items to display
:param str sort: filter to apply when listing/searching videos
"""
self.instance = instance
self.list_settings = {
"sort": sort,
"count": count
}
# The value "video_filter" is directly retrieved from the settings so
# it must be converted into one of the expected values by the REST APIs
if 'all-local' in video_filter:
self.filter = 'all-local'
else:
self.filter = 'local'
def _request(self, method, url, params=None, data=None):
"""Call a REST API on the instance
:param str method: REST API method (get, post, put, delete, etc.)
:param str url: URL of the REST API endpoint relative to the PeerTube
instance
:param dict params: dict of the parameters to send in the request
:param dict data: dict of the data to send with the request
:return: the response as JSON data
:rtype: dict
"""
# Build the URL of the REST API
api_url = urljoin("{}/api/v1/".format(self.instance), url)
# Send a request with a time-out of 5 seconds
response = requests.request(method=method,
url=api_url,
timeout=5,
params=params,
data=data)
json = response.json()
# Use Request.raise_for_status() to raise an exception if the HTTP
# request didn't succeed
try:
response.raise_for_status()
except requests.HTTPError as exception:
# Print in Kodi's log some information about the request
debug("Error when sending a {} request to {} with params={} and"
" data={}".format(method, url, params, data))
# Report the error to the user with a notification: if the response
# contains an "error" attribute, use it as error message, otherwise
# use a default message.
if "error" in json:
message = json["error"]
debug(message)
else:
message = ("No details returned by the server. Check the log"
" for more information.")
notif_error(title="Request error", message=message)
raise exception
return json
def _build_params(self, **kwargs):
"""Build the parameters to send with a request from the common settings
This method returns a dictionnary containing the common settings from
self.list_settings plus the arguments passed to this function. The keys
in the dictionnary will have the same name as the arguments passed to
this function.
:return: the common settings plus other parameters
:rtype: dict
"""
# Initialize the dict from the common settings (the common settings are
# copied otherwise any modification will also impact the attribute).
params = self.list_settings.copy()
# Add all the arguments to the dict
for param in kwargs:
params[param] = kwargs[param]
return params
def get_video(self, video_id):
"""Get the information of a video
:param str video_id: ID or UUID of the video
:return: the information of the video as returned by the REST API
:rtype: dict
"""
return self._request(method="GET", url="videos/{}".format(video_id))
def list_videos(self, start):
"""List the videos in the instance
:param str start: index of the first video to display
:return: the list of videos as returned by the REST API
:rtype: dict
"""
# Build the parameters that will be sent in the request
params = self._build_params(filter=self.filter, start=start)
return self._request(method="GET", url="videos", params=params)
def search_videos(self, keywords, start):
"""Search for videos on the instance and beyond.
:param str keywords: keywords to seach for
:param str start: index of the first video to display
:return: the videos matching the keywords as returned by the REST API
:rtype: dict
"""
# Build the parameters that will be send in the request
params = self._build_params(search=keywords,
filter=self.filter,
start=start)
return self._request(method="GET", url="search/videos", params=params)
def list_instances(start):
"""List all the peertube instances from joinpeertube.org
:param str start: index of the first instance to display
:return: the list of instances as returned by the REST API
:rtype: dict
"""
# URL of the REST API
api_url = "https://instances.joinpeertube.org/api/v1/instances"
# Build the parameters that will be sent in the request from the settings
params = {
"count": get_setting("items_per_page"),
"start": start
}
# Send a request with a time-out of 5 seconds
response = requests.get(url=api_url, timeout=5, params=params)
json = response.json()
# Use Request.raise_for_status() to raise an exception if the HTTP
# request didn't succeed
try:
response.raise_for_status()
except requests.HTTPError as exception:
# Print in Kodi's log some information about the request
debug("Error when getting the list of instances with params={}"
.format(params))
# Report the error to the user with a notification: use the details of
# the error if it exists in the response, otherwise use a default
# message.
try:
# Convert the reponse to a list to get the first error whatever its
# name. Then get the second element in the sublist which contains
# the details of the error.
message = list(json["errors"].items())[0][1]["msg"]
debug(message)
except KeyError:
message = ("No details returned by the server. Check the log"
" for more information.")
notif_error(title="Request error", message=message)
raise exception
return json