Added YouTube support to new media webplugin

This commit is contained in:
Fabio Manganiello 2019-06-25 22:46:25 +02:00
parent cf23e2fc72
commit e55735f409
16 changed files with 256 additions and 74 deletions

View File

@ -1,4 +1,10 @@
.media-plugin { .media-plugin {
#media-info {
.modal {
max-width: 90%;
}
}
.info-container { .info-container {
.row { .row {
display: flex; display: flex;

View File

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

View File

@ -1,6 +1,5 @@
MediaHandlers.file = Vue.extend({ MediaHandlers.file = MediaHandlers.base.extend({
props: { props: {
bus: { type: Object },
iconClass: { iconClass: {
type: String, type: String,
default: 'fa fa-hdd', default: 'fa fa-hdd',
@ -64,10 +63,6 @@ MediaHandlers.file = Vue.extend({
return item; return item;
}, },
play: function(item) {
this.bus.$emit('play', item);
},
download: async function(item) { download: async function(item) {
this.bus.$on('streaming-started', (media) => { this.bus.$on('streaming-started', (media) => {
if (media.resource === item.url) { if (media.resource === item.url) {
@ -78,41 +73,35 @@ MediaHandlers.file = Vue.extend({
this.bus.$emit('start-streaming', item.url); 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({ MediaHandlers.generic = MediaHandlers.file.extend({
props: { props: {
bus: { type: Object },
iconClass: { iconClass: {
type: String, type: String,
default: 'fa fa-globe', default: 'fa fa-globe',
}, },
}, },
computed: {
dropdownItems: function() {
return [
{
text: 'Play',
icon: 'play',
action: this.play,
},
{
text: 'View info',
icon: 'info',
action: this.info,
},
];
},
},
methods: { methods: {
getMetadata: async function(url) { getMetadata: async function(url) {
return { return {
@ -120,10 +109,6 @@ MediaHandlers.generic = MediaHandlers.file.extend({
title: url, title: url,
}; };
}, },
info: async function(item) {
this.bus.$emit('info', (await this.getMetadata(item)));
},
}, },
}); });

View File

@ -1,6 +1,5 @@
MediaHandlers.youtube = Vue.extend({ MediaHandlers.youtube = MediaHandlers.base.extend({
props: { props: {
bus: { type: Object },
iconClass: { iconClass: {
type: String, type: String,
default: 'fab fa-youtube', default: 'fab fa-youtube',
@ -19,7 +18,13 @@ MediaHandlers.youtube = Vue.extend({
{ {
text: 'Download (on server)', text: 'Download (on server)',
icon: 'download', 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) { getMetadata: function(url) {
// TODO
return {}; 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');
}, },
}, },
}); });

View File

@ -110,6 +110,9 @@ Vue.component('media', {
let status = await this.selectedDevice.play(item.url, item.subtitles); let status = await this.selectedDevice.play(item.url, item.subtitles);
if (item.title)
status.title = item.title;
this.subsModal.visible = false; this.subsModal.visible = false;
this.onStatusUpdate({ this.onStatusUpdate({
device: this.selectedDevice, device: this.selectedDevice,
@ -207,6 +210,10 @@ Vue.component('media', {
const status = event.status; const status = event.status;
this.syncPosition(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]) if (!this.status[dev.type])
Vue.set(this.status, dev.type, {}); Vue.set(this.status, dev.type, {});
Vue.set(this.status[dev.type], dev.name, status); Vue.set(this.status[dev.type], dev.name, status);
@ -224,10 +231,20 @@ Vue.component('media', {
if (event.plugin.startsWith('media.')) if (event.plugin.startsWith('media.'))
event.plugin = event.plugin.substr(6); event.plugin = event.plugin.substr(6);
if (this.status[event.player] && this.status[event.player][event.plugin]) var type, player;
Vue.set(this.status[event.player], event.plugin, status); if (this.status[event.player] && this.status[event.player][event.plugin]) {
else if (this.status[event.plugin] && this.status[event.plugin][event.player]) type = event.player;
Vue.set(this.status[event.plugin], event.player, status); 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() { timerFunc: function() {

View File

@ -15,6 +15,12 @@ Vue.component('media-search', {
obj[type] = true; obj[type] = true;
return obj; 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) { 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); const protocol = this.isUrl(this.query);
if (protocol) { if (protocol) {

View File

@ -4,7 +4,7 @@
<div class="controls"> <div class="controls">
<div class="col-3 item-container"> <div class="col-3 item-container">
<div class="item-info"> <div class="item-info">
<span v-text="status.title" <span v-text="status.title || status.url"
@click="bus.$emit('info-load', status.url)" @click="bus.$emit('info-load', status.url)"
v-if="status.title"></span> v-if="status.title"></span>
</div> </div>

View File

@ -5,8 +5,12 @@
{% include 'plugins/media/info.html' %} {% include 'plugins/media/info.html' %}
{% include 'plugins/media/subs.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) %} {% 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> <script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/handlers/' + script) }}"></script>
{% endif %}
{% endfor %} {% endfor %}
<link rel="stylesheet" href="{{ url_for('static', filename='flag-icons/css/flag-icon.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='flag-icons/css/flag-icon.css') }}">

View File

@ -4,7 +4,9 @@
<div class="info-container"> <div class="info-container">
<div class="row"> <div class="row">
<div class="col-3 attr">URL</div> <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>
<div class="row" v-if="item.title"> <div class="row" v-if="item.title">
@ -12,6 +14,26 @@
<div class="col-9 value" v-text="item.title"></div> <div class="col-9 value" v-text="item.title"></div>
</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="row" v-if="item.duration">
<div class="col-3 attr">Duration</div> <div class="col-3 attr">Duration</div>
<div class="col-9 value" v-text="convertTime(item.duration)"></div> <div class="col-9 value" v-text="convertTime(item.duration)"></div>

View File

@ -18,11 +18,11 @@
</div> </div>
<div class="row types fade-in" :class="{hidden: !showFilter}"> <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" <input type="checkbox"
name="type" name="type"
:id="'media-type-' + type" :id="'media-type-' + type"
v-model.lazy="types[type]"> v-model.lazy="searchTypes[type]">
<label :for="'media-type-' + type" v-text="type"></label> <label :for="'media-type-' + type" v-text="type"></label>
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@ import functools
import os import os
import queue import queue
import re import re
import requests
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
@ -80,7 +81,7 @@ class MediaPlugin(Plugin):
:type media_dirs: list :type media_dirs: list
:param download_dir: Directory where external resources/torrents will be :param download_dir: Directory where external resources/torrents will be
downloaded (default: none) downloaded (default: ~/Downloads)
:type download_dir: str :type download_dir: str
:param env: Environment variables key-values to pass to the :param env: Environment variables key-values to pass to the
@ -110,7 +111,6 @@ class MediaPlugin(Plugin):
raise AttributeError('No media plugin configured') raise AttributeError('No media plugin configured')
media_dirs = media_dirs or player_config.get('media_dirs', []) 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': if self.__class__.__name__ == 'MediaPlugin':
# Populate this plugin with the actions of the configured player # 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(
self.download_dir = os.path.abspath(os.path.expanduser(download_dir)) download_dir or player_config.get('download_dir') or
if not os.path.isdir(self.download_dir): os.path.join((os.environ['HOME'] or self._env.get('HOME') or '/'), 'Downloads')))
raise RuntimeError('download_dir [{}] is not a valid directory'
.format(self.download_dir))
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._is_playing_torrent = False
self._videos_queue = [] self._videos_queue = []
@ -494,6 +494,30 @@ class MediaPlugin(Plugin):
).group(1).split(':')[::-1])] ).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): def is_local(self):
return self._is_local return self._is_local
@ -507,7 +531,6 @@ class MediaPlugin(Plugin):
if os.path.isfile(subtitles): if os.path.isfile(subtitles):
return os.path.abspath(subtitles) return os.path.abspath(subtitles)
else: else:
import requests
content = requests.get(subtitles).content content = requests.get(subtitles).content
f = tempfile.NamedTemporaryFile(prefix='media_subs_', f = tempfile.NamedTemporaryFile(prefix='media_subs_',
suffix='.srt', delete=False) suffix='.srt', delete=False)

View File

@ -278,7 +278,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
return { return {
'duration': self._player.duration(), '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(), 'fullscreen': self._player.fullscreen(),
'mute': self._player._is_muted, 'mute': self._player._is_muted,
'path': self._player.get_source(), 'path': self._player.get_source(),
@ -286,7 +286,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
'position': self._player.position(), 'position': self._player.position(),
'seekable': self._player.can_seek(), 'seekable': self._player.can_seek(),
'state': state, '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(), 'url': self._player.get_source(),
'volume': self._player.volume(), 'volume': self._player.volume(),
'volume_max': 100, 'volume_max': 100,

View File

@ -1,5 +1,6 @@
import logging import logging
class MediaSearcher: class MediaSearcher:
""" """
Base class for media searchers Base class for media searchers
@ -8,7 +9,6 @@ class MediaSearcher:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.logger = logging.getLogger(self.__class__.__name__) self.logger = logging.getLogger(self.__class__.__name__)
def search(self, query, *args, **kwargs): def search(self, query, *args, **kwargs):
raise NotImplementedError('The search method should be implemented ' + raise NotImplementedError('The search method should be implemented ' +
'by a derived class') 'by a derived class')

View File

@ -215,11 +215,12 @@ class LocalMediaSearcher(MediaSearcher):
filter(MediaToken.token.in_(query_tokens)). \ filter(MediaToken.token.in_(query_tokens)). \
group_by(MediaFile.path). \ group_by(MediaFile.path). \
having(func.count(MediaFileToken.token_id) >= len(query_tokens)): having(func.count(MediaFileToken.token_id) >= len(query_tokens)):
results[file_record.path] = { if (os.path.isfile(file_record.path)):
'url': 'file://' + file_record.path, results[file_record.path] = {
'title': os.path.basename(file_record.path), 'url': 'file://' + file_record.path,
'size': os.path.getsize(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

@ -1,11 +1,12 @@
import re import re
import urllib import urllib.parse
import urllib.request
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.plugins.media.search import MediaSearcher from platypush.plugins.media.search import MediaSearcher
class YoutubeMediaSearcher(MediaSearcher): class YoutubeMediaSearcher(MediaSearcher):
def search(self, query): def search(self, query, **kwargs):
""" """
Performs a YouTube search either using the YouTube API (faster and Performs a YouTube search either using the YouTube API (faster and
recommended, it requires the :mod:`platypush.plugins.google.youtube` recommended, it requires the :mod:`platypush.plugins.google.youtube`
@ -23,11 +24,12 @@ class YoutubeMediaSearcher(MediaSearcher):
return self._youtube_search_html_parse(query=query) return self._youtube_search_html_parse(query=query)
def _youtube_search_api(self, query): @staticmethod
def _youtube_search_api(query):
return [ return [
{ {
'url': 'https://www.youtube.com/watch?v=' + item['id']['videoId'], '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 for item in get_plugin('google.youtube').search(query=query).output
if item.get('id', {}).get('kind') == 'youtube#video' if item.get('id', {}).get('kind') == 'youtube#video'
@ -53,7 +55,7 @@ class YoutubeMediaSearcher(MediaSearcher):
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

View File

@ -45,6 +45,8 @@ class MediaVlcPlugin(MediaPlugin):
self._default_fullscreen = fullscreen self._default_fullscreen = fullscreen
self._default_volume = volume self._default_volume = volume
self._on_stop_callbacks = [] self._on_stop_callbacks = []
self._title = None
self._filename = None
@classmethod @classmethod
def _watched_event_types(cls): def _watched_event_types(cls):
@ -76,6 +78,9 @@ class MediaVlcPlugin(MediaPlugin):
def _reset_state(self): def _reset_state(self):
self._latest_seek = None self._latest_seek = None
self._title = None
self._filename = None
if self._player: if self._player:
self._player.release() self._player.release()
self._player = None self._player = None
@ -105,8 +110,12 @@ class MediaVlcPlugin(MediaPlugin):
for cbk in self._on_stop_callbacks: for cbk in self._on_stop_callbacks:
cbk() cbk()
elif event.type == EventType.MediaPlayerTitleChanged: 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) self._post_event(NewPlayingMediaEvent, resource=event.u.new_title)
elif event.type == EventType.MediaPlayerMediaChanged: elif event.type == EventType.MediaPlayerMediaChanged:
self._filename = event.u.filename
self._title = event.u.new_title
self._post_event(NewPlayingMediaEvent, resource=event.u.filename) self._post_event(NewPlayingMediaEvent, resource=event.u.filename)
elif event.type == EventType.MediaPlayerLengthChanged: elif event.type == EventType.MediaPlayerLengthChanged:
self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource()) self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource())
@ -396,8 +405,8 @@ class MediaVlcPlugin(MediaPlugin):
status['path'] = status['url'] status['path'] = status['url']
status['pause'] = status['state'] == PlayerState.PAUSE.value status['pause'] = status['state'] == PlayerState.PAUSE.value
status['percent_pos'] = self._player.get_position()*100 status['percent_pos'] = self._player.get_position()*100
status['filename'] = urllib.parse.unquote(status['url']).split('/')[-1] status['filename'] = self._filename
status['title'] = status['filename'] status['title'] = self._title
status['volume'] = self._player.audio_get_volume() status['volume'] = self._player.audio_get_volume()
status['volume_max'] = 100 status['volume_max'] = 100