[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;
box-shadow: 2.5px 0 4.5px 2px $nav-collapsed-fg;
margin-left: 2.5px;
overflow: hidden;
overflow: auto;
.menu-button {
position: absolute;

View File

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

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

View File

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

View File

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