From 9305f86d0c971f217781f8ca57a720189adf9cb1 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 24 Jun 2019 01:01:08 +0200 Subject: [PATCH] New media webplugin WIP --- .../source/webpanel/plugins/media/index.scss | 1 + .../source/webpanel/plugins/media/info.scss | 24 +++++++++ .../static/js/plugins/media/handlers/file.js | 47 ++++++++++++---- .../js/plugins/media/handlers/torrent.js | 2 +- .../js/plugins/media/handlers/youtube.js | 2 +- .../http/static/js/plugins/media/index.js | 53 +++++++++++++++---- .../http/static/js/plugins/media/info.js | 13 +++++ .../js/plugins/media/players/browser.js | 1 + .../js/plugins/media/players/chromecast.js | 1 + .../static/js/plugins/media/players/local.js | 1 + .../http/static/js/plugins/media/results.js | 8 --- .../http/templates/plugins/media/index.html | 7 +++ .../http/templates/plugins/media/info.html | 26 +++++++++ platypush/plugins/file.py | 7 +++ platypush/plugins/google/youtube.py | 21 ++++---- platypush/plugins/media/__init__.py | 52 ++++++++++++++---- platypush/plugins/media/search/local.py | 14 ++--- platypush/plugins/media/search/torrent.py | 2 +- 18 files changed, 222 insertions(+), 60 deletions(-) create mode 100644 platypush/backend/http/static/css/source/webpanel/plugins/media/info.scss create mode 100644 platypush/backend/http/static/js/plugins/media/info.js create mode 100644 platypush/backend/http/templates/plugins/media/info.html diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/media/index.scss b/platypush/backend/http/static/css/source/webpanel/plugins/media/index.scss index ab9ab7864b..456e074369 100644 --- a/platypush/backend/http/static/css/source/webpanel/plugins/media/index.scss +++ b/platypush/backend/http/static/css/source/webpanel/plugins/media/index.scss @@ -8,6 +8,7 @@ @import 'webpanel/plugins/media/devices'; @import 'webpanel/plugins/media/results'; @import 'webpanel/plugins/media/controls'; +@import 'webpanel/plugins/media/info'; .media-plugin { display: flex; 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 new file mode 100644 index 0000000000..294e073e08 --- /dev/null +++ b/platypush/backend/http/static/css/source/webpanel/plugins/media/info.scss @@ -0,0 +1,24 @@ +.media-plugin { + .info-container { + .row { + display: flex; + align-items: center; + padding: .75em .5em; + border-bottom: $default-border-2; + + &:hover { + background: $hover-bg; + border-radius: 1em; + } + + .attr { + font-size: 1.1em; + } + + .value { + text-align: right; + } + } + } +} + 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 6d71ce231a..e2a9defdd4 100644 --- a/platypush/backend/http/static/js/plugins/media/handlers/file.js +++ b/platypush/backend/http/static/js/plugins/media/handlers/file.js @@ -23,7 +23,7 @@ MediaHandlers.file = Vue.extend({ }, { - text: 'Download', + text: 'Download (on client)', icon: 'download', action: this.download, }, @@ -42,21 +42,46 @@ MediaHandlers.file = Vue.extend({ return !!url.match('^(file://)?/'); }, - getMetadata: function(url) { - return { - url: url, - title: url.split('/').pop(), - }; + getMetadata: async function(item, onlyBase=false) { + if (typeof item === 'string') { + item = { + url: item, + }; + } + + if (!item.path) + item.path = item.url.startsWith('file://') ? item.url.substr(7) : item.url; + + if (!item.title) + item.title = item.path.split('/').pop(); + + if (!item.size && !onlyBase) + item.size = await request('file.getsize', {filename: item.path}); + + if (!item.duration && !onlyBase) + item.duration = await request('media.get_media_file_duration', {filename: item.path}); + + return item; }, play: function(item) { this.bus.$emit('play', item); }, - download: function(item) { + download: async function(item) { + this.bus.$on('streaming-started', (media) => { + if (media.resource === item.url) { + this.bus.$off('streaming-started'); + window.open(media.url + '?download', '_blank'); + } + }); + + this.bus.$emit('start-streaming', item.url); }, - info: function(item) { + info: async function(item) { + this.bus.$emit('info-loading'); + this.bus.$emit('info', (await this.getMetadata(item))); }, searchSubtitles: function(item) { @@ -74,12 +99,16 @@ MediaHandlers.generic = MediaHandlers.file.extend({ }, methods: { - getMetadata: function(url) { + getMetadata: async function(url) { return { url: url, 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/torrent.js b/platypush/backend/http/static/js/plugins/media/handlers/torrent.js index 490fe02992..2df004098b 100644 --- a/platypush/backend/http/static/js/plugins/media/handlers/torrent.js +++ b/platypush/backend/http/static/js/plugins/media/handlers/torrent.js @@ -17,7 +17,7 @@ MediaHandlers.torrent = Vue.extend({ }, { - text: 'Download', + text: 'Download (on server)', icon: 'download', action: this.download, }, 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 99925ba8f5..08a880bba4 100644 --- a/platypush/backend/http/static/js/plugins/media/handlers/youtube.js +++ b/platypush/backend/http/static/js/plugins/media/handlers/youtube.js @@ -17,7 +17,7 @@ MediaHandlers.youtube = Vue.extend({ }, { - text: 'Download', + text: 'Download (on server)', icon: 'download', action: this.download, }, diff --git a/platypush/backend/http/static/js/plugins/media/index.js b/platypush/backend/http/static/js/plugins/media/index.js index 7e50da60c7..8adff6ba7e 100644 --- a/platypush/backend/http/static/js/plugins/media/index.js +++ b/platypush/backend/http/static/js/plugins/media/index.js @@ -24,6 +24,16 @@ const mediaUtils = { ret.push(t.m, t.s); return ret.join(':'); }, + + convertSize: function(size) { + size = parseInt(size); // Normalize strings + + const units = ['B', 'KB', 'MB', 'GB']; + let s=size, i=0; + + for (; s > 1024 && i < units.length; i++, s = parseInt(s/1024)); + return (size / Math.pow(2, 10*i)).toFixed(2) + ' ' + units[i]; + }, }, }; @@ -38,10 +48,17 @@ Vue.component('media', { results: [], status: {}, selectedDevice: {}, + loading: { results: false, media: false, }, + + infoModal: { + visible: false, + loading: false, + item: {}, + }, }; }, @@ -52,14 +69,11 @@ Vue.component('media', { }, methods: { - refresh: async function() { - }, - onResultsLoading: function() { this.loading.results = true; }, - onResultsReady: function(results) { + onResultsReady: async function(results) { for (const result of results) { if (result.type && MediaHandlers[result.type]) { result.handler = MediaHandlers[result.type]; @@ -76,7 +90,7 @@ Vue.component('media', { } } - Object.entries(result.handler.getMetadata(result.url)).forEach(entry => { + Object.entries(await result.handler.getMetadata(result, onlyBase=true)).forEach(entry => { Vue.set(result, entry[0], entry[1]); }); } @@ -131,14 +145,31 @@ Vue.component('media', { }, info: function(item) { - // TODO - console.log(item); + for (const [attr, value] of Object.entries(item)) { + Vue.set(this.infoModal.item, attr, value); + } + + this.infoModal.loading = false; + this.infoModal.visible = true; + }, + + infoLoading: function() { + this.infoModal.loading = true; + this.infoModal.visible = true; }, startStreaming: async function(item) { - return await request('media.start_streaming', { - media: item.url, + const resource = item instanceof Object ? item.url : item; + const ret = await request('media.start_streaming', { + media: resource, }); + + this.bus.$emit('streaming-started', { + url: ret.url, + resource: resource, + }); + + return ret; }, selectDevice: async function(device) { @@ -199,8 +230,6 @@ Vue.component('media', { }, created: function() { - this.refresh(); - for (const [type, Handler] of Object.entries(MediaHandlers)) { MediaHandlers[type] = new Handler(); MediaHandlers[type].bus = this.bus; @@ -219,10 +248,12 @@ Vue.component('media', { this.bus.$on('seek', this.seek); this.bus.$on('volume', this.setVolume); this.bus.$on('info', this.info); + this.bus.$on('info-loading', this.infoLoading); this.bus.$on('selected-device', this.selectDevice); this.bus.$on('results-loading', this.onResultsLoading); this.bus.$on('results-ready', this.onResultsReady); this.bus.$on('status-update', this.onStatusUpdate); + this.bus.$on('start-streaming', this.startStreaming); setInterval(this.timerFunc, 1000); }, diff --git a/platypush/backend/http/static/js/plugins/media/info.js b/platypush/backend/http/static/js/plugins/media/info.js new file mode 100644 index 0000000000..8360d53d52 --- /dev/null +++ b/platypush/backend/http/static/js/plugins/media/info.js @@ -0,0 +1,13 @@ +Vue.component('media-info', { + template: '#tmpl-media-info', + mixins: [mediaUtils], + props: { + bus: { type: Object }, + + item: { + type: Object, + default: () => {}, + } + }, +}); + diff --git a/platypush/backend/http/static/js/plugins/media/players/browser.js b/platypush/backend/http/static/js/plugins/media/players/browser.js index 0ceb18b04b..dbe871daec 100644 --- a/platypush/backend/http/static/js/plugins/media/players/browser.js +++ b/platypush/backend/http/static/js/plugins/media/players/browser.js @@ -10,6 +10,7 @@ MediaPlayers.browser = Vue.extend({ default: () => { return { youtube: true, + generic: true, }; }, }, diff --git a/platypush/backend/http/static/js/plugins/media/players/chromecast.js b/platypush/backend/http/static/js/plugins/media/players/chromecast.js index 4999723245..2e7d24d5aa 100644 --- a/platypush/backend/http/static/js/plugins/media/players/chromecast.js +++ b/platypush/backend/http/static/js/plugins/media/players/chromecast.js @@ -10,6 +10,7 @@ MediaPlayers.chromecast = Vue.extend({ default: () => { return { youtube: true, + generic: true, }; }, }, diff --git a/platypush/backend/http/static/js/plugins/media/players/local.js b/platypush/backend/http/static/js/plugins/media/players/local.js index fa49407955..0e232524be 100644 --- a/platypush/backend/http/static/js/plugins/media/players/local.js +++ b/platypush/backend/http/static/js/plugins/media/players/local.js @@ -11,6 +11,7 @@ MediaPlayers.local = Vue.extend({ return { file: true, youtube: true, + generic: true, }; }, }, diff --git a/platypush/backend/http/static/js/plugins/media/results.js b/platypush/backend/http/static/js/plugins/media/results.js index 75f993d4ce..2fb1128d12 100644 --- a/platypush/backend/http/static/js/plugins/media/results.js +++ b/platypush/backend/http/static/js/plugins/media/results.js @@ -60,14 +60,6 @@ Vue.component('media-results', { this.selectedItem = item; openDropdown(this.$refs.mediaItemDropdown); }, - - play: function(item) { - this.bus.$emit('play', item); - }, - - info: function(item) { - this.bus.$emit('info', item); - }, }, created: function() { diff --git a/platypush/backend/http/templates/plugins/media/index.html b/platypush/backend/http/templates/plugins/media/index.html index 0e1232c3ee..8c32ec14cb 100644 --- a/platypush/backend/http/templates/plugins/media/index.html +++ b/platypush/backend/http/templates/plugins/media/index.html @@ -2,6 +2,8 @@ {% include 'plugins/media/controls.html' %} {% include 'plugins/media/results.html' %} {% include 'plugins/media/item.html' %} +{% include 'plugins/media/info.html' %} + {% for script in utils.search_directory(static_folder + '/js/plugins/media/handlers', 'js', recursive=True) %} @@ -35,6 +37,11 @@ :status="selectedDevice && status[selectedDevice.type] && status[selectedDevice.type][selectedDevice.name] ? status[selectedDevice.type][selectedDevice.name] : {}" v-if="selectedDevice && status[selectedDevice.type] && status[selectedDevice.type][selectedDevice.name] && (status[selectedDevice.type][selectedDevice.name].state === 'play' || status[selectedDevice.type][selectedDevice.name].state === 'pause')"> + + +
Loading
+ +
diff --git a/platypush/backend/http/templates/plugins/media/info.html b/platypush/backend/http/templates/plugins/media/info.html new file mode 100644 index 0000000000..8de91a0385 --- /dev/null +++ b/platypush/backend/http/templates/plugins/media/info.html @@ -0,0 +1,26 @@ + + + + diff --git a/platypush/plugins/file.py b/platypush/plugins/file.py index 49950c8cac..28b57e93d3 100644 --- a/platypush/plugins/file.py +++ b/platypush/plugins/file.py @@ -54,5 +54,12 @@ class FilePlugin(Plugin): with open(self._get_path(filename), 'a') as f: f.write(content) + @action + def getsize(self, filename): + """ + Get the size of the specified filename in bytes + """ + return os.path.getsize(filename) + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/google/youtube.py b/platypush/plugins/google/youtube.py index 4912969e16..1b447a317f 100644 --- a/platypush/plugins/google/youtube.py +++ b/platypush/plugins/google/youtube.py @@ -2,16 +2,11 @@ .. moduleauthor:: Fabio Manganiello """ -import base64 -import datetime -import os - from platypush.plugins import action from platypush.plugins.google import GooglePlugin -from platypush.plugins.calendar import CalendarInterface -class GoogleYoutubePlugin(GooglePlugin, CalendarInterface): +class GoogleYoutubePlugin(GooglePlugin): """ YouTube plugin """ @@ -24,31 +19,33 @@ class GoogleYoutubePlugin(GooglePlugin, CalendarInterface): # See https://developers.google.com/youtube/v3/getting-started#resources _default_types = ['video'] - def __init__(self, *args, **kwargs): super().__init__(scopes=self.scopes, *args, **kwargs) - @action def search(self, parts=None, query='', types=None, max_results=25, **kwargs): """ Search for YouTube content. - :param parts: List of parts to get (default: snippet). See the `YouTube API documentation `_. + :param parts: List of parts to get (default: snippet). See the `YouTube API documentation + `_. :type parts: list[str] or str :param query: Query string (default: empty string) :type query: str - :param types: List of types to retrieve (default: video). See the `YouTube API documentation `_. + :param types: List of types to retrieve (default: video). See the `YouTube API documentation + `_. :type types: list[str] or str :param max_results: Maximum number of items that will be returned (default: 25). :type max_results: int - :param kwargs: Any extra arguments that will be transparently passed to the YouTube API, see the `YouTube API documentation `_. + :param kwargs: Any extra arguments that will be transparently passed to the YouTube API, see the + `YouTube API documentation `_. - :return: A list of YouTube resources, see the `YouTube API documentation `_. + :return: A list of YouTube resources, see the `YouTube API documentation + `_. """ parts = parts or self._default_parts[:] diff --git a/platypush/plugins/media/__init__.py b/platypush/plugins/media/__init__.py index 78ac8a08f2..2b6835cfa8 100644 --- a/platypush/plugins/media/__init__.py +++ b/platypush/plugins/media/__init__.py @@ -1,4 +1,5 @@ import enum +import functools import os import queue import re @@ -28,10 +29,12 @@ class MediaPlugin(Plugin): Requires: * A media player installed (supported so far: mplayer, vlc, mpv, omxplayer, chromecast) - * The :class:`platypush.plugins.media.webtorrent` plugin for optional torrent support through webtorrent (recommended) + * The :class:`platypush.plugins.media.webtorrent` plugin for optional torrent support through webtorrent + (recommended) * **python-libtorrent** (``pip install python-libtorrent``), optional, for torrent support over native library * **youtube-dl** installed on your system (see your distro instructions), optional for YouTube support * **requests** (``pip install requests``), optional, for local files over HTTP streaming supporting + * **ffmpeg**,optional, to get media files metadata To start the local media stream service over HTTP you will also need the :class:`platypush.backend.http.HttpBackend` backend enabled. @@ -154,12 +157,12 @@ class MediaPlugin(Plugin): # The Chromecast has already its native way to handle YouTube return resource - resource = self._get_youtube_content(resource) + resource = self.get_youtube_url(resource).output elif resource.startswith('magnet:?'): try: get_plugin('media.webtorrent') return resource # media.webtorrent will handle this - except: + except Exception: pass torrents = get_plugin('torrent') @@ -448,20 +451,49 @@ class MediaPlugin(Plugin): html = '' self.logger.info('{} YouTube video results for the search query "{}"' - .format(len(results), query)) + .format(len(results), query)) return results - @classmethod - def _get_youtube_content(cls, url): + @action + def get_youtube_url(self, url): m = re.match('youtube:video:(.*)', url) - if m: url = 'https://www.youtube.com/watch?v={}'.format(m.group(1)) - - proc = subprocess.Popen(['youtube-dl','-f','best', '-g', url], - stdout=subprocess.PIPE) + if m: + url = 'https://www.youtube.com/watch?v={}'.format(m.group(1)) + proc = subprocess.Popen(['youtube-dl', '-f', 'best', '-g', url], stdout=subprocess.PIPE) return proc.stdout.read().decode("utf-8", "strict")[:-1] + @action + def get_youtube_info(self, url): + m = re.match('youtube:video:(.*)', url) + if m: + url = 'https://www.youtube.com/watch?v={}'.format(m.group(1)) + + proc = subprocess.Popen(['youtube-dl', '-j', url], stdout=subprocess.PIPE) + return proc.stdout.read().decode("utf-8", "strict")[:-1] + + @action + def get_media_file_duration(self, filename): + """ + Get the duration of a media file in seconds. Requires ffmpeg + """ + + if filename.startswith('file://'): + filename = filename[7:] + + result = subprocess.Popen(["ffprobe", filename], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + return functools.reduce( + lambda t, t_i: t + t_i, + [float(t) * pow(60, i) for (i, t) in enumerate(re.search( + '^Duration:\s*([^,]+)', [x.decode() + for x in result.stdout.readlines() + if "Duration" in x.decode()] + .pop().strip() + ).group(1).split(':')[::-1])] + ) + def is_local(self): return self._is_local diff --git a/platypush/plugins/media/search/local.py b/platypush/plugins/media/search/local.py index f35baa2537..1faf58e6bf 100644 --- a/platypush/plugins/media/search/local.py +++ b/platypush/plugins/media/search/local.py @@ -43,14 +43,15 @@ class LocalMediaSearcher(MediaSearcher): if not self._db_engine: self._db_engine = create_engine( 'sqlite:///{}'.format(self.db_file), - connect_args = { 'check_same_thread': False }) + connect_args = {'check_same_thread': False}) Base.metadata.create_all(self._db_engine) Session.configure(bind=self._db_engine) return Session() - def _get_or_create_dir_entry(self, session, path): + @staticmethod + def _get_or_create_dir_entry(session, path): record = session.query(MediaDirectory).filter_by(path=path).first() if record is None: record = MediaDirectory.build(path=path) @@ -103,13 +104,11 @@ class LocalMediaSearcher(MediaSearcher): return session.query(MediaToken).filter( MediaToken.token.in_(tokens)).all() - @classmethod def _get_file_records(cls, dir_record, session): return session.query(MediaFile).filter_by( directory_id=dir_record.id).all() - def scan(self, media_dir, session=None, dir_record=None): """ Scans a media directory and stores the search results in the internal @@ -186,8 +185,7 @@ class LocalMediaSearcher(MediaSearcher): session.commit() - - def search(self, query): + def search(self, query, **kwargs): """ Searches in the configured media directories given a query. It uses the built-in SQLite index if available. If any directory has changed since @@ -216,10 +214,12 @@ class LocalMediaSearcher(MediaSearcher): join(MediaToken). \ filter(MediaToken.token.in_(query_tokens)). \ group_by(MediaFile.path). \ - order_by(func.count(MediaFileToken.token_id).desc()): + having(func.count(MediaFileToken.token_id) >= len(query_tokens)): + # order_by(func.count(MediaFileToken.token_id).desc()): 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/torrent.py b/platypush/plugins/media/search/torrent.py index cfc3936169..aa27b52324 100644 --- a/platypush/plugins/media/search/torrent.py +++ b/platypush/plugins/media/search/torrent.py @@ -8,7 +8,7 @@ class TorrentMediaSearcher(MediaSearcher): torrents = get_plugin('torrent') if not torrents: raise RuntimeError('Torrent plugin not available/configured') - return torrents.search(query).output + return torrents.search(query, ).output