diff --git a/.gitmodules b/.gitmodules index 80246cd7..add9d8b4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "docs/wiki"] path = docs/wiki 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 diff --git a/platypush/backend/http/__init__.py b/platypush/backend/http/__init__.py index 94f2ab3f..4712ec30 100644 --- a/platypush/backend/http/__init__.py +++ b/platypush/backend/http/__init__.py @@ -377,8 +377,7 @@ class HttpBackend(Backend): with self._media_map_lock: if media_id in media_map: - raise FileExistsError('"{}" is already registered on "{}"'. - format(source, media_map[media_id].url)) + return media_map[media_id] media_hndl = MediaHandler.build(source, url=media_url) media_map[media_id] = media_hndl @@ -480,8 +479,6 @@ class HttpBackend(Backend): try: media_hndl = register_media(source) return jsonify(dict(media_hndl)) - except FileExistsError as e: - abort(409, str(e)) except FileNotFoundError as e: abort(404, str(e)) except AttributeError as e: diff --git a/platypush/backend/http/static/css/application.css b/platypush/backend/http/static/css/application.css index 58039222..149dcd91 100644 --- a/platypush/backend/http/static/css/application.css +++ b/platypush/backend/http/static/css/application.css @@ -57,6 +57,19 @@ header { 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 { text-align: right; padding-right: 30px; diff --git a/platypush/backend/http/static/css/media.css b/platypush/backend/http/static/css/media.css index 0a6f6799..f0959643 100644 --- a/platypush/backend/http/static/css/media.css +++ b/platypush/backend/http/static/css/media.css @@ -10,6 +10,9 @@ form#video-ctrl { text-align: center; } + form#video-ctrl * > button[data-modal="#media-subtitles-modal"] { + display: none; + } #video-seeker-container { margin-top: 0.5em; @@ -139,3 +142,39 @@ form#video-ctrl { 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; + } + diff --git a/platypush/backend/http/static/flag-icons b/platypush/backend/http/static/flag-icons new file mode 160000 index 00000000..c8031a67 --- /dev/null +++ b/platypush/backend/http/static/flag-icons @@ -0,0 +1 @@ +Subproject commit c8031a673e8428b842199c0dcf66d12b6ed1d1d6 diff --git a/platypush/backend/http/static/js/application.js b/platypush/backend/http/static/js/application.js index b14a542f..1753e06b 100644 --- a/platypush/backend/http/static/js/application.js +++ b/platypush/backend/http/static/js/application.js @@ -149,7 +149,7 @@ $(document).ready(function() { var initModalOpenBindings = function() { $('body').on('mouseup touchend', '[data-modal]', function(event) { - var $source = $(event.target); + var $source = $(this); var $modal = $($source.data('modal')); $modal.height($(document).height() + 2); @@ -167,13 +167,13 @@ $(document).ready(function() { var initModalCloseBindings = function() { $('body').on('mouseup touchend', '[data-dismiss-modal]', function(event) { - var $source = $(event.target); + var $source = $(this); var $modal = $($source.data('dismiss-modal')); $modal.fadeOut(); }); $('body').on('mouseup touchend', function(event) { - var $source = $(event.target); + var $source = $(this); if (!$source.parents('.modal').length && !$source.data('modal') && !$source.data('dismiss-modal')) { @@ -186,7 +186,7 @@ $(document).ready(function() { var initPanelOpenBindings = function() { $('body').on('mouseup touchend', '[data-panel]', function(event) { - var $source = $(event.target); + var $source = $(this); var $panel = $($source.data('panel')); setTimeout(() => { $panel.show(); @@ -196,7 +196,7 @@ $(document).ready(function() { var initPanelCloseBindings = function() { $('body').on('mouseup touchend', function(event) { - var $source = $(event.target); + var $source = $(this); if ($source.data('panel') || $source.parents('[data-panel]').length) { var $panel = $source.data('panel') ? $($source.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) { var $notificationContainer = $('#notification-container'); var $notification = $('
').addClass('notification'); @@ -348,3 +363,10 @@ function createNotification(options) { $notification.fadeIn(); } +function showError(errorMessage) { + createNotification({ + 'icon': 'exclamation', + 'text': errorMessage, + }); +} + diff --git a/platypush/backend/http/static/js/media.js b/platypush/backend/http/static/js/media.js index 105342f5..7b0ce9bd 100644 --- a/platypush/backend/http/static/js/media.js +++ b/platypush/backend/http/static/js/media.js @@ -12,10 +12,49 @@ $(document).ready(function() { $searchBarContainer = $('#media-search-bar-container'), $mediaBtnsContainer = $('#media-btns-container'), $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, 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(''); for (var video of videos) { var $videoResult = $('
') @@ -30,7 +69,7 @@ $(document).ready(function() { } }; - var getVideoIconByUrl = function(url) { + const getVideoIconByUrl = function(url) { var $icon = $(''); if (url.startsWith('file://')) { @@ -48,7 +87,7 @@ $(document).ready(function() { return $iconContainer; }; - var getSelectedDevice = function() { + const getSelectedDevice = function() { var device = { isBrowser: false, isRemote: false, name: undefined }; var $remoteDevice = $devsPanel.find('.cast-device.selected') .filter((i, dev) => !$(dev).data('local') && !$(dev).data('browser') && $(dev).data('name')); @@ -65,7 +104,7 @@ $(document).ready(function() { return device; }; - var startStreamingTorrent = function(torrent) { + const startStreamingTorrent = function(torrent) { return new Promise((resolve, reject) => { execute( { @@ -88,7 +127,7 @@ $(document).ready(function() { }); }; - var startStreaming = function(media) { + const startStreaming = function(media) { if (media.startsWith('magnet:?')) { return new Promise((resolve, reject) => { startStreamingTorrent(media) @@ -105,17 +144,8 @@ $(document).ready(function() { data: JSON.stringify({ source: media }), complete: (xhr, textStatus) => { - var url; if (xhr.status == 200) { - url = xhr.responseJSON.url; - } else if (xhr.status == 409) { - // Media mount point already registered - url = xhr.responseText.match( - /.*is already registered on ("|")(https?:\/\/[^\/]+\/media\/[0-9a-f]+\.[0-9a-z]+)("|").*/)[2] - } - - if (url) { - var uri = url.match(/https?:\/\/[^\/]+(\/media\/.*)/)[1] + var uri = xhr.responseJSON.url.match(/https?:\/\/[^\/]+(\/media\/.*)/)[1] resolve(uri); } else { 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) => { $.ajax({ 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) => { var results = $videoResults.html(); var onVideoLoading = function() { @@ -146,57 +343,24 @@ $(document).ready(function() { $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(); - execute( - requestArgs, - function(response) { - $videoResults.html(results); - resolve(resource); - }, - - function(xhr, status, error) { - onVideoReady(); + startStreaming(resource) + .then((url) => { + url = url + '?download=1' + window.open(url, '_blank'); + resolve(url); + }) + .catch((xhr, status, error) => { reject(xhr.responseText); - } - ); + }) + .finally(() => { + onVideoReady(); + }); }); }; - var download = function(resource) { - // TODO - }; - - var initBindings = function() { + const initBindings = function() { + window.registerEventListener(onEvent); $searchForm.on('submit', function(event) { var $input = $(this).find('input[name=video-search-text]'); var resource = $input.val(); @@ -347,11 +511,12 @@ $(document).ready(function() { }); $mediaItemPanel.on('click', '[data-action]', function() { - if ($(this).hasClass('disabled')) { + var $action = $(this); + if ($action.hasClass('disabled')) { return; } - var action = $(this).data('action'); + var action = $action.data('action'); var resource = $mediaItemPanel.data('resource'); if (!resource) { return; @@ -365,9 +530,65 @@ $(document).ready(function() { $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 = $('
').addClass('row media-subtitle-container') + .data('download-link', sub.SubDownloadLink) + .data('resource', resource); + + var $subFlagIconContainer = $('
').addClass('one column'); + var $subFlagIcon = $('') + .addClass(flagCode ? 'flag-icon flag-icon-' + flagCode : ( + sub.IsLocal ? 'fa fa-download' : '')) + .text(!(flagCode || sub.IsLocal) ? '?' : ''); + + var $subMovieName = $('
').addClass('five columns') + .text(sub.MovieName); + + var $subFileName = $('
').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'); execute( @@ -414,7 +635,7 @@ $(document).ready(function() { ); }; - var init = function() { + const init = function() { initRemoteDevices(); initBindings(); }; diff --git a/platypush/backend/http/templates/plugins/media.html b/platypush/backend/http/templates/plugins/media.html index 46398d47..ec1ca751 100644 --- a/platypush/backend/http/templates/plugins/media.html +++ b/platypush/backend/http/templates/plugins/media.html @@ -1,5 +1,12 @@ + + +
@@ -114,6 +125,28 @@ + +
diff --git a/platypush/message/event/media.py b/platypush/message/event/media.py index 85af3e2b..b30a6758 100644 --- a/platypush/message/event/media.py +++ b/platypush/message/event/media.py @@ -8,6 +8,20 @@ class MediaEvent(Event): 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): """ Event triggered when a new media content is played diff --git a/platypush/plugins/media/__init__.py b/platypush/plugins/media/__init__.py index 815d1151..dd0a7d4c 100644 --- a/platypush/plugins/media/__init__.py +++ b/platypush/plugins/media/__init__.py @@ -213,6 +213,10 @@ class MediaPlugin(Plugin): def toggle_subtitles(self, *args, **kwargs): raise self._NOT_IMPLEMENTED_ERR + @action + def set_subtitles(self, filename, *args, **kwargs): + raise self._NOT_IMPLEMENTED_ERR + @action def is_playing(self, *args, **kwargs): raise self._NOT_IMPLEMENTED_ERR @@ -339,7 +343,7 @@ class MediaPlugin(Plugin): @action - def start_streaming(self, media): + def start_streaming(self, media, download=False): """ Starts streaming local media over the specified HTTP port. The stream will be available to HTTP clients on @@ -348,6 +352,10 @@ class MediaPlugin(Plugin): :param media: Media to stream :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:: { @@ -366,8 +374,9 @@ class MediaPlugin(Plugin): return self.logger.info('Starting streaming {}'.format(media)) - response = requests.put('{url}/media'.format(url=http.local_base_url), - json = { 'source': media }) + response = requests.put('{url}/media{download}'.format( + url=http.local_base_url, download='?download' if download else ''), + json = { 'source': media }) if not response.ok: self.logger.warning('Unable to start streaming: {}'. diff --git a/platypush/plugins/media/chromecast.py b/platypush/plugins/media/chromecast.py index f5c22f06..9f5bc3b7 100644 --- a/platypush/plugins/media/chromecast.py +++ b/platypush/plugins/media/chromecast.py @@ -4,10 +4,12 @@ import pychromecast 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.media import MediaPlugin from platypush.utils import get_mime_type +from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ + MediaStopEvent, MediaPauseEvent, NewPlayingMediaEvent class MediaChromecastPlugin(MediaPlugin): @@ -157,6 +159,9 @@ class MediaChromecastPlugin(MediaPlugin): if not chromecast: chromecast = self.chromecast + get_bus().post(MediaPlayRequestEvent(resource=resource, + device=chromecast)) + cast = self.get_chromecast(chromecast) cast.wait() @@ -197,10 +202,13 @@ class MediaChromecastPlugin(MediaPlugin): mc.play_media(resource, content_type, title=title, thumb=image_url, current_time=current_time, autoplay=autoplay, stream_type=stream_type, subtitles=subtitles, - subtitles_lang=subtitles_lang, subtitles_mime=subtitles_mime, - subtitle_id=subtitle_id) + subtitles_lang=subtitles_lang, + subtitles_mime=subtitles_mime, subtitle_id=subtitle_id) mc.block_until_active() + get_bus().post(MediaPlayEvent(resource=resource, + device=chromecast)) + @classmethod @@ -223,14 +231,20 @@ class MediaChromecastPlugin(MediaPlugin): def pause(self, chromecast=None): cast = self.get_chromecast(chromecast or self.chromecast) 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: - return cast.media_controller.pause() + ret = cast.media_controller.pause() + get_bus().post(MediaPauseEvent(device=chromecast or self.chromecast)) + return ret @action 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 @@ -276,13 +290,38 @@ class MediaChromecastPlugin(MediaPlugin): @action - def enable_subtitle(self, chromecast=None): - return self.get_chromecast(chromecast or self.chromecast).media_controller.enable_subtitle() + def list_subtitles(self, chromecast=None): + return self.get_chromecast(chromecast or self.chromecast) \ + .media_controller.subtitle_tracks @action - def disable_subtitle(self, chromecast=None): - return self.get_chromecast(chromecast or self.chromecast).media_controller.disable_subtitle() + def enable_subtitles(self, chromecast=None, track_id=None): + 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 diff --git a/platypush/plugins/media/mplayer.py b/platypush/plugins/media/mplayer.py index ac81c2c6..d3648f82 100644 --- a/platypush/plugins/media/mplayer.py +++ b/platypush/plugins/media/mplayer.py @@ -1,14 +1,15 @@ import os import select import subprocess +import tempfile import threading import time from platypush.context import get_bus, get_plugin from platypush.message.response import Response from platypush.plugins.media import PlayerState, MediaPlugin -from platypush.message.event.media import MediaPlayEvent, MediaPauseEvent, \ - MediaStopEvent, NewPlayingMediaEvent +from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ + MediaPauseEvent, MediaStopEvent, NewPlayingMediaEvent from platypush.plugins import action 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) :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 executable :type args: list @@ -237,19 +241,46 @@ class MediaMplayerPlugin(MediaPlugin): 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 - def play(self, resource, mplayer_args=None): + def play(self, resource, subtitles=None, mplayer_args=None): """ Play a resource. :param resource: Resource to play - can be a local file or a remote URL :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 MPlayer executable :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) if resource.startswith('file://'): resource = resource[7:] @@ -258,12 +289,16 @@ class MediaMplayerPlugin(MediaPlugin): return get_plugin('media.webtorrent').play(resource) 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 def pause(self): """ Toggle the paused state """ - return self._exec('pause') + ret = self._exec('pause') + get_bus().post(MediaPauseEvent()) + return ret def _stop_torrent(self): if self._is_playing_torrent: @@ -276,13 +311,15 @@ class MediaMplayerPlugin(MediaPlugin): @action def stop(self): """ Stop the playback """ - return self._exec('stop') + # return self._exec('stop') + return self.quit() @action def quit(self): """ Quit the player """ self._stop_torrent() self._exec('quit') + get_bus().post(MediaStopEvent()) @action def voldown(self, step=10.0): @@ -310,6 +347,12 @@ class MediaMplayerPlugin(MediaPlugin): subs = self.get_property('sub_visibility').output.get('sub_visibility') 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 def is_playing(self): """ diff --git a/platypush/plugins/media/omxplayer.py b/platypush/plugins/media/omxplayer.py index c512c6b3..7c641bc3 100644 --- a/platypush/plugins/media/omxplayer.py +++ b/platypush/plugins/media/omxplayer.py @@ -46,7 +46,7 @@ class MediaOmxplayerPlugin(MediaPlugin): self._handlers = { e.value: [] for e in PlayerEvent } @action - def play(self, resource): + def play(self, resource, subtitles=None, *args, **kwargs): """ Play a resource. @@ -58,6 +58,9 @@ class MediaOmxplayerPlugin(MediaPlugin): * Torrents (format: Magnet links, Torrent URLs or local Torrent files) """ + if subtitles: + args.append('--subtitles', subtitles) + resource = self._get_resource(resource) if self._player: try: diff --git a/platypush/plugins/media/subtitles.py b/platypush/plugins/media/subtitles.py new file mode 100644 index 00000000..93f82313 --- /dev/null +++ b/platypush/plugins/media/subtitles.py @@ -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: diff --git a/requirements.txt b/requirements.txt index ecb28a69..5926ef44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -124,3 +124,6 @@ inputs # soundfile # numpy +# Support for media subtitles +# git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles + diff --git a/setup.py b/setup.py index b4e2e2ad..e1533eac 100755 --- a/setup.py +++ b/setup.py @@ -97,6 +97,7 @@ setup( 'Support for sound devices': ['sounddevice', 'soundfile', 'numpy'], # '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 media subtitles: ['git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles'] }, )