From d617443af686ebfeb18b461114f4dfbc303b89a9 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Wed, 15 Nov 2023 03:04:49 +0100 Subject: [PATCH] [YouTube] Added subscriptions+channels support. Closes: #337 --- .../src/components/panels/Media/Nav.vue | 2 +- .../panels/Media/Providers/YouTube.vue | 17 ++ .../Media/Providers/YouTube/Channel.vue | 188 ++++++++++++++++++ .../Media/Providers/YouTube/Subscriptions.vue | 137 +++++++++++++ .../src/components/panels/Media/Results.vue | 14 +- platypush/plugins/youtube/__init__.py | 76 ++++++- platypush/schemas/piped.py | 107 ++++++++++ 7 files changed, 529 insertions(+), 12 deletions(-) create mode 100644 platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube/Channel.vue create mode 100644 platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube/Subscriptions.vue diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Nav.vue b/platypush/backend/http/webapp/src/components/panels/Media/Nav.vue index 2409dac4c..93e370a40 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Nav.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Nav.vue @@ -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; diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube.vue b/platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube.vue index 297422e88..ee341678c 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube.vue @@ -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() { diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube/Channel.vue b/platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube/Channel.vue new file mode 100644 index 000000000..bebd83539 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube/Channel.vue @@ -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> diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube/Subscriptions.vue b/platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube/Subscriptions.vue new file mode 100644 index 000000000..e97cdf793 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Media/Providers/YouTube/Subscriptions.vue @@ -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> diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Results.vue b/platypush/backend/http/webapp/src/components/panels/Media/Results.vue index b82362841..c45530b35 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Results.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Results.vue @@ -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 }, }, diff --git a/platypush/plugins/youtube/__init__.py b/platypush/plugins/youtube/__init__.py index e9ad17043..d4250a9cc 100644 --- a/platypush/plugins/youtube/__init__.py +++ b/platypush/plugins/youtube/__init__.py @@ -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: diff --git a/platypush/schemas/piped.py b/platypush/schemas/piped.py index 96fcf4495..98c28bf09 100644 --- a/platypush/schemas/piped.py +++ b/platypush/schemas/piped.py @@ -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