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/results';
@import 'webpanel/plugins/media/controls';
@import 'webpanel/plugins/media/info';
.media-plugin {
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',
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)));
},
},
});

View file

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

View file

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

View file

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

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: () => {
return {
youtube: true,
generic: true,
};
},
},

View file

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

View file

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

View file

@ -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() {

View file

@ -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) %}
<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] : {}"
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>
<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>
</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:
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:

View file

@ -2,16 +2,11 @@
.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com>
"""
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 <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
:param query: Query string (default: empty string)
: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
: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 <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[:]

View file

@ -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')
@ -452,16 +455,45 @@ class MediaPlugin(Plugin):
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

View file

@ -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()

View file

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