forked from platypush/platypush
[YouTube] Added (read-only) playlists support.
This commit is contained in:
parent
b491f81cda
commit
4853f51c8b
6 changed files with 268 additions and 10 deletions
|
@ -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;
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue