[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 untrusted user: blacklight
GPG key ID: D90FBA7F76362774
6 changed files with 268 additions and 10 deletions

View file

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

View file

@ -8,6 +8,7 @@
<div class="body" v-else>
<Feed @play="$emit('play', $event)" v-if="selectedView === 'feed'" />
<Playlists @play="$emit('play', $event)" v-if="selectedView === 'playlists'" />
<Index @select="selectView" v-else />
</div>
</div>
@ -22,6 +23,7 @@ import MediaProvider from "./Mixin";
import Feed from "./YouTube/Feed";
import Index from "./YouTube/Index";
import NoToken from "./YouTube/NoToken";
import Playlists from "./YouTube/Playlists";
export default {
mixins: [MediaProvider],
@ -31,6 +33,7 @@ export default {
Loading,
MediaNav,
NoToken,
Playlists,
},
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
from platypush.plugins import Plugin, action
from platypush.schemas.piped import PipedVideoSchema
from platypush.schemas.piped import PipedPlaylistSchema, PipedVideoSchema
class YoutubePlugin(Plugin):
@ -16,7 +16,7 @@ class YoutubePlugin(Plugin):
prevent scraping, and it requires the user to tinker with the OAuth layer,
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.
It thus requires a link to a valid Piped instance.
@ -58,13 +58,15 @@ class YoutubePlugin(Plugin):
if auth:
kwargs['params'] = kwargs.get('params', {})
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.raise_for_status()
return rs.json()
@action
def search(self, query: str, **_):
def search(self, query: str, **_) -> List[dict]:
"""
Search for YouTube videos.
@ -83,7 +85,7 @@ class YoutubePlugin(Plugin):
return results
@action
def get_feed(self):
def get_feed(self) -> List[dict]:
"""
Retrieve the YouTube feed.
@ -95,5 +97,32 @@ class YoutubePlugin(Plugin):
"""
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:

View file

@ -104,3 +104,56 @@ class PipedVideoSchema(Schema):
data['uploaded'] = datetime.fromtimestamp(data["uploaded"] / 1000)
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,
},
)