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-info {
.modal {
max-width: 90%;
}
}
.info-container {
.row {
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: {
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)));
},
},
});

View file

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

View file

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

View file

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

View file

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

View file

@ -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') }}">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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