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