Added YouTube support to new media webplugin
This commit is contained in:
parent
cf23e2fc72
commit
e55735f409
16 changed files with 256 additions and 74 deletions
|
@ -1,4 +1,10 @@
|
|||
.media-plugin {
|
||||
#media-info {
|
||||
.modal {
|
||||
max-width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.info-container {
|
||||
.row {
|
||||
display: flex;
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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)));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<div class="controls">
|
||||
<div class="col-3 item-container">
|
||||
<div class="item-info">
|
||||
<span v-text="status.title"
|
||||
<span v-text="status.title || status.url"
|
||||
@click="bus.$emit('info-load', status.url)"
|
||||
v-if="status.title"></span>
|
||||
</div>
|
||||
|
|
|
@ -5,8 +5,12 @@
|
|||
{% include 'plugins/media/info.html' %}
|
||||
{% include 'plugins/media/subs.html' %}
|
||||
|
||||
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/handlers/base.js') }}"></script>
|
||||
|
||||
{% for script in utils.search_directory(static_folder + '/js/plugins/media/handlers', 'js', recursive=True) %}
|
||||
{% if script != 'base.js' %}
|
||||
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/handlers/' + script) }}"></script>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='flag-icons/css/flag-icon.css') }}">
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
<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 class="col-9 value">
|
||||
<a :href="item.url" v-text="item.url" target="_blank"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="item.title">
|
||||
|
@ -12,6 +14,26 @@
|
|||
<div class="col-9 value" v-text="item.title"></div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="item.description">
|
||||
<div class="col-3 attr">Description</div>
|
||||
<div class="col-9 value" v-text="item.description"></div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="item.channelTitle">
|
||||
<div class="col-3 attr">Channel</div>
|
||||
<div class="col-9 value">
|
||||
<a :href="'https://www.youtube.com/channel/' + item.channelId" v-if="item.channelId" target="_blank">
|
||||
{% raw %}{{ item.channelTitle }}{% endraw %}
|
||||
</a>
|
||||
<span v-text="item.channelTitle" v-else></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="item.publishedAt">
|
||||
<div class="col-3 attr">Published</div>
|
||||
<div class="col-9 value" v-text="new Date(item.publishedAt).toDateString()"></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>
|
||||
|
|
|
@ -18,11 +18,11 @@
|
|||
</div>
|
||||
|
||||
<div class="row types fade-in" :class="{hidden: !showFilter}">
|
||||
<div class="type" v-for="config,type in types">
|
||||
<div class="type" v-for="config,type in searchTypes">
|
||||
<input type="checkbox"
|
||||
name="type"
|
||||
:id="'media-type-' + type"
|
||||
v-model.lazy="types[type]">
|
||||
v-model.lazy="searchTypes[type]">
|
||||
<label :for="'media-type-' + type" v-text="type"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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', '<No 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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue