Compare commits

...

4 commits

Author SHA1 Message Date
d4354e81f8
[Youtube UI] Added playlist operations.
All checks were successful
continuous-integration/drone/push Build is passing
- `add_to_playlist`
- `remove_from_playlist`
2024-06-27 00:24:31 +02:00
701623c99d
Merge branch 'master' into 391/improve-youtube-support 2024-06-27 00:22:22 +02:00
8880b966fc
[youtube] Fixed playlist operations URLs.
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-27 00:21:27 +02:00
5d4bfb3f90
Fixed un-bumped version in setup.py 2024-06-27 00:21:03 +02:00
14 changed files with 411 additions and 30 deletions

View file

@ -43,13 +43,21 @@ export default {
this.close() this.close()
}, },
show() { open() {
this.$refs.modal.show() this.$refs.modal.show()
}, },
close() { close() {
this.$refs.modal.hide() this.$refs.modal.hide()
}, },
show() {
this.open()
},
hide() {
this.close()
},
}, },
} }
</script> </script>

View file

@ -3,7 +3,7 @@
<form @submit.prevent="onConfirm"> <form @submit.prevent="onConfirm">
<div class="dialog-content"> <div class="dialog-content">
<slot /> <slot />
<input type="text" ref="input" /> <input type="text" ref="input" v-model="value_" />
</div> </div>
<div class="buttons"> <div class="buttons">
@ -38,26 +38,79 @@ export default {
type: String, type: String,
default: "Cancel", default: "Cancel",
}, },
visible: {
type: Boolean,
default: false,
},
value: {
type: String,
default: "",
},
},
data() {
return {
value_: "",
visible_: false,
}
}, },
methods: { methods: {
onConfirm() { onConfirm() {
this.$emit('input', this.$refs.input.value) this.$emit('input', this.value_)
this.close() this.close()
}, },
show() { open() {
if (this.visible_)
return
this.value_ = this.value
this.$refs.modal.show() this.$refs.modal.show()
this.visible_ = true
this.focus()
}, },
close() { close() {
if (!this.visible_)
return
this.value_ = ""
this.$refs.modal.hide() this.$refs.modal.hide()
this.visible_ = false
},
show() {
this.open()
},
hide() {
this.close()
},
focus() {
this.$nextTick(() => {
this.$refs.input.focus()
})
},
},
watch: {
visible(val) {
if (val) {
this.open()
} else {
this.close()
}
}, },
}, },
mounted() { mounted() {
this.visible_ = this.visible
this.value_ = this.value || ""
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.input.value = ""
this.$refs.input.focus() this.$refs.input.focus()
}) })
}, },

View file

@ -23,9 +23,11 @@
<component <component
:is="mediaProvider" :is="mediaProvider"
:filter="filter" :filter="filter"
@add-to-playlist="$emit('add-to-playlist', $event)"
@back="mediaProvider = null" @back="mediaProvider = null"
@path-change="$emit('path-change', $event)" @path-change="$emit('path-change', $event)"
@play="$emit('play', $event)" /> @play="$emit('play', $event)"
/>
</div> </div>
</div> </div>
</keep-alive> </keep-alive>
@ -39,8 +41,17 @@ import Utils from "@/Utils";
import providersMetadata from "./Providers/meta.json"; import providersMetadata from "./Providers/meta.json";
export default { export default {
emits: ['path-change', 'play'],
mixins: [Utils], mixins: [Utils],
emits: [
'add-to-playlist',
'create-playlist',
'path-change',
'play',
'remove-from-playlist',
'remove-playlist',
'rename-playlist',
],
components: { components: {
Browser, Browser,
Loading, Loading,

View file

@ -39,6 +39,7 @@
:plugin-name="pluginName" :plugin-name="pluginName"
:loading="loading" :loading="loading"
:filter="browserFilter" :filter="browserFilter"
@add-to-playlist="addToPlaylistItem = $event"
@select="onResultSelect($event)" @select="onResultSelect($event)"
@play="play" @play="play"
@view="view" @view="view"
@ -51,6 +52,7 @@
v-else-if="selectedView === 'torrents'" /> v-else-if="selectedView === 'torrents'" />
<Browser :filter="browserFilter" <Browser :filter="browserFilter"
@add-to-playlist="addToPlaylistItem = $event"
@path-change="browserFilter = ''" @path-change="browserFilter = ''"
@play="play($event)" @play="play($event)"
v-else-if="selectedView === 'browser'" /> v-else-if="selectedView === 'browser'" />
@ -75,6 +77,16 @@
<UrlPlayer :value="urlPlay" @input="urlPlay = $event.target.value" @play="playUrl($event)" /> <UrlPlayer :value="urlPlay" @input="urlPlay = $event.target.value" @play="playUrl($event)" />
</Modal> </Modal>
</div> </div>
<div class="add-to-playlist-container" v-if="addToPlaylistItem">
<Modal title="Add to playlist" :visible="addToPlaylistItem != null" @close="addToPlaylistItem = null">
<PlaylistAdder
:item="addToPlaylistItem"
@done="addToPlaylistItem = null"
@close="addToPlaylistItem = null"
/>
</Modal>
</div>
</div> </div>
</keep-alive> </keep-alive>
</template> </template>
@ -88,6 +100,7 @@ import Header from "@/components/panels/Media/Header";
import MediaUtils from "@/components/Media/Utils"; import MediaUtils from "@/components/Media/Utils";
import MediaView from "@/components/Media/View"; import MediaView from "@/components/Media/View";
import Nav from "@/components/panels/Media/Nav"; import Nav from "@/components/panels/Media/Nav";
import PlaylistAdder from "@/components/panels/Media/PlaylistAdder";
import Results from "@/components/panels/Media/Results"; import Results from "@/components/panels/Media/Results";
import Subtitles from "@/components/panels/Media/Subtitles"; import Subtitles from "@/components/panels/Media/Subtitles";
import Transfers from "@/components/panels/Torrent/Transfers"; import Transfers from "@/components/panels/Torrent/Transfers";
@ -102,6 +115,7 @@ export default {
MediaView, MediaView,
Modal, Modal,
Nav, Nav,
PlaylistAdder,
Results, Results,
Subtitles, Subtitles,
Transfers, Transfers,
@ -139,6 +153,7 @@ export default {
awaitingPlayTorrent: null, awaitingPlayTorrent: null,
urlPlay: null, urlPlay: null,
browserFilter: null, browserFilter: null,
addToPlaylistItem: null,
torrentPlugin: null, torrentPlugin: null,
torrentPlugins: [ torrentPlugins: [
'torrent', 'torrent',
@ -487,4 +502,10 @@ export default {
} }
} }
} }
:deep(.add-to-playlist-container) {
.body {
padding: 0 !important;
}
}
</style> </style>

View file

@ -4,6 +4,7 @@
:class="{selected: selected}" :class="{selected: selected}"
@click.right.prevent="$refs.dropdown.toggle()" @click.right.prevent="$refs.dropdown.toggle()"
v-if="!hidden"> v-if="!hidden">
<div class="thumbnail"> <div class="thumbnail">
<MediaImage :item="item" @play="$emit('play')" /> <MediaImage :item="item" @play="$emit('play')" />
</div> </div>
@ -19,6 +20,10 @@
v-if="item.type === 'torrent'" /> v-if="item.type === 'torrent'" />
<DropdownItem icon-class="fa fa-window-maximize" text="View in browser" @click="$emit('view')" <DropdownItem icon-class="fa fa-window-maximize" text="View in browser" @click="$emit('view')"
v-if="item.type === 'file'" /> v-if="item.type === 'file'" />
<DropdownItem icon-class="fa fa-list" text="Add to playlist" @click="$emit('add-to-playlist')"
v-if="item.type === 'youtube'" />
<DropdownItem icon-class="fa fa-trash" text="Remove from playlist" @click="$refs.confirmPlaylistRemove.open()"
v-if="playlist" />
<DropdownItem icon-class="fa fa-info-circle" text="Info" @click="$emit('select')" /> <DropdownItem icon-class="fa fa-info-circle" text="Info" @click="$emit('select')" />
</Dropdown> </Dropdown>
</div> </div>
@ -35,10 +40,15 @@
{{ formatDateTime(item.created_at, true) }} {{ formatDateTime(item.created_at, true) }}
</div> </div>
</div> </div>
<ConfirmDialog ref="confirmPlaylistRemove" @input="$emit('remove-from-playlist')">
Are you sure you want to remove this item from the playlist?
</ConfirmDialog>
</div> </div>
</template> </template>
<script> <script>
import ConfirmDialog from "@/components/elements/ConfirmDialog";
import Dropdown from "@/components/elements/Dropdown"; import Dropdown from "@/components/elements/Dropdown";
import DropdownItem from "@/components/elements/DropdownItem"; import DropdownItem from "@/components/elements/DropdownItem";
import Icons from "./icons.json"; import Icons from "./icons.json";
@ -46,9 +56,23 @@ import MediaImage from "./MediaImage";
import Utils from "@/Utils"; import Utils from "@/Utils";
export default { export default {
components: {Dropdown, DropdownItem, MediaImage},
mixins: [Utils], mixins: [Utils],
emits: ['play', 'select', 'view', 'download'], components: {
ConfirmDialog,
Dropdown,
DropdownItem,
MediaImage,
},
emits: [
'add-to-playlist',
'download',
'play',
'remove-from-playlist',
'select',
'view',
],
props: { props: {
item: { item: {
type: Object, type: Object,
@ -64,6 +88,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
playlist: {
default: null,
},
}, },
data() { data() {
@ -87,6 +115,10 @@ export default {
border: 1px solid transparent; border: 1px solid transparent;
border-bottom: 1px solid transparent !important; border-bottom: 1px solid transparent !important;
@include from($tablet) {
max-height: max(25em, 25%);
}
&.selected { &.selected {
box-shadow: $border-shadow-bottom; box-shadow: $border-shadow-bottom;
background: $selected-bg; background: $selected-bg;

View file

@ -0,0 +1,162 @@
<template>
<div class="playlist-adder-container">
<Loading v-if="loading" />
<TextPrompt ref="newPlaylistName" :visible="showNewPlaylist" @input="createPlaylist($event)">
Playlist name
</TextPrompt>
<div class="playlists">
<div class="playlist new-playlist">
<button @click="showNewPlaylist = true">
<i class="fa fa-plus" />
Create new playlist
</button>
</div>
<div class="playlist" v-for="playlist in playlists" :key="playlist.id">
<button @click="addToPlaylist(playlist.id)">
<i class="fa fa-list" />
{{ playlist.name }}
</button>
</div>
</div>
</div>
</template>
<script>
import Loading from "@/components/Loading";
import Utils from "@/Utils";
import TextPrompt from "@/components/elements/TextPrompt"
export default {
emits: ['done'],
mixins: [Utils],
components: {Loading, TextPrompt},
props: {
item: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
playlists: [],
showNewPlaylist: false,
}
},
methods: {
async createPlaylist(name) {
name = name?.trim()
if (!name?.length)
return
this.loading = true
try {
const playlist = await this.request('youtube.create_playlist', {
name: name,
})
await this.request('youtube.add_to_playlist', {
playlist_id: playlist.id,
video_id: this.item.id || this.item.url,
})
this.$emit('done')
this.notify({
text: 'Playlist created and video added',
image: {
icon: 'check',
}
})
} finally {
this.loading = false
this.showNewPlaylist = false
}
},
async refreshPlaylists() {
this.loading = true
try {
this.playlists = await this.request('youtube.get_playlists')
} finally {
this.loading = false
}
},
async addToPlaylist(playlistId) {
this.loading = true
try {
await this.request('youtube.add_to_playlist', {
playlist_id: playlistId,
video_id: this.item.id || this.item.url,
})
this.notify({
text: 'Video added to playlist',
image: {
icon: 'check',
}
})
this.$emit('done')
} finally {
this.loading = false
}
},
},
mounted() {
this.refreshPlaylists()
},
}
</script>
<style lang="scss" scoped>
.playlist-adder-container {
min-width: 300px;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
.playlists {
width: 100%;
overflow-y: auto;
}
.playlist {
button {
width: 100%;
text-align: left;
padding: 0.5em 1em;
border: none;
background: none;
cursor: pointer;
transition: background 0.2s, color 0.2s;
&:hover {
background: $hover-bg;
}
i {
margin-right: 0.5em;
}
}
}
.new-playlist {
button {
font-weight: bold;
border-bottom: $default-border;
}
}
}
</style>

View file

@ -2,8 +2,18 @@
import Utils from "@/Utils"; import Utils from "@/Utils";
export default { export default {
emits: ['back', 'path-change', 'play'],
mixins: [Utils], mixins: [Utils],
emits: [
'add-to-playlist',
'back',
'create-playlist',
'path-change',
'play',
'remove-from-playlist',
'remove-playlist',
'rename-playlist',
],
props: { props: {
filter: { filter: {
type: String, type: String,

View file

@ -8,17 +8,27 @@
<div class="body" v-else> <div class="body" v-else>
<Feed :filter="filter" <Feed :filter="filter"
@play="$emit('play', $event)" v-if="selectedView === 'feed'" /> @add-to-playlist="$emit('add-to-playlist', $event)"
@play="$emit('play', $event)"
v-if="selectedView === 'feed'"
/>
<Playlists :filter="filter" <Playlists :filter="filter"
:selected-playlist="selectedPlaylist" :selected-playlist="selectedPlaylist"
@add-to-playlist="$emit('add-to-playlist', $event)"
@play="$emit('play', $event)" @play="$emit('play', $event)"
@remove-from-playlist="removeFromPlaylist"
@select="onPlaylistSelected" @select="onPlaylistSelected"
v-else-if="selectedView === 'playlists'" /> v-else-if="selectedView === 'playlists'"
/>
<Subscriptions :filter="filter" <Subscriptions :filter="filter"
:selected-channel="selectedChannel" :selected-channel="selectedChannel"
@play="$emit('play', $event)" @play="$emit('play', $event)"
@select="onChannelSelected" @select="onChannelSelected"
v-else-if="selectedView === 'subscriptions'" /> v-else-if="selectedView === 'subscriptions'"
/>
<Index @select="selectView" v-else /> <Index @select="selectView" v-else />
</div> </div>
</div> </div>
@ -87,6 +97,21 @@ export default {
} }
}, },
async removeFromPlaylist(event) {
const playlistId = event.playlist_id
const videoId = event.item.url
this.loading = true
try {
await this.request('youtube.remove_from_playlist', {
playlist_id: playlistId,
video_id: videoId,
})
} finally {
this.loading = false
}
},
selectView(view) { selectView(view) {
this.selectedView = view this.selectedView = view
if (view === 'playlists') if (view === 'playlists')

View file

@ -9,6 +9,7 @@
:filter="filter" :filter="filter"
:sources="{'youtube': true}" :sources="{'youtube': true}"
:selected-result="selectedResult" :selected-result="selectedResult"
@add-to-playlist="$emit('add-to-playlist', $event)"
@select="selectedResult = $event" @select="selectedResult = $event"
@play="$emit('play', $event)" @play="$emit('play', $event)"
v-else /> v-else />
@ -22,8 +23,12 @@ import Results from "@/components/panels/Media/Results";
import Utils from "@/Utils"; import Utils from "@/Utils";
export default { export default {
emits: ['play'],
mixins: [Utils], mixins: [Utils],
emits: [
'add-to-playlist',
'play',
],
components: { components: {
Loading, Loading,
NoItems, NoItems,

View file

@ -8,9 +8,12 @@
<Results :results="items" <Results :results="items"
:sources="{'youtube': true}" :sources="{'youtube': true}"
:filter="filter" :filter="filter"
:playlist="id"
:selected-result="selectedResult" :selected-result="selectedResult"
@select="selectedResult = $event" @add-to-playlist="$emit('add-to-playlist', $event)"
@play="$emit('play', $event)" @play="$emit('play', $event)"
@remove-from-playlist="$emit('remove-from-playlist', $event)"
@select="selectedResult = $event"
v-else /> v-else />
</div> </div>
</template> </template>
@ -22,8 +25,13 @@ import Results from "@/components/panels/Media/Results";
import Utils from "@/Utils"; import Utils from "@/Utils";
export default { export default {
emits: ['play'],
mixins: [Utils], mixins: [Utils],
emits: [
'add-to-playlist',
'play',
'remove-from-playlist',
],
components: { components: {
Loading, Loading,
NoItems, NoItems,

View file

@ -18,7 +18,14 @@
</div> </div>
<div class="playlist-body" v-else> <div class="playlist-body" v-else>
<Playlist :id="selectedPlaylist" :filter="filter" @play="$emit('play', $event)" /> <Playlist
:id="selectedPlaylist"
:filter="filter"
:playlist="playlist"
@add-to-playlist="$emit('add-to-playlist', $event)"
@remove-from-playlist="$emit('remove-from-playlist', {item: $event, playlist_id: selectedPlaylist})"
@play="$emit('play', $event)"
/>
</div> </div>
</div> </div>
</template> </template>
@ -31,8 +38,17 @@ import Playlist from "./Playlist";
import Utils from "@/Utils"; import Utils from "@/Utils";
export default { export default {
emits: ['play', 'select'],
mixins: [Utils], mixins: [Utils],
emits: [
'add-to-playlist',
'create-playlist',
'play',
'remove-from-playlist',
'remove-playlist',
'rename-playlist',
'select',
],
components: { components: {
Loading, Loading,
MediaImage, MediaImage,

View file

@ -4,9 +4,12 @@
<div class="grid" ref="grid" v-if="results?.length" @scroll="onScroll"> <div class="grid" ref="grid" v-if="results?.length" @scroll="onScroll">
<Item v-for="(item, i) in visibleResults" <Item v-for="(item, i) in visibleResults"
:key="i" :key="i"
:item="item"
:selected="selectedResult === i"
:hidden="!!Object.keys(sources || {}).length && !sources[item.type]" :hidden="!!Object.keys(sources || {}).length && !sources[item.type]"
:item="item"
:playlist="playlist"
:selected="selectedResult === i"
@add-to-playlist="$emit('add-to-playlist', item)"
@remove-from-playlist="$emit('remove-from-playlist', item)"
@select="$emit('select', i)" @select="$emit('select', i)"
@play="$emit('play', item)" @play="$emit('play', item)"
@view="$emit('view', item)" @view="$emit('view', item)"
@ -30,7 +33,16 @@ import Modal from "@/components/Modal";
export default { export default {
components: {Info, Item, Loading, Modal}, components: {Info, Item, Loading, Modal},
emits: ['select', 'play', 'view', 'download', 'scroll-end'], emits: [
'add-to-playlist',
'download',
'play',
'remove-from-playlist',
'scroll-end',
'select',
'view',
],
props: { props: {
loading: { loading: {
type: Boolean, type: Boolean,
@ -64,6 +76,10 @@ export default {
type: Number, type: Number,
default: 25, default: 25,
}, },
playlist: {
default: null,
},
}, },
data() { data() {

View file

@ -1,4 +1,5 @@
import base64 import base64
import re
from functools import lru_cache from functools import lru_cache
from typing import List, Optional from typing import List, Optional
@ -99,6 +100,14 @@ class YoutubePlugin(Plugin):
PipedChannelSchema().dump(self._request(f'channel/{id}')) or {} # type: ignore PipedChannelSchema().dump(self._request(f'channel/{id}')) or {} # type: ignore
) )
@staticmethod
def _get_video_id(id_or_url: str) -> str:
m = re.search(r'/watch\?v=([^&]+)', id_or_url)
if m:
return m.group(1)
return id_or_url
@action @action
def search(self, query: str, **_) -> List[dict]: def search(self, query: str, **_) -> List[dict]:
""" """
@ -215,10 +224,10 @@ class YoutubePlugin(Plugin):
:param playlist_id: Piped playlist ID. :param playlist_id: Piped playlist ID.
""" """
self._request( self._request(
'playlists/add', 'user/playlists/add',
method='post', method='post',
json={ json={
'videoIds': [video_id], 'videoIds': [self._get_video_id(video_id)],
'playlistId': playlist_id, 'playlistId': playlist_id,
}, },
) )
@ -235,13 +244,15 @@ class YoutubePlugin(Plugin):
Note that either the video ID or the index must be provided. Note that either the video ID or the index must be provided.
:param video_id: YouTube video ID. :param video_id: YouTube video ID or URL.
:param index: (0-based) index of the video in the playlist. :param index: (0-based) index of the video in the playlist.
:param playlist_id: Piped playlist ID. :param playlist_id: Piped playlist ID.
""" """
assert video_id or index, 'Either the video ID or the index must be provided' assert video_id or index, 'Either the video ID or the index must be provided'
if index is None: if index is None:
assert video_id
video_id = self._get_video_id(video_id)
index = next( index = next(
( (
i i
@ -250,7 +261,7 @@ class YoutubePlugin(Plugin):
'relatedStreams', [] 'relatedStreams', []
) )
) )
if v.get('id') == video_id if self._get_video_id(v.get('url')) == video_id
), ),
None, None,
) )
@ -262,7 +273,7 @@ class YoutubePlugin(Plugin):
return return
self._request( self._request(
'playlists/remove', 'user/playlists/remove',
method='post', method='post',
json={ json={
'index': index, 'index': index,
@ -278,8 +289,11 @@ class YoutubePlugin(Plugin):
:param name: Playlist name. :param name: Playlist name.
:return: Playlist information. :return: Playlist information.
""" """
name = name.strip()
assert name, 'Playlist name cannot be empty'
playlist_id = self._request( playlist_id = self._request(
'playlists/create', 'user/playlists/create',
method='post', method='post',
json={'name': name}, json={'name': name},
).get('playlistId') ).get('playlistId')
@ -312,7 +326,7 @@ class YoutubePlugin(Plugin):
return return
self._request( self._request(
'playlists/rename', 'user/playlists/rename',
method='post', method='post',
json={ json={
'playlistId': id, 'playlistId': id,
@ -328,7 +342,7 @@ class YoutubePlugin(Plugin):
:param id: Piped playlist ID. :param id: Piped playlist ID.
""" """
self._request( self._request(
'playlists/delete', 'user/playlists/delete',
method='post', method='post',
json={'playlistId': id}, json={'playlistId': id},
) )

View file

@ -66,7 +66,7 @@ backend = pkg_files('platypush/backend')
setup( setup(
name="platypush", name="platypush",
version="1.1.0", version="1.1.1",
author="Fabio Manganiello", author="Fabio Manganiello",
author_email="fabio@manganiello.tech", author_email="fabio@manganiello.tech",
description="Platypush service", description="Platypush service",