Subtitles support

- Added support for local and OpenSubtitles media subs
- Added management of media events in web panel
This commit is contained in:
Fabio Manganiello 2019-02-11 00:55:20 +01:00
parent 630850ee9a
commit 34f0264d5e
16 changed files with 713 additions and 96 deletions

3
.gitmodules vendored
View File

@ -4,3 +4,6 @@
[submodule "docs/wiki"] [submodule "docs/wiki"]
path = docs/wiki path = docs/wiki
url = https://github.com/BlackLight/platypush.wiki.git url = https://github.com/BlackLight/platypush.wiki.git
[submodule "platypush/backend/http/static/flag-icons"]
path = platypush/backend/http/static/flag-icons
url = git@github.com:lipis/flag-icon-css.git

View File

@ -377,8 +377,7 @@ class HttpBackend(Backend):
with self._media_map_lock: with self._media_map_lock:
if media_id in media_map: if media_id in media_map:
raise FileExistsError('"{}" is already registered on "{}"'. return media_map[media_id]
format(source, media_map[media_id].url))
media_hndl = MediaHandler.build(source, url=media_url) media_hndl = MediaHandler.build(source, url=media_url)
media_map[media_id] = media_hndl media_map[media_id] = media_hndl
@ -480,8 +479,6 @@ class HttpBackend(Backend):
try: try:
media_hndl = register_media(source) media_hndl = register_media(source)
return jsonify(dict(media_hndl)) return jsonify(dict(media_hndl))
except FileExistsError as e:
abort(409, str(e))
except FileNotFoundError as e: except FileNotFoundError as e:
abort(404, str(e)) abort(404, str(e))
except AttributeError as e: except AttributeError as e:

View File

@ -57,6 +57,19 @@ header {
padding: 2.5rem 2rem 1.5rem 2rem; padding: 2.5rem 2rem 1.5rem 2rem;
} }
.form-footer {
text-align: right;
margin-top: 2rem;
border-top: 1px solid #ddd;
}
.form-footer * > input[type=button],
.form-footer * > button {
margin-top: 2rem;
text-transform: uppercase;
font-size: 1.3rem;
}
#date-time { #date-time {
text-align: right; text-align: right;
padding-right: 30px; padding-right: 30px;

View File

@ -10,6 +10,9 @@
form#video-ctrl { form#video-ctrl {
text-align: center; text-align: center;
} }
form#video-ctrl * > button[data-modal="#media-subtitles-modal"] {
display: none;
}
#video-seeker-container { #video-seeker-container {
margin-top: 0.5em; margin-top: 0.5em;
@ -139,3 +142,39 @@ form#video-ctrl {
color: #666; color: #666;
} }
#media-subtitles-modal * > .media-subtitles-results-container {
display: none;
padding: .75rem;
}
#media-subtitles-modal * > .media-subtitles-results-header {
background: #eee;
margin-bottom: 1rem;
padding: 1rem .25rem;
border: 1px solid #ccc;
}
#media-subtitles-modal * > .media-subtitles-results {
padding: .75rem;
}
#media-subtitles-modal * > .media-subtitles-message {
display: none;
}
#media-subtitles-modal * > .media-subtitle-container {
cursor: pointer;
}
#media-subtitles-modal * > .media-subtitle-container:nth-child(odd) {
background-color: #f2f2f2;
}
#media-subtitles-modal * > .media-subtitle-container.selected {
background-color: #c8ffd0 !important;
}
#media-subtitles-modal * > .media-subtitle-container:hover {
background-color: #daf8e2 !important;
}

@ -0,0 +1 @@
Subproject commit c8031a673e8428b842199c0dcf66d12b6ed1d1d6

View File

@ -149,7 +149,7 @@ $(document).ready(function() {
var initModalOpenBindings = function() { var initModalOpenBindings = function() {
$('body').on('mouseup touchend', '[data-modal]', function(event) { $('body').on('mouseup touchend', '[data-modal]', function(event) {
var $source = $(event.target); var $source = $(this);
var $modal = $($source.data('modal')); var $modal = $($source.data('modal'));
$modal.height($(document).height() + 2); $modal.height($(document).height() + 2);
@ -167,13 +167,13 @@ $(document).ready(function() {
var initModalCloseBindings = function() { var initModalCloseBindings = function() {
$('body').on('mouseup touchend', '[data-dismiss-modal]', function(event) { $('body').on('mouseup touchend', '[data-dismiss-modal]', function(event) {
var $source = $(event.target); var $source = $(this);
var $modal = $($source.data('dismiss-modal')); var $modal = $($source.data('dismiss-modal'));
$modal.fadeOut(); $modal.fadeOut();
}); });
$('body').on('mouseup touchend', function(event) { $('body').on('mouseup touchend', function(event) {
var $source = $(event.target); var $source = $(this);
if (!$source.parents('.modal').length if (!$source.parents('.modal').length
&& !$source.data('modal') && !$source.data('modal')
&& !$source.data('dismiss-modal')) { && !$source.data('dismiss-modal')) {
@ -186,7 +186,7 @@ $(document).ready(function() {
var initPanelOpenBindings = function() { var initPanelOpenBindings = function() {
$('body').on('mouseup touchend', '[data-panel]', function(event) { $('body').on('mouseup touchend', '[data-panel]', function(event) {
var $source = $(event.target); var $source = $(this);
var $panel = $($source.data('panel')); var $panel = $($source.data('panel'));
setTimeout(() => { setTimeout(() => {
$panel.show(); $panel.show();
@ -196,7 +196,7 @@ $(document).ready(function() {
var initPanelCloseBindings = function() { var initPanelCloseBindings = function() {
$('body').on('mouseup touchend', function(event) { $('body').on('mouseup touchend', function(event) {
var $source = $(event.target); var $source = $(this);
if ($source.data('panel') || $source.parents('[data-panel]').length) { if ($source.data('panel') || $source.parents('[data-panel]').length) {
var $panel = $source.data('panel') ? $($source.data('panel')) : var $panel = $source.data('panel') ? $($source.data('panel')) :
$($source.parents('[data-panel]').data('panel')); $($source.parents('[data-panel]').data('panel'));
@ -270,6 +270,21 @@ function execute(request, onSuccess, onError, onComplete) {
}); });
} }
function run(request) {
request['type'] = 'request';
return new Promise((resolve, reject) => {
execute(request,
onSuccess = (response) => {
resolve(response);
},
onError = (xhr, status, error) => {
reject(xhr, status, error);
}
);
});
}
function createNotification(options) { function createNotification(options) {
var $notificationContainer = $('#notification-container'); var $notificationContainer = $('#notification-container');
var $notification = $('<div></div>').addClass('notification'); var $notification = $('<div></div>').addClass('notification');
@ -348,3 +363,10 @@ function createNotification(options) {
$notification.fadeIn(); $notification.fadeIn();
} }
function showError(errorMessage) {
createNotification({
'icon': 'exclamation',
'text': errorMessage,
});
}

View File

@ -12,10 +12,49 @@ $(document).ready(function() {
$searchBarContainer = $('#media-search-bar-container'), $searchBarContainer = $('#media-search-bar-container'),
$mediaBtnsContainer = $('#media-btns-container'), $mediaBtnsContainer = $('#media-btns-container'),
$mediaItemPanel = $('#media-item-panel'), $mediaItemPanel = $('#media-item-panel'),
$mediaSubtitlesModal = $('#media-subtitles-modal'),
$mediaSubtitlesResultsContainer = $mediaSubtitlesModal.find('.media-subtitles-results-container'),
$mediaSubtitlesResults = $mediaSubtitlesModal.find('.media-subtitles-results'),
$mediaSubtitlesMessage = $mediaSubtitlesModal.find('.media-subtitles-message'),
$mediaSearchSubtitles = $ctrlForm.find('[data-modal="#media-subtitles-modal"]'),
prevVolume = undefined, prevVolume = undefined,
selectedResource = undefined; selectedResource = undefined;
var updateVideoResults = function(videos) { const onEvent = (event) => {
switch (event.args.type) {
case 'platypush.message.event.media.MediaPlayRequestEvent':
createNotification({
'icon': 'stream',
'html': 'Processing media' + ('resource' in event.args
? ' ' + event.args.resource : ''),
});
break;
case 'platypush.message.event.media.MediaPlayEvent':
createNotification({
'icon': 'play',
'html': 'Starting media playback' + ('resource' in event.args
? ' for ' + event.args.resource : ''),
});
break;
case 'platypush.message.event.media.MediaPauseEvent':
createNotification({
'icon': 'pause',
'html': 'Media playback paused',
});
break;
case 'platypush.message.event.media.MediaStopEvent':
createNotification({
'icon': 'stop',
'html': 'Media playback stopped',
});
break;
}
};
const updateVideoResults = function(videos) {
$videoResults.html(''); $videoResults.html('');
for (var video of videos) { for (var video of videos) {
var $videoResult = $('<div></div>') var $videoResult = $('<div></div>')
@ -30,7 +69,7 @@ $(document).ready(function() {
} }
}; };
var getVideoIconByUrl = function(url) { const getVideoIconByUrl = function(url) {
var $icon = $('<i class="fa"></i>'); var $icon = $('<i class="fa"></i>');
if (url.startsWith('file://')) { if (url.startsWith('file://')) {
@ -48,7 +87,7 @@ $(document).ready(function() {
return $iconContainer; return $iconContainer;
}; };
var getSelectedDevice = function() { const getSelectedDevice = function() {
var device = { isBrowser: false, isRemote: false, name: undefined }; var device = { isBrowser: false, isRemote: false, name: undefined };
var $remoteDevice = $devsPanel.find('.cast-device.selected') var $remoteDevice = $devsPanel.find('.cast-device.selected')
.filter((i, dev) => !$(dev).data('local') && !$(dev).data('browser') && $(dev).data('name')); .filter((i, dev) => !$(dev).data('local') && !$(dev).data('browser') && $(dev).data('name'));
@ -65,7 +104,7 @@ $(document).ready(function() {
return device; return device;
}; };
var startStreamingTorrent = function(torrent) { const startStreamingTorrent = function(torrent) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execute( execute(
{ {
@ -88,7 +127,7 @@ $(document).ready(function() {
}); });
}; };
var startStreaming = function(media) { const startStreaming = function(media) {
if (media.startsWith('magnet:?')) { if (media.startsWith('magnet:?')) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
startStreamingTorrent(media) startStreamingTorrent(media)
@ -105,17 +144,8 @@ $(document).ready(function() {
data: JSON.stringify({ source: media }), data: JSON.stringify({ source: media }),
complete: (xhr, textStatus) => { complete: (xhr, textStatus) => {
var url;
if (xhr.status == 200) { if (xhr.status == 200) {
url = xhr.responseJSON.url; var uri = xhr.responseJSON.url.match(/https?:\/\/[^\/]+(\/media\/.*)/)[1]
} else if (xhr.status == 409) {
// Media mount point already registered
url = xhr.responseText.match(
/.*is already registered on ("|&quot;)(https?:\/\/[^\/]+\/media\/[0-9a-f]+\.[0-9a-z]+)("|&quot;).*/)[2]
}
if (url) {
var uri = url.match(/https?:\/\/[^\/]+(\/media\/.*)/)[1]
resolve(uri); resolve(uri);
} else { } else {
reject(Error(xhr.responseText)); reject(Error(xhr.responseText));
@ -125,7 +155,7 @@ $(document).ready(function() {
}); });
}; };
var stopStreaming = function(media_id) { const stopStreaming = function(media_id) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
$.ajax({ $.ajax({
type: 'DELETE', type: 'DELETE',
@ -135,7 +165,174 @@ $(document).ready(function() {
}); });
}; };
var play = function(resource) { const getSubtitles = function(resource) {
return new Promise((resolve, reject) => {
if (!window.config.media.subtitles) {
return; // media.subtitles plugin not configured
}
run({
action: 'media.subtitles.get_subtitles',
args: { 'resource': resource }
}).then((response) => {
resolve(response.response.output);
}).catch((error) => {
reject(error.message);
});
});
};
const downloadSubtitles = function(link, mediaResource) {
return new Promise((resolve, reject) => {
run({
action: 'media.subtitles.download',
args: {
'link': link,
'media_resource': mediaResource,
}
}).then((response) => {
resolve(response.response.output.filename);
}).catch((error) => {
reject(error.message);
});
});
};
const setSubtitles = (filename) => {
return new Promise((resolve, reject) => {
run({
action: 'media.set_subtitles',
args: { 'filename': filename }
}).then((response) => {
resolve(response.response.output);
}).catch((error) => {
reject(error.message);
});
});
};
const playOnChromecast = (resource, device, subtitles) => {
return new Promise((resolve, reject) => {
var requestArgs = {
action: 'media.chromecast.play',
args: {
'resource': resource,
'chromecast': device,
},
};
// TODO support for subtitles on Chromecast through internal streaming server
run(requestArgs).then((response) => {
resolve(response);
}).catch((error) => {
reject(error.message);
});
});
};
const playInBrowser = (resource, subtitles) => {
return new Promise((resolve, reject) => {
// TODO support for subtitles in local player
startStreaming(resource).then((url) => {
window.open(url, '_blank');
resolve(url);
}).catch((xhr, status, error) => {
reject(xhr.responseText);
});
});
};
const playOnServer = (resource, subtitles) => {
return new Promise((resolve, reject) => {
var requestArgs = {
action: 'media.play',
args: { 'resource': resource },
};
if (subtitles) {
requestArgs.args.subtitles = subtitles;
}
run(requestArgs).then((response) => {
resolve(resource);
}).catch((error) => {
reject(error.message);
});
});
};
const _play = (resource, subtitles) => {
return new Promise((resolve, reject) => {
var playHndl;
var selectedDevice = getSelectedDevice();
if (selectedDevice.isBrowser) {
playHndl = playInBrowser(resource, subtitles);
} else if (selectedDevice.isRemote) {
playHndl = playOnChromecast(resource, selectedDevice.name, subtitles);
} else {
playHndl = playOnServer(resource, subtitles);
}
playHndl.then((response) => {
resolve(resource);
}).catch((error) => {
showError('Playback error: ' + error.message);
reject(error.message);
});
});
};
const play = (resource) => {
return new Promise((resolve, reject) => {
var results = $videoResults.html();
var onVideoLoading = () => { $videoResults.text('Loading video...'); };
var onVideoReady = () => {
$videoResults.html(results);
resolve(resource);
};
$mediaSearchSubtitles.data('resource', resource);
onVideoLoading();
var subtitlesConf = window.config.media.subtitles;
// TODO populate the subtitles panel and show the subtitles button
if (subtitlesConf && 'language' in subtitlesConf) {
tryFetchSubtitles(resource).then((subtitles) => {
if (!subtitles) {
showError('Cannot get subtitles: ' + error);
_play(resource).finally(onVideoReady);
} else {
_play(resource, subtitles).finally(onVideoReady);
}
});
} else {
_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();
});
});
});
};
const download = function(resource) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
var results = $videoResults.html(); var results = $videoResults.html();
var onVideoLoading = function() { var onVideoLoading = function() {
@ -146,57 +343,24 @@ $(document).ready(function() {
$videoResults.html(results); $videoResults.html(results);
}; };
var requestArgs = {
type: 'request',
action: 'media.play',
args: { resource: resource },
};
var selectedDevice = getSelectedDevice();
if (selectedDevice.isBrowser) {
onVideoLoading();
startStreaming(resource)
.then((url) => {
window.open(url, '_blank');
resolve(url);
})
.catch((xhr, status, error) => {
reject(xhr.responseText);
})
.finally(() => {
onVideoReady();
});
return;
}
if (selectedDevice.isRemote) {
requestArgs.action = 'media.chromecast.play';
requestArgs.args.chromecast = selectedDevice.name;
}
onVideoLoading(); onVideoLoading();
execute( startStreaming(resource)
requestArgs, .then((url) => {
function(response) { url = url + '?download=1'
$videoResults.html(results); window.open(url, '_blank');
resolve(resource); resolve(url);
}, })
.catch((xhr, status, error) => {
function(xhr, status, error) {
onVideoReady();
reject(xhr.responseText); reject(xhr.responseText);
} })
); .finally(() => {
onVideoReady();
});
}); });
}; };
var download = function(resource) { const initBindings = function() {
// TODO window.registerEventListener(onEvent);
};
var initBindings = function() {
$searchForm.on('submit', function(event) { $searchForm.on('submit', function(event) {
var $input = $(this).find('input[name=video-search-text]'); var $input = $(this).find('input[name=video-search-text]');
var resource = $input.val(); var resource = $input.val();
@ -347,11 +511,12 @@ $(document).ready(function() {
}); });
$mediaItemPanel.on('click', '[data-action]', function() { $mediaItemPanel.on('click', '[data-action]', function() {
if ($(this).hasClass('disabled')) { var $action = $(this);
if ($action.hasClass('disabled')) {
return; return;
} }
var action = $(this).data('action'); var action = $action.data('action');
var resource = $mediaItemPanel.data('resource'); var resource = $mediaItemPanel.data('resource');
if (!resource) { if (!resource) {
return; return;
@ -365,9 +530,65 @@ $(document).ready(function() {
$mediaItemPanel.find('[data-action]').removeClass('disabled'); $mediaItemPanel.find('[data-action]').removeClass('disabled');
}); });
}); });
$mediaSearchSubtitles.on('mouseup touchend', () => {
var resource = $mediaSearchSubtitles.data('resource');
if (!resource) {
return;
}
$mediaSubtitlesMessage.text('Loading subtitles...');
$mediaSubtitlesResults.text('');
$mediaSubtitlesMessage.show();
$mediaSubtitlesResultsContainer.hide();
getSubtitles(resource).then((subs) => {
if (!subs.length) {
$mediaSubtitlesMessage.text('No subtitles found');
return;
}
for (var sub of subs) {
var flagCode;
if ('ISO639' in sub) {
switch(sub.ISO639) {
case 'en': flagCode = 'gb'; 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();
}).catch((error) => {
$mediaSubtitlesMessage.text('Unable to load subtitles: ' + error.message);
});
});
}; };
var initRemoteDevices = function() { const initRemoteDevices = function() {
$devsList.find('.cast-device[data-remote]').addClass('disabled'); $devsList.find('.cast-device[data-remote]').addClass('disabled');
execute( execute(
@ -414,7 +635,7 @@ $(document).ready(function() {
); );
}; };
var init = function() { const init = function() {
initRemoteDevices(); initRemoteDevices();
initBindings(); initBindings();
}; };

View File

@ -1,5 +1,12 @@
<script type="text/javascript" src="{{ url_for('static', filename='js/media.js') }}"></script> <script type="text/javascript" src="{{ url_for('static', filename='js/media.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/media.css') }}"></script> <link rel="stylesheet" href="{{ url_for('static', filename='css/media.css') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='flag-icons/css/flag-icon.min.css') }}"></script>
<script type="text/javascript">
window.config = window.config || {};
window.config.media = window.config.media || {};
window.config.media.subtitles = JSON.parse('{{ utils.to_json(utils.get_config("media.subtitles")) | safe }}');
</script>
<div class="row" id="video-container"> <div class="row" id="video-container">
<form action="#" id="video-search"> <form action="#" id="video-search">
@ -86,6 +93,10 @@
<button data-action="next"> <button data-action="next">
<i class="fa fa-step-forward"></i> <i class="fa fa-step-forward"></i>
</button> </button>
<button data-action="subtitles" data-modal="#media-subtitles-modal">
<i class="fa fa-comment"></i>
</button>
</div> </div>
</div> </div>
@ -114,6 +125,28 @@
</div> </div>
</div> </div>
<div id="media-subtitles-modal" class="modal">
<div class="modal-container">
<div class="modal-header">
Subtitles results
</div>
<div class="modal-body">
<div class="row media-subtitles-results-container">
<div class="row media-subtitles-results-header">
<div class="one column">Lang</div>
<div class="five columns">Movie</div>
<div class="six columns">Subtitles</div>
</div>
<div class="row media-subtitles-results">
</div>
</div>
<div class="row media-subtitles-message">No media selected or playing</div>
</div>
</div>
</div>
<div class="row" id="video-results-container"> <div class="row" id="video-results-container">
<div id="video-results"></div> <div id="video-results"></div>
</div> </div>

View File

@ -8,6 +8,20 @@ class MediaEvent(Event):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class MediaPlayRequestEvent(MediaEvent):
"""
Event triggered when a new media playback request is received
"""
def __init__(self, resource=None, *args, **kwargs):
"""
:param resource: File name or URI of the played video
:type resource: str
"""
super().__init__(*args, resource=resource, **kwargs)
class MediaPlayEvent(MediaEvent): class MediaPlayEvent(MediaEvent):
""" """
Event triggered when a new media content is played Event triggered when a new media content is played

View File

@ -213,6 +213,10 @@ class MediaPlugin(Plugin):
def toggle_subtitles(self, *args, **kwargs): def toggle_subtitles(self, *args, **kwargs):
raise self._NOT_IMPLEMENTED_ERR raise self._NOT_IMPLEMENTED_ERR
@action
def set_subtitles(self, filename, *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
@ -339,7 +343,7 @@ class MediaPlugin(Plugin):
@action @action
def start_streaming(self, media): def start_streaming(self, media, download=False):
""" """
Starts streaming local media over the specified HTTP port. Starts streaming local media over the specified HTTP port.
The stream will be available to HTTP clients on The stream will be available to HTTP clients on
@ -348,6 +352,10 @@ class MediaPlugin(Plugin):
:param media: Media to stream :param media: Media to stream
:type media: str :type media: str
:param download: Set to True if you prefer to download the file from
the streaming link instead of streaming it
:type download: bool
:returns: dict containing the streaming URL.Example:: :returns: dict containing the streaming URL.Example::
{ {
@ -366,8 +374,9 @@ class MediaPlugin(Plugin):
return return
self.logger.info('Starting streaming {}'.format(media)) self.logger.info('Starting streaming {}'.format(media))
response = requests.put('{url}/media'.format(url=http.local_base_url), response = requests.put('{url}/media{download}'.format(
json = { 'source': media }) url=http.local_base_url, download='?download' if download else ''),
json = { 'source': media })
if not response.ok: if not response.ok:
self.logger.warning('Unable to start streaming: {}'. self.logger.warning('Unable to start streaming: {}'.

View File

@ -4,10 +4,12 @@ import pychromecast
from pychromecast.controllers.youtube import YouTubeController from pychromecast.controllers.youtube import YouTubeController
from platypush.context import get_plugin from platypush.context import get_plugin, get_bus
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
from platypush.plugins.media import MediaPlugin from platypush.plugins.media import MediaPlugin
from platypush.utils import get_mime_type from platypush.utils import get_mime_type
from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \
MediaStopEvent, MediaPauseEvent, NewPlayingMediaEvent
class MediaChromecastPlugin(MediaPlugin): class MediaChromecastPlugin(MediaPlugin):
@ -157,6 +159,9 @@ class MediaChromecastPlugin(MediaPlugin):
if not chromecast: if not chromecast:
chromecast = self.chromecast chromecast = self.chromecast
get_bus().post(MediaPlayRequestEvent(resource=resource,
device=chromecast))
cast = self.get_chromecast(chromecast) cast = self.get_chromecast(chromecast)
cast.wait() cast.wait()
@ -197,10 +202,13 @@ class MediaChromecastPlugin(MediaPlugin):
mc.play_media(resource, content_type, title=title, thumb=image_url, mc.play_media(resource, content_type, title=title, thumb=image_url,
current_time=current_time, autoplay=autoplay, current_time=current_time, autoplay=autoplay,
stream_type=stream_type, subtitles=subtitles, stream_type=stream_type, subtitles=subtitles,
subtitles_lang=subtitles_lang, subtitles_mime=subtitles_mime, subtitles_lang=subtitles_lang,
subtitle_id=subtitle_id) subtitles_mime=subtitles_mime, subtitle_id=subtitle_id)
mc.block_until_active() mc.block_until_active()
get_bus().post(MediaPlayEvent(resource=resource,
device=chromecast))
@classmethod @classmethod
@ -223,14 +231,20 @@ class MediaChromecastPlugin(MediaPlugin):
def pause(self, chromecast=None): def pause(self, chromecast=None):
cast = self.get_chromecast(chromecast or self.chromecast) cast = self.get_chromecast(chromecast or self.chromecast)
if cast.media_controller.is_paused: if cast.media_controller.is_paused:
return cast.media_controller.play() ret = cast.media_controller.play()
get_bus().post(MediaPlayEvent(device=chromecast or self.chromecast))
return ret
elif cast.media_controller.is_playing: elif cast.media_controller.is_playing:
return cast.media_controller.pause() ret = cast.media_controller.pause()
get_bus().post(MediaPauseEvent(device=chromecast or self.chromecast))
return ret
@action @action
def stop(self, chromecast=None): def stop(self, chromecast=None):
return self.get_chromecast(chromecast or self.chromecast).media_controller.stop() ret = self.get_chromecast(chromecast or self.chromecast).media_controller.stop()
get_bus().post(MediaStopEvent(device=chromecast or self.chromecast))
return ret
@action @action
@ -276,13 +290,38 @@ class MediaChromecastPlugin(MediaPlugin):
@action @action
def enable_subtitle(self, chromecast=None): def list_subtitles(self, chromecast=None):
return self.get_chromecast(chromecast or self.chromecast).media_controller.enable_subtitle() return self.get_chromecast(chromecast or self.chromecast) \
.media_controller.subtitle_tracks
@action @action
def disable_subtitle(self, chromecast=None): def enable_subtitles(self, chromecast=None, track_id=None):
return self.get_chromecast(chromecast or self.chromecast).media_controller.disable_subtitle() mc = self.get_chromecast(chromecast or self.chromecast).media_controller
if track_id is not None:
return mc.enable_subtitle(track_id)
elif mc.subtitle_tracks:
return mc.enable_subtitle(mc.subtitle_tracks[0].get('trackId'))
@action
def disable_subtitles(self, chromecast=None, track_id=None):
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
if track_name:
return mc.disable_subtitle(track_name)
elif mc.current_subtitle_tracks:
return mc.disable_subtitle(mc.current_subtitle_tracks[0])
@action
def toggle_subtitles(self, chromecast=None):
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
all_subs = mc.status.subtitle_tracks
cur_subs = mc.status.status.current_subtitle_tracks
if cur_subs:
return self.disable_subtitle(chromecast, cur_subs[0])
else:
return self.enable_subtitle(chromecast, all_subs[0].get('trackId'))
@action @action

View File

@ -1,14 +1,15 @@
import os import os
import select import select
import subprocess import subprocess
import tempfile
import threading import threading
import time import time
from platypush.context import get_bus, get_plugin from platypush.context import get_bus, get_plugin
from platypush.message.response import Response from platypush.message.response import Response
from platypush.plugins.media import PlayerState, MediaPlugin from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.message.event.media import MediaPlayEvent, MediaPauseEvent, \ from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \
MediaStopEvent, NewPlayingMediaEvent MediaPauseEvent, MediaStopEvent, NewPlayingMediaEvent
from platypush.plugins import action from platypush.plugins import action
from platypush.utils import find_bins_in_path from platypush.utils import find_bins_in_path
@ -62,6 +63,9 @@ class MediaMplayerPlugin(MediaPlugin):
from MPlayer before considering a response ready (default: 0.5 seconds) from MPlayer before considering a response ready (default: 0.5 seconds)
:type mplayer_timeout: float :type mplayer_timeout: float
:param subtitles: Path to the subtitles file
:type subtitles: str
:param args: Default arguments that will be passed to the MPlayer :param args: Default arguments that will be passed to the MPlayer
executable executable
:type args: list :type args: list
@ -237,19 +241,46 @@ class MediaMplayerPlugin(MediaPlugin):
return _thread return _thread
def _get_subtitles_file(self, subtitles):
if not subtitles:
return
if subtitles.startswith('file://'):
subtitles = subtitles[len('file://'):]
if os.path.isfile(subtitles):
return os.path.abspath(subtitles)
else:
import requests
content = requests.get(subtitles).content
f = tempfile.NamedTemporaryFile(prefix='media_subs_',
suffix='.srt', delete=False)
with f:
f.write(content)
return f.name
@action @action
def play(self, resource, mplayer_args=None): def play(self, resource, subtitles=None, mplayer_args=None):
""" """
Play a resource. Play a resource.
:param resource: Resource to play - can be a local file or a remote URL :param resource: Resource to play - can be a local file or a remote URL
:type resource: str :type resource: str
:param subtitles: Path to optional subtitle file
:type subtitles: str
:param mplayer_args: Extra runtime arguments that will be passed to the :param mplayer_args: Extra runtime arguments that will be passed to the
MPlayer executable MPlayer executable
:type mplayer_args: list[str] :type mplayer_args: list[str]
""" """
get_bus().post(MediaPlayRequestEvent(resource=resource))
if subtitles:
mplayer_args = mplayer_args or []
mplayer_args += ['-sub', self._get_subtitles_file(subtitles)]
resource = self._get_resource(resource) resource = self._get_resource(resource)
if resource.startswith('file://'): if resource.startswith('file://'):
resource = resource[7:] resource = resource[7:]
@ -258,12 +289,16 @@ class MediaMplayerPlugin(MediaPlugin):
return get_plugin('media.webtorrent').play(resource) return get_plugin('media.webtorrent').play(resource)
self._is_playing_torrent = False self._is_playing_torrent = False
return self._exec('loadfile', resource, mplayer_args=mplayer_args) ret = self._exec('loadfile', resource, mplayer_args=mplayer_args)
get_bus().post(MediaPlayEvent(resource=resource))
return ret
@action @action
def pause(self): def pause(self):
""" Toggle the paused state """ """ Toggle the paused state """
return self._exec('pause') ret = self._exec('pause')
get_bus().post(MediaPauseEvent())
return ret
def _stop_torrent(self): def _stop_torrent(self):
if self._is_playing_torrent: if self._is_playing_torrent:
@ -276,13 +311,15 @@ class MediaMplayerPlugin(MediaPlugin):
@action @action
def stop(self): def stop(self):
""" Stop the playback """ """ Stop the playback """
return self._exec('stop') # return self._exec('stop')
return self.quit()
@action @action
def quit(self): def quit(self):
""" Quit the player """ """ Quit the player """
self._stop_torrent() self._stop_torrent()
self._exec('quit') self._exec('quit')
get_bus().post(MediaStopEvent())
@action @action
def voldown(self, step=10.0): def voldown(self, step=10.0):
@ -310,6 +347,12 @@ class MediaMplayerPlugin(MediaPlugin):
subs = self.get_property('sub_visibility').output.get('sub_visibility') subs = self.get_property('sub_visibility').output.get('sub_visibility')
return self._exec('sub_visibility', int(not subs)) return self._exec('sub_visibility', int(not subs))
@action
def set_subtitles(self, filename):
""" Sets media subtitles from filename """
self._exec('sub_visibility', 1)
return self._exec('sub_load', filename)
@action @action
def is_playing(self): def is_playing(self):
""" """

View File

@ -46,7 +46,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
self._handlers = { e.value: [] for e in PlayerEvent } self._handlers = { e.value: [] for e in PlayerEvent }
@action @action
def play(self, resource): def play(self, resource, subtitles=None, *args, **kwargs):
""" """
Play a resource. Play a resource.
@ -58,6 +58,9 @@ class MediaOmxplayerPlugin(MediaPlugin):
* Torrents (format: Magnet links, Torrent URLs or local Torrent files) * Torrents (format: Magnet links, Torrent URLs or local Torrent files)
""" """
if subtitles:
args.append('--subtitles', subtitles)
resource = self._get_resource(resource) resource = self._get_resource(resource)
if self._player: if self._player:
try: try:

View File

@ -0,0 +1,176 @@
import gzip
import os
import requests
import tempfile
from platypush.message.response import Response
from platypush.plugins import Plugin, action
from platypush.utils import find_files_by_ext, get_mime_type
class MediaSubtitlesPlugin(Plugin):
"""
Plugin to get video subtitles from OpenSubtitles
Requires:
* **python-opensubtitles** (``pip install -e 'git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles'``)
* **requests** (``pip install requests``)
"""
def __init__(self, username, password, language=None, *args, **kwargs):
"""
:param username: Your OpenSubtitles username
:type username: str
:param password: Your OpenSubtitles password
:type password: str
:param language: Preferred language name, ISO639 code or OpenSubtitles
language ID to be used for the subtitles. Also supports an (ordered)
list of preferred languages
:type language: str or list[str]
"""
from pythonopensubtitles.opensubtitles import OpenSubtitles
super().__init__(*args, **kwargs)
self._ost = OpenSubtitles()
self._token = self._ost.login(username, password)
self.languages = []
if language:
if isinstance(language, str):
self.languages.append(language.lower())
elif isinstance(language, list):
self.languages.extend([l.lower() for l in language])
else:
raise AttributeError('{} is neither a string nor a list'.format(
language))
@action
def get_subtitles(self, resource, language=None):
"""
Get the subtitles data for a video resource
:param resource: Media file, torrent or URL to the media resource
:type resource: str
:param language: Language name or code (default: configured preferred
language). Choose 'all' for all the languages
:type language: str
"""
from pythonopensubtitles.utils import File
if resource.startswith('file://'):
resource = resource[len('file://'):]
resource = os.path.abspath(os.path.expanduser(resource))
if not os.path.isfile(resource):
return (None, '{} is not a valid file'.format(resource))
file = resource
cwd = os.getcwd()
media_dir = os.path.dirname(resource)
os.chdir(media_dir)
file = file.split(os.sep)[-1]
local_subs = [{
'IsLocal': True,
'MovieName': '[Local subtitle]',
'SubFileName': sub.split(os.sep)[-1],
'SubDownloadLink': 'file://' + os.path.join(media_dir, sub),
} for sub in find_files_by_ext(media_dir, '.srt', '.vtt')]
self.logger.info('Found {} local subtitles for {}'.format(
len(local_subs), file))
languages = [language.lower()] if language else self.languages
try:
file_hash = File(file).get_hash()
subs = self._ost.search_subtitles([{
'sublanguageid': 'all',
'moviehash': file_hash,
}])
subs = [
sub for sub in subs if not languages or languages[0] == 'all' or
sub.get('LanguageName', '').lower() in languages or
sub.get('SubLanguageID', '').lower() in languages or
sub.get('ISO639', '').lower() in languages
]
for sub in subs:
sub['IsLocal'] = False
self.logger.info('Found {} OpenSubtitles items for {}'.format(
len(subs), file))
return local_subs + subs
finally:
os.chdir(cwd)
@action
def download(self, link, media_resource=None, path=None):
"""
Downloads a subtitle link (.srt/.vtt file or gzip/zip OpenSubtitles
archive link) to the specified directory
:param link: Local subtitles file or OpenSubtitles gzip download link
:type link: str
:param path: Path where the subtitle file will be downloaded (default:
temporary file under /tmp)
:type path: str
:param media_resource: Name of the media resource. If set and if it's a
media local file then the subtitles will be saved in the same folder
:type media_resource: str
:returns: dict. Format::
{
"filename": "/path/to/subtitle/file.srt"
}
"""
if link.startswith('file://'):
link = link[len('file://'):]
if os.path.isfile(link):
return { 'filename': link }
gzip_content = requests.get(link).content
f = None
if not path and media_resource:
if media_resource.startswith('file://'):
media_resource = media_resource[len('file://'):]
if os.path.isfile(media_resource):
media_resource = os.path.abspath(media_resource)
path = os.path.join(
os.path.dirname(media_resource),
'.'.join(os.path.basename(media_resource).split('.')[:-1])) + '.srt'
if path:
f = open(path, 'wb')
else:
f = tempfile.NamedTemporaryFile(prefix='media_subs_',
suffix='.srt', delete=False)
path = f.name
try:
with f:
f.write(gzip.decompress(gzip_content))
except Exception as e:
os.unlink(path)
raise e
return { 'filename': path }
# vim:sw=4:ts=4:et:

View File

@ -124,3 +124,6 @@ inputs
# soundfile # soundfile
# numpy # numpy
# Support for media subtitles
# git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles

View File

@ -97,6 +97,7 @@ setup(
'Support for sound devices': ['sounddevice', 'soundfile', 'numpy'], 'Support for sound devices': ['sounddevice', 'soundfile', 'numpy'],
# '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']
}, },
) )