forked from platypush/platypush
New media webplugin WIP
This commit is contained in:
parent
ba800ef8e2
commit
9305f86d0c
18 changed files with 222 additions and 60 deletions
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ MediaHandlers.torrent = Vue.extend({
|
|||
},
|
||||
|
||||
{
|
||||
text: 'Download',
|
||||
text: 'Download (on server)',
|
||||
icon: 'download',
|
||||
action: this.download,
|
||||
},
|
||||
|
|
|
@ -17,7 +17,7 @@ MediaHandlers.youtube = Vue.extend({
|
|||
},
|
||||
|
||||
{
|
||||
text: 'Download',
|
||||
text: 'Download (on server)',
|
||||
icon: 'download',
|
||||
action: this.download,
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
13
platypush/backend/http/static/js/plugins/media/info.js
Normal file
13
platypush/backend/http/static/js/plugins/media/info.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
Vue.component('media-info', {
|
||||
template: '#tmpl-media-info',
|
||||
mixins: [mediaUtils],
|
||||
props: {
|
||||
bus: { type: Object },
|
||||
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ MediaPlayers.browser = Vue.extend({
|
|||
default: () => {
|
||||
return {
|
||||
youtube: true,
|
||||
generic: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
@ -10,6 +10,7 @@ MediaPlayers.chromecast = Vue.extend({
|
|||
default: () => {
|
||||
return {
|
||||
youtube: true,
|
||||
generic: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
@ -11,6 +11,7 @@ MediaPlayers.local = Vue.extend({
|
|||
return {
|
||||
file: true,
|
||||
youtube: true,
|
||||
generic: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
26
platypush/backend/http/templates/plugins/media/info.html
Normal file
26
platypush/backend/http/templates/plugins/media/info.html
Normal 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>
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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[:]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue