Podcasts add get episode feed and download, add edit podcast episode modal

This commit is contained in:
advplyr 2022-03-27 15:37:04 -05:00
parent 08e1782253
commit 3f8e685d64
16 changed files with 398 additions and 23 deletions

View File

@ -8,7 +8,7 @@
</div>
<div v-if="loaded && !shelves.length && isRootUser && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">Library is empty!</p>
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
@ -53,6 +53,9 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
bookCoverWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio) return coverSize * 1.6

View File

@ -7,7 +7,7 @@
</template>
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">Library is empty!</p>
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
@ -143,6 +143,9 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
isEntityBook() {
return this.entityName === 'series-books' || this.entityName === 'books'
},

View File

@ -1,11 +1,11 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4">
<div class="flex items-center mb-4">
<!-- <div class="flex items-center mb-4">
<p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p>
<div class="flex-grow" />
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check for new episodes</ui-btn>
</div>
</div> -->
<div class="w-full p-4 bg-primary">
<p>Podcast Episodes</p>

View File

@ -0,0 +1,129 @@
<template>
<modals-modal v-model="show" name="podcast-episode-edit-modal" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div class="flex flex-wrap">
<div class="w-1/3 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
</div>
<div class="w-1/3 p-1">
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
</div>
<div class="w-1/3 p-1">
<ui-text-input-with-label v-model="newEpisode.pubDate" label="Pub Date" />
</div>
<div class="w-full p-1">
<ui-text-input-with-label v-model="newEpisode.title" label="Title" />
</div>
<div class="w-full p-1">
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
</div>
<div class="w-full p-1">
<ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" />
</div>
</div>
<div class="flex justify-end pt-4">
<ui-btn @click="submit">Submit</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false,
newEpisode: {
episode: null,
episodeType: null,
title: null,
subtitle: null,
description: null,
pubDate: null
}
}
},
watch: {
episode: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
episodeId() {
return this.episode ? this.episode.id : null
},
title() {
if (!this.libraryItem) return ''
return this.libraryItem.media.metadata.title || 'Unknown'
}
},
methods: {
init() {
this.newEpisode.episode = this.episode.episode || ''
this.newEpisode.episodeType = this.episode.episodeType || ''
this.newEpisode.title = this.episode.title || ''
this.newEpisode.subtitle = this.episode.subtitle || ''
this.newEpisode.description = this.episode.description || ''
this.newEpisode.pubDate = this.episode.pubDate || ''
},
getUpdatePayload() {
var updatePayload = {}
for (const key in this.newEpisode) {
if (this.newEpisode[key] != this.episode[key]) {
updatePayload[key] = this.newEpisode[key]
}
}
return updatePayload
},
submit() {
const payload = this.getUpdatePayload()
if (!Object.keys(payload).length) {
return this.$toast.info('No updates were made')
}
this.processing = true
this.$axios
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
.then(() => {
this.processing = false
this.$toast.success('Podcast episode updated')
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed update episode'
console.error('Failed update episode', error)
this.processing = false
this.$toast.error(errorMsg)
})
}
},
mounted() {}
}
</script>

View File

@ -0,0 +1,129 @@
<template>
<modals-modal v-model="show" name="podcast-episodes-modal" :width="1200" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
<div
v-for="(episode, index) in episodes"
:key="index"
class="relative"
:class="episode.enclosure && itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(index)"
>
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<span v-if="episode.enclosure && itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
<p class="text-gray-400 text-xs mb-0.5">Published {{ $dateDistanceFromNow(episode.publishedAt) }}</p>
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
<p class="break-words mb-1">{{ episode.title }}</p>
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
<!-- <span class="material-icons cursor-pointer text-lg hover:text-success" @click="saveEpisode(episode)">save</span> -->
</div>
</div>
</div>
<div class="flex justify-end pt-4">
<ui-btn :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episodes: {
type: Array,
default: () => []
}
},
data() {
return {
processing: false,
selectedEpisodes: {}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
if (!this.libraryItem) return ''
return this.libraryItem.media.metadata.title || 'Unknown'
},
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
},
buttonText() {
if (!this.episodesSelected.length) return 'No Episodes Selected'
return `Download ${this.episodesSelected.length} Episode${this.episodesSelected.length > 1 ? 's' : ''}`
},
itemEpisodes() {
if (!this.libraryItem) return []
return this.libraryItem.media.episodes || []
},
itemEpisodeMap() {
var map = {}
this.itemEpisodes.forEach((item) => {
if (item.enclosure) map[item.enclosure.url] = true
})
return map
}
},
methods: {
toggleSelectEpisode(index) {
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
},
submit() {
var episodesToDownload = []
if (this.episodesSelected.length) {
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
}
console.log('Podcast payload', episodesToDownload)
this.processing = true
this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
.then(() => {
this.processing = false
this.$toast.success('Started downloading episodes')
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
console.error('Failed to download episodes', error)
this.processing = false
this.$toast.error(errorMsg)
})
}
},
mounted() {}
}
</script>
<style scoped>
#podcast-wrapper {
min-height: 400px;
max-height: 80vh;
}
#episodes-scroll {
max-height: calc(80vh - 200px);
}
</style>

View File

@ -14,7 +14,7 @@
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
</div>
<div class="p-1 w-full">
<ui-text-input-with-label v-model="podcast.title" label="Title" />
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
</div>
<div class="p-1 w-full">
<ui-text-input-with-label v-model="podcast.author" label="Author" />
@ -169,6 +169,9 @@ export default {
}
},
methods: {
titleUpdated() {
this.folderUpdated()
},
folderUpdated() {
if (!this.selectedFolderPath || !this.podcast.title) {
this.fullPath = ''
@ -219,9 +222,10 @@ export default {
this.$router.push(`/item/${libraryItem.id}`)
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to create podcast'
console.error('Failed to create podcast', error)
this.processing = false
this.$toast.error('Failed to create podcast')
this.$toast.error(errorMsg)
})
},
saveEpisode(episode) {
@ -251,13 +255,11 @@ export default {
}
}
},
mounted() {
console.log('Podcast feed data', this.podcastFeedData)
}
mounted() {}
}
</script>
<style>
<style scoped>
#podcast-wrapper {
min-height: 400px;
max-height: 80vh;

View File

@ -113,7 +113,9 @@ export default {
mouseleave() {
this.isHovering = false
},
clickEdit() {},
clickEdit() {
this.$emit('edit', this.episode)
},
playClick() {
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')

View File

@ -1,16 +1,16 @@
<template>
<div class="w-full py-6">
<p class="text-lg mb-0 font-semibold">Episodes</p>
<p v-if="!episodes.length" class="py-4 text-center text-lg">
No Episodes
</p>
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
<draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'episode' : null">
<template v-for="episode in episodesCopy">
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" />
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" />
</template>
</transition-group>
</draggable>
<modals-podcast-edit-episode v-model="showEditEpisodeModal" :library-item="libraryItem" :episode="selectedEpisode" />
</div>
</template>
@ -35,7 +35,9 @@ export default {
group: 'description',
ghostClass: 'ghost'
},
episodesCopy: []
episodesCopy: [],
selectedEpisode: null,
showEditEpisodeModal: false
}
},
watch: {
@ -57,6 +59,10 @@ export default {
}
},
methods: {
editEpisode(episode) {
this.selectedEpisode = episode
this.showEditEpisodeModal = true
},
draggableUpdate() {
var episodesUpdate = {
episodes: this.episodesCopy.map((b) => b.id)

View File

@ -1,6 +1,11 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled" :class="className" @click="clickBtn">
<span :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
<span v-else :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
</button>
</template>
@ -14,7 +19,8 @@ export default {
default: 'primary'
},
outlined: Boolean,
borderless: Boolean
borderless: Boolean,
loading: Boolean
},
data() {
return {}
@ -34,7 +40,7 @@ export default {
},
methods: {
clickBtn(e) {
if (this.disabled) {
if (this.disabled || this.loading) {
e.preventDefault()
return
}

View File

@ -2,7 +2,7 @@
<div>
<p class="text-xl">Stats for library {{ currentLibraryName }}</p>
<stats-preview-icons :library-stats="libraryStats" />
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-12">
<div class="w-80 my-6 mx-auto">

View File

@ -107,6 +107,7 @@
</div>
</div>
<!-- Icon buttons -->
<div class="flex items-center justify-center md:justify-start pt-4">
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
@ -137,6 +138,10 @@
<ui-tooltip v-if="!isPodcast" text="Collections" direction="top">
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip>
<ui-tooltip v-if="isPodcast" text="Find Episodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip>
</div>
<div class="my-4 max-w-2xl">
@ -151,6 +156,8 @@
</div>
</div>
</div>
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
</div>
</template>
@ -175,7 +182,10 @@ export default {
data() {
return {
resettingProgress: false,
isProcessingReadUpdate: false
isProcessingReadUpdate: false,
fetchingRSSFeed: false,
showPodcastEpisodeFeed: false,
podcastFeedEpisodes: []
}
},
computed: {
@ -330,6 +340,28 @@ export default {
}
},
methods: {
async findEpisodesClick() {
if (!this.mediaMetadata.feedUrl) {
return this.$toast.error('Podcast does not have an RSS Feed')
}
this.fetchingRSSFeed = true
var podcastfeed = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
console.error('Failed to get feed', error)
this.$toast.error('Failed to get podcast feed')
return null
})
this.fetchingRSSFeed = false
if (!podcastfeed) return
console.log('Podcast feed', podcastfeed)
if (!podcastfeed.episodes || !podcastfeed.episodes.length) {
this.$toast.info('No episodes found in RSS feed')
return
}
this.podcastFeedEpisodes = podcastfeed.episodes
this.showPodcastEpisodeFeed = true
},
showEditCover() {
this.$store.commit('setBookshelfBookIds', [])
this.$store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'cover' })

View File

@ -25,7 +25,7 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
}
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || !bytes === 0) {
if (isNaN(bytes) || bytes == 0) {
return '0 Bytes'
}
const k = 1024

View File

@ -126,5 +126,46 @@ class PodcastController {
episodes: newEpisodes || []
})
}
async downloadEpisodes(req, res) {
var libraryItem = this.db.getLibraryItem(req.params.id)
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
return res.sendStatus(404)
}
if (!req.user.canUpload || !req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
return res.sendStatus(404)
}
var episodes = req.body
if (!episodes || !episodes.length) {
return res.sendStatus(400)
}
this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes)
res.sendStatus(200)
}
async updateEpisode(req, res) {
var libraryItem = this.db.getLibraryItem(req.params.id)
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
return res.sendStatus(404)
}
if (!req.user.canUpload || !req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
return res.sendStatus(404)
}
var episodeId = req.params.episodeId
if (!libraryItem.media.checkHasEpisode(episodeId)) {
return res.status(500).send('Episode not found')
}
var wasUpdated = libraryItem.media.updateEpisode(episodeId, req.body)
if (wasUpdated) {
await this.db.insertLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json(libraryItem.toJSONExpanded())
}
}
module.exports = new PodcastController()

View File

@ -115,6 +115,20 @@ class PodcastEpisode {
this.updatedAt = Date.now()
}
update(payload) {
var hasUpdates = false
for (const key in this.toJSON()) {
if (payload[key] != undefined && payload[key] != this[key]) {
this[key] = payload[key]
hasUpdates = true
}
}
if (hasUpdates) {
this.updatedAt = Date.now()
}
return hasUpdates
}
// Only checks container format
checkCanDirectPlay(payload) {
var supportedMimeTypes = payload.supportedMimeTypes || []

View File

@ -115,6 +115,12 @@ class Podcast {
return hasUpdates
}
updateEpisode(id, payload) {
var episode = this.episodes.find(ep => ep.id == id)
if (!episode) return false
return episode.update(payload)
}
updateCover(coverPath) {
coverPath = coverPath.replace(/\\/g, '/')
if (this.coverPath === coverPath) return false

View File

@ -177,6 +177,8 @@ class ApiRouter {
this.router.post('/podcasts', PodcastController.create.bind(this))
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
this.router.get('/podcasts/:id/checknew', PodcastController.checkNewEpisodes.bind(this))
this.router.post('/podcasts/:id/download-episodes', PodcastController.downloadEpisodes.bind(this))
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.updateEpisode.bind(this))
//
// Misc Routes