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