Compare commits

...

1 Commits

Author SHA1 Message Date
Michaël Arnauts
8529c3b403 Playback from cache 2020-04-13 10:35:57 +02:00
10 changed files with 364 additions and 18 deletions

View File

@ -28,7 +28,7 @@ jobs:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies
run: | 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" sudo pip install coverage --install-option="--install-scripts=/usr/bin"
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt

View File

@ -70,6 +70,10 @@ msgctxt "#30102"
msgid "Go to Program" msgid "Go to Program"
msgstr "" msgstr ""
msgctxt "#30103"
msgid "Download to cache"
msgstr ""
### CODE ### CODE
msgctxt "#30204" msgctxt "#30204"
@ -144,6 +148,42 @@ msgctxt "#30717"
msgid "This program is not available in the catalogue." msgid "This program is not available in the catalogue."
msgstr "" 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 ### SETTINGS
msgctxt "#30800" msgctxt "#30800"
@ -177,3 +217,15 @@ msgstr ""
msgctxt "#30831" msgctxt "#30831"
msgid "Update local metadata now" msgid "Update local metadata now"
msgstr "" 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" msgid "Go to Program"
msgstr "Ga naar programma" msgstr "Ga naar programma"
msgctxt "#30103"
msgid "Download to cache"
msgstr "Downloaden naar cache"
### CODE ### CODE
msgctxt "#30204" msgctxt "#30204"
@ -145,6 +149,42 @@ msgctxt "#30717"
msgid "This program is not available in the catalogue." msgid "This program is not available in the catalogue."
msgstr "Dit programma is niet beschikbaar in de catalogus." 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 ### SETTINGS
msgctxt "#30800" msgctxt "#30800"
@ -178,3 +218,15 @@ msgstr "Vernieuw de lokale metdata automatisch in de achtergrond"
msgctxt "#30831" msgctxt "#30831"
msgid "Update local metadata now" msgid "Update local metadata now"
msgstr "De lokale metadata nu vernieuwen" 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) 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>') @routing.route('/play/page/<channel>/<page>')
def play_from_page(channel, page): def play_from_page(channel, page):
""" Play the requested item """ """ 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, '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'], return TitleItem(title=info_dict['title'],
path=kodiutils.url_for('play', uuid=item.uuid), path=kodiutils.url_for('play', uuid=item.uuid),
art_dict=art_dict, art_dict=art_dict,
info_dict=info_dict, info_dict=info_dict,
stream_dict=stream_dict, stream_dict=stream_dict,
prop_dict=prop_dict, prop_dict=prop_dict,
is_playable=True) is_playable=True,
context_menu=context_menu)
raise Exception('Unknown video_type') raise Exception('Unknown video_type')

View File

@ -4,8 +4,10 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
import logging import logging
import os
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.downloader import Downloader
from resources.lib.viervijfzes.auth import AuthApi from resources.lib.viervijfzes.auth import AuthApi
from resources.lib.viervijfzes.auth_awsidp import InvalidLoginException, AuthenticationException from resources.lib.viervijfzes.auth_awsidp import InvalidLoginException, AuthenticationException
from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException
@ -18,6 +20,8 @@ class Player:
def __init__(self): def __init__(self):
""" Initialise object """ """ 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): def play_from_page(self, channel, path):
""" Play the requested item. """ Play the requested item.
@ -30,17 +34,82 @@ class Player:
# Play this now we have the uuid # Play this now we have the uuid
self.play(episode.uuid) self.play(episode.uuid)
@staticmethod def play(self, uuid):
def play(item):
""" Play the requested item. """ 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 # Workaround for Raspberry Pi 3 and older
omxplayer = kodiutils.get_global_setting('videoplayer.useomxplayer') omxplayer = kodiutils.get_global_setting('videoplayer.useomxplayer')
if omxplayer is False: if omxplayer is False:
kodiutils.set_global_setting('videoplayer.useomxplayer', True) 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: try:
# Check if we have credentials # Check if we have credentials
if not kodiutils.get_setting('username') or not kodiutils.get_setting('password'): 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? message=kodiutils.localize(30701)) # To watch a video, you need to enter your credentials. Do you want to enter them now?
if confirm: if confirm:
kodiutils.open_settings() kodiutils.open_settings()
kodiutils.end_of_directory() return None
return
# Fetch an auth token now
try: try:
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
# Get stream information # 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: except (InvalidLoginException, AuthenticationException) as ex:
_LOGGER.error(ex) _LOGGER.error(ex)
kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex))) kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex)))
kodiutils.end_of_directory() return None
return
except GeoblockedException: except GeoblockedException:
kodiutils.ok_dialog(heading=kodiutils.localize(30709), message=kodiutils.localize(30710)) # This video is geo-blocked... kodiutils.ok_dialog(heading=kodiutils.localize(30709), message=kodiutils.localize(30710)) # This video is geo-blocked...
return return None
except UnavailableException: except UnavailableException:
kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable... kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable...
return return None
# Play this item return resolved_stream
kodiutils.play(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="30829" type="bool" id="metadata_update" default="true" subsetting="true"/>
<setting label="30831" type="action" action="RunPlugin(plugin://plugin.video.viervijfzes/metadata/update)"/> <setting label="30831" type="action" action="RunPlugin(plugin://plugin.video.viervijfzes/metadata/update)"/>
</category> </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> </settings>

View File

@ -5,4 +5,4 @@ from __future__ import absolute_import, division, unicode_literals
import logging 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()