Added support for VTT subtitles and subtitles toggling both in local and browser media players

This commit is contained in:
Fabio Manganiello 2019-02-12 01:30:55 +01:00
parent 41c34b4bc5
commit 5cbd0fdfe7
10 changed files with 257 additions and 107 deletions

View file

@ -11,7 +11,7 @@ import time
from multiprocessing import Process from multiprocessing import Process
from flask import Flask, Response, abort, jsonify, request as http_request, \ from flask import Flask, Response, abort, jsonify, request as http_request, \
render_template, send_from_directory, make_response render_template, send_from_directory
from redis import Redis from redis import Redis
@ -379,8 +379,20 @@ class HttpBackend(Backend):
if media_id in media_map: if media_id in media_map:
return media_map[media_id] return media_map[media_id]
subfile = None
if subtitles:
try:
subfile = get_plugin('media.subtitles').download(
link=subtitles, convert_to_vtt=True
).output.get('filename')
except Exception as e:
self.logger.warning('Unable to load subtitle {}: {}'
.format(subtitles, str(e)))
media_hndl = MediaHandler.build(source, url=media_url, media_hndl = MediaHandler.build(source, url=media_url,
subtitles=subtitles) subtitles=subfile)
media_map[media_id] = media_hndl media_map[media_id] = media_hndl
media_hndl.media_id = media_id media_hndl.media_id = media_id
@ -456,8 +468,9 @@ class HttpBackend(Backend):
media_url=media_hndl.url.replace( media_url=media_hndl.url.replace(
self.remote_base_url, ''), self.remote_base_url, ''),
media_type=media_hndl.mime_type, media_type=media_hndl.mime_type,
subtitles_url='/media/subtitles/{}.srt'. subtitles_url='/media/subtitles/{}.vtt'.
format(media_id)) format(media_id) if media_hndl.subtitles
else None)
else: else:
return Response(media_hndl.get_data( return Response(media_hndl.get_data(
from_bytes=from_bytes, to_bytes=to_bytes, from_bytes=from_bytes, to_bytes=to_bytes,
@ -508,12 +521,13 @@ class HttpBackend(Backend):
subfile = get_plugin('media.subtitles').download( subfile = get_plugin('media.subtitles').download(
link=subtitles[0].get('SubDownloadLink'), link=subtitles[0].get('SubDownloadLink'),
media_resource=media_hndl.path).get('filename') media_resource=media_hndl.path,
convert_to_vtt=True).get('filename')
media_hndl.set_subtitles(subfile) media_hndl.set_subtitles(subfile)
return { return {
'filename': subfile, 'filename': subfile,
'url': self.remote_base_url + '/media/subtitles/' + media_id + '.srt', 'url': self.remote_base_url + '/media/subtitles/' + media_id + '.vtt',
} }
def remove_subtitles(media_id): def remove_subtitles(media_id):
@ -530,7 +544,7 @@ class HttpBackend(Backend):
return {} return {}
@app.route('/media/subtitles/<media_id>.srt', methods=['GET', 'PUT', 'DELETE']) @app.route('/media/subtitles/<media_id>.vtt', methods=['GET', 'POST', 'DELETE'])
def handle_subtitles(media_id): def handle_subtitles(media_id):
""" """
This route can be used to download and/or expose subtitle files This route can be used to download and/or expose subtitle files
@ -590,7 +604,7 @@ class HttpBackend(Backend):
ret = dict(media_hndl) ret = dict(media_hndl)
if media_hndl.subtitles: if media_hndl.subtitles:
ret['subtitles_url'] = self.remote_base_url + \ ret['subtitles_url'] = self.remote_base_url + \
'/media/subtitles/' + media_hndl.media_id + '.srt' '/media/subtitles/' + media_hndl.media_id + '.vtt'
return jsonify(ret) return jsonify(ret)
except FileNotFoundError as e: except FileNotFoundError as e:
abort(404, str(e)) abort(404, str(e))
@ -754,6 +768,7 @@ class HttpBackend(Backend):
self.logger.info('Initialized HTTP backend on port {}'.format(self.port)) self.logger.info('Initialized HTTP backend on port {}'.format(self.port))
self.app = self.webserver() self.app = self.webserver()
self.app.debug = False
self.server_proc = Process(target=self.app.run, self.server_proc = Process(target=self.app.run,
name='WebServer', name='WebServer',
kwargs=kwargs) kwargs=kwargs)

View file

@ -53,8 +53,9 @@ class MediaHandler:
def __iter__(self): def __iter__(self):
for attr in ['name', 'source', 'mime_type', 'url', 'subtitles', for attr in ['name', 'source', 'mime_type', 'url', 'subtitles',
'prefix_handlers']: 'prefix_handlers', 'media_id']:
yield (attr, getattr(self, attr)) if hasattr(self, attr):
yield (attr, getattr(self, attr))

View file

@ -18,7 +18,9 @@ $(document).ready(function() {
$mediaSubtitlesMessage = $mediaSubtitlesModal.find('.media-subtitles-message'), $mediaSubtitlesMessage = $mediaSubtitlesModal.find('.media-subtitles-message'),
$mediaSearchSubtitles = $ctrlForm.find('[data-modal="#media-subtitles-modal"]'), $mediaSearchSubtitles = $ctrlForm.find('[data-modal="#media-subtitles-modal"]'),
prevVolume = undefined, prevVolume = undefined,
selectedResource = undefined; selectedResource = undefined,
browserVideoWindow = undefined,
browserVideoElement = undefined;
const onEvent = (event) => { const onEvent = (event) => {
switch (event.args.type) { switch (event.args.type) {
@ -158,7 +160,7 @@ $(document).ready(function() {
resolve(uri, subtitles); resolve(uri, subtitles);
} else { } else {
reject(Error(xhr.responseText)); reject(xhr.responseText);
} }
}, },
}); });
@ -192,13 +194,14 @@ $(document).ready(function() {
}); });
}; };
const downloadSubtitles = function(link, mediaResource) { const downloadSubtitles = function(link, mediaResource, vtt=false) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
run({ run({
action: 'media.subtitles.download', action: 'media.subtitles.download',
args: { args: {
'link': link, 'link': link,
'media_resource': mediaResource, 'media_resource': mediaResource,
'convert_to_vtt': vtt,
} }
}).then((response) => { }).then((response) => {
resolve(response.response.output.filename); resolve(response.response.output.filename);
@ -244,10 +247,17 @@ $(document).ready(function() {
const playInBrowser = (resource, subtitles) => { const playInBrowser = (resource, subtitles) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
startStreaming(resource, subtitles).then((url, subtitles) => { startStreaming(resource, subtitles).then((url, subtitles) => {
window.open(url + '?webplayer', '_blank'); browserVideoWindow = window.open(
url + '?webplayer', '_blank');
browserVideoWindow.addEventListener('load', () => {
browserVideoElement = browserVideoWindow.document
.querySelector('#video-player');
});
resolve(url, subtitles); resolve(url, subtitles);
}).catch((xhr, status, error) => { }).catch((error) => {
reject(xhr.responseText); reject(error);
}); });
}); });
}; };
@ -287,8 +297,8 @@ $(document).ready(function() {
playHndl.then((response) => { playHndl.then((response) => {
resolve(resource); resolve(resource);
}).catch((error) => { }).catch((error) => {
showError('Playback error: ' + error.message); showError('Playback error: ' + (error ? error : 'undefined'));
reject(error.message); reject(error);
}); });
}); });
}; };
@ -302,44 +312,36 @@ $(document).ready(function() {
resolve(resource); resolve(resource);
}; };
var defaultPlay = () => { _play(resource).finally(onVideoReady); };
$mediaSearchSubtitles.data('resource', resource); $mediaSearchSubtitles.data('resource', resource);
onVideoLoading(); onVideoLoading();
var subtitlesConf = window.config.media.subtitles; var subtitlesConf = window.config.media.subtitles;
if (subtitlesConf) { if (subtitlesConf) {
populateSubtitlesModal(resource); populateSubtitlesModal(resource).then((subs) => {
if ('language' in subtitlesConf) {
if ('language' in subtitlesConf) { if (subs) {
tryFetchSubtitles(resource).then((subtitles) => { downloadSubtitles(subs[0].SubDownloadLink, resource).then((subtitles) => {
if (!subtitles) { _play(resource, subtitles).finally(onVideoReady);
showError('Cannot get subtitles'); resolve(resource, subtitles);
_play(resource).finally(onVideoReady); }).catch((error) => {
defaultPlay();
resolve(resource);
});
} else { } else {
_play(resource, subtitles).finally(onVideoReady); defaultPlay();
resolve(resource);
} }
}); } else {
} defaultPlay();
} else { resolve(resource);
_play(resource).finally(onVideoReady); }
}
});
};
const tryFetchSubtitles = (resource) => {
return new Promise((resolve, reject) => {
getSubtitles(resource).then((subs) => {
if (!subs) {
resolve();
return; // No subtitles found
}
downloadSubtitles(subs[0].SubDownloadLink, resource).then((filename) => {
resolve(filename);
}).catch((error) => {
resolve();
}); });
}); } else {
defaultPlay();
resolve(resource);
}
}); });
}; };
@ -361,8 +363,8 @@ $(document).ready(function() {
window.open(url, '_blank'); window.open(url, '_blank');
resolve(url); resolve(url);
}) })
.catch((xhr, status, error) => { .catch((error) => {
reject(xhr.responseText); reject(error);
}) })
.finally(() => { .finally(() => {
onVideoReady(); onVideoReady();
@ -371,54 +373,114 @@ $(document).ready(function() {
}; };
const populateSubtitlesModal = (resource) => { const populateSubtitlesModal = (resource) => {
$mediaSubtitlesMessage.text('Loading subtitles...'); return new Promise((resolve, reject) => {
$mediaSubtitlesResults.text(''); $mediaSubtitlesMessage.text('Loading subtitles...');
$mediaSubtitlesMessage.show(); $mediaSubtitlesResults.text('');
$mediaSubtitlesResultsContainer.hide(); $mediaSubtitlesMessage.show();
$mediaSubtitlesResultsContainer.hide();
getSubtitles(resource).then((subs) => { getSubtitles(resource).then((subs) => {
if (!subs.length) { if (!subs) {
$mediaSubtitlesMessage.text('No subtitles found'); $mediaSubtitlesMessage.text('No subtitles found');
return; resolve();
} }
$mediaSearchSubtitles.show(); $mediaSearchSubtitles.show();
for (var sub of subs) { for (var sub of subs) {
var flagCode; var flagCode;
if ('ISO639' in sub) { if ('ISO639' in sub) {
switch(sub.ISO639) { switch(sub.ISO639) {
case 'en': flagCode = 'gb'; break; case 'en': flagCode = 'gb'; break;
default: flagCode = sub.ISO639; break; default: flagCode = sub.ISO639; break;
}
}
var $subContainer = $('<div></div>').addClass('row media-subtitle-container')
.data('download-link', sub.SubDownloadLink)
.data('resource', resource);
var $subFlagIconContainer = $('<div></div>').addClass('one column');
var $subFlagIcon = $('<span></span>')
.addClass(flagCode ? 'flag-icon flag-icon-' + flagCode : (
sub.IsLocal ? 'fa fa-download' : ''))
.text(!(flagCode || sub.IsLocal) ? '?' : '');
var $subMovieName = $('<div></div>').addClass('five columns')
.text(sub.MovieName);
var $subFileName = $('<div></div>').addClass('six columns')
.text(sub.SubFileName);
$subFlagIcon.appendTo($subFlagIconContainer);
$subFlagIconContainer.appendTo($subContainer);
$subMovieName.appendTo($subContainer);
$subFileName.appendTo($subContainer);
$subContainer.appendTo($mediaSubtitlesResults);
}
$mediaSubtitlesMessage.hide();
$mediaSubtitlesResultsContainer.show();
resolve(subs);
}).catch((error) => {
$mediaSubtitlesMessage.text('Unable to load subtitles: ' + error.message);
reject(error);
});
});
};
const setBrowserPlayerSubs = (resource, subtitles) => {
var mediaId;
if (!browserVideoElement) {
showError('No video is currently playing in the browser');
return;
}
return new Promise((resolve, reject) => {
$.get('/media').then((media) => {
for (var m of media) {
if (m.source === resource) {
mediaId = m.media_id;
break;
} }
} }
var $subContainer = $('<div></div>').addClass('row media-subtitle-container') if (!mediaId) {
.data('download-link', sub.SubDownloadLink) reject(resource + ' is not a registered media');
.data('resource', resource); return;
}
var $subFlagIconContainer = $('<div></div>').addClass('one column'); return $.ajax({
var $subFlagIcon = $('<span></span>') type: 'POST',
.addClass(flagCode ? 'flag-icon flag-icon-' + flagCode : ( url: '/media/subtitles/' + mediaId + '.vtt',
sub.IsLocal ? 'fa fa-download' : '')) contentType: 'application/json',
.text(!(flagCode || sub.IsLocal) ? '?' : ''); data: JSON.stringify({
'filename': subtitles,
}),
});
}).then(() => {
resolve(resource, subtitles);
}).catch((error) => {
reject('Cannot set subtitles for ' + resource + ': ' + error);
});
});
};
var $subMovieName = $('<div></div>').addClass('five columns') const setLocalPlayerSubs = (resource, subtitles) => {
.text(sub.MovieName); return new Promise((resolve, reject) => {
run({
var $subFileName = $('<div></div>').addClass('six columns') action: 'media.remove_subtitles'
.text(sub.SubFileName); }).then((response) => {
return run({
$subFlagIcon.appendTo($subFlagIconContainer); action: 'media.set_subtitles',
$subFlagIconContainer.appendTo($subContainer); args: {
$subMovieName.appendTo($subContainer); 'filename': subtitles,
$subFileName.appendTo($subContainer); }
$subContainer.appendTo($mediaSubtitlesResults); })
} }).then((response) => {
resolve(response);
$mediaSubtitlesMessage.hide(); }).catch((error) => {
$mediaSubtitlesResultsContainer.show(); reject(error);
}).catch((error) => { });
$mediaSubtitlesMessage.text('Unable to load subtitles: ' + error.message);
}); });
}; };
@ -435,6 +497,7 @@ $(document).ready(function() {
}; };
$input.prop('disabled', true); $input.prop('disabled', true);
$mediaItemPanel.find('[data-action]').removeClass('disabled');
$videoResults.text('Searching...'); $videoResults.text('Searching...');
if (resource.match(new RegExp('^https?://')) || if (resource.match(new RegExp('^https?://')) ||
@ -594,8 +657,29 @@ $(document).ready(function() {
}); });
}); });
// $mediaSearchSubtitles.on('mouseup touchend', () => { $mediaSubtitlesModal.on('mouseup touchend', '.media-subtitle-container', (event) => {
// }); var resource = $(event.currentTarget).data('resource');
var link = $(event.currentTarget).data('downloadLink');
var selectedDevice = getSelectedDevice();
if (selectedDevice.isRemote) {
showError('Changing subtitles at runtime on Chromecast is not yet supported');
return;
}
var convertToVTT = selectedDevice.isBrowser;
downloadSubtitles(link, resource, convertToVTT).then((subtitles) => {
if (selectedDevice.isBrowser) {
return setBrowserPlayerSubs(resource, subtitles);
} else {
return setLocalPlayerSubs(resource, subtitles);
}
}).catch((error) => {
console.warning('Could not load subtitles ' + link +
' to the player: ' + (error || 'undefined error'))
});
});
}; };
const initRemoteDevices = function() { const initRemoteDevices = function() {
@ -607,8 +691,7 @@ $(document).ready(function() {
action: 'media.chromecast.get_chromecasts', action: 'media.chromecast.get_chromecasts',
}, },
function(results) { onSuccess = function(results) {
$devsRefreshBtn.removeClass('disabled');
$devsList.find('.cast-device[data-remote]').remove(); $devsList.find('.cast-device[data-remote]').remove();
if (!results || results.response.errors.length) { if (!results || results.response.errors.length) {
@ -641,6 +724,10 @@ $(document).ready(function() {
$nameContainer.appendTo($cast); $nameContainer.appendTo($cast);
$cast.appendTo($devsList); $cast.appendTo($devsList);
} }
},
onComplete = function() {
$devsRefreshBtn.removeClass('disabled');
} }
); );
}; };

View file

@ -1,16 +1,18 @@
<script> <script>
window.addEventListener('load', () => { window.addEventListener('load', () => {
var video = document.querySelector('#video'); var video = document.querySelector('#video-player');
video.addEventListener('load', () => { video.addEventListener('loadedmetadata', () => {
var tracks = video.textTracks; video.play();
if (tracks.length) {
tracks[0].mode = 'showing';
}
}); });
var tracks = video.textTracks;
if (tracks.length) {
tracks[0].mode = 'showing';
}
}, false); }, false);
</script> </script>
<video id="video" controls autoplay preload="metadata"> <video id="video-player" controls autoplay preload="metadata" width="100%" height="100%">
<source src="{{ media_url }}" type="{{ media_type }}"> <source src="{{ media_url }}" type="{{ media_type }}">
{% if subtitles_url %} {% if subtitles_url %}
<track label="Subtitles" kind="subtitles" src="{{ subtitles_url }}" default> <track label="Subtitles" kind="subtitles" src="{{ subtitles_url }}" default>

View file

@ -208,11 +208,11 @@ class Request(Message):
if response and response.is_error(): if response and response.is_error():
logger.warning(('Response processed with errors from ' + logger.warning(('Response processed with errors from ' +
'action {}.{}: {}').format( 'action {}: {}').format(
plugin, self.action, str(response))) self.action, str(response)))
else: else:
logger.info('Processed response from action {}.{}: {}'. logger.info('Processed response from action {}: {}'.
format(plugin, self.action, str(response))) format(self.action, str(response)))
except Exception as e: except Exception as e:
# Retry mechanism # Retry mechanism
plugin.logger.exception(e) plugin.logger.exception(e)

View file

@ -217,6 +217,10 @@ class MediaPlugin(Plugin):
def set_subtitles(self, filename, *args, **kwargs): def set_subtitles(self, filename, *args, **kwargs):
raise self._NOT_IMPLEMENTED_ERR raise self._NOT_IMPLEMENTED_ERR
@action
def remove_subtitles(self, *args, **kwargs):
raise self._NOT_IMPLEMENTED_ERR
@action @action
def is_playing(self, *args, **kwargs): def is_playing(self, *args, **kwargs):
raise self._NOT_IMPLEMENTED_ERR raise self._NOT_IMPLEMENTED_ERR

View file

@ -353,6 +353,14 @@ class MediaMplayerPlugin(MediaPlugin):
self._exec('sub_visibility', 1) self._exec('sub_visibility', 1)
return self._exec('sub_load', filename) return self._exec('sub_load', filename)
@action
def remove_subtitles(self, index=None):
""" Removes the subtitle specified by the index (default: all) """
if index is None:
return self._exec('sub_remove')
else:
return self._exec('sub_remove', index)
@action @action
def is_playing(self): def is_playing(self):
""" """

View file

@ -2,6 +2,7 @@ import gzip
import os import os
import requests import requests
import tempfile import tempfile
import threading
from platypush.message.response import Response from platypush.message.response import Response
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@ -15,6 +16,7 @@ class MediaSubtitlesPlugin(Plugin):
Requires: Requires:
* **python-opensubtitles** (``pip install -e 'git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles'``) * **python-opensubtitles** (``pip install -e 'git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles'``)
* **webvtt** (``pip install webvtt-py``), optional, to convert srt subtitles into vtt format ready for web streaming
* **requests** (``pip install requests``) * **requests** (``pip install requests``)
""" """
@ -39,6 +41,7 @@ class MediaSubtitlesPlugin(Plugin):
self._ost = OpenSubtitles() self._ost = OpenSubtitles()
self._token = self._ost.login(username, password) self._token = self._ost.login(username, password)
self.languages = [] self.languages = []
self._file_lock = threading.RLock()
if language: if language:
if isinstance(language, str): if isinstance(language, str):
@ -116,7 +119,7 @@ class MediaSubtitlesPlugin(Plugin):
@action @action
def download(self, link, media_resource=None, path=None): def download(self, link, media_resource=None, path=None, convert_to_vtt=False):
""" """
Downloads a subtitle link (.srt/.vtt file or gzip/zip OpenSubtitles Downloads a subtitle link (.srt/.vtt file or gzip/zip OpenSubtitles
archive link) to the specified directory archive link) to the specified directory
@ -132,6 +135,10 @@ class MediaSubtitlesPlugin(Plugin):
media local file then the subtitles will be saved in the same folder media local file then the subtitles will be saved in the same folder
:type media_resource: str :type media_resource: str
:param convert_to_vtt: If set to True, then the downloaded subtitles
will be converted to VTT format (default: no conversion)
:type convert_to_vtt: bool
:returns: dict. Format:: :returns: dict. Format::
{ {
@ -142,6 +149,8 @@ class MediaSubtitlesPlugin(Plugin):
if link.startswith('file://'): if link.startswith('file://'):
link = link[len('file://'):] link = link[len('file://'):]
if os.path.isfile(link): if os.path.isfile(link):
if convert_to_vtt:
link = self.to_vtt(link).output
return { 'filename': link } return { 'filename': link }
gzip_content = requests.get(link).content gzip_content = requests.get(link).content
@ -166,11 +175,33 @@ class MediaSubtitlesPlugin(Plugin):
try: try:
with f: with f:
f.write(gzip.decompress(gzip_content)) f.write(gzip.decompress(gzip_content))
if convert_to_vtt:
path = self.to_vtt(path).output
except Exception as e: except Exception as e:
os.unlink(path) os.unlink(path)
raise e raise e
return { 'filename': path } return { 'filename': path }
@action
def to_vtt(self, filename):
"""
Get the VTT content given an SRT file. Will return the original content if
the file is already in VTT format.
"""
if filename.lower().endswith('.vtt'):
return filename
import webvtt
with self._file_lock:
try:
webvtt.read(filename)
return filename
except Exception as e:
webvtt.from_srt(filename).save()
return '.'.join(filename.split('.')[:-1]) + '.vtt'
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -126,4 +126,5 @@ inputs
# Support for media subtitles # Support for media subtitles
# git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles # git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles
# webvtt-py

View file

@ -95,9 +95,10 @@ setup(
'Support for Plex plugin': ['plexapi'], 'Support for Plex plugin': ['plexapi'],
'Support for Chromecast plugin': ['pychromecast'], 'Support for Chromecast plugin': ['pychromecast'],
'Support for sound devices': ['sounddevice', 'soundfile', 'numpy'], 'Support for sound devices': ['sounddevice', 'soundfile', 'numpy'],
'Support for web media subtitles': ['webvtt-py']
# 'Support for Leap Motion backend': ['git+ssh://git@github.com:BlackLight/leap-sdk-python3.git'], # 'Support for Leap Motion backend': ['git+ssh://git@github.com:BlackLight/leap-sdk-python3.git'],
# 'Support for Flic buttons': ['git+https://@github.com/50ButtonsEach/fliclib-linux-hci.git'] # 'Support for Flic buttons': ['git+https://@github.com/50ButtonsEach/fliclib-linux-hci.git']
# 'Support for media subtitles: ['git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles'] # 'Support for media subtitles': ['git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles']
}, },
) )