Change new podcast modal to remove episode download list #494, Fix error when importing many episodes (set max size to 5MB) #493, show podcast episodes downloading and in queue on podcast landing page

This commit is contained in:
advplyr 2022-04-23 19:41:06 -05:00
parent ebc9e1a888
commit 034d858f18
14 changed files with 222 additions and 126 deletions

View File

@ -89,9 +89,12 @@ export default {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast' return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
}, },
emptyMessage() { emptyMessage() {
if (this.page === 'series') return `You have no series` if (this.page === 'series') return 'You have no series'
if (this.page === 'collections') return "You haven't made any collections yet" if (this.page === 'collections') return "You haven't made any collections yet"
if (this.hasFilter) return `No Results for filter "${this.filterName}: ${this.filterValue}"` if (this.hasFilter) {
if (this.filterName === 'Issues') return 'No Issues'
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
}
return 'No results' return 'No results'
}, },
entityName() { entityName() {

View File

@ -272,7 +272,7 @@ export default {
return this.userProgress ? !!this.userProgress.isFinished : false return this.userProgress ? !!this.userProgress.isFinished : false
}, },
showError() { showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid return this.numMissingParts || this.isMissing || this.isInvalid
}, },
isStreaming() { isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
@ -292,22 +292,19 @@ export default {
isInvalid() { isInvalid() {
return this._libraryItem.isInvalid return this._libraryItem.isInvalid
}, },
hasMissingParts() { numMissingParts() {
return this._libraryItem.hasMissingParts if (this.isPodcast) return 0
}, return this.media.numMissingParts
hasInvalidParts() {
return this._libraryItem.hasInvalidParts
}, },
errorText() { errorText() {
if (this.isMissing) return 'Item directory is missing!' if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) return 'Item has no audio tracks & ebook' else if (this.isInvalid) {
var txt = '' if (this.isPodcast) return 'Podcast has no episodes'
if (this.hasMissingParts) { return 'Item has no audio tracks & ebook'
txt = `${this.hasMissingParts} missing parts.`
} }
if (this.hasInvalidParts) { var txt = ''
if (this.hasMissingParts) txt += ' ' if (this.numMissingParts) {
txt += `${this.hasInvalidParts} invalid parts.` txt = `${this.numMissingParts} missing parts.`
} }
return txt || 'Unknown Error' return txt || 'Unknown Error'
}, },

View File

@ -132,6 +132,11 @@ export default {
text: 'Tag', text: 'Tag',
value: 'tags', value: 'tags',
sublist: true sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
} }
] ]
} }
@ -166,26 +171,26 @@ export default {
selectedText() { selectedText() {
if (!this.selected) return '' if (!this.selected) return ''
var parts = this.selected.split('.') var parts = this.selected.split('.')
var filterName = this.selectItems.find((i) => i.value === parts[0]); var filterName = this.selectItems.find((i) => i.value === parts[0])
var filterValue = null; var filterValue = null
if (parts.length > 1) { if (parts.length > 1) {
var decoded = this.$decode(parts[1]) var decoded = this.$decode(parts[1])
if (decoded.startsWith('aut_')) { if (decoded.startsWith('aut_')) {
var author = this.authors.find((au) => au.id == decoded) var author = this.authors.find((au) => au.id == decoded)
if (author) filterValue = author.name; if (author) filterValue = author.name
} else if (decoded.startsWith('ser_')) { } else if (decoded.startsWith('ser_')) {
var series = this.series.find((se) => se.id == decoded) var series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name if (series) filterValue = series.name
} else { } else {
filterValue = decoded; filterValue = decoded
} }
} }
if (filterName && filterValue) { if (filterName && filterValue) {
return `${filterName.text}: ${filterValue}`; return `${filterName.text}: ${filterValue}`
} else if (filterName) { } else if (filterName) {
return filterName.text; return filterName.text
} else if (filterValue) { } else if (filterValue) {
return filterValue; return filterValue
} else { } else {
return '' return ''
} }
@ -212,7 +217,7 @@ export default {
return ['Finished', 'In Progress', 'Not Started'] return ['Finished', 'In Progress', 'Not Started']
}, },
missing() { missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Volume Number', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language', ] return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Volume Number', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
}, },
sublistItems() { sublistItems() {
return (this[this.sublist] || []).map((item) => { return (this[this.sublist] || []).map((item) => {

View File

@ -124,7 +124,13 @@ export default {
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)]) episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
} }
console.log('Podcast payload', episodesToDownload) var payloadSize = JSON.stringify(episodesToDownload).length
var sizeInMb = payloadSize / 1024 / 1024
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
console.log('Request size', sizeInMb)
if (sizeInMb > 4.99) {
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
}
this.processing = true this.processing = true
this.$axios this.$axios
@ -144,7 +150,8 @@ export default {
init() { init() {
for (let i = 0; i < this.episodes.length; i++) { for (let i = 0; i < this.episodes.length; i++) {
var episode = this.episodes[i] var episode = this.episodes[i]
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) { // Do not include episodes already downloaded if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
// Do not include episodes already downloaded
this.$set(this.selectedEpisodes, String(i), false) this.$set(this.selectedEpisodes, String(i), false)
} }
} }

View File

@ -1,63 +1,42 @@
<template> <template>
<modals-modal v-model="show" name="new-podcast-modal" :width="1200" :height="'unset'" :processing="processing"> <modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <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> <p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div> </div>
</template> </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="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 class="flex flex-wrap"> <div class="w-full p-4">
<div class="w-full md:w-1/2 p-4"> <p class="text-lg font-semibold mb-2">Details</p>
<p class="text-lg font-semibold mb-2">Details</p>
<div class="flex flex-wrap"> <div v-if="podcast.imageUrl" class="p-1 w-full">
<div v-if="podcast.imageUrl" class="p-1 w-full"> <img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" /> </div>
</div> <div class="flex">
<div class="p-1 w-full"> <div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" /> <ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
</div> </div>
<div class="p-1 w-full"> <div class="w-full md:w-1/2 p-2">
<ui-text-input-with-label v-model="podcast.author" label="Author" /> <ui-text-input-with-label v-model="podcast.author" label="Author" />
</div>
<div class="p-1 w-full">
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
</div>
<div class="p-1 w-full">
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
</div>
<div class="p-1 w-full">
<ui-textarea-with-label v-model="podcast.description" label="Description" />
</div>
<div class="p-1 w-full">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
</div>
<div class="p-1 w-full">
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
</div>
</div> </div>
</div> </div>
<div class="w-full md:w-1/2 p-4"> <div class="flex">
<p class="text-lg font-semibold mb-2">Episodes</p> <div class="w-full md:w-1/2 p-2">
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto"> <ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
<div class="relative"> </div>
<div class="absolute top-0 left-0 h-full flex items-center p-2"> <div class="w-full md:w-1/2 p-2">
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" /> <ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
</div> </div>
<div class="px-8 py-2"> </div>
<p class="font-semibold text-gray-200">Select all episodes</p> <div class="p-2 w-full">
</div> <ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
</div> </div>
<div v-for="(episode, index) in episodes" :key="index" class="relative cursor-pointer" :class="selectedEpisodes[String(index)] ? 'bg-success bg-opacity-10' : index % 2 == 0 ? 'bg-primary bg-opacity-25 hover:bg-opacity-40' : 'bg-primary bg-opacity-5 hover:bg-opacity-25'" @click="toggleSelectEpisode(index)"> <div class="flex">
<div class="absolute top-0 left-0 h-full flex items-center p-2"> <div class="w-full md:w-1/2 p-2">
<ui-checkbox v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" /> <ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
</div> </div>
<div class="px-8 py-2"> <div class="w-full md:w-1/2 p-2">
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p> <ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
<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>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -66,7 +45,7 @@
<div class="px-4"> <div class="px-4">
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" /> <ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div> </div>
<ui-btn color="success" :disabled="disableSubmit" @click="submit">{{ buttonText }}</ui-btn> <ui-btn color="success" @click="submit">Add Podcast</ui-btn>
</div> </div>
</div> </div>
</modals-modal> </modals-modal>
@ -104,8 +83,7 @@ export default {
itunesId: '', itunesId: '',
itunesArtistId: '', itunesArtistId: '',
autoDownloadEpisodes: false autoDownloadEpisodes: false
}, }
selectedEpisodes: {}
} }
}, },
watch: { watch: {
@ -127,16 +105,6 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
selectAll: {
get() {
return this.episodesSelected.length == this.episodes.length
},
set(val) {
for (const key in this.selectedEpisodes) {
this.selectedEpisodes[key] = val
}
}
},
title() { title() {
return this._podcastData.title return this._podcastData.title
}, },
@ -166,17 +134,6 @@ export default {
if (!this.podcastFeedData) return [] if (!this.podcastFeedData) return []
return this.podcastFeedData.episodes || [] return this.podcastFeedData.episodes || []
}, },
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
},
disableSubmit() {
return !this.episodesSelected.length && !this.podcast.autoDownloadEpisodes
},
buttonText() {
if (!this.episodesSelected.length) return 'Add Podcast'
if (this.episodesSelected.length == 1) return 'Add Podcast & Download 1 Episode'
return `Add Podcast & Download ${this.episodesSelected.length} Episodes`
},
selectedFolder() { selectedFolder() {
return this.folders.find((f) => f.id === this.selectedFolderId) return this.folders.find((f) => f.id === this.selectedFolderId)
}, },
@ -196,15 +153,7 @@ export default {
} }
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title) this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
}, },
toggleSelectEpisode(index) {
this.selectedEpisodes[String(index)] = !this.selectedEpisodes[String(index)]
},
submit() { submit() {
var episodesToDownload = []
if (this.episodesSelected.length) {
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
}
const podcastPayload = { const podcastPayload = {
path: this.fullPath, path: this.fullPath,
folderId: this.selectedFolderId, folderId: this.selectedFolderId,
@ -224,8 +173,7 @@ export default {
language: this.podcast.language language: this.podcast.language
}, },
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
}, }
episodesToDownload
} }
console.log('Podcast payload', podcastPayload) console.log('Podcast payload', podcastPayload)
@ -260,10 +208,6 @@ export default {
this.podcast.language = this._podcastData.language || '' this.podcast.language = this._podcastData.language || ''
this.podcast.autoDownloadEpisodes = false this.podcast.autoDownloadEpisodes = false
for (let i = 0; i < this.episodes.length; i++) {
this.$set(this.selectedEpisodes, String(i), false)
}
if (this.folderItems[0]) { if (this.folderItems[0]) {
this.selectedFolderId = this.folderItems[0].value this.selectedFolderId = this.folderItems[0].value
this.folderUpdated() this.folderUpdated()

View File

@ -95,6 +95,21 @@
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p> <p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
</div> </div>
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div class="flex items-center">
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode{{ episodeDownloadsQueued.length === 1 ? '' : 's' }} queued for download</p>
<span class="material-icons hover:text-error text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
</div>
</div>
<div v-if="episodesDownloading.length" class="px-4 py-2 mt-4 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
<widgets-loading-spinner />
<p class="text-sm py-1 pl-4">Downloading episode "{{ episode.episodeDisplayTitle }}"</p>
</div>
</div>
<!-- Progress --> <!-- Progress -->
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''"> <div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p> <p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
@ -163,7 +178,9 @@ export default {
if (!store.state.user.user) { if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`) return redirect(`/login?redirect=${route.path}`)
} }
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors`).catch((error) => {
// Include episode downloads for podcasts
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
@ -181,7 +198,9 @@ export default {
isProcessingReadUpdate: false, isProcessingReadUpdate: false,
fetchingRSSFeed: false, fetchingRSSFeed: false,
showPodcastEpisodeFeed: false, showPodcastEpisodeFeed: false,
podcastFeedEpisodes: [] podcastFeedEpisodes: [],
episodesDownloading: [],
episodeDownloadsQueued: []
} }
}, },
computed: { computed: {
@ -333,6 +352,20 @@ export default {
} }
}, },
methods: { methods: {
clearDownloadQueue() {
if (confirm('Are you sure you want to clear episode download queue?')) {
this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
.then(() => {
this.$toast.success('Episode download queue cleared')
this.episodeDownloadQueued = []
})
.catch((error) => {
console.error('Failed to clear queue', error)
this.$toast.error('Failed to clear queue')
})
}
},
async findEpisodesClick() { async findEpisodesClick() {
if (!this.mediaMetadata.feedUrl) { if (!this.mediaMetadata.feedUrl) {
return this.$toast.error('Podcast does not have an RSS Feed') return this.$toast.error('Podcast does not have an RSS Feed')
@ -425,17 +458,44 @@ export default {
collectionsClick() { collectionsClick() {
this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowUserCollectionsModal', true) this.$store.commit('globals/setShowUserCollectionsModal', true)
},
episodeDownloadQueued(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued.push(episodeDownload)
}
},
episodeDownloadStarted(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading.push(episodeDownload)
}
},
episodeDownloadFinished(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
}
} }
}, },
mounted() { mounted() {
if (this.libraryItem.episodesDownloading) {
this.episodeDownloadsQueued = this.libraryItem.episodesDownloading || []
}
// use this items library id as the current // use this items library id as the current
if (this.libraryId) { if (this.libraryId) {
this.$store.commit('libraries/setCurrentLibrary', this.libraryId) this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
} }
this.$root.socket.on('item_updated', this.libraryItemUpdated) this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
}, },
beforeDestroy() { beforeDestroy() {
this.$root.socket.off('item_updated', this.libraryItemUpdated) this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
} }
} }
</script> </script>

View File

@ -153,8 +153,8 @@ class Server {
app.use(this.auth.cors) app.use(this.auth.cors)
app.use(fileUpload()) app.use(fileUpload())
app.use(express.urlencoded({ extended: true, limit: "3mb" })); app.use(express.urlencoded({ extended: true, limit: "5mb" }));
app.use(express.json({ limit: "3mb" })) app.use(express.json({ limit: "5mb" }))
// Static path to generated nuxt // Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist') const distPath = Path.join(global.appRoot, '/client/dist')

View File

@ -21,6 +21,9 @@ class LibraryItemController {
} }
}).filter(au => au) }).filter(au => au)
} }
} else if (includeEntities.includes('downloads')) {
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
item.episodesDownloading = downloadsInQueue.map(d => d.toJSONForClient())
} }
return res.json(item) return res.json(item)

View File

@ -29,8 +29,8 @@ class PodcastController {
var podcastPath = payload.path.replace(/\\/g, '/') var podcastPath = payload.path.replace(/\\/g, '/')
if (await fs.pathExists(podcastPath)) { if (await fs.pathExists(podcastPath)) {
Logger.error(`[PodcastController] Attempt to create podcast when folder path already exists "${podcastPath}"`) Logger.error(`[PodcastController] Podcast folder already exists "${podcastPath}"`)
return res.status(400).send('Path already exists') return res.status(400).send('Podcast already exists')
} }
var success = await fs.ensureDir(podcastPath).then(() => true).catch((error) => { var success = await fs.ensureDir(podcastPath).then(() => true).catch((error) => {
@ -63,7 +63,8 @@ class PodcastController {
// Download and save cover image // Download and save cover image
if (payload.media.metadata.imageUrl) { if (payload.media.metadata.imageUrl) {
// TODO: Scan cover image to library files // TODO: Scan cover image to library files
var coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl) // Podcast cover will always go into library item folder
var coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
if (coverResponse) { if (coverResponse) {
if (coverResponse.error) { if (coverResponse.error) {
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`) Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
@ -101,6 +102,7 @@ class PodcastController {
Logger.error('Invalid podcast feed request response') Logger.error('Invalid podcast feed request response')
return res.status(500).send('Bad response from feed request') return res.status(500).send('Bad response from feed request')
} }
Logger.debug(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
var payload = await parsePodcastRssFeedXml(data.data, includeRaw) var payload = await parsePodcastRssFeedXml(data.data, includeRaw)
if (!payload) { if (!payload) {
return res.status(500).send('Invalid podcast RSS feed') return res.status(500).send('Invalid podcast RSS feed')
@ -128,6 +130,26 @@ class PodcastController {
}) })
} }
clearEpisodeDownloadQueue(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[PodcastController] User attempting to clear download queue without permission "${req.user.username}"`)
return res.sendStatus(500)
}
this.podcastManager.clearDownloadQueue(req.params.id)
res.sendStatus(200)
}
getEpisodeDownloads(req, res) {
var libraryItem = this.db.getLibraryItem(req.params.id)
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
return res.sendStatus(404)
}
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
res.json({
downloads: downloadsInQueue.map(d => d.toJSONForClient())
})
}
async downloadEpisodes(req, res) { async downloadEpisodes(req, res) {
var libraryItem = this.db.getLibraryItem(req.params.id) var libraryItem = this.db.getLibraryItem(req.params.id)
if (!libraryItem || libraryItem.mediaType !== 'podcast') { if (!libraryItem || libraryItem.mediaType !== 'podcast') {

View File

@ -119,9 +119,10 @@ class CoverManager {
} }
} }
async downloadCoverFromUrl(libraryItem, url) { async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) {
try { try {
var coverDirPath = this.getCoverDirectory(libraryItem) // Force save cover with library item is used for adding new podcasts
var coverDirPath = forceLibraryItemFolder ? libraryItem.path : this.getCoverDirectory(libraryItem)
await fs.ensureDir(coverDirPath) await fs.ensureDir(coverDirPath)
var temppath = Path.posix.join(coverDirPath, 'cover') var temppath = Path.posix.join(coverDirPath, 'cover')

View File

@ -35,6 +35,23 @@ class PodcastManager {
} }
} }
getEpisodeDownloadsInQueue(libraryItemId) {
return this.downloadQueue.filter(d => d.libraryItemId === libraryItemId)
}
clearDownloadQueue(libraryItemId = null) {
if (!this.downloadQueue.length) return
if (!libraryItemId) {
Logger.info(`[PodcastManager] Clearing all downloads in queue (${this.downloadQueue.length})`)
this.downloadQueue = []
} else {
var itemDownloads = this.getEpisodeDownloadsInQueue(libraryItemId)
Logger.info(`[PodcastManager] Clearing downloads in queue for item "${libraryItemId}" (${itemDownloads.length})`)
this.downloadQueue = this.downloadQueue.filter(d => d.libraryItemId !== libraryItemId)
}
}
async downloadPodcastEpisodes(libraryItem, episodesToDownload) { async downloadPodcastEpisodes(libraryItem, episodesToDownload) {
var index = libraryItem.media.episodes.length + 1 var index = libraryItem.media.episodes.length + 1
episodesToDownload.forEach((ep) => { episodesToDownload.forEach((ep) => {
@ -50,8 +67,11 @@ class PodcastManager {
async startPodcastEpisodeDownload(podcastEpisodeDownload) { async startPodcastEpisodeDownload(podcastEpisodeDownload) {
if (this.currentDownload) { if (this.currentDownload) {
this.downloadQueue.push(podcastEpisodeDownload) this.downloadQueue.push(podcastEpisodeDownload)
this.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
return return
} }
this.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload this.currentDownload = podcastEpisodeDownload
// Ignores all added files to this dir // Ignores all added files to this dir
@ -65,11 +85,17 @@ class PodcastManager {
success = await this.scanAddPodcastEpisodeAudioFile() success = await this.scanAddPodcastEpisodeAudioFile()
if (!success) { if (!success) {
await fs.remove(this.currentDownload.targetPath) await fs.remove(this.currentDownload.targetPath)
this.currentDownload.setFinished(false)
} else { } else {
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`) Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
this.currentDownload.setFinished(true)
} }
} else {
this.currentDownload.setFinished(false)
} }
this.emitter('episode_download_finished', this.currentDownload.toJSONForClient())
this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)
this.currentDownload = null this.currentDownload = null
if (this.downloadQueue.length) { if (this.downloadQueue.length) {

View File

@ -10,22 +10,42 @@ class PodcastEpisodeDownload {
this.libraryItem = null this.libraryItem = null
this.isDownloading = false this.isDownloading = false
this.isFinished = false
this.failed = false
this.startedAt = null this.startedAt = null
this.createdAt = null this.createdAt = null
this.finishedAt = null this.finishedAt = null
} }
toJSONForClient() {
return {
id: this.id,
// podcastEpisode: this.podcastEpisode ? this.podcastEpisode.toJSON() : null,
episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.bestFilename : null,
url: this.url,
libraryItemId: this.libraryItem ? this.libraryItem.id : null,
isDownloading: this.isDownloading,
isFinished: this.isFinished,
failed: this.failed,
startedAt: this.startedAt,
createdAt: this.createdAt,
finishedAt: this.finishedAt
}
}
get targetFilename() { get targetFilename() {
return sanitizeFilename(`${this.podcastEpisode.bestFilename}.mp3`) return sanitizeFilename(`${this.podcastEpisode.bestFilename}.mp3`)
} }
get targetPath() { get targetPath() {
return Path.join(this.libraryItem.path, this.targetFilename) return Path.join(this.libraryItem.path, this.targetFilename)
} }
get targetRelPath() { get targetRelPath() {
return this.targetFilename return this.targetFilename
} }
get libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
}
setData(podcastEpisode, libraryItem) { setData(podcastEpisode, libraryItem) {
this.id = getId('epdl') this.id = getId('epdl')
@ -34,5 +54,11 @@ class PodcastEpisodeDownload {
this.libraryItem = libraryItem this.libraryItem = libraryItem
this.createdAt = Date.now() this.createdAt = Date.now()
} }
setFinished(success) {
this.finishedAt = Date.now()
this.isFinished = true
this.failed = !success
}
} }
module.exports = PodcastEpisodeDownload module.exports = PodcastEpisodeDownload

View File

@ -178,6 +178,8 @@ class ApiRouter {
this.router.post('/podcasts', PodcastController.create.bind(this)) this.router.post('/podcasts', PodcastController.create.bind(this))
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this)) this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
this.router.get('/podcasts/:id/checknew', PodcastController.checkNewEpisodes.bind(this)) this.router.get('/podcasts/:id/checknew', PodcastController.checkNewEpisodes.bind(this))
this.router.get('/podcasts/:id/downloads', PodcastController.getEpisodeDownloads.bind(this))
this.router.get('/podcasts/:id/clear-queue', PodcastController.clearEpisodeDownloadQueue.bind(this))
this.router.post('/podcasts/:id/download-episodes', PodcastController.downloadEpisodes.bind(this)) this.router.post('/podcasts/:id/download-episodes', PodcastController.downloadEpisodes.bind(this))
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.updateEpisode.bind(this)) this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.updateEpisode.bind(this))

View File

@ -57,7 +57,7 @@ module.exports = {
} else if (filterBy === 'issues') { } else if (filterBy === 'issues') {
filtered = filtered.filter(ab => { filtered = filtered.filter(ab => {
// TODO: Update filter for issues // TODO: Update filter for issues
return ab.isMissing return ab.isMissing || ab.isInvalid
// return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid // return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
}) })
} }