[YouTube] Added (read-only) playlists support.

This commit is contained in:
Fabio Manganiello 2023-11-14 21:47:36 +01:00
parent b491f81cda
commit 4853f51c8b
Signed by: blacklight
GPG key ID: D90FBA7F76362774
6 changed files with 268 additions and 10 deletions

View file

@ -1,6 +1,7 @@
<template> <template>
<div class="image-container" :class="{ 'with-image': !!item?.image }"> <div class="image-container"
<div class="play-overlay" @click="$emit('play', item)"> :class="{ 'with-image': !!item?.image }">
<div class="play-overlay" @click="$emit('play', item)" v-if="hasPlay">
<i class="fas fa-play" /> <i class="fas fa-play" />
</div> </div>
@ -25,8 +26,11 @@
</a> </a>
</span> </span>
<span class="duration" v-if="item?.duration != null" <span class="bottom-overlay duration" v-if="item?.duration != null"
v-text="convertTime(item.duration)" /> v-text="convertTime(item.duration)" />
<span class="bottom-overlay videos" v-else-if="item?.videos != null">
{{ item.videos }} items
</span>
</div> </div>
</template> </template>
@ -41,7 +45,12 @@ export default {
item: { item: {
type: Object, type: Object,
default: () => {}, default: () => {},
} },
hasPlay: {
type: Boolean,
default: true,
},
}, },
data() { data() {
@ -94,7 +103,7 @@ export default {
right: 0; right: 0;
} }
.duration { .bottom-overlay {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
right: 0; right: 0;

View file

@ -8,6 +8,7 @@
<div class="body" v-else> <div class="body" v-else>
<Feed @play="$emit('play', $event)" v-if="selectedView === 'feed'" /> <Feed @play="$emit('play', $event)" v-if="selectedView === 'feed'" />
<Playlists @play="$emit('play', $event)" v-if="selectedView === 'playlists'" />
<Index @select="selectView" v-else /> <Index @select="selectView" v-else />
</div> </div>
</div> </div>
@ -22,6 +23,7 @@ import MediaProvider from "./Mixin";
import Feed from "./YouTube/Feed"; 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";
export default { export default {
mixins: [MediaProvider], mixins: [MediaProvider],
@ -31,6 +33,7 @@ export default {
Loading, Loading,
MediaNav, MediaNav,
NoToken, NoToken,
Playlists,
}, },
data() { data() {

View file

@ -0,0 +1,74 @@
<template>
<div class="media-youtube-playlist">
<Loading v-if="loading" />
<NoItems :with-shadow="false" v-else-if="!items?.length">
No videos found.
</NoItems>
<Results :results="items"
:sources="{'youtube': true}"
:selected-result="selectedResult"
@select="selectedResult = $event"
@play="$emit('play', $event)"
v-else />
</div>
</template>
<script>
import NoItems from "@/components/elements/NoItems";
import Loading from "@/components/Loading";
import Results from "@/components/panels/Media/Results";
import Utils from "@/Utils";
export default {
emits: ['play'],
mixins: [Utils],
components: {
Loading,
NoItems,
Results,
},
props: {
id: {
type: String,
required: true,
},
},
data() {
return {
items: [],
loading: false,
selectedResult: null,
}
},
methods: {
async loadItems() {
this.loading = true
try {
this.items = (
await this.request('youtube.get_playlist', {id: this.id})
).map(item => ({
...item,
type: 'youtube',
}))
} finally {
this.loading = false
}
},
},
mounted() {
this.loadItems()
},
}
</script>
<style lang="scss" scoped>
.media-youtube-playlist {
height: 100%;
overflow: auto;
}
</style>

View file

@ -0,0 +1,90 @@
<template>
<div class="media-youtube-playlists">
<div class="playlists-index" v-if="!selectedPlaylist">
<Loading v-if="loading" />
<NoItems :with-shadow="false" v-else-if="!playlists?.length">
No playlists found.
</NoItems>
<div class="body grid" v-else>
<div class="playlist item"
v-for="(playlist, id) in playlistsById"
:key="id"
@click="selectedPlaylist = id">
<MediaImage :item="playlist" :has-play="false" />
<div class="title">{{ playlist.name }}</div>
</div>
</div>
</div>
<div class="playlist-body" v-else>
<Playlist :id="selectedPlaylist" @play="$emit('play', $event)" />
</div>
</div>
</template>
<script>
import MediaImage from "@/components/panels/Media/MediaImage";
import NoItems from "@/components/elements/NoItems";
import Loading from "@/components/Loading";
import Playlist from "./Playlist";
import Utils from "@/Utils";
export default {
emits: ['play'],
mixins: [Utils],
components: {
Loading,
MediaImage,
NoItems,
Playlist,
},
data() {
return {
playlists: [],
loading: false,
selectedPlaylist: null,
}
},
computed: {
playlistsById() {
return this.playlists.reduce((acc, playlist) => {
acc[playlist.id] = playlist
return acc
}, {})
},
},
methods: {
async loadPlaylists() {
this.loading = true
try {
this.playlists = (await this.request('youtube.get_playlists'))
} finally {
this.loading = false
}
},
},
mounted() {
this.loadPlaylists()
},
}
</script>
<style lang="scss" scoped>
.media-youtube-playlists {
height: 100%;
overflow: auto;
.item {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
</style>

View file

@ -1,9 +1,9 @@
from typing import 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 PipedVideoSchema from platypush.schemas.piped import PipedPlaylistSchema, PipedVideoSchema
class YoutubePlugin(Plugin): class YoutubePlugin(Plugin):
@ -16,7 +16,7 @@ class YoutubePlugin(Plugin):
prevent scraping, and it requires the user to tinker with the OAuth layer, prevent scraping, and it requires the user to tinker with the OAuth layer,
app permissions and app validation in order to get it working. app permissions and app validation in order to get it working.
Instead, it relies on a `Piped <https://docs.piped.video/`_, an open-source Instead, it relies on a `Piped <https://docs.piped.video/>`_, an open-source
alternative YouTube gateway. alternative YouTube gateway.
It thus requires a link to a valid Piped instance. It thus requires a link to a valid Piped instance.
@ -58,13 +58,15 @@ class YoutubePlugin(Plugin):
if auth: if auth:
kwargs['params'] = kwargs.get('params', {}) kwargs['params'] = kwargs.get('params', {})
kwargs['params']['authToken'] = self._auth_token kwargs['params']['authToken'] = self._auth_token
kwargs['headers'] = kwargs.get('headers', {})
kwargs['headers']['Authorization'] = self._auth_token
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()
@action @action
def search(self, query: str, **_): def search(self, query: str, **_) -> List[dict]:
""" """
Search for YouTube videos. Search for YouTube videos.
@ -83,7 +85,7 @@ class YoutubePlugin(Plugin):
return results return results
@action @action
def get_feed(self): def get_feed(self) -> List[dict]:
""" """
Retrieve the YouTube feed. Retrieve the YouTube feed.
@ -95,5 +97,32 @@ class YoutubePlugin(Plugin):
""" """
return PipedVideoSchema(many=True).dump(self._request('feed')) or [] return PipedVideoSchema(many=True).dump(self._request('feed')) or []
@action
def get_playlists(self) -> List[dict]:
"""
Retrieve the playlists saved by the user logged in to the Piped
instance.
:return: .. schema:: piped.PipedPlaylistSchema(many=True)
"""
return (
PipedPlaylistSchema(many=True).dump(self._request('user/playlists')) or []
)
@action
def get_playlist(self, id: str) -> List[dict]: # pylint: disable=redefined-builtin
"""
Retrieve the videos in a playlist.
:param id: Piped playlist ID.
:return: .. schema:: piped.PipedVideoSchema(many=True)
"""
return (
PipedVideoSchema(many=True).dump(
self._request(f'playlists/{id}').get('relatedStreams', [])
)
or []
)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -104,3 +104,56 @@ class PipedVideoSchema(Schema):
data['uploaded'] = datetime.fromtimestamp(data["uploaded"] / 1000) data['uploaded'] = datetime.fromtimestamp(data["uploaded"] / 1000)
return data return data
class PipedPlaylistSchema(Schema):
"""
Class for playlist items returned by the Piped API.
"""
class Meta:
"""
Exclude unknown fields.
"""
unknown = EXCLUDE
id = fields.UUID(
required=True,
metadata={
'description': 'Playlist ID',
'example': '12345678-1234-1234-1234-1234567890ab',
},
)
name = StrippedString(
missing='[No Name]',
metadata={
'description': 'Playlist name',
'example': 'My Playlist Name',
},
)
image = fields.Url(
attribute='thumbnail',
metadata={
'description': 'Image URL',
'example': 'https://i.ytimg.com/vi/1234567890/hqdefault.jpg',
},
)
description = StrippedString(
attribute='shortDescription',
metadata={
'description': 'Video description',
'example': 'My video description',
},
)
videos = fields.Int(
missing=0,
metadata={
'description': 'Number of videos in the playlist',
'example': 10,
},
)