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>
|
<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;
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
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:
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
Loading…
Reference in a new issue