Subtitles support
- Added support for local and OpenSubtitles media subs - Added management of media events in web panel
This commit is contained in:
parent
630850ee9a
commit
34f0264d5e
16 changed files with 713 additions and 96 deletions
3
.gitmodules
vendored
3
.gitmodules
vendored
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
1
platypush/backend/http/static/flag-icons
Submodule
1
platypush/backend/http/static/flag-icons
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit c8031a673e8428b842199c0dcf66d12b6ed1d1d6
|
|
@ -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 = $('<div></div>').addClass('notification');
|
||||
|
@ -348,3 +363,10 @@ function createNotification(options) {
|
|||
$notification.fadeIn();
|
||||
}
|
||||
|
||||
function showError(errorMessage) {
|
||||
createNotification({
|
||||
'icon': 'exclamation',
|
||||
'text': errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = $('<div></div>')
|
||||
|
@ -30,7 +69,7 @@ $(document).ready(function() {
|
|||
}
|
||||
};
|
||||
|
||||
var getVideoIconByUrl = function(url) {
|
||||
const getVideoIconByUrl = function(url) {
|
||||
var $icon = $('<i class="fa"></i>');
|
||||
|
||||
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,18 +343,10 @@ $(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) => {
|
||||
url = url + '?download=1'
|
||||
window.open(url, '_blank');
|
||||
resolve(url);
|
||||
})
|
||||
|
@ -167,36 +356,11 @@ $(document).ready(function() {
|
|||
.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();
|
||||
reject(xhr.responseText);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
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 = $('<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');
|
||||
|
||||
execute(
|
||||
|
@ -414,7 +635,7 @@ $(document).ready(function() {
|
|||
);
|
||||
};
|
||||
|
||||
var init = function() {
|
||||
const init = function() {
|
||||
initRemoteDevices();
|
||||
initBindings();
|
||||
};
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
<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='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">
|
||||
<form action="#" id="video-search">
|
||||
|
@ -86,6 +93,10 @@
|
|||
<button data-action="next">
|
||||
<i class="fa fa-step-forward"></i>
|
||||
</button>
|
||||
|
||||
<button data-action="subtitles" data-modal="#media-subtitles-modal">
|
||||
<i class="fa fa-comment"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -114,6 +125,28 @@
|
|||
</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 id="video-results"></div>
|
||||
</div>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,7 +374,8 @@ class MediaPlugin(Plugin):
|
|||
return
|
||||
|
||||
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(
|
||||
url=http.local_base_url, download='?download' if download else ''),
|
||||
json = { 'source': media })
|
||||
|
||||
if not response.ok:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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:
|
||||
|
|
176
platypush/plugins/media/subtitles.py
Normal file
176
platypush/plugins/media/subtitles.py
Normal 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:
|
|
@ -124,3 +124,6 @@ inputs
|
|||
# soundfile
|
||||
# numpy
|
||||
|
||||
# Support for media subtitles
|
||||
# git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles
|
||||
|
||||
|
|
1
setup.py
1
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']
|
||||
},
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in a new issue