diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/media/info.scss b/platypush/backend/http/static/css/source/webpanel/plugins/media/info.scss index 294e073e0..620d18418 100644 --- a/platypush/backend/http/static/css/source/webpanel/plugins/media/info.scss +++ b/platypush/backend/http/static/css/source/webpanel/plugins/media/info.scss @@ -1,4 +1,10 @@ .media-plugin { + #media-info { + .modal { + max-width: 90%; + } + } + .info-container { .row { display: flex; diff --git a/platypush/backend/http/static/js/plugins/media/handlers/base.js b/platypush/backend/http/static/js/plugins/media/handlers/base.js new file mode 100644 index 000000000..b6e505442 --- /dev/null +++ b/platypush/backend/http/static/js/plugins/media/handlers/base.js @@ -0,0 +1,64 @@ +MediaHandlers.base = Vue.extend({ + props: { + bus: { type: Object }, + iconClass: { + type: String, + }, + }, + + computed: { + dropdownItems: function() { + return [ + { + text: 'Play', + icon: 'play', + action: this.play, + }, + + { + text: 'View info', + icon: 'info', + action: this.info, + }, + ]; + }, + }, + + methods: { + matchesUrl: function(url) { + return false; + }, + + getMetadata: async function(item, onlyBase=false) { + return {}; + }, + + play: function(item) { + this.bus.$emit('play', item); + }, + + info: async function(item) { + this.bus.$emit('info-loading'); + this.bus.$emit('info', {...item, ...(await this.getMetadata(item))}); + }, + + infoLoad: function(url) { + if (!this.matchesUrl(url)) + return; + + this.info(url); + }, + + searchSubtitles: function(item) { + this.bus.$emit('search-subs', item); + }, + }, + + created: function() { + const self = this; + setTimeout(() => { + self.infoLoadWatch = self.bus.$on('info-load', this.infoLoad); + }, 1000); + }, +}); + diff --git a/platypush/backend/http/static/js/plugins/media/handlers/file.js b/platypush/backend/http/static/js/plugins/media/handlers/file.js index 47262b8d7..28aa18486 100644 --- a/platypush/backend/http/static/js/plugins/media/handlers/file.js +++ b/platypush/backend/http/static/js/plugins/media/handlers/file.js @@ -1,6 +1,5 @@ -MediaHandlers.file = Vue.extend({ +MediaHandlers.file = MediaHandlers.base.extend({ props: { - bus: { type: Object }, iconClass: { type: String, default: 'fa fa-hdd', @@ -64,10 +63,6 @@ MediaHandlers.file = Vue.extend({ return item; }, - play: function(item) { - this.bus.$emit('play', item); - }, - download: async function(item) { this.bus.$on('streaming-started', (media) => { if (media.resource === item.url) { @@ -78,41 +73,35 @@ MediaHandlers.file = Vue.extend({ this.bus.$emit('start-streaming', item.url); }, - - info: async function(item) { - this.bus.$emit('info-loading'); - this.bus.$emit('info', (await this.getMetadata(item))); - }, - - infoLoad: function(url) { - if (!this.matchesUrl(url)) - return; - - this.info(url); - }, - - searchSubtitles: function(item) { - this.bus.$emit('search-subs', item); - }, - }, - - created: function() { - const self = this; - setTimeout(() => { - self.infoLoadWatch = self.bus.$on('info-load', this.infoLoad); - }, 1000); }, }); MediaHandlers.generic = MediaHandlers.file.extend({ props: { - bus: { type: Object }, iconClass: { type: String, default: 'fa fa-globe', }, }, + computed: { + dropdownItems: function() { + return [ + { + text: 'Play', + icon: 'play', + action: this.play, + }, + + { + text: 'View info', + icon: 'info', + action: this.info, + }, + ]; + }, + }, + methods: { getMetadata: async function(url) { return { @@ -120,10 +109,6 @@ MediaHandlers.generic = MediaHandlers.file.extend({ title: url, }; }, - - info: async function(item) { - this.bus.$emit('info', (await this.getMetadata(item))); - }, }, }); diff --git a/platypush/backend/http/static/js/plugins/media/handlers/youtube.js b/platypush/backend/http/static/js/plugins/media/handlers/youtube.js index 08a880bba..6ea9e07c3 100644 --- a/platypush/backend/http/static/js/plugins/media/handlers/youtube.js +++ b/platypush/backend/http/static/js/plugins/media/handlers/youtube.js @@ -1,6 +1,5 @@ -MediaHandlers.youtube = Vue.extend({ +MediaHandlers.youtube = MediaHandlers.base.extend({ props: { - bus: { type: Object }, iconClass: { type: String, default: 'fab fa-youtube', @@ -19,7 +18,13 @@ MediaHandlers.youtube = Vue.extend({ { text: 'Download (on server)', icon: 'download', - action: this.download, + action: this.downloadServer, + }, + + { + text: 'Download (on client)', + icon: 'download', + action: this.downloadClient, }, { @@ -37,17 +42,55 @@ MediaHandlers.youtube = Vue.extend({ }, getMetadata: function(url) { - // TODO return {}; }, - play: function(item) { + _getRawUrl: async function(url) { + if (url.indexOf('.googlevideo.com') < 0) { + url = await request('media.get_youtube_url', {url: url}); + } + + return url; }, - download: function(item) { + play: async function(item) { + if (typeof item === 'string') + item = {url: item}; + + let url = await this._getRawUrl(item.url); + this.bus.$emit('play', {...item, url:url}); }, - info: function(item) { + downloadServer: async function(item) { + createNotification({ + text: 'Downloading video', + image: { + icon: 'download', + }, + }); + + let url = await this._getRawUrl(item.url); + let args = { + url: url, + } + + if (item.title) { + args.filename = item.title + '.webm'; + } + + let path = await request('media.download', args); + + createNotification({ + text: 'Video downloaded to ' + path, + image: { + icon: 'check', + }, + }); + }, + + downloadClient: async function(item) { + let url = await this._getRawUrl(item.url); + window.open(url, '_blank'); }, }, }); diff --git a/platypush/backend/http/static/js/plugins/media/index.js b/platypush/backend/http/static/js/plugins/media/index.js index 6005820e9..c1e4b5227 100644 --- a/platypush/backend/http/static/js/plugins/media/index.js +++ b/platypush/backend/http/static/js/plugins/media/index.js @@ -110,6 +110,9 @@ Vue.component('media', { let status = await this.selectedDevice.play(item.url, item.subtitles); + if (item.title) + status.title = item.title; + this.subsModal.visible = false; this.onStatusUpdate({ device: this.selectedDevice, @@ -207,6 +210,10 @@ Vue.component('media', { const status = event.status; this.syncPosition(status); + if (status.state !== 'stop' && this.status[dev.type] && this.status[dev.type][dev.name]) { + status.title = status.title || this.status[dev.type][dev.name].title; + } + if (!this.status[dev.type]) Vue.set(this.status, dev.type, {}); Vue.set(this.status[dev.type], dev.name, status); @@ -224,10 +231,20 @@ Vue.component('media', { if (event.plugin.startsWith('media.')) event.plugin = event.plugin.substr(6); - if (this.status[event.player] && this.status[event.player][event.plugin]) - Vue.set(this.status[event.player], event.plugin, status); - else if (this.status[event.plugin] && this.status[event.plugin][event.player]) - Vue.set(this.status[event.plugin], event.player, status); + var type, player; + if (this.status[event.player] && this.status[event.player][event.plugin]) { + type = event.player; + player = event.plugin; + } else if (this.status[event.plugin] && this.status[event.plugin][event.player]) { + type = event.plugin; + player = event.player; + } + + if (status.state !== 'stop') { + status.title = status.title || this.status[type][player].title; + } + + Vue.set(this.status[type], player, status); }, timerFunc: function() { diff --git a/platypush/backend/http/static/js/plugins/media/search.js b/platypush/backend/http/static/js/plugins/media/search.js index 15520f40c..8b7f5a16f 100644 --- a/platypush/backend/http/static/js/plugins/media/search.js +++ b/platypush/backend/http/static/js/plugins/media/search.js @@ -15,6 +15,12 @@ Vue.component('media-search', { obj[type] = true; return obj; }, {}), + + searchTypes: Object.keys(this.supportedTypes).reduce((obj, type) => { + if (type !== 'generic' && type !== 'base') + obj[type] = true; + return obj; + }, {}), }; }, @@ -31,7 +37,7 @@ Vue.component('media-search', { }, search: async function(event) { - const types = Object.entries(this.types).filter(t => t[0] !== 'generic' && t[1]).map(t => t[0]); + const types = Object.entries(this.searchTypes).filter(t => t[1]).map(t => t[0]); const protocol = this.isUrl(this.query); if (protocol) { diff --git a/platypush/backend/http/templates/plugins/media/controls.html b/platypush/backend/http/templates/plugins/media/controls.html index 268afb46f..90205177b 100644 --- a/platypush/backend/http/templates/plugins/media/controls.html +++ b/platypush/backend/http/templates/plugins/media/controls.html @@ -4,7 +4,7 @@
-
diff --git a/platypush/backend/http/templates/plugins/media/index.html b/platypush/backend/http/templates/plugins/media/index.html index d5e511034..51c759cd3 100644 --- a/platypush/backend/http/templates/plugins/media/index.html +++ b/platypush/backend/http/templates/plugins/media/index.html @@ -5,8 +5,12 @@ {% include 'plugins/media/info.html' %} {% include 'plugins/media/subs.html' %} + + {% for script in utils.search_directory(static_folder + '/js/plugins/media/handlers', 'js', recursive=True) %} + {% if script != 'base.js' %} + {% endif %} {% endfor %} diff --git a/platypush/backend/http/templates/plugins/media/info.html b/platypush/backend/http/templates/plugins/media/info.html index 8de91a038..f98b7a02a 100644 --- a/platypush/backend/http/templates/plugins/media/info.html +++ b/platypush/backend/http/templates/plugins/media/info.html @@ -4,7 +4,9 @@
URL
-
+
+ +
@@ -12,6 +14,26 @@
+
+
Description
+
+
+ + + +
+
Published
+
+
+
Duration
diff --git a/platypush/backend/http/templates/plugins/media/search.html b/platypush/backend/http/templates/plugins/media/search.html index bb1f44958..c759b8894 100644 --- a/platypush/backend/http/templates/plugins/media/search.html +++ b/platypush/backend/http/templates/plugins/media/search.html @@ -18,11 +18,11 @@
-
+
+ v-model.lazy="searchTypes[type]">
diff --git a/platypush/plugins/media/__init__.py b/platypush/plugins/media/__init__.py index 2b6835cfa..11e9b3dc9 100644 --- a/platypush/plugins/media/__init__.py +++ b/platypush/plugins/media/__init__.py @@ -3,6 +3,7 @@ import functools import os import queue import re +import requests import subprocess import tempfile import threading @@ -80,7 +81,7 @@ class MediaPlugin(Plugin): :type media_dirs: list :param download_dir: Directory where external resources/torrents will be - downloaded (default: none) + downloaded (default: ~/Downloads) :type download_dir: str :param env: Environment variables key-values to pass to the @@ -110,7 +111,6 @@ class MediaPlugin(Plugin): raise AttributeError('No media plugin configured') media_dirs = media_dirs or player_config.get('media_dirs', []) - download_dir = download_dir or player_config.get('download_dir') if self.__class__.__name__ == 'MediaPlugin': # Populate this plugin with the actions of the configured player @@ -130,14 +130,14 @@ class MediaPlugin(Plugin): ) ) - if download_dir: - self.download_dir = os.path.abspath(os.path.expanduser(download_dir)) - if not os.path.isdir(self.download_dir): - raise RuntimeError('download_dir [{}] is not a valid directory' - .format(self.download_dir)) + self.download_dir = os.path.abspath(os.path.expanduser( + download_dir or player_config.get('download_dir') or + os.path.join((os.environ['HOME'] or self._env.get('HOME') or '/'), 'Downloads'))) - self.media_dirs.add(self.download_dir) + if not os.path.isdir(self.download_dir): + os.makedirs(self.download_dir, exist_ok=True) + self.media_dirs.add(self.download_dir) self._is_playing_torrent = False self._videos_queue = [] @@ -494,6 +494,30 @@ class MediaPlugin(Plugin): ).group(1).split(':')[::-1])] ) + @action + def download(self, url, filename=None, directory=None): + """ + Download a media URL + + :param url: Media URL + :param filename: Media filename (default: URL filename) + :param directory: Destination directory (default: download_dir) + :return: The absolute path to the downloaded file + """ + + if not filename: + filename = url.split('/')[-1] + if not directory: + directory = self.download_dir + + path = os.path.join(directory, filename) + content = requests.get(url).content + + with open(path, 'wb') as f: + f.write(content) + + return path + def is_local(self): return self._is_local @@ -507,7 +531,6 @@ class MediaPlugin(Plugin): 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) diff --git a/platypush/plugins/media/omxplayer.py b/platypush/plugins/media/omxplayer.py index d78a7f856..1c4df6415 100644 --- a/platypush/plugins/media/omxplayer.py +++ b/platypush/plugins/media/omxplayer.py @@ -278,7 +278,7 @@ class MediaOmxplayerPlugin(MediaPlugin): return { 'duration': self._player.duration(), - 'filename': urllib.parse.unquote(self._player.get_source()).split('/')[-1], + 'filename': urllib.parse.unquote(self._player.get_source()).split('/')[-1] if self._player.get_source().startswith('file://') else None, 'fullscreen': self._player.fullscreen(), 'mute': self._player._is_muted, 'path': self._player.get_source(), @@ -286,7 +286,7 @@ class MediaOmxplayerPlugin(MediaPlugin): 'position': self._player.position(), 'seekable': self._player.can_seek(), 'state': state, - 'title': urllib.parse.unquote(self._player.get_source()).split('/')[-1], + 'title': urllib.parse.unquote(self._player.get_source()).split('/')[-1] if self._player.get_source().startswith('file://') else None, 'url': self._player.get_source(), 'volume': self._player.volume(), 'volume_max': 100, diff --git a/platypush/plugins/media/search/__init__.py b/platypush/plugins/media/search/__init__.py index e72695c24..25ca14f2a 100644 --- a/platypush/plugins/media/search/__init__.py +++ b/platypush/plugins/media/search/__init__.py @@ -1,5 +1,6 @@ import logging + class MediaSearcher: """ Base class for media searchers @@ -8,7 +9,6 @@ class MediaSearcher: def __init__(self, *args, **kwargs): self.logger = logging.getLogger(self.__class__.__name__) - def search(self, query, *args, **kwargs): raise NotImplementedError('The search method should be implemented ' + 'by a derived class') diff --git a/platypush/plugins/media/search/local.py b/platypush/plugins/media/search/local.py index f3530a817..d79bbd7cd 100644 --- a/platypush/plugins/media/search/local.py +++ b/platypush/plugins/media/search/local.py @@ -215,11 +215,12 @@ class LocalMediaSearcher(MediaSearcher): filter(MediaToken.token.in_(query_tokens)). \ group_by(MediaFile.path). \ having(func.count(MediaFileToken.token_id) >= len(query_tokens)): - results[file_record.path] = { - 'url': 'file://' + file_record.path, - 'title': os.path.basename(file_record.path), - 'size': os.path.getsize(file_record.path) - } + if (os.path.isfile(file_record.path)): + results[file_record.path] = { + 'url': 'file://' + file_record.path, + 'title': os.path.basename(file_record.path), + 'size': os.path.getsize(file_record.path) + } return results.values() diff --git a/platypush/plugins/media/search/youtube.py b/platypush/plugins/media/search/youtube.py index 2f406839c..18c5f9d29 100644 --- a/platypush/plugins/media/search/youtube.py +++ b/platypush/plugins/media/search/youtube.py @@ -1,11 +1,12 @@ import re -import urllib +import urllib.parse +import urllib.request from platypush.context import get_plugin from platypush.plugins.media.search import MediaSearcher class YoutubeMediaSearcher(MediaSearcher): - def search(self, query): + def search(self, query, **kwargs): """ Performs a YouTube search either using the YouTube API (faster and recommended, it requires the :mod:`platypush.plugins.google.youtube` @@ -23,11 +24,12 @@ class YoutubeMediaSearcher(MediaSearcher): return self._youtube_search_html_parse(query=query) - def _youtube_search_api(self, query): + @staticmethod + def _youtube_search_api(query): return [ { 'url': 'https://www.youtube.com/watch?v=' + item['id']['videoId'], - 'title': item.get('snippet', {}).get('title', ''), + **item.get('snippet', {}), } for item in get_plugin('google.youtube').search(query=query).output if item.get('id', {}).get('kind') == 'youtube#video' @@ -53,7 +55,7 @@ class YoutubeMediaSearcher(MediaSearcher): html = '' self.logger.info('{} YouTube video results for the search query "{}"' - .format(len(results), query)) + .format(len(results), query)) return results diff --git a/platypush/plugins/media/vlc.py b/platypush/plugins/media/vlc.py index eabcf1ad2..9043c2b76 100644 --- a/platypush/plugins/media/vlc.py +++ b/platypush/plugins/media/vlc.py @@ -45,6 +45,8 @@ class MediaVlcPlugin(MediaPlugin): self._default_fullscreen = fullscreen self._default_volume = volume self._on_stop_callbacks = [] + self._title = None + self._filename = None @classmethod def _watched_event_types(cls): @@ -76,6 +78,9 @@ class MediaVlcPlugin(MediaPlugin): def _reset_state(self): self._latest_seek = None + self._title = None + self._filename = None + if self._player: self._player.release() self._player = None @@ -105,8 +110,12 @@ class MediaVlcPlugin(MediaPlugin): for cbk in self._on_stop_callbacks: cbk() elif event.type == EventType.MediaPlayerTitleChanged: + self._filename = event.u.filename + self._title = event.u.new_title self._post_event(NewPlayingMediaEvent, resource=event.u.new_title) elif event.type == EventType.MediaPlayerMediaChanged: + self._filename = event.u.filename + self._title = event.u.new_title self._post_event(NewPlayingMediaEvent, resource=event.u.filename) elif event.type == EventType.MediaPlayerLengthChanged: self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource()) @@ -396,8 +405,8 @@ class MediaVlcPlugin(MediaPlugin): status['path'] = status['url'] status['pause'] = status['state'] == PlayerState.PAUSE.value status['percent_pos'] = self._player.get_position()*100 - status['filename'] = urllib.parse.unquote(status['url']).split('/')[-1] - status['title'] = status['filename'] + status['filename'] = self._filename + status['title'] = self._title status['volume'] = self._player.audio_get_volume() status['volume_max'] = 100