Allow playing videos only with the video ID

Now the "play_video" action can be called with the ID of the video (and
optionally the URL of the instance hosting the video) as parameter
instead of the full URL: it will allow other add-ons to call the add-on
to play videos since the full URL contains the resolution which is not
known.

It led to some refactoring and changes in the code:
* Only the instance and the id of a video is retrieved when browsing and
  listing videos which improves the performance a lot (the video URL and
  the resolution are defined only when the video is played)
* the "https://" prefix is now automatically added to the instances URL
  because the instance-related REST APIs use URLs without this prefix.
  It also simplifies the external API because the user does not have to
  provide this prefix.
  Consequently the prefix was removed from the default value of the
  selected instance in the settings: it simplifies the code but it
  generates a non-backward compatible change. The impact is limited
  because it can be easily fixed by resetting the settings to the
  default value and there are very few users currently.

Other changes:
 - manage errors when retrieving the information of a video
 - fix some PEP 8 errors
This commit is contained in:
Thomas 2021-03-31 21:58:10 +00:00 committed by Thomas Bétous
parent d14bf5b094
commit 7a21bd92ac
3 changed files with 179 additions and 88 deletions

View File

@ -1,15 +1,19 @@
A Kodi add-on for watching content hosted on [Peertube](http://joinpeertube.org/). A Kodi add-on for watching content hosted on [Peertube](http://joinpeertube.org/).
This code is still proof-of-concept but it works, and you're welcome to improve it. This code is still proof-of-concept but it works, and you're welcome to improve
it.
# Features # Features
* Browse all videos on a PeerTube instance * Browse all videos on a PeerTube instance
* Search for videos on a PeerTube instance * Search for videos on a PeerTube instance
* Select Peertube instance to use (Doesn't work yet) * Select Peertube instance to use (Doesn't work yet)
* Select the preferred video resolution: the plugin will try to play the select video resolution. * Select the preferred video resolution: the plugin will try to play the select
If it's not available, it will play the lower resolution that is the closest from your preference. video resolution.
If not available, it will play the higher resolution that is the closest from your preference. If it's not available, it will play the lower resolution that is the closest
from your preference.
If not available, it will play the higher resolution that is the closest from
your preference.
# User settings # User settings
@ -23,11 +27,31 @@ If not available, it will play the higher resolution that is the closest from yo
* all-local will only display the videos which are local to the selected * all-local will only display the videos which are local to the selected
instance plus the private and unlisted videos **(requires admin privileges)** instance plus the private and unlisted videos **(requires admin privileges)**
# API
This add-on can be called from other add-ons in Kodi to play videos thanks to
the following API:
`plugin://plugin.video.peertube/?action=play_video&instance=<instance>&id=<id>`
where:
* `<instance>` is the base URL of the instance hosting the video
* `<id>` is the ID or the UUID of the video on the instance server
For instance to play the video behind the URL
`https://framatube.org/videos/watch/9c9de5e8-0a1e-484a-b099-e80766180a6d` call
the add-on with:
`plugin://plugin.video.peertube/?action=play_video&instance=framatube.org&id=9c9de5e8-0a1e-484a-b099-e80766180a6d`
# Limitations # Limitations
* This add-on doesn't support Webtorrent yet. So, it cannot download/share from/to regular PeerTube clients. * This add-on doesn't support Webtorrent yet. So, it cannot download/share
The reason is that it uses the libtorrent python library which doesn't support it yet (see https://github.com/arvidn/libtorrent/issues/223) from/to regular PeerTube clients. The reason is that it uses the libtorrent
* The add)on doesn't delete the downloaded files at the moment. So, it may fills up your disk. python library which doesn't support it yet (see
https://github.com/arvidn/libtorrent/issues/223)
* The add-on doesn't delete the downloaded files at the moment. So, it may fill
up your disk.
# Requirements # Requirements

View File

@ -1,11 +1,14 @@
# A Kodi Addon to play video hosted on the peertube service (http://joinpeertube.org/) """ A Kodi Addon to play video hosted on the PeerTube service
# (http://joinpeertube.org/)
# TODO: - Delete downloaded files by default
# - Allow people to choose if they want to keep their download after watching? TODO:
# - Do sanity checks on received data - Delete downloaded files by default
# - Handle languages better (with .po files) - Allow people to choose if they want to keep their download after watching?
# - Get the best quality torrent given settings and/or available bandwidth - Do sanity checks on received data
# See how they do that in the peerTube client's code - Handle languages better (with .po files)
- Get the best quality torrent given settings and/or available bandwidth
See how they do that in the peerTube client's code
"""
import sys import sys
try: try:
@ -22,7 +25,6 @@ import xbmc
import xbmcaddon import xbmcaddon
import xbmcgui import xbmcgui
import xbmcplugin import xbmcplugin
import xbmcvfs
class PeertubeAddon(): class PeertubeAddon():
@ -51,7 +53,8 @@ class PeertubeAddon():
self.plugin_id = plugin_id self.plugin_id = plugin_id
# Select preferred instance by default # Select preferred instance by default
self.selected_inst = addon.getSetting('preferred_instance') self.selected_inst = 'https://{}'\
.format(addon.getSetting('preferred_instance'))
# Get the number of videos to show per page # Get the number of videos to show per page
self.items_per_page = int(addon.getSetting('items_per_page')) self.items_per_page = int(addon.getSetting('items_per_page'))
@ -102,20 +105,25 @@ class PeertubeAddon():
response.raise_for_status() response.raise_for_status()
except requests.HTTPError as e: except requests.HTTPError as e:
xbmcgui.Dialog().notification('Communication error', xbmcgui.Dialog().notification('Communication error',
'Error during request on {0}' 'Error when sending request {0}'
.format(self.selected_inst), .format(req),
xbmcgui.NOTIFICATION_ERROR) xbmcgui.NOTIFICATION_ERROR)
# Print the JSON as it may contain an 'error' key with the details # If the JSON contains an 'error' key, print it
# of the error error_details = data.get('error')
self.debug('Error => "{}"'.format(data['error'])) if error_details is not None:
self.debug('Error => "{}"'.format(data['error']))
raise e raise e
# Return when no results are found # Try to get the number of elements in the response
if data['total'] == 0: results_found = data.get('total', None)
self.debug('No result found') # If the information is available in the response, use it
return None if results_found is not None:
else: # Return when no results are found
self.debug('Found {0} results'.format(data['total'])) if results_found == 0:
self.debug('No result found')
return None
else:
self.debug('Found {0} results'.format(results_found))
return data return data
@ -134,7 +142,9 @@ class PeertubeAddon():
if data_type == 'videos': if data_type == 'videos':
# Add thumbnail # Add thumbnail
list_item.setArt({'thumb': self.selected_inst + '/' + data['thumbnailPath']}) list_item.setArt({
'thumb': '{0}/{1}'.format(self.selected_inst,
data['thumbnailPath'])})
# Set a fanart image for the list item. # Set a fanart image for the list item.
# list_item.setProperty('fanart_image', data['thumb']) # list_item.setProperty('fanart_image', data['thumb'])
@ -148,7 +158,8 @@ class PeertubeAddon():
# For videos, add a rating based on likes and dislikes # For videos, add a rating based on likes and dislikes
if data['likes'] > 0 or data['dislikes'] > 0: if data['likes'] > 0 or data['dislikes'] > 0:
info['rating'] = data['likes']/(data['likes'] + data['dislikes']) info['rating'] = data['likes'] / (
data['likes'] + data['dislikes'])
# Set additional info for the list item. # Set additional info for the list item.
list_item.setInfo('video', info) list_item.setInfo('video', info)
@ -156,51 +167,16 @@ class PeertubeAddon():
# Videos are playable # Videos are playable
list_item.setProperty('IsPlayable', 'true') list_item.setProperty('IsPlayable', 'true')
# Find the URL of the best possible video matching user's preferences # Build the Kodi URL to play the associated video only with the
# TODO: Error handling # id of the video. The instance is omitted because the
current_res = 0 # currently selected instance will be used automatically.
higher_res = -1
torrent_url = ''
response = requests.get(self.selected_inst + '/api/v1/videos/'
+ data['uuid'])
metadata = response.json()
self.debug('Looking for the best possible video quality matching user preferences')
for f in metadata['files']:
# Get file 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
self.debug('Found video with preferred resolution')
torrent_url = f['torrentUrl']
break
elif res < self.preferred_resolution and res > current_res:
# Else, 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']))
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']))
backup_url = f['torrentUrl']
higher_res = res
# Use smallest file with an higher resolution, when we didn't find a resolution equal or
# lower than the user's preferred one
if not torrent_url:
self.debug('Using video with higher resolution as alternative')
torrent_url = backup_url
# Compose the correct URL for Kodi
url = self.build_kodi_url({ url = self.build_kodi_url({
'action': 'play_video', 'action': 'play_video',
'url': torrent_url 'id': data['uuid']
}) })
elif data_type == 'instances': elif data_type == 'instances':
# TODO: Add a context menu to select instance as preferred instance # TODO: Add a context menu to select instance as preferred
# Instances are not playable # Instances are not playable
list_item.setProperty('IsPlayable', 'false') list_item.setProperty('IsPlayable', 'false')
@ -225,6 +201,71 @@ class PeertubeAddon():
return listing 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)
current_res = 0
higher_res = -1
torrent_url = ''
# Retrieve the information about the video
metadata = self.query_peertube(urljoin(instance,
'/api/v1/videos/{}'
.format(video_id)))
self.debug(
'Looking for the best resolution matching the user preferences')
for f in metadata['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
self.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']))
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']))
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')
torrent_url = backup_url
return torrent_url
def build_video_rest_api_request(self, search, start): def build_video_rest_api_request(self, search, start):
"""Build the URL of an HTTP request using the PeerTube videos REST API. """Build the URL of an HTTP request using the PeerTube videos REST API.
@ -284,20 +325,24 @@ class PeertubeAddon():
} }
# Join the base URL with the REST API and the parameters # Join the base URL with the REST API and the parameters
req = '{0}?{1}'.format('https://instances.joinpeertube.org/api/v1/instances', req = 'https://instances.joinpeertube.org/api/v1/instances?{0}'\
urlencode(params)) .format(urlencode(params))
return req return req
def search_videos(self, start): def search_videos(self, start):
""" """
Function to search for videos on a PeerTube instance and navigate in the results Function to search for videos on a PeerTube instance and navigate
in the results
:param start: string :param start: string
:result: None :result: None
""" """
# Show a 'Search videos' dialog # Show a 'Search videos' dialog
search = xbmcgui.Dialog().input(heading='Search videos on ' + self.selected_inst, type=xbmcgui.INPUT_ALPHANUM) search = xbmcgui.Dialog().input(
heading='Search videos on {}'.format(self.selected_inst),
type=xbmcgui.INPUT_ALPHANUM)
# Go back to main menu when user cancels # Go back to main menu when user cancels
if not search: if not search:
@ -311,7 +356,9 @@ class PeertubeAddon():
# Exit directly when no result is found # Exit directly when no result is found
if not results: if not results:
xbmcgui.Dialog().notification('No videos found', 'No videos found matching query', xbmcgui.NOTIFICATION_WARNING) xbmcgui.Dialog().notification('No videos found',
'No videos found matching query',
xbmcgui.NOTIFICATION_WARNING)
return None return None
# Create array of xmbcgui.ListItem's # Create array of xmbcgui.ListItem's
@ -325,7 +372,9 @@ class PeertubeAddon():
def browse_videos(self, start): def browse_videos(self, start):
""" """
Function to navigate through all the video published by a PeerTube instance Function to navigate through all the video published by a PeerTube
instance
:param start: string :param start: string
:return: None :return: None
""" """
@ -369,13 +418,15 @@ class PeertubeAddon():
def play_video_continue(self, data): def play_video_continue(self, data):
""" """
Callback function to let the play_video function resume when the PeertubeDownloader Callback function to let the play_video function resume when the
has downloaded all the torrent's metadata PeertubeDownloader has downloaded all the torrent's metadata
:param data: dict :param data: dict
:return: None :return: None
""" """
self.debug('Received metadata_downloaded signal, will start playing media') self.debug(
'Received metadata_downloaded signal, will start playing media')
self.play = 1 self.play = 1
self.torrent_f = data['file'] self.torrent_f = data['file']
@ -393,8 +444,11 @@ class PeertubeAddon():
# Start a downloader thread # Start a downloader thread
AddonSignals.sendSignal('start_download', {'url': torrent_url}) AddonSignals.sendSignal('start_download', {'url': torrent_url})
# Wait until the PeerTubeDownloader has downloaded all the torrent's metadata # Wait until the PeerTubeDownloader has downloaded all the torrent's
AddonSignals.registerSlot('plugin.video.peertube', 'metadata_downloaded', self.play_video_continue) # metadata
AddonSignals.registerSlot('plugin.video.peertube',
'metadata_downloaded',
self.play_video_continue)
timeout = 0 timeout = 0
while self.play == 0 and timeout < 10: while self.play == 0 and timeout < 10:
xbmc.sleep(1000) xbmc.sleep(1000)
@ -402,7 +456,10 @@ class PeertubeAddon():
# Abort in case of timeout # Abort in case of timeout
if timeout == 10: if timeout == 10:
xbmcgui.Dialog().notification('Download timeout', 'Timeout fetching ' + torrent_url, xbmcgui.NOTIFICATION_ERROR) xbmcgui.Dialog().notification('Download timeout',
'Timeout fetching {}'
.format(torrent_url),
xbmcgui.NOTIFICATION_ERROR)
return None return None
else: else:
# Wait a little before starting playing the torrent # Wait a little before starting playing the torrent
@ -422,8 +479,11 @@ class PeertubeAddon():
:return: None :return: None
""" """
self.selected_inst = 'https://' + instance self.selected_inst = 'https://{}'.format(instance)
xbmcgui.Dialog().notification('Current instance changed', 'Changed current instance to {0}'.format(self.selected_inst), xbmcgui.NOTIFICATION_INFO) xbmcgui.Dialog().notification('Current instance changed',
'Changed current instance to {0}'
.format(self.selected_inst),
xbmcgui.NOTIFICATION_INFO)
self.debug('Changing currently selected instance to {0}' self.debug('Changing currently selected instance to {0}'
.format(self.selected_inst)) .format(self.selected_inst))
@ -496,12 +556,19 @@ class PeertubeAddon():
# Browse peerTube instances # Browse peerTube instances
self.browse_instances(params['start']) self.browse_instances(params['start'])
elif action == 'play_video': elif action == 'play_video':
# Play video from provided URL. # This action comes with the id of the video to play as
self.play_video(params['url']) # 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': elif action == 'select_instance':
self.select_instance(params['url']) self.select_instance(params['url'])
else: else:
# Display the addon's main menu when the plugin is called from Kodi UI without any parameters # Display the addon's main menu when the plugin is called from
# Kodi UI without any parameters
self.main_menu() self.main_menu()
return None return None

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?> <?xml version="1.0" encoding="utf-8" standalone="yes"?>
<settings> <settings>
<setting id="preferred_instance" type="text" default="https://framatube.org" label="30000"/> <setting id="preferred_instance" type="text" default="framatube.org" label="30000"/>
<setting type="sep"/> <setting type="sep"/>
<setting id="items_per_page" type="select" values="10|20|50|100" default="20" label="30001"/> <setting id="items_per_page" type="select" values="10|20|50|100" default="20" label="30001"/>
<setting id="video_sort_method" type="select" values="likes|views" default='views' label="30002"/> <setting id="video_sort_method" type="select" values="likes|views" default='views' label="30002"/>