Playback from cache
This commit is contained in:
parent
74769ffc47
commit
8529c3b403
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
@ -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 ""
|
||||
|
@ -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"
|
||||
|
@ -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 """
|
||||
|
84
resources/lib/downloader.py
Normal file
84
resources/lib/downloader.py
Normal 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
|
@ -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')
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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
52
tests/test_downloader.py
Normal 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()
|
Loading…
Reference in New Issue
Block a user