New media webplugin WIP

This commit is contained in:
Fabio Manganiello 2019-06-24 01:01:08 +02:00
parent ba800ef8e2
commit 9305f86d0c
18 changed files with 222 additions and 60 deletions

View File

@ -8,6 +8,7 @@
@import 'webpanel/plugins/media/devices'; @import 'webpanel/plugins/media/devices';
@import 'webpanel/plugins/media/results'; @import 'webpanel/plugins/media/results';
@import 'webpanel/plugins/media/controls'; @import 'webpanel/plugins/media/controls';
@import 'webpanel/plugins/media/info';
.media-plugin { .media-plugin {
display: flex; display: flex;

View File

@ -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;
}
}
}
}

View File

@ -23,7 +23,7 @@ MediaHandlers.file = Vue.extend({
}, },
{ {
text: 'Download', text: 'Download (on client)',
icon: 'download', icon: 'download',
action: this.download, action: this.download,
}, },
@ -42,21 +42,46 @@ MediaHandlers.file = Vue.extend({
return !!url.match('^(file://)?/'); return !!url.match('^(file://)?/');
}, },
getMetadata: function(url) { getMetadata: async function(item, onlyBase=false) {
return { if (typeof item === 'string') {
url: url, item = {
title: url.split('/').pop(), 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) { play: function(item) {
this.bus.$emit('play', 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) { searchSubtitles: function(item) {
@ -74,12 +99,16 @@ MediaHandlers.generic = MediaHandlers.file.extend({
}, },
methods: { methods: {
getMetadata: function(url) { getMetadata: async function(url) {
return { return {
url: url, url: url,
title: url, title: url,
}; };
}, },
info: async function(item) {
this.bus.$emit('info', (await this.getMetadata(item)));
},
}, },
}); });

View File

@ -17,7 +17,7 @@ MediaHandlers.torrent = Vue.extend({
}, },
{ {
text: 'Download', text: 'Download (on server)',
icon: 'download', icon: 'download',
action: this.download, action: this.download,
}, },

View File

@ -17,7 +17,7 @@ MediaHandlers.youtube = Vue.extend({
}, },
{ {
text: 'Download', text: 'Download (on server)',
icon: 'download', icon: 'download',
action: this.download, action: this.download,
}, },

View File

@ -24,6 +24,16 @@ const mediaUtils = {
ret.push(t.m, t.s); ret.push(t.m, t.s);
return ret.join(':'); 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: [], results: [],
status: {}, status: {},
selectedDevice: {}, selectedDevice: {},
loading: { loading: {
results: false, results: false,
media: false, media: false,
}, },
infoModal: {
visible: false,
loading: false,
item: {},
},
}; };
}, },
@ -52,14 +69,11 @@ Vue.component('media', {
}, },
methods: { methods: {
refresh: async function() {
},
onResultsLoading: function() { onResultsLoading: function() {
this.loading.results = true; this.loading.results = true;
}, },
onResultsReady: function(results) { onResultsReady: async function(results) {
for (const result of results) { for (const result of results) {
if (result.type && MediaHandlers[result.type]) { if (result.type && MediaHandlers[result.type]) {
result.handler = 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]); Vue.set(result, entry[0], entry[1]);
}); });
} }
@ -131,14 +145,31 @@ Vue.component('media', {
}, },
info: function(item) { info: function(item) {
// TODO for (const [attr, value] of Object.entries(item)) {
console.log(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) { startStreaming: async function(item) {
return await request('media.start_streaming', { const resource = item instanceof Object ? item.url : item;
media: item.url, 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) { selectDevice: async function(device) {
@ -199,8 +230,6 @@ Vue.component('media', {
}, },
created: function() { created: function() {
this.refresh();
for (const [type, Handler] of Object.entries(MediaHandlers)) { for (const [type, Handler] of Object.entries(MediaHandlers)) {
MediaHandlers[type] = new Handler(); MediaHandlers[type] = new Handler();
MediaHandlers[type].bus = this.bus; MediaHandlers[type].bus = this.bus;
@ -219,10 +248,12 @@ Vue.component('media', {
this.bus.$on('seek', this.seek); this.bus.$on('seek', this.seek);
this.bus.$on('volume', this.setVolume); this.bus.$on('volume', this.setVolume);
this.bus.$on('info', this.info); this.bus.$on('info', this.info);
this.bus.$on('info-loading', this.infoLoading);
this.bus.$on('selected-device', this.selectDevice); this.bus.$on('selected-device', this.selectDevice);
this.bus.$on('results-loading', this.onResultsLoading); this.bus.$on('results-loading', this.onResultsLoading);
this.bus.$on('results-ready', this.onResultsReady); this.bus.$on('results-ready', this.onResultsReady);
this.bus.$on('status-update', this.onStatusUpdate); this.bus.$on('status-update', this.onStatusUpdate);
this.bus.$on('start-streaming', this.startStreaming);
setInterval(this.timerFunc, 1000); setInterval(this.timerFunc, 1000);
}, },

View File

@ -0,0 +1,13 @@
Vue.component('media-info', {
template: '#tmpl-media-info',
mixins: [mediaUtils],
props: {
bus: { type: Object },
item: {
type: Object,
default: () => {},
}
},
});

View File

@ -10,6 +10,7 @@ MediaPlayers.browser = Vue.extend({
default: () => { default: () => {
return { return {
youtube: true, youtube: true,
generic: true,
}; };
}, },
}, },

View File

@ -10,6 +10,7 @@ MediaPlayers.chromecast = Vue.extend({
default: () => { default: () => {
return { return {
youtube: true, youtube: true,
generic: true,
}; };
}, },
}, },

View File

@ -11,6 +11,7 @@ MediaPlayers.local = Vue.extend({
return { return {
file: true, file: true,
youtube: true, youtube: true,
generic: true,
}; };
}, },
}, },

View File

@ -60,14 +60,6 @@ Vue.component('media-results', {
this.selectedItem = item; this.selectedItem = item;
openDropdown(this.$refs.mediaItemDropdown); openDropdown(this.$refs.mediaItemDropdown);
}, },
play: function(item) {
this.bus.$emit('play', item);
},
info: function(item) {
this.bus.$emit('info', item);
},
}, },
created: function() { created: function() {

View File

@ -2,6 +2,8 @@
{% include 'plugins/media/controls.html' %} {% include 'plugins/media/controls.html' %}
{% include 'plugins/media/results.html' %} {% include 'plugins/media/results.html' %}
{% include 'plugins/media/item.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) %} {% for script in utils.search_directory(static_folder + '/js/plugins/media/handlers', 'js', recursive=True) %}
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/handlers/' + script) }}"></script> <script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/handlers/' + script) }}"></script>
@ -35,6 +37,11 @@
:status="selectedDevice && status[selectedDevice.type] && status[selectedDevice.type][selectedDevice.name] ? status[selectedDevice.type][selectedDevice.name] : {}" :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')"> 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')">
</media-controls> </media-controls>
<modal id="media-info" title="Media info" v-model="infoModal.visible" ref="modalInfo">
<div class="loading" v-if="infoModal.loading">Loading</div>
<media-info :bus="bus" :item="infoModal.item" v-else></media-info>
</modal>
</div> </div>
</script> </script>

View File

@ -0,0 +1,26 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/info.js') }}"></script>
<script type="text/x-template" id="tmpl-media-info">
<div class="info-container">
<div class="row">
<div class="col-3 attr">URL</div>
<div class="col-9 value" v-text="item.url"></div>
</div>
<div class="row" v-if="item.title">
<div class="col-3 attr">Title</div>
<div class="col-9 value" v-text="item.title"></div>
</div>
<div class="row" v-if="item.duration">
<div class="col-3 attr">Duration</div>
<div class="col-9 value" v-text="convertTime(item.duration)"></div>
</div>
<div class="row" v-if="item.size">
<div class="col-3 attr">Size</div>
<div class="col-9 value" v-text="convertSize(item.size)"></div>
</div>
</div>
</script>

View File

@ -54,5 +54,12 @@ class FilePlugin(Plugin):
with open(self._get_path(filename), 'a') as f: with open(self._get_path(filename), 'a') as f:
f.write(content) 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: # vim:sw=4:ts=4:et:

View File

@ -2,16 +2,11 @@
.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com> .. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com>
""" """
import base64
import datetime
import os
from platypush.plugins import action from platypush.plugins import action
from platypush.plugins.google import GooglePlugin from platypush.plugins.google import GooglePlugin
from platypush.plugins.calendar import CalendarInterface
class GoogleYoutubePlugin(GooglePlugin, CalendarInterface): class GoogleYoutubePlugin(GooglePlugin):
""" """
YouTube plugin YouTube plugin
""" """
@ -24,31 +19,33 @@ class GoogleYoutubePlugin(GooglePlugin, CalendarInterface):
# See https://developers.google.com/youtube/v3/getting-started#resources # See https://developers.google.com/youtube/v3/getting-started#resources
_default_types = ['video'] _default_types = ['video']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(scopes=self.scopes, *args, **kwargs) super().__init__(scopes=self.scopes, *args, **kwargs)
@action @action
def search(self, parts=None, query='', types=None, max_results=25, **kwargs): def search(self, parts=None, query='', types=None, max_results=25, **kwargs):
""" """
Search for YouTube content. Search for YouTube content.
:param parts: List of parts to get (default: snippet). See the `YouTube API documentation <https://developers.google.com/youtube/v3/getting-started#part>`_. :param parts: List of parts to get (default: snippet). See the `YouTube API documentation
<https://developers.google.com/youtube/v3/getting-started#part>`_.
:type parts: list[str] or str :type parts: list[str] or str
:param query: Query string (default: empty string) :param query: Query string (default: empty string)
:type query: str :type query: str
:param types: List of types to retrieve (default: video). See the `YouTube API documentation <https://developers.google.com/youtube/v3/getting-started#resources>`_. :param types: List of types to retrieve (default: video). See the `YouTube API documentation
<https://developers.google.com/youtube/v3/getting-started#resources>`_.
:type types: list[str] or str :type types: list[str] or str
:param max_results: Maximum number of items that will be returned (default: 25). :param max_results: Maximum number of items that will be returned (default: 25).
:type max_results: int :type max_results: int
:param kwargs: Any extra arguments that will be transparently passed to the YouTube API, see the `YouTube API documentation <https://developers.google.com/youtube/v3/docs/search/list#parameters>`_. :param kwargs: Any extra arguments that will be transparently passed to the YouTube API, see the
`YouTube API documentation <https://developers.google.com/youtube/v3/docs/search/list#parameters>`_.
:return: A list of YouTube resources, see the `YouTube API documentation <https://developers.google.com/youtube/v3/docs/search#resource>`_. :return: A list of YouTube resources, see the `YouTube API documentation
<https://developers.google.com/youtube/v3/docs/search#resource>`_.
""" """
parts = parts or self._default_parts[:] parts = parts or self._default_parts[:]

View File

@ -1,4 +1,5 @@
import enum import enum
import functools
import os import os
import queue import queue
import re import re
@ -28,10 +29,12 @@ class MediaPlugin(Plugin):
Requires: Requires:
* A media player installed (supported so far: mplayer, vlc, mpv, omxplayer, chromecast) * 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 * **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 * **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 * **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 To start the local media stream service over HTTP you will also need the
:class:`platypush.backend.http.HttpBackend` backend enabled. :class:`platypush.backend.http.HttpBackend` backend enabled.
@ -154,12 +157,12 @@ class MediaPlugin(Plugin):
# The Chromecast has already its native way to handle YouTube # The Chromecast has already its native way to handle YouTube
return resource return resource
resource = self._get_youtube_content(resource) resource = self.get_youtube_url(resource).output
elif resource.startswith('magnet:?'): elif resource.startswith('magnet:?'):
try: try:
get_plugin('media.webtorrent') get_plugin('media.webtorrent')
return resource # media.webtorrent will handle this return resource # media.webtorrent will handle this
except: except Exception:
pass pass
torrents = get_plugin('torrent') torrents = get_plugin('torrent')
@ -448,20 +451,49 @@ class MediaPlugin(Plugin):
html = '' html = ''
self.logger.info('{} YouTube video results for the search query "{}"' self.logger.info('{} YouTube video results for the search query "{}"'
.format(len(results), query)) .format(len(results), query))
return results return results
@classmethod @action
def _get_youtube_content(cls, url): def get_youtube_url(self, url):
m = re.match('youtube:video:(.*)', url) m = re.match('youtube:video:(.*)', url)
if m: url = 'https://www.youtube.com/watch?v={}'.format(m.group(1)) 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)
proc = subprocess.Popen(['youtube-dl', '-f', 'best', '-g', url], stdout=subprocess.PIPE)
return proc.stdout.read().decode("utf-8", "strict")[:-1] 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): def is_local(self):
return self._is_local return self._is_local

View File

@ -43,14 +43,15 @@ class LocalMediaSearcher(MediaSearcher):
if not self._db_engine: if not self._db_engine:
self._db_engine = create_engine( self._db_engine = create_engine(
'sqlite:///{}'.format(self.db_file), 'sqlite:///{}'.format(self.db_file),
connect_args = { 'check_same_thread': False }) connect_args = {'check_same_thread': False})
Base.metadata.create_all(self._db_engine) Base.metadata.create_all(self._db_engine)
Session.configure(bind=self._db_engine) Session.configure(bind=self._db_engine)
return Session() 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() record = session.query(MediaDirectory).filter_by(path=path).first()
if record is None: if record is None:
record = MediaDirectory.build(path=path) record = MediaDirectory.build(path=path)
@ -103,13 +104,11 @@ class LocalMediaSearcher(MediaSearcher):
return session.query(MediaToken).filter( return session.query(MediaToken).filter(
MediaToken.token.in_(tokens)).all() MediaToken.token.in_(tokens)).all()
@classmethod @classmethod
def _get_file_records(cls, dir_record, session): def _get_file_records(cls, dir_record, session):
return session.query(MediaFile).filter_by( return session.query(MediaFile).filter_by(
directory_id=dir_record.id).all() directory_id=dir_record.id).all()
def scan(self, media_dir, session=None, dir_record=None): def scan(self, media_dir, session=None, dir_record=None):
""" """
Scans a media directory and stores the search results in the internal Scans a media directory and stores the search results in the internal
@ -186,8 +185,7 @@ class LocalMediaSearcher(MediaSearcher):
session.commit() session.commit()
def search(self, query, **kwargs):
def search(self, query):
""" """
Searches in the configured media directories given a query. It uses the Searches in the configured media directories given a query. It uses the
built-in SQLite index if available. If any directory has changed since built-in SQLite index if available. If any directory has changed since
@ -216,10 +214,12 @@ class LocalMediaSearcher(MediaSearcher):
join(MediaToken). \ join(MediaToken). \
filter(MediaToken.token.in_(query_tokens)). \ filter(MediaToken.token.in_(query_tokens)). \
group_by(MediaFile.path). \ 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] = { results[file_record.path] = {
'url': 'file://' + file_record.path, 'url': 'file://' + file_record.path,
'title': os.path.basename(file_record.path), 'title': os.path.basename(file_record.path),
'size': os.path.getsize(file_record.path)
} }
return results.values() return results.values()

View File

@ -8,7 +8,7 @@ class TorrentMediaSearcher(MediaSearcher):
torrents = get_plugin('torrent') torrents = get_plugin('torrent')
if not torrents: if not torrents:
raise RuntimeError('Torrent plugin not available/configured') raise RuntimeError('Torrent plugin not available/configured')
return torrents.search(query).output return torrents.search(query, ).output