forked from platypush/platypush
parent
9ed7026aaf
commit
d617443af6
7 changed files with 529 additions and 12 deletions
|
@ -63,7 +63,7 @@ nav {
|
|||
position: relative;
|
||||
box-shadow: 2.5px 0 4.5px 2px $nav-collapsed-fg;
|
||||
margin-left: 2.5px;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
|
||||
.menu-button {
|
||||
position: absolute;
|
||||
|
|
|
@ -14,6 +14,11 @@
|
|||
@play="$emit('play', $event)"
|
||||
@select="onPlaylistSelected"
|
||||
v-else-if="selectedView === 'playlists'" />
|
||||
<Subscriptions :filter="filter"
|
||||
:selected-channel="selectedChannel"
|
||||
@play="$emit('play', $event)"
|
||||
@select="onChannelSelected"
|
||||
v-else-if="selectedView === 'subscriptions'" />
|
||||
<Index @select="selectView" v-else />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -29,6 +34,7 @@ import Feed from "./YouTube/Feed";
|
|||
import Index from "./YouTube/Index";
|
||||
import NoToken from "./YouTube/NoToken";
|
||||
import Playlists from "./YouTube/Playlists";
|
||||
import Subscriptions from "./YouTube/Subscriptions";
|
||||
|
||||
export default {
|
||||
mixins: [MediaProvider],
|
||||
|
@ -39,6 +45,7 @@ export default {
|
|||
MediaNav,
|
||||
NoToken,
|
||||
Playlists,
|
||||
Subscriptions,
|
||||
},
|
||||
|
||||
data() {
|
||||
|
@ -46,6 +53,7 @@ export default {
|
|||
youtubeConfig: null,
|
||||
selectedView: null,
|
||||
selectedPlaylist: null,
|
||||
selectedChannel: null,
|
||||
path: [],
|
||||
}
|
||||
},
|
||||
|
@ -83,6 +91,8 @@ export default {
|
|||
this.selectedView = view
|
||||
if (view === 'playlists')
|
||||
this.selectedPlaylist = null
|
||||
else if (view === 'subscriptions')
|
||||
this.selectedChannel = null
|
||||
|
||||
if (view?.length) {
|
||||
this.path = [
|
||||
|
@ -102,6 +112,13 @@ export default {
|
|||
title: playlist.name,
|
||||
})
|
||||
},
|
||||
|
||||
onChannelSelected(channel) {
|
||||
this.selectedChannel = channel.id
|
||||
this.path.push({
|
||||
title: channel.name,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
|
|
|
@ -0,0 +1,188 @@
|
|||
<template>
|
||||
<div class="media-youtube-channel" @scroll="onScroll">
|
||||
<Loading v-if="loading" />
|
||||
|
||||
<div class="channel" @scroll="onScroll" v-else-if="channel">
|
||||
<div class="header">
|
||||
<div class="banner">
|
||||
<img :src="channel.banner" v-if="channel?.banner?.length" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<a :href="channel.url" target="_blank" rel="noopener noreferrer">
|
||||
<div class="image">
|
||||
<img :src="channel.image" v-if="channel?.image?.length" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="info">
|
||||
<a class="title" :href="channel.url" target="_blank" rel="noopener noreferrer">
|
||||
{{ channel?.name }}
|
||||
</a>
|
||||
<div class="description">{{ channel?.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Results :results="channel.items"
|
||||
:filter="filter"
|
||||
:selected-result="selectedResult"
|
||||
ref="results"
|
||||
@select="selectedResult = $event"
|
||||
@play="$emit('play', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Loading from "@/components/Loading";
|
||||
import Results from "@/components/panels/Media/Results";
|
||||
import Utils from "@/Utils";
|
||||
|
||||
export default {
|
||||
emits: ['play'],
|
||||
mixins: [Utils],
|
||||
components: {
|
||||
Loading,
|
||||
Results,
|
||||
},
|
||||
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
filter: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
channel: null,
|
||||
loading: false,
|
||||
loadingNextPage: false,
|
||||
selectedResult: null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
itemsByUrl() {
|
||||
return this.channel?.items.reduce((acc, item) => {
|
||||
acc[item.url] = item
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadChannel() {
|
||||
this.loading = true
|
||||
try {
|
||||
this.channel = await this.request('youtube.get_channel', {id: this.id})
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async loadNextPage() {
|
||||
if (!this.channel?.next_page_token || this.loadingNextPage)
|
||||
return
|
||||
|
||||
try {
|
||||
const nextPage = await this.request(
|
||||
'youtube.get_channel',
|
||||
{id: this.id, next_page_token: this.channel.next_page_token}
|
||||
)
|
||||
|
||||
this.channel.items.push(...nextPage.items.filter(item => !this.itemsByUrl[item.url]))
|
||||
this.channel.next_page_token = nextPage.next_page_token
|
||||
this.$refs.results.maxResultIndex += this.$refs.results.resultIndexStep
|
||||
} finally {
|
||||
this.loadingNextPage = false
|
||||
}
|
||||
},
|
||||
|
||||
onScroll(e) {
|
||||
const el = e.target
|
||||
if (!el)
|
||||
return
|
||||
|
||||
const bottom = (el.scrollHeight - el.scrollTop) <= el.clientHeight + 150
|
||||
if (!bottom)
|
||||
return
|
||||
|
||||
this.loadNextPage()
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadChannel()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.media-youtube-channel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
.header {
|
||||
border-bottom: $default-border-2;
|
||||
padding-bottom: 0.5em;
|
||||
|
||||
.banner {
|
||||
max-height: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
height: 100px;
|
||||
margin: -2.5em 2em 0.5em 0.5em;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
|
||||
@include from($desktop) {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $default-fg-2;
|
||||
font-size: 1.7em;
|
||||
font-weight: bold;
|
||||
margin: 0.5em 0;
|
||||
text-decoration: dotted;
|
||||
|
||||
&:hover {
|
||||
color: $default-hover-fg;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.9em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,137 @@
|
|||
<template>
|
||||
<div class="media-youtube-subscriptions">
|
||||
<div class="subscriptions-index" v-if="!selectedChannel">
|
||||
<Loading v-if="loading" />
|
||||
<NoItems :with-shadow="false" v-else-if="!channels?.length">
|
||||
No channels found.
|
||||
</NoItems>
|
||||
|
||||
<div class="body grid" v-else>
|
||||
<div class="channel item"
|
||||
v-for="(channel, id) in channelsById"
|
||||
:key="id"
|
||||
@click="$emit('select', channel)">
|
||||
<div class="image">
|
||||
<img :src="channel.image" :alt="channel.name" />
|
||||
</div>
|
||||
<div class="title">{{ channel.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subscription-body" v-else>
|
||||
<Channel :id="selectedChannel" :filter="filter" @play="$emit('play', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Channel from "./Channel";
|
||||
import NoItems from "@/components/elements/NoItems";
|
||||
import Loading from "@/components/Loading";
|
||||
import Utils from "@/Utils";
|
||||
|
||||
export default {
|
||||
emits: ['play', 'select'],
|
||||
mixins: [Utils],
|
||||
components: {
|
||||
Channel,
|
||||
Loading,
|
||||
NoItems,
|
||||
},
|
||||
|
||||
props: {
|
||||
selectedChannel: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
filter: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
channels: [],
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
channelsById() {
|
||||
return this.channels
|
||||
.filter(channel => !this.filter || channel.name.toLowerCase().includes(this.filter.toLowerCase()))
|
||||
.reduce((acc, channel) => {
|
||||
acc[channel.id] = channel
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadSubscriptions() {
|
||||
this.loading = true
|
||||
try {
|
||||
this.channels = (await this.request('youtube.get_subscriptions'))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadSubscriptions()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.media-youtube-subscriptions {
|
||||
height: 100%;
|
||||
|
||||
.channel.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: $default-border-2;
|
||||
box-shadow: $border-shadow-bottom;
|
||||
border-radius: 0.5em;
|
||||
cursor: pointer;
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 10em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 5em;
|
||||
height: 5em;
|
||||
border-radius: 0.5em;
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.1em;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
||||
img {
|
||||
filter: contrast(70%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subscription-body {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,16 +1,12 @@
|
|||
<template>
|
||||
<div class="media-results" @scroll="onScroll">
|
||||
<Loading v-if="loading" />
|
||||
<NoItems v-else-if="!results?.length" :with-shadow="false">
|
||||
No search results
|
||||
</NoItems>
|
||||
|
||||
<div class="grid" ref="grid" v-else>
|
||||
<div class="grid" ref="grid" v-if="results?.length">
|
||||
<Item v-for="(item, i) in visibleResults"
|
||||
:key="i"
|
||||
:item="item"
|
||||
:selected="selectedResult === i"
|
||||
:hidden="!sources[item.type]"
|
||||
:hidden="!!Object.keys(sources || {}).length && !sources[item.type]"
|
||||
@select="$emit('select', i)"
|
||||
@play="$emit('play', item)"
|
||||
@view="$emit('view', item)"
|
||||
|
@ -30,11 +26,10 @@ import Info from "@/components/panels/Media/Info";
|
|||
import Item from "./Item";
|
||||
import Loading from "@/components/Loading";
|
||||
import Modal from "@/components/Modal";
|
||||
import NoItems from "@/components/elements/NoItems";
|
||||
|
||||
export default {
|
||||
components: {Info, Item, Loading, Modal, NoItems},
|
||||
emits: ['select', 'play', 'view', 'download'],
|
||||
components: {Info, Item, Loading, Modal},
|
||||
emits: ['select', 'play', 'view', 'download', 'scroll-end'],
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
|
@ -95,6 +90,7 @@ export default {
|
|||
if (!bottom)
|
||||
return
|
||||
|
||||
this.$emit('scroll-end')
|
||||
this.maxResultIndex += this.resultIndexStep
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
import base64
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from platypush.plugins import Plugin, action
|
||||
from platypush.schemas.piped import PipedPlaylistSchema, PipedVideoSchema
|
||||
from platypush.schemas.piped import (
|
||||
PipedChannelSchema,
|
||||
PipedPlaylistSchema,
|
||||
PipedVideoSchema,
|
||||
)
|
||||
|
||||
|
||||
class YoutubePlugin(Plugin):
|
||||
|
@ -53,7 +59,9 @@ class YoutubePlugin(Plugin):
|
|||
def _api_url(self, path: str = '') -> str:
|
||||
return f"{self._piped_api_url}/{path}"
|
||||
|
||||
def _request(self, path: str, auth: bool = True, **kwargs):
|
||||
def _request(
|
||||
self, path: str, body: Optional[str] = None, auth: bool = True, **kwargs
|
||||
):
|
||||
timeout = kwargs.pop('timeout', self._timeout)
|
||||
if auth:
|
||||
kwargs['params'] = kwargs.get('params', {})
|
||||
|
@ -61,10 +69,26 @@ class YoutubePlugin(Plugin):
|
|||
kwargs['headers'] = kwargs.get('headers', {})
|
||||
kwargs['headers']['Authorization'] = self._auth_token
|
||||
|
||||
if body:
|
||||
kwargs['data'] = body
|
||||
|
||||
rs = requests.get(self._api_url(path), timeout=timeout, **kwargs)
|
||||
rs.raise_for_status()
|
||||
return rs.json()
|
||||
|
||||
@lru_cache(maxsize=10) # noqa
|
||||
def _get_channel(self, id: str) -> dict: # pylint: disable=redefined-builtin
|
||||
if (
|
||||
id.startswith('http')
|
||||
or id.startswith('https')
|
||||
or id.startswith('/channel/')
|
||||
):
|
||||
id = id.split('/')[-1]
|
||||
|
||||
return (
|
||||
PipedChannelSchema().dump(self._request(f'channel/{id}')) or {} # type: ignore
|
||||
)
|
||||
|
||||
@action
|
||||
def search(self, query: str, **_) -> List[dict]:
|
||||
"""
|
||||
|
@ -124,5 +148,53 @@ class YoutubePlugin(Plugin):
|
|||
or []
|
||||
)
|
||||
|
||||
@action
|
||||
def get_subscriptions(self) -> List[dict]:
|
||||
"""
|
||||
Retrieve the channels subscribed by the user logged in to the Piped
|
||||
instance.
|
||||
|
||||
:return: .. schema:: piped.PipedChannelSchema(many=True)
|
||||
"""
|
||||
return PipedChannelSchema(many=True).dump(self._request('subscriptions')) or []
|
||||
|
||||
@action
|
||||
def get_channel(
|
||||
self,
|
||||
id: str, # pylint: disable=redefined-builtin
|
||||
next_page_token: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Retrieve the information and videos of a channel given its ID or URL.
|
||||
|
||||
:param id: Channel ID or URL.
|
||||
:param next_page_token: Optional token to retrieve the next page of
|
||||
results.
|
||||
:return: .. schema:: piped.PipedChannelSchema
|
||||
"""
|
||||
if (
|
||||
id.startswith('http')
|
||||
or id.startswith('https')
|
||||
or id.startswith('/channel/')
|
||||
):
|
||||
id = id.split('/')[-1]
|
||||
|
||||
info = {}
|
||||
if next_page_token:
|
||||
info = self._get_channel(id).copy()
|
||||
info.pop('next_page_token', None)
|
||||
info['items'] = []
|
||||
next_page = base64.b64decode(next_page_token.encode()).decode()
|
||||
response = {
|
||||
**info,
|
||||
**self._request(
|
||||
f'nextpage/channel/{id}', params={'nextpage': next_page}, auth=False
|
||||
),
|
||||
}
|
||||
else:
|
||||
response = self._request(f'channel/{id}')
|
||||
|
||||
return PipedChannelSchema().dump(response) or {} # type: ignore
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import base64
|
||||
from datetime import datetime
|
||||
|
||||
from marshmallow import EXCLUDE, fields, pre_dump
|
||||
|
@ -157,3 +158,109 @@ class PipedPlaylistSchema(Schema):
|
|||
'example': 10,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PipedChannelSchema(Schema):
|
||||
"""
|
||||
Class for channel items returned by the Piped API.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""
|
||||
Exclude unknown fields.
|
||||
"""
|
||||
|
||||
unknown = EXCLUDE
|
||||
|
||||
id = fields.String(
|
||||
required=True,
|
||||
metadata={
|
||||
'description': 'Channel ID',
|
||||
'example': '1234567890',
|
||||
},
|
||||
)
|
||||
|
||||
url = fields.String(
|
||||
required=True,
|
||||
metadata={
|
||||
'description': 'Channel URL',
|
||||
'example': 'https://youtube.com/channel/1234567890',
|
||||
},
|
||||
)
|
||||
|
||||
name = StrippedString(
|
||||
missing='[No Name]',
|
||||
metadata={
|
||||
'description': 'Channel name',
|
||||
'example': 'My Channel Name',
|
||||
},
|
||||
)
|
||||
|
||||
description = StrippedString(
|
||||
metadata={
|
||||
'description': 'Channel description',
|
||||
'example': 'My channel description',
|
||||
},
|
||||
)
|
||||
|
||||
image = fields.Url(
|
||||
attribute='avatar',
|
||||
metadata={
|
||||
'description': 'Channel image URL',
|
||||
'example': 'https://i.ytimg.com/vi/1234567890/hqdefault.jpg',
|
||||
},
|
||||
)
|
||||
|
||||
banner = fields.Url(
|
||||
attribute='bannerUrl',
|
||||
metadata={
|
||||
'description': 'Channel banner URL',
|
||||
'example': 'https://i.ytimg.com/vi/1234567890/hqdefault.jpg',
|
||||
},
|
||||
)
|
||||
|
||||
subscribers = fields.Int(
|
||||
attribute='subscriberCount',
|
||||
missing=0,
|
||||
metadata={
|
||||
'description': 'Number of subscribers',
|
||||
'example': 1000,
|
||||
},
|
||||
)
|
||||
|
||||
next_page_token = fields.String(
|
||||
attribute='nextpage',
|
||||
metadata={
|
||||
'description': 'The token that should be passed to get the next page of results',
|
||||
'example': '1234567890',
|
||||
},
|
||||
)
|
||||
|
||||
items = fields.Nested(PipedVideoSchema, attribute='relatedStreams', many=True)
|
||||
|
||||
@pre_dump
|
||||
def normalize_id_and_url(self, data: dict, **_):
|
||||
if data.get('id'):
|
||||
if not data.get('url'):
|
||||
data['url'] = f'https://youtube.com/channel/{data["id"]}'
|
||||
elif data.get('url'):
|
||||
data['id'] = data['url'].split('/')[-1]
|
||||
data['url'] = f'https://youtube.com{data["url"]}'
|
||||
else:
|
||||
raise AssertionError('Channel ID or URL not found')
|
||||
|
||||
return data
|
||||
|
||||
@pre_dump
|
||||
def normalize_avatar(self, data: dict, **_):
|
||||
if data.get('avatarUrl'):
|
||||
data['avatar'] = data.pop('avatarUrl')
|
||||
|
||||
return data
|
||||
|
||||
@pre_dump
|
||||
def serialize_next_page_token(self, data: dict, **_):
|
||||
if data.get('nextpage'):
|
||||
data['nextpage'] = base64.b64encode(data['nextpage'].encode()).decode()
|
||||
|
||||
return data
|
||||
|
|
Loading…
Reference in a new issue