Playback from cache

This commit is contained in:
Michaël Arnauts 2020-04-02 20:21:00 +02:00
parent 74769ffc47
commit 8529c3b403
10 changed files with 364 additions and 18 deletions

View File

@ -28,7 +28,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
sudo apt-get install gettext
sudo apt-get install --no-install-recommends gettext ffmpeg
sudo pip install coverage --install-option="--install-scripts=/usr/bin"
python -m pip install --upgrade pip
pip install -r requirements.txt

View File

@ -70,6 +70,10 @@ msgctxt "#30102"
msgid "Go to Program"
msgstr ""
msgctxt "#30103"
msgid "Download to cache"
msgstr ""
### CODE
msgctxt "#30204"
@ -144,6 +148,42 @@ msgctxt "#30717"
msgid "This program is not available in the catalogue."
msgstr ""
msgctxt "#30718"
msgid "Could not cache this episode since the cache folder is not set or does not exist."
msgstr ""
msgctxt "#30719"
msgid "Could not cache this episode since ffmpeg seems to be unavailable."
msgstr ""
msgctxt "#30720"
msgid "This episode is cached locally. Do you want to play from cache or stream it?"
msgstr ""
msgctxt "#30721"
msgid "Stream"
msgstr ""
msgctxt "#30722"
msgid "Play from cache"
msgstr ""
msgctxt "#30723"
msgid "Starting download..."
msgstr ""
msgctxt "#30724"
msgid "Downloading... ({amount}%)"
msgstr ""
msgctxt "#30725"
msgid "Download has finished. You can now play this episode from cache."
msgstr ""
msgctxt "#30726"
msgid "This episode is already cached. Do you want to download it again?"
msgstr ""
### SETTINGS
msgctxt "#30800"
@ -177,3 +217,15 @@ msgstr ""
msgctxt "#30831"
msgid "Update local metadata now"
msgstr ""
msgctxt "#30840"
msgid "Playback from cache"
msgstr ""
msgctxt "#30841"
msgid "Allow to download episodes to a cache"
msgstr ""
msgctxt "#30843"
msgid "Select the folder for the cached episodes"
msgstr ""

View File

@ -71,6 +71,10 @@ msgctxt "#30102"
msgid "Go to Program"
msgstr "Ga naar programma"
msgctxt "#30103"
msgid "Download to cache"
msgstr "Downloaden naar cache"
### CODE
msgctxt "#30204"
@ -145,6 +149,42 @@ msgctxt "#30717"
msgid "This program is not available in the catalogue."
msgstr "Dit programma is niet beschikbaar in de catalogus."
msgctxt "#30718"
msgid "Could not cache this episode since the cache folder is not set or does not exist."
msgstr "Kon deze aflevering niet cachen omdat de cache folder niet is ingesteld is of niet bestaat."
msgctxt "#30719"
msgid "Could not cache this episode since ffmpeg seems to be unavailable."
msgstr "Kon deze aflevering niet cachen omdat ffmpeg niet beschikbaar lijkt te zijn."
msgctxt "#30720"
msgid "This episode is cached locally. Do you want to play from cache or stream it?"
msgstr "Deze aflevering is lokaal gecached. Wil je deze afspelen vanuit de cache of streamen?"
msgctxt "#30721"
msgid "Stream"
msgstr "Stream"
msgctxt "#30722"
msgid "Play from cache"
msgstr "Afspelen vanuit de cache"
msgctxt "#30723"
msgid "Starting download..."
msgstr "Bezig met starten van de download..."
msgctxt "#30724"
msgid "Downloading... ({amount}%)"
msgstr "Bezig met downloaden... ({amount}%)"
msgctxt "#30725"
msgid "Download has finished. You can now play this episode from cache."
msgstr "De download is voltooid. Je kan deze aflevering nu afspelen vanuit de cache."
msgctxt "#30726"
msgid "This episode is already cached. Do you want to download it again?"
msgstr "Deze aflevering is al gecached. Wil je deze opnieuw downloaden?"
### SETTINGS
msgctxt "#30800"
@ -178,3 +218,15 @@ msgstr "Vernieuw de lokale metdata automatisch in de achtergrond"
msgctxt "#30831"
msgid "Update local metadata now"
msgstr "De lokale metadata nu vernieuwen"
msgctxt "#30840"
msgid "Playback from cache"
msgstr "Afspelen vanuit de cache"
msgctxt "#30841"
msgid "Allow to download episodes to a cache"
msgstr "Toestaan om afleveringen te downloaden naar de cache"
msgctxt "#30843"
msgid "Select the folder for the cached episodes"
msgstr "Selecteer de map voor de gecachte afleveringen"

View File

@ -92,6 +92,13 @@ def play(uuid):
Player().play(uuid)
@routing.route('/download/catalog/<uuid>')
def download(uuid):
""" Download the requested item to cache """
from resources.lib.modules.player import Player
Player().download(uuid)
@routing.route('/play/page/<channel>/<page>')
def play_from_page(channel, page):
""" Play the requested item """

View File

@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""Episode Downloader"""
from __future__ import absolute_import, division, unicode_literals
import logging
import re
import subprocess
_LOGGER = logging.getLogger('downloader')
class Downloader:
""" Allows to download an episode to disk for caching purposes. """
def __init__(self):
pass
@staticmethod
def check():
""" Check if we have ffmpeg installed."""
try:
proc = subprocess.Popen(['ffmpeg', '-version'], stderr=subprocess.PIPE)
except OSError:
return False
# Wait for the process to finish
output = proc.stderr.readlines()
proc.wait()
# Check error code
if proc.returncode != 0:
_LOGGER.error(output)
return False
# TODO: Check version
_LOGGER.debug('Output: %s', output)
return True
@staticmethod
def download(stream, output, progress_callback=None):
"""Download the stream to destination."""
try:
cmd = ['ffmpeg', '-y', '-loglevel', 'info', '-i', stream, '-codec', 'copy', output]
# `universal_newlines` makes proc.stderr.readline() also work on \r
proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, universal_newlines=True)
except OSError:
return False
regex_total = re.compile(r"Duration: (\d{2}):(\d{2}):(\d{2})")
regex_current = re.compile(r"time=(\d{2}):(\d{2}):(\d{2})")
# Keep looping over ffmpeg output
total = None
while True:
line = proc.stderr.readline()
if not line:
break
_LOGGER.debug('ffmpeg output: %s', line.rstrip())
# Read the current status that is printed every few seconds.
match = regex_current.search(line)
if match and progress_callback:
cancel = progress_callback(total, int(match.group(1)) * 3600 + int(match.group(2)) * 60 + int(match.group(3)))
if cancel:
proc.terminate()
continue
# Read the total stream duration if we haven't found it already. It's there somewhere in the output. We'll find it.
if not total:
match = regex_total.search(line)
if match:
total = int(match.group(1)) * 3600 + int(match.group(2)) * 60 + int(match.group(3))
# Wait for ffmpeg to be fully finished
proc.wait()
# Check error code
if proc.returncode != 0:
return False
return True

View File

@ -118,12 +118,22 @@ class Menu:
'duration': item.duration,
})
if kodiutils.get_setting_bool('episode_cache_enabled'):
context_menu = [(
kodiutils.localize(30103), # Download to cache
'Container.Update(%s)' %
kodiutils.url_for('download', uuid=item.uuid)
)]
else:
context_menu = []
return TitleItem(title=info_dict['title'],
path=kodiutils.url_for('play', uuid=item.uuid),
art_dict=art_dict,
info_dict=info_dict,
stream_dict=stream_dict,
prop_dict=prop_dict,
is_playable=True)
is_playable=True,
context_menu=context_menu)
raise Exception('Unknown video_type')

View File

@ -4,8 +4,10 @@
from __future__ import absolute_import, division, unicode_literals
import logging
import os
from resources.lib import kodiutils
from resources.lib.downloader import Downloader
from resources.lib.viervijfzes.auth import AuthApi
from resources.lib.viervijfzes.auth_awsidp import InvalidLoginException, AuthenticationException
from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException
@ -18,6 +20,8 @@ class Player:
def __init__(self):
""" Initialise object """
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
self._api = ContentApi(auth)
def play_from_page(self, channel, path):
""" Play the requested item.
@ -30,17 +34,82 @@ class Player:
# Play this now we have the uuid
self.play(episode.uuid)
@staticmethod
def play(item):
def play(self, uuid):
""" Play the requested item.
:type item: string
:type uuid: string
"""
if kodiutils.get_setting_bool('episode_cache_enabled'):
# Check for a cached version
cached_file = self._check_cached_episode(uuid)
if cached_file:
kodiutils.play(cached_file)
return
# Workaround for Raspberry Pi 3 and older
omxplayer = kodiutils.get_global_setting('videoplayer.useomxplayer')
if omxplayer is False:
kodiutils.set_global_setting('videoplayer.useomxplayer', True)
# Resolve the stream
resolved_stream = self._fetch_stream(uuid)
if not resolved_stream:
kodiutils.end_of_directory()
return
# Play this item
kodiutils.play(resolved_stream)
def download(self, uuid):
""" Download the requested item to cache.
:type uuid: string
"""
# We can notify Kodi already that we won't be returning a listing.
# This also fixes an odd Kodi bug where a starting a Progress() without closing the directory listing causes Kodi to hang.
kodiutils.end_of_directory()
# Check ffmpeg
if not Downloader.check():
kodiutils.ok_dialog(message=kodiutils.localize(30719)) # Could not download this episode since ffmpeg seems to be unavailable.
return
# Check download folder
download_folder = kodiutils.get_setting('episode_cache_folder').rstrip('/')
if not os.path.exists(download_folder):
kodiutils.ok_dialog(message=kodiutils.localize(30718)) # Could not download this episode since the download folder is not set or does not exist.
return
# Check if we already have downloaded this file
download_path = '%s/%s.mp4' % (download_folder, uuid)
if os.path.isfile(download_path):
# You have already downloaded this episode. Do you want to download it again?
result = kodiutils.yesno_dialog(message=kodiutils.localize(30726))
if not result:
return
# Download this item
downloader = Downloader()
progress = kodiutils.progress(message=kodiutils.localize(30723)) # Starting download...
def callback(total, current):
""" Callback function to update the progress bar. """
percentage = current * 100 / total
progress.update(int(percentage), kodiutils.localize(30724, amount=round(percentage, 2))) # Downloading... ({amount}%)
return progress.iscanceled()
# Resolve the stream and start the download
resolved_stream = self._fetch_stream(uuid)
status = downloader.download(resolved_stream, download_path, callback)
# Close the progress bar
progress.close()
if status:
kodiutils.ok_dialog(message=kodiutils.localize(30725)) # Download has finished. You can now play this episode from cache.
def _fetch_stream(self, uuid):
""" Fetches the HLS stream of the item.
:type uuid: string
"""
try:
# Check if we have credentials
if not kodiutils.get_setting('username') or not kodiutils.get_setting('password'):
@ -48,29 +117,45 @@ class Player:
message=kodiutils.localize(30701)) # To watch a video, you need to enter your credentials. Do you want to enter them now?
if confirm:
kodiutils.open_settings()
kodiutils.end_of_directory()
return
return None
# Fetch an auth token now
try:
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
# Get stream information
resolved_stream = ContentApi(auth).get_stream_by_uuid(item)
resolved_stream = self._api.get_stream_by_uuid(uuid)
except (InvalidLoginException, AuthenticationException) as ex:
_LOGGER.error(ex)
kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex)))
kodiutils.end_of_directory()
return
return None
except GeoblockedException:
kodiutils.ok_dialog(heading=kodiutils.localize(30709), message=kodiutils.localize(30710)) # This video is geo-blocked...
return
return None
except UnavailableException:
kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable...
return
return None
# Play this item
kodiutils.play(resolved_stream)
return resolved_stream
@staticmethod
def _check_cached_episode(uuid):
""" Check if this episode is available in the download cache.
:type uuid: string
"""
download_folder = kodiutils.get_setting('episode_cache_folder').rstrip('/')
if not download_folder or not os.path.exists(download_folder):
return None
# Check if we already have downloaded this file
download_path = '%s/%s.mp4' % (download_folder, uuid)
if os.path.isfile(download_path):
# You have cached this episode. Do you want to play from your cache or stream it?
result = kodiutils.yesno_dialog(message=kodiutils.localize(30720),
yeslabel=kodiutils.localize(30721), # Stream
nolabel=kodiutils.localize(30722)) # Play from cache
if not result:
return download_path
return None

View File

@ -11,4 +11,8 @@
<setting label="30829" type="bool" id="metadata_update" default="true" subsetting="true"/>
<setting label="30831" type="action" action="RunPlugin(plugin://plugin.video.viervijfzes/metadata/update)"/>
</category>
<category label="30840"> <!-- Playback from cache -->
<setting label="30841" type="bool" id="episode_cache_enabled" default="true"/>
<setting label="30843" type="folder" id="episode_cache_folder" source="local" option="writeable" enable="eq(-1,true)"/>
</category>
</settings>

View File

@ -5,4 +5,4 @@ from __future__ import absolute_import, division, unicode_literals
import logging
logging.basicConfig()
logging.basicConfig(level=logging.DEBUG)

52
tests/test_downloader.py Normal file
View File

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
""" Tests for the episode downloader """
# pylint: disable=missing-docstring,no-self-use
from __future__ import absolute_import, division, print_function, unicode_literals
import logging
import unittest
from resources.lib import kodiutils
from resources.lib.downloader import Downloader
from resources.lib.viervijfzes.auth import AuthApi
from resources.lib.viervijfzes.content import ContentApi, Program
_LOGGER = logging.getLogger('test-downloader')
class TestDownloader(unittest.TestCase):
def __init__(self, *args, **kwargs):
super(TestDownloader, self).__init__(*args, **kwargs)
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
def test_check(self):
""" Test if ffmpeg is installed. """
status = Downloader.check()
self.assertTrue(status)
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
def test_download(self):
""" Test to download a stream. """
program = self._api.get_program('vier', 'de-mol')
self.assertIsInstance(program, Program)
episode = program.episodes[0]
stream = self._api.get_stream_by_uuid(episode.uuid)
filename = '/tmp/download-test.mp4'
def progress_callback(total, seconds):
_LOGGER.info('Downloading... Progress = %d / %d seconds', seconds, total)
# Terminate when we have downloaded 5 seconds, we just want to test this
return seconds > 5
status = Downloader().download(stream=stream, output=filename, progress_callback=progress_callback)
self.assertFalse(status) # status is false since we cancelled
if __name__ == '__main__':
unittest.main()