[YouTube] Added subscriptions+channels support.
continuous-integration/drone/push Build is failing Details

Closes: #337
This commit is contained in:
Fabio Manganiello 2023-11-15 03:04:49 +01:00
parent 9ed7026aaf
commit d617443af6
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
7 changed files with 529 additions and 12 deletions

View File

@ -63,7 +63,7 @@ nav {
position: relative; position: relative;
box-shadow: 2.5px 0 4.5px 2px $nav-collapsed-fg; box-shadow: 2.5px 0 4.5px 2px $nav-collapsed-fg;
margin-left: 2.5px; margin-left: 2.5px;
overflow: hidden; overflow: auto;
.menu-button { .menu-button {
position: absolute; position: absolute;

View File

@ -14,6 +14,11 @@
@play="$emit('play', $event)" @play="$emit('play', $event)"
@select="onPlaylistSelected" @select="onPlaylistSelected"
v-else-if="selectedView === 'playlists'" /> 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 /> <Index @select="selectView" v-else />
</div> </div>
</div> </div>
@ -29,6 +34,7 @@ import Feed from "./YouTube/Feed";
import Index from "./YouTube/Index"; import Index from "./YouTube/Index";
import NoToken from "./YouTube/NoToken"; import NoToken from "./YouTube/NoToken";
import Playlists from "./YouTube/Playlists"; import Playlists from "./YouTube/Playlists";
import Subscriptions from "./YouTube/Subscriptions";
export default { export default {
mixins: [MediaProvider], mixins: [MediaProvider],
@ -39,6 +45,7 @@ export default {
MediaNav, MediaNav,
NoToken, NoToken,
Playlists, Playlists,
Subscriptions,
}, },
data() { data() {
@ -46,6 +53,7 @@ export default {
youtubeConfig: null, youtubeConfig: null,
selectedView: null, selectedView: null,
selectedPlaylist: null, selectedPlaylist: null,
selectedChannel: null,
path: [], path: [],
} }
}, },
@ -83,6 +91,8 @@ export default {
this.selectedView = view this.selectedView = view
if (view === 'playlists') if (view === 'playlists')
this.selectedPlaylist = null this.selectedPlaylist = null
else if (view === 'subscriptions')
this.selectedChannel = null
if (view?.length) { if (view?.length) {
this.path = [ this.path = [
@ -102,6 +112,13 @@ export default {
title: playlist.name, title: playlist.name,
}) })
}, },
onChannelSelected(channel) {
this.selectedChannel = channel.id
this.path.push({
title: channel.name,
})
},
}, },
mounted() { mounted() {

View File

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

View File

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

View File

@ -1,16 +1,12 @@
<template> <template>
<div class="media-results" @scroll="onScroll"> <div class="media-results" @scroll="onScroll">
<Loading v-if="loading" /> <Loading v-if="loading" />
<NoItems v-else-if="!results?.length" :with-shadow="false"> <div class="grid" ref="grid" v-if="results?.length">
No search results
</NoItems>
<div class="grid" ref="grid" v-else>
<Item v-for="(item, i) in visibleResults" <Item v-for="(item, i) in visibleResults"
:key="i" :key="i"
:item="item" :item="item"
:selected="selectedResult === i" :selected="selectedResult === i"
:hidden="!sources[item.type]" :hidden="!!Object.keys(sources || {}).length && !sources[item.type]"
@select="$emit('select', i)" @select="$emit('select', i)"
@play="$emit('play', item)" @play="$emit('play', item)"
@view="$emit('view', item)" @view="$emit('view', item)"
@ -30,11 +26,10 @@ import Info from "@/components/panels/Media/Info";
import Item from "./Item"; import Item from "./Item";
import Loading from "@/components/Loading"; import Loading from "@/components/Loading";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import NoItems from "@/components/elements/NoItems";
export default { export default {
components: {Info, Item, Loading, Modal, NoItems}, components: {Info, Item, Loading, Modal},
emits: ['select', 'play', 'view', 'download'], emits: ['select', 'play', 'view', 'download', 'scroll-end'],
props: { props: {
loading: { loading: {
type: Boolean, type: Boolean,
@ -95,6 +90,7 @@ export default {
if (!bottom) if (!bottom)
return return
this.$emit('scroll-end')
this.maxResultIndex += this.resultIndexStep this.maxResultIndex += this.resultIndexStep
}, },
}, },

View File

@ -1,9 +1,15 @@
import base64
from functools import lru_cache
from typing import List, Optional from typing import List, Optional
import requests import requests
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
from platypush.schemas.piped import PipedPlaylistSchema, PipedVideoSchema from platypush.schemas.piped import (
PipedChannelSchema,
PipedPlaylistSchema,
PipedVideoSchema,
)
class YoutubePlugin(Plugin): class YoutubePlugin(Plugin):
@ -53,7 +59,9 @@ class YoutubePlugin(Plugin):
def _api_url(self, path: str = '') -> str: def _api_url(self, path: str = '') -> str:
return f"{self._piped_api_url}/{path}" 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) timeout = kwargs.pop('timeout', self._timeout)
if auth: if auth:
kwargs['params'] = kwargs.get('params', {}) kwargs['params'] = kwargs.get('params', {})
@ -61,10 +69,26 @@ class YoutubePlugin(Plugin):
kwargs['headers'] = kwargs.get('headers', {}) kwargs['headers'] = kwargs.get('headers', {})
kwargs['headers']['Authorization'] = self._auth_token kwargs['headers']['Authorization'] = self._auth_token
if body:
kwargs['data'] = body
rs = requests.get(self._api_url(path), timeout=timeout, **kwargs) rs = requests.get(self._api_url(path), timeout=timeout, **kwargs)
rs.raise_for_status() rs.raise_for_status()
return rs.json() 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 @action
def search(self, query: str, **_) -> List[dict]: def search(self, query: str, **_) -> List[dict]:
""" """
@ -124,5 +148,53 @@ class YoutubePlugin(Plugin):
or [] 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: # vim:sw=4:ts=4:et:

View File

@ -1,3 +1,4 @@
import base64
from datetime import datetime from datetime import datetime
from marshmallow import EXCLUDE, fields, pre_dump from marshmallow import EXCLUDE, fields, pre_dump
@ -157,3 +158,109 @@ class PipedPlaylistSchema(Schema):
'example': 10, '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