From 8529c3b403cff9c5ffcb85c203e7d0b01bbff4ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Thu, 2 Apr 2020 20:21:00 +0200 Subject: [PATCH] Playback from cache --- .github/workflows/ci.yml | 2 +- .../resource.language.en_gb/strings.po | 52 ++++++++ .../resource.language.nl_nl/strings.po | 52 ++++++++ resources/lib/addon.py | 7 ++ resources/lib/downloader.py | 84 +++++++++++++ resources/lib/modules/menu.py | 12 +- resources/lib/modules/player.py | 115 +++++++++++++++--- resources/settings.xml | 4 + tests/__init__.py | 2 +- tests/test_downloader.py | 52 ++++++++ 10 files changed, 364 insertions(+), 18 deletions(-) create mode 100644 resources/lib/downloader.py create mode 100644 tests/test_downloader.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 949c6cf..13d6c78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 345d60d..019da14 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -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 "" diff --git a/resources/language/resource.language.nl_nl/strings.po b/resources/language/resource.language.nl_nl/strings.po index 4aab7b5..21f30f2 100644 --- a/resources/language/resource.language.nl_nl/strings.po +++ b/resources/language/resource.language.nl_nl/strings.po @@ -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" diff --git a/resources/lib/addon.py b/resources/lib/addon.py index cd326bc..0ac749d 100644 --- a/resources/lib/addon.py +++ b/resources/lib/addon.py @@ -92,6 +92,13 @@ def play(uuid): Player().play(uuid) +@routing.route('/download/catalog/') +def download(uuid): + """ Download the requested item to cache """ + from resources.lib.modules.player import Player + Player().download(uuid) + + @routing.route('/play/page//') def play_from_page(channel, page): """ Play the requested item """ diff --git a/resources/lib/downloader.py b/resources/lib/downloader.py new file mode 100644 index 0000000..3787e95 --- /dev/null +++ b/resources/lib/downloader.py @@ -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 diff --git a/resources/lib/modules/menu.py b/resources/lib/modules/menu.py index 3db2c3d..386e953 100644 --- a/resources/lib/modules/menu.py +++ b/resources/lib/modules/menu.py @@ -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') diff --git a/resources/lib/modules/player.py b/resources/lib/modules/player.py index 3ddf60b..251684d 100644 --- a/resources/lib/modules/player.py +++ b/resources/lib/modules/player.py @@ -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 diff --git a/resources/settings.xml b/resources/settings.xml index 277f2de..7d371a3 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -11,4 +11,8 @@ + + + + diff --git a/tests/__init__.py b/tests/__init__.py index 8272521..31cfeb1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,4 +5,4 @@ from __future__ import absolute_import, division, unicode_literals import logging -logging.basicConfig() +logging.basicConfig(level=logging.DEBUG) diff --git a/tests/test_downloader.py b/tests/test_downloader.py new file mode 100644 index 0000000..017feaf --- /dev/null +++ b/tests/test_downloader.py @@ -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()