Support better initial download of big videos

In this commit, the new version of vfs.libtorrent is used which
replaces `write()` entirely with `seek()`. Now the size of the
initial chunk that will be downloaded before playback starts is
not defined anymore in vfs.libtorrent, but passed as argument
to `seek()`.
It is quite hard to define how much of a video must be downloaded
before starting the playback because most of the time the chosen
value works great for short videos but not for long videos. It
was decided to define this size based on the duration rather
than on the size of the file. Consequently the user can now
configure in the settings of the add-on of many "seconds" of the
video must be downloaded before the playback starts.

As the configured values is likely to not work properly on all
machines (espcially slow ones), a strategy was implemented to
detect when the playback fails to start because the amount of
the video that was downloaded is not big enough. When it occurs
the user has the choice to try again to play the video or not.
This use case is detected with a combination of the callbacks
`onPlayBackStopped()` and `onAVStarted()` (using
`onPlayBackError()` would have been easier but for some
reason it is not called in this case).

Other changes:
* refactor the play_video action
* clean-up strings and variables that were not used
This commit is contained in:
Thomas Bétous 2021-11-01 14:54:47 +01:00
parent 1ebb06d271
commit 89675dfb30
8 changed files with 270 additions and 128 deletions

View File

@ -77,6 +77,10 @@ msgctxt "#30013"
msgid "Display a notification when the service starts"
msgstr "Eine Benachrichtigung anzeigen, wenn der Dienst startet"
msgctxt "#30014"
msgid "Wait time before starting playback (seconds)"
msgstr ""
# -----------------------------------
# Other strings (from 30400 to 30999)
# -----------------------------------
@ -86,7 +90,7 @@ msgid "PeerTube service started"
msgstr "PeerTube-Dienst gestartet"
msgctxt "#30401"
msgid "Torrents can now be controlled."
msgid "You may now start playing videos."
msgstr ""
msgctxt "#30402"
@ -129,13 +133,9 @@ msgctxt "#30411"
msgid "No videos found matching the keywords '{}'"
msgstr "Keine Videos zu den Stichworten gefunden '{}'"
msgctxt "#30412"
msgid "Error: libtorrent could not be imported"
msgstr "Fehler: libtorrent konnte nicht importiert werden"
# 30412 is not used anymore
msgctxt "#30413"
msgid "PeerTube cannot play videos without libtorrent\nPlease follow the instructions at {}"
msgstr "PeerTube kann keine Videos ohne libtorrent abspielen\nBitte folgen Sie den Anweisungen unter {}"
# 30413 is not used anymore
# 30414 is not used anymore
@ -157,9 +157,7 @@ msgctxt "#30419"
msgid "{} is now the selected instance."
msgstr "{} ist nun die ausgewählte Instanz."
msgctxt "#30420"
msgid "You can still browse and search videos but you will not be able to play them (except live videos).\nPlease follow the instructions at {}"
msgstr "Sie können weiterhin Videos durchsuchen und suchen, aber Sie können sie nicht abspielen (außer Live-Videos).\nBefolgen Sie bitte die Anweisungen unter {}"
# 30420 is not used anymore
msgctxt "#30421"
msgid "Download error"
@ -168,3 +166,11 @@ msgstr ""
msgctxt "#30422"
msgid "Error when trying to download the video. Check the log for more information."
msgstr ""
msgctxt "#30423"
msgid "Playback error"
msgstr ""
msgctxt "#30424"
msgid "When the playback started, the portion of the video that was downloaded was probably not big enough. Do you want to try again to play the video?\n(If this error occurs often you should increase the initial wait time in the settings.)"
msgstr ""

View File

@ -77,6 +77,10 @@ msgctxt "#30013"
msgid "Display a notification when the service starts"
msgstr ""
msgctxt "#30014"
msgid "Wait time before starting playback (seconds)"
msgstr ""
# -----------------------------------
# Other strings (from 30400 to 30999)
# -----------------------------------
@ -86,7 +90,7 @@ msgid "PeerTube service started"
msgstr ""
msgctxt "#30401"
msgid "Torrents can now be controlled."
msgid "You may now start playing videos."
msgstr ""
msgctxt "#30402"
@ -129,13 +133,9 @@ msgctxt "#30411"
msgid "No videos found matching the keywords '{}'"
msgstr ""
msgctxt "#30412"
msgid "Error: libtorrent could not be imported"
msgstr ""
# 30412 is not used anymore
msgctxt "#30413"
msgid "PeerTube cannot play videos without libtorrent\nPlease follow the instructions at {}"
msgstr ""
# 30413 is not used anymore
# 30414 is not used anymore
@ -157,9 +157,7 @@ msgctxt "#30419"
msgid "{} is now the selected instance."
msgstr ""
msgctxt "#30420"
msgid "You can still browse and search videos but you will not be able to play them (except live videos).\nPlease follow the instructions at {}"
msgstr ""
# 30420 is not used anymore
msgctxt "#30421"
msgid "Download error"
@ -168,3 +166,11 @@ msgstr ""
msgctxt "#30422"
msgid "Error when trying to download the video. Check the log for more information."
msgstr ""
msgctxt "#30423"
msgid "Playback error"
msgstr ""
msgctxt "#30424"
msgid "When the playback started, the portion of the video that was downloaded was probably not big enough. Do you want to try again to play the video?\n(If this error occurs often you should increase the initial wait time in the settings.)"
msgstr ""

View File

@ -77,6 +77,10 @@ msgctxt "#30013"
msgid "Display a notification when the service starts"
msgstr "Afficher une notification au démarrage du service"
msgctxt "#30014"
msgid "Wait time before starting playback (seconds)"
msgstr "Délai d'attente avec de lancer la lecture (secondes)"
# -----------------------------------
# Other strings (from 30400 to 30999)
# -----------------------------------
@ -86,8 +90,8 @@ msgid "PeerTube service started"
msgstr "Le service PeerTube a démarré"
msgctxt "#30401"
msgid "Torrents can now be controlled."
msgstr "Les torrents peuvent maintenant être contrôlés."
msgid "You may now start playing videos."
msgstr "Vous pouvez maintenant lire des vidéos."
msgctxt "#30402"
msgid "Request error"
@ -129,13 +133,9 @@ msgctxt "#30411"
msgid "No videos found matching the keywords '{}'"
msgstr "Aucune vidéo ne correspond aux mots-clés '{}'"
msgctxt "#30412"
msgid "Error: libtorrent could not be imported"
msgstr "Erreur: libtorrent n'a pas pu être importé"
# 30412 is not used anymore
msgctxt "#30413"
msgid "PeerTube cannot play videos without libtorrent\nPlease follow the instructions at {}"
msgstr "PeerTube ne peut pas lire de vidéos sans libtorrent.\nMerci de suivre les instructions depuis {}"
# 30413 is not used anymore
# 30414 is not used anymore
@ -157,9 +157,7 @@ msgctxt "#30419"
msgid "{} is now the selected instance."
msgstr "{} est maintenant l'instance sélectionnée."
msgctxt "#30420"
msgid "You can still browse and search videos but you will not be able to play them (except live videos).\nPlease follow the instructions at {}"
msgstr "Vous pouvez parcourir ou chercher des vidéos mais vous ne pourrez pas les lire (sauf les live).\nMerci de suivre les instructions depuis {}"
# 30420 is not used anymore
msgctxt "#30421"
msgid "Download error"
@ -168,3 +166,11 @@ msgstr "Erreur de téléchargement"
msgctxt "#30422"
msgid "Error when trying to download the video. Check the log for more information."
msgstr "Une erreur est survenue pendant le téléchargement de la vidéo. Voir le journal pour plus d'informations."
msgctxt "#30423"
msgid "Playback error"
msgstr "Erreur de lecture"
msgctxt "#30424"
msgid "When the playback started, the portion of the video that was downloaded was probably not big enough. Do you want to try again to play the video?\n(If this error occurs often you should increase the initial wait time in the settings.)"
msgstr "Quand la lecture a démarré, la portion de la vidéo qui avait été téléchargée était certainement trop petite. Voulez-vous essayer de lire la vidéo à nouveau ?\n(Si cette erreur survient souvent vous devriez augmenter le délai d'attente initial dans les paramètres.)"

View File

@ -33,11 +33,6 @@ class PeerTubeAddon():
self.preferred_resolution = \
int(kodi.get_setting("preferred_resolution"))
# Nothing to play at initialisation
self.play = False
self.torrent_name = ""
self.torrent_file = ""
# Create a PeerTube object to send requests: settings which are used
# only by this object are directly retrieved from the settings
self.peertube = PeerTube(
@ -200,46 +195,35 @@ class PeerTubeAddon():
return next_page_item
def _get_video_url(self, video_id, instance=None):
"""Return the URL of a video and its type (live or not)
Find the URL of the video with the best possible quality matching
user's preferences.
The information whether the video is live or not will also be returned.
:param str video_id: ID of the torrent linked with the video
:param str instance: PeerTube instance hosting the video (optional)
:return: a boolean indicating if the video is a live stream and the URL
of the video (containing the resolution for non-live videos) as a
string
:rtype: tuple
def _get_url_with_resolution(self, list_of_url_and_resolutions):
"""
Build the URL of the video
PeerTube creates 1 URL for each resolution so we browse all the
available resolutions and select the best possible quality matching
user's preferences.
If the preferred resolution cannot be found, the one just below will
be used. If it is not possible the one just above we will be used.
:param list list_of_url_and_resolutions: list of dict containing 2 keys:
the resolution and the associated URL.
:return: the URL matching the selected 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 = ""
is_live = False
for video in video_files:
url = None
for video in list_of_url_and_resolutions:
# Get the resolution
resolution = video.get("resolution")
if resolution is None:
# If there is no resolution in the dict, then the video is a
# live stream: no need to find the best resolution as there is
# only 1 URL in this case
url = video["url"]
is_live = True
return (is_live, url)
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"]
return (is_live, url)
return video["url"]
elif (resolution < self.preferred_resolution
and resolution > current_resolution):
# Otherwise, try to find the best one just below the user's
@ -262,12 +246,12 @@ class PeerTubeAddon():
# 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:
if url is None:
kodi.debug("Using video with higher resolution as alternative ({})"
.format(higher_resolution))
url = backup_url
return (is_live, url)
return url
def _home_page(self):
"""Display the items of the home page of the add-on"""
@ -325,39 +309,86 @@ class PeerTubeAddon():
# Create the associated items in Kodi
kodi.create_items_in_ui(list_of_videos)
def _play_video(self, torrent_url):
def _play_video(self, video_id, instance):
"""
Get the required information and play the video
:param str video_id: ID of the torrent linked with the video
:param str instance: PeerTube instance hosting the video
"""
# Get the information of the video including the different resolutions
# available
video_info = self.peertube.get_video_info(video_id, instance)
# Check if the video is a live (Kodi can play live videos (.m3u8) out of
# the box whereas torrents must first be downloaded)
if video_info["is_live"]:
kodi.play(video_info["files"][0]["url"])
else:
# Get the URL of the file which resolution is the closest to the
# user's preferences
url = self._get_url_with_resolution(video_info["files"])
self._download_and_play(url, int(video_info["duration"]))
def _download_and_play(self, torrent_url, duration):
"""
Start the torrent's download and play it while being downloaded
The user configures in the settings the number of seconds of the file
that must be downloaded before the playback starts.
:param str torrent_url: URL of the torrent file to download and play
:param int duration: duration of the video behind the URL in seconds
"""
kodi.debug("Starting torrent download ({})".format(torrent_url))
# Download the torrent using vfs.libtorrent: the torrent URL must be
# URL encoded to be correctly read by vfs.libtorrent
# URL-encoded to be correctly read by vfs.libtorrent
vfs_url = "torrent://{}".format(quote_plus(torrent_url))
torrent = xbmcvfs.File(vfs_url)
AddonSignals.sendSignal("get_torrent", {"torrent_url": vfs_url})
# Get information about the torrent
torrent_info = json.loads(torrent.read())
# Download the file
if(torrent.write("download")):
if torrent_info["nb_files"] > 1:
kodi.warning("There are more than 1 file in {} but only the"
" first one will be played.".format(torrent_url))
# Get information about the torrent
torrent_info = json.loads(torrent.read())
# Compute the amount of the file that we want to wait to be downloaded
# before playing the video. It is based on the number of seconds
# configured by the user and the total duration of the video.
initial_chunk_proportion = (int(kodi.get_setting("initial_wait_time"))
* 100. / duration)
# TODO: Remove the dot in 100. in python 3? Or keep it to suport both
# python2 and python3
kodi.debug("initial_chunk_proportion = {}".format(initial_chunk_proportion))
# Download the file, waiting for "initial_chunk_proportion" % of the
# file to be downloaded (seek() takes only integers so the proportion
# is multiplied to have more granularity.)
if(torrent.seek(initial_chunk_proportion*100, 0) != -1):
# Build the path of the downloaded file
self.torrent_file = os.path.join(torrent_info["save_path"],
torrent_file = os.path.join(torrent_info["save_path"],
torrent_info["files"][0]["path"])
if torrent_info["nb_files"] > 1:
kodi.warning("There are more than 1 file in {} but only the"
" first one will be played.".format(torrent_url))
# Send information about the torrent to the service so that it can
# control the torrent later(e.g. pause the download when the
# playback stops)
AddonSignals.sendSignal("torrent_information",
{
"run_url": kodi.build_kodi_url(kodi.get_run_parameters()),
"torrent_url": vfs_url
}
)
# Play the file
kodi.debug("Starting video playback of {}".format(self.torrent_file))
kodi.play(self.torrent_file)
kodi.debug("Starting video playback of {}".format(torrent_file))
kodi.play(torrent_file)
else:
kodi.notif_error(title=kodi.get_string(30421),
@ -407,20 +438,8 @@ class PeerTubeAddon():
# 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 of the video
# (containing the resolution) and the type of the video (live
# or not).
is_live, url = self._get_video_url(
instance=params.get("instance"),video_id=params.get("id"))
# Play the video (Kodi can play live videos (.m3u8) out of the
# box whereas torrents must first be downloaded)
if is_live:
kodi.play(url)
else:
self._play_video(url)
self._play_video(instance=params.get("instance"),
video_id=params.get("id"))
elif action == "select_instance":
# Set the selected instance as the preferred instance
self._select_instance(params["url"])

View File

@ -14,6 +14,7 @@ try:
except ImportError:
# Python 2.x
from urlparse import parse_qsl
#TODO: remove this and all the Kodistubs comments for Matrix
from requests.compat import urlencode
@ -210,7 +211,15 @@ class KodiUtils:
:param str title: Title of the box
:param str message: Message in the box
"""
xbmcgui.Dialog().ok(heading=title, line1=message)
return xbmcgui.Dialog().ok(heading=title, line1=message)
#TODO: rename "line1" to message for Matrix
#TODO: this function is not used anymore: keep it?
def open_dialog_progress(self):
"""Open a dialog box with a progress bar
"""
return xbmcgui.DialogProgress()
def open_input_box(self, title):
"""Open a box for the user to input alphanumeric data
@ -229,6 +238,17 @@ class KodiUtils:
return entered_string.decode("utf-8")
else:
return entered_string
#TODO: keep this code for Matrix?
def open_yes_no_dialog(self, title, message):
"""Open a dialog box with "Yes" "No" buttons
:param str title: Title of the box
:param str message: Message in the box
"""
return xbmcgui.Dialog().yesno(heading=title, line1=message)
#TODO: rename "line1" to "message" for Matrix
def play(self, url):
"""Play the media behind the URL

View File

@ -143,31 +143,44 @@ class PeerTube:
sort_methods = ["likes", "views"]
return sort_methods[int(kodi.get_setting("video_sort_method"))]
def get_video_urls(self, video_id, instance=None):
"""Return the URLs of a video
def get_video_info(self, video_id, instance=None):
"""Return the info of a video in a simple form
PeerTube creates 1 URL for each resolution of a video so this method
returns a list of URL/resolution pairs. In the case of a live video,
only an URL will be returned (no resolution).
Get the information of the video from the PeerTube instance and
preprocess some of it so that it can be easily used outside of this
class. The returned information are:
- the type of the video (live or not)
- a list of URL/resolution pairs (PeerTube creates 1 URL for each
resolution of a video). In the case of a live video, only 1 URL will
be returned (as there is no resolution).
- the duration (in seconds) of the video (only if it is not a live)
:param str video_id: ID or UUID of the video
: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
:return: information of the video
:rtype: dict
"""
# Get the information about the video
metadata = self._request(method="GET",
url="videos/{}".format(video_id),
instance=instance)
video_info = {}
if metadata["isLive"]:
# When the video is a live, yield the unique playlist URL (there is
# no resolution in this case)
yield {
"url": metadata['streamingPlaylists'][0]['playlistUrl'],
}
video_info["is_live"] = True
# When the video is a live, return the unique playlist URL (there is
# no resolution in this case). Even in this case the format of the
# structure is preserved: we use a list of dict with the key "url"
video_info["files"] = [
{"url": metadata['streamingPlaylists'][0]['playlistUrl']}
]
else:
video_info["is_live"] = False
# Add the duration in the returned info
video_info["duration"] = metadata["duration"]
# For non live videos, the files corresponding to different
# resolutions available for a video may be stored in "files" or
# "streamingPlaylists[].files" depending if WebTorrent is enabled
@ -178,11 +191,17 @@ class PeerTube:
else:
files = metadata["streamingPlaylists"][0]["files"]
video_urls = []
for file in files:
yield {
"resolution": int(file["resolution"]["id"]),
"url": file["torrentUrl"],
}
video_urls.append(
{
"resolution": int(file["resolution"]["id"]),
"url": file["torrentUrl"],
}
)
video_info["files"] = video_urls
return video_info
def list_videos(self, start):
"""List the videos in the instance

View File

@ -9,6 +9,6 @@
<setting id="video_filter" type="select" lvalues="30009|30010" default="0" label="30005"/>
<setting label="30008" type="lsep"/>
<setting id="preferred_resolution" type="select" values="1080|720|480|360|240" default='480' label="30003"/>
<!--<setting id="delete_files" type="bool" default="true" label="30004"/>-->
<setting id="initial_wait_time" type="slider" range="0,1,30" default="10" option="int" label="30014"/>
</category>
</settings>

View File

@ -10,6 +10,7 @@
"""
import AddonSignals
import xbmc
import xbmcgui
import xbmcvfs
from resources.lib.kodi_utils import kodi
@ -18,26 +19,91 @@ class PeertubePlayer(xbmc.Player):
# Initialize the attributes and call the parent class constructor
def __init__(self):
self.torrent_url = None
self.run_url = None
self.playback_started = False
super(xbmc.Player, self).__init__()
# TODO: Use the python3 format on Matrix
# super().__init__()
# This function will be called through AddonSignals when a video is played.
# It will save the URL expected by vfs.libtorrent to control the torrent.
def receive_torrent(self, data):
self.torrent_url = data["torrent_url"]
kodi.debug(message="Received handle:\n{}".format(self.torrent_url), prefix="PeertubePlayer")
def debug(self, message):
"""Log a debug message with the name of the class as prefix
# Callback when the playback is stopped. It is used to pause the torrent to
# avoid downloading in background a video which may never be played.
def onPlayBackStopped(self):
:param str message: message to log
"""
kodi.debug(message=message, prefix="PeertubePlayer")
def onAVStarted(self):
"""Callback called when Kodi has a video stream.
When it is called we consider that the video is actually being played
and we set the according flag.
"""
# Check if the file that is being played belongs to this add-on to not
# conflict with other add-ons or players.
if self.torrent_url is not None:
kodi.debug(message="Playback stopped: pausing torrent...", prefix="PeertubePlayer")
# Get the torrent handle
torrent = xbmcvfs.File(self.torrent_url)
# Call seek() to pause the torrent. 0 is used as second argument so
# that GetLength() is not called.
torrent.seek(0, 0)
self.torrent_url = None
self.playback_started = True
self.debug(message="Playback started for {}".format(self.file_name))
def onPlayBackStopped(self):
"""Callback called when the playback stops
If the file was actually being played (i.e. onAVStarted() was called for
this file before), then we consider that the playback didn't encounter
any error and it was stopped willingly by the user. In this case we
pause the download of the torrent to avoid downloading in background a
video which may never be played again.
But if no file was being played, we ask the user if we should try again
to play the file: it supports the use case when the playback started
whereas the portion of the file that was downloaded was not big enough.
"""
# First check if the file that was being played belongs to this add-on
# to not conflict with other add-ons or players.
if self.torrent_url is not None:
# Then check if the playback actually started: if the playback
# didn't start (probably because there was a too small portion of
# the file that was downloaded), do not pause the torrent (Kodi
# do not call onPlayBackError() in this case for some reason...)
# and ask the user what should be done.
# Otherwise pause the torrent because we consider the user decided
# to stop the playback.
if self.playback_started:
self.debug(message="Playback stopped: pausing download...")
self.pause_torrent()
self.torrent_url = None
self.playback_started = False
else:
self.debug(message="Playback stopped but an error was"
" detected: asking the user what should be"
" done.")
if kodi.open_yes_no_dialog(title=kodi.get_string(30423),
message=kodi.get_string(30424)):
self.debug(message="Trying to play the video again...")
self.play(item=self.run_url)
else:
self.debug(message="Pausing the download...")
self.pause_torrent()
self.torrent_url = None
self.playback_started = False
def pause_torrent(self):
"""Pause download of the torrent self.torrent_url"""
# Get the torrent handle
torrent = xbmcvfs.File(self.torrent_url)
# Call seek() with -1 to pause the torrent. 0 is used as second argument
# so that GetLength() is not called.
# TODO: ommit the second argument on Matrix
torrent.seek(-1, 0)
def update_torrent_info(self, data):
"""Save the information about the torrent being played currently
This function is called through AddonSignals when a video is played.
"""
self.torrent_url = data["torrent_url"]
self.run_url = data["run_url"]
self.debug(message="Received information:\nURL={}\nrun_url={}"
.format(self.torrent_url, self.run_url))
class PeertubeService():
"""
@ -67,8 +133,8 @@ class PeertubeService():
# Signal that is sent by the main script of the add-on
AddonSignals.registerSlot(kodi.addon_id,
"get_torrent",
self.player.receive_torrent)
"torrent_information",
self.player.update_torrent_info)
# Display a notification now that the service started.