Compare commits

..

21 Commits

Author SHA1 Message Date
advplyr
dd2d61f38e Version bump 2.1.2 2022-08-06 17:05:23 -05:00
advplyr
ca2c2f2702 Fix:Episode match encode title query string 2022-08-06 16:51:25 -05:00
advplyr
1fc929ab33 Update:Min width on libraries dropdown 2022-08-06 16:41:26 -05:00
advplyr
f5495d64a9 Update:Show resolution under covers in covers tab #885 2022-08-06 08:25:31 -05:00
advplyr
d6afb17bf2 Fix:Library stats page overflowing under side nav #896 2022-08-06 08:10:12 -05:00
advplyr
2cba9d8f4a Fix:Finished items showing yellow bar on item page 2022-08-06 08:00:28 -05:00
advplyr
e02169907d Add:Filter for RSS feeds open #893 2022-08-06 07:58:19 -05:00
advplyr
24a142e718 Add:RSS feed icon over library item covers when feed is open #893 2022-08-05 19:23:18 -05:00
advplyr
2cb4f972d7 Update:Collections page show total duration 2022-08-04 18:22:35 -05:00
advplyr
513d946faa Update:Updates to collections page for mobile screen sizes 2022-08-04 18:06:39 -05:00
advplyr
87d1f457ba Update:Editing series on book in edit modal sets focus to first available input #889 2022-08-03 18:38:08 -05:00
advplyr
8810f90226 Update:Cleanup edit modal match tab for mobile screens 2022-08-03 18:27:38 -05:00
advplyr
3d3571013f Add:Show audiobook duration and narrator in audible match results #886 2022-08-03 18:06:25 -05:00
advplyr
605a6d8b25 Update:Match tab searches for results when opening tab #882 2022-08-03 17:53:19 -05:00
advplyr
1bfa4b31f2 Fix:Audiobook RSS feed offset pubDate for each track to ensure correct order #888 2022-08-03 17:01:28 -05:00
advplyr
7a14b49aea Fix:Continue series shelf showing in reverse order 2022-08-02 16:41:52 -05:00
advplyr
95ac74d748 Fix:Overdrive chapter parser crash server on invalid meta data #880 2022-08-01 18:28:56 -05:00
advplyr
fddf850a41 Add:Cron validation api endpoint 2022-08-01 18:06:22 -05:00
advplyr
d93d4f3236 Update:Auto-download new podcast episode check max failed attempts to 24 2022-07-31 14:00:17 -05:00
advplyr
91f15d5a23 Update:Scanned in podcast episodes remove file ext from title 2022-07-31 13:52:34 -05:00
advplyr
516c5c3308 Add:Podcast episode match tab and find episode by title api route 2022-07-31 13:12:37 -05:00
45 changed files with 652 additions and 193 deletions

View File

@@ -88,6 +88,7 @@ export default {
if (this.page === 'collections') return "You haven't made any collections yet"
if (this.hasFilter) {
if (this.filterName === 'Issues') return 'No Issues'
else if (this.filterName === 'Feed-open') return 'No RSS feeds are open'
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
}
return 'No results'

View File

@@ -1,16 +1,20 @@
<template>
<div class="w-full border-b border-gray-700 pb-2">
<div v-if="book" class="w-full border-b border-gray-700 pb-2">
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
<div class="h-24 bg-primary" :style="{ minWidth: 96 / bookCoverAspectRatio + 'px' }">
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
</div>
<div v-if="!isPodcast" class="px-4 flex-grow">
<div class="flex items-center">
<h1 class="text-base">{{ book.title }}</h1>
<div class="flex-grow" />
<p>{{ book.publishedYear }}</p>
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
<div class="w-full bg-primary">
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
</div>
<p class="text-gray-300 text-sm">{{ book.author }}</p>
</div>
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
<div class="flex items-center">
<h1 class="text-sm md:text-base">{{ book.title }}</h1>
<div class="flex-grow" />
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
</div>
<p class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration) }}</p>
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
<p class="leading-3 text-xs text-gray-400">
@@ -25,7 +29,7 @@
<div v-else class="px-4 flex-grow">
<h1>{{ book.title }}</h1>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
<p class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
</div>
</div>
@@ -63,7 +67,7 @@ export default {
selectMatch() {
var book = { ...this.book }
book.cover = this.selectedCover
this.$emit('select', this.book)
this.$emit('select', book)
},
clickCover(cover) {
this.selectedCover = cover

View File

@@ -78,6 +78,10 @@
</div>
</ui-tooltip>
<div v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
</div>
<!-- Series sequence -->
<div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
@@ -444,6 +448,10 @@ export default {
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier
},
rssFeed() {
if (this.booksInSeries) return null
return this.store.getters['feeds/getFeedForItem'](this.libraryItemId)
}
},
methods: {

View File

@@ -116,6 +116,11 @@ export default {
text: 'Issues',
value: 'issues',
sublist: false
},
{
text: 'RSS Feed Open',
value: 'feed-open',
sublist: false
}
],
podcastItems: [

View File

@@ -125,6 +125,9 @@ export default {
},
userToken() {
return this.$store.getters['user/getToken']
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
}
},
methods: {

View File

@@ -1,6 +1,6 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full h-full relative">
<div class="relative rounded-sm" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full h-full relative overflow-hidden">
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
@@ -17,6 +17,8 @@
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
</div>
</div>
<p v-if="!imageFailed" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
</div>
</template>
@@ -54,6 +56,9 @@ export default {
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
}
},
methods: {

View File

@@ -8,10 +8,10 @@
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
<div class="flex">
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" label="Series Name" />
</div>
<div class="w-24 sm:w-28 md:w-40 p-1">
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" label="Sequence" />
</div>
</div>
<div class="flex justify-end mt-2 p-1">
@@ -59,9 +59,26 @@ export default {
set(val) {
this.$emit('input', val)
}
},
isNewSeries() {
if (!this.selectedSeries || !this.selectedSeries.id) return false
return this.selectedSeries.id.startsWith('new')
}
},
methods: {
setInputFocus() {
if (this.isNewSeries) {
// Focus on series input if new series
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.setFocus()
}
} else {
// Focus on sequence input if existing series
if (this.$refs.sequenceInput) {
this.$refs.sequenceInput.setFocus()
}
}
},
submitSeriesForm() {
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.blur()
@@ -92,6 +109,8 @@ export default {
this.$store.commit('setInnerModalOpen', true)
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.setInputFocus()
},
setHide() {
if (this.content) this.content.style.transform = 'scale(0)'

View File

@@ -31,9 +31,9 @@
<div v-if="showLocalCovers" class="flex items-center justify-center">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
</template>
@@ -58,7 +58,7 @@
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
<p v-if="!coversFound.length">No Covers Found</p>
<template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</template>

View File

@@ -1,14 +1,14 @@
<template>
<div id="match-wrapper" class="w-full h-full overflow-hidden px-4 py-6 relative">
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
<div class="w-36 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div>
<div class="w-72 px-1">
<div class="flex-grow md:w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
</div>
<div v-show="provider != 'itunes'" class="w-72 px-1">
<div v-show="provider != 'itunes'" class="w-60 md:w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
@@ -20,7 +20,7 @@
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
<p>No Results</p>
</div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper mt-4">
<template v-for="(res, index) in searchResults">
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
</template>
@@ -299,7 +299,7 @@ export default {
this.isProcessing = true
this.lastSearch = searchQuery
var searchEntity = this.isPodcast ? 'podcast' : 'books'
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`).catch((error) => {
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 10000 }).catch((error) => {
console.error('Failed', error)
return []
})
@@ -363,6 +363,10 @@ export default {
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
if (this.searchTitle) {
this.submitSearch()
}
},
selectMatch(match) {
if (match) {
@@ -497,6 +501,11 @@ export default {
<style>
.matchListWrapper {
height: calc(100% - 80px);
height: calc(100% - 124px);
}
@media (min-width: 768px) {
.matchListWrapper {
height: calc(100% - 80px);
}
}
</style>

View File

@@ -5,33 +5,14 @@
<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-y-auto" style="max-height: 80vh">
<div class="flex flex-wrap">
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
</div>
<div class="w-2/5 p-1">
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" 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 default-style">
<ui-rich-text-editor v-if="show" label="Description" v-model="newEpisode.description" />
</div>
</div>
<div class="flex justify-end pt-4">
<ui-btn @click="submit">Submit</ui-btn>
</div>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
<div ref="wrapper" class="p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episode" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div>
</modals-modal>
</template>
@@ -41,25 +22,19 @@ export default {
data() {
return {
processing: false,
newEpisode: {
season: null,
episode: null,
episodeType: null,
title: null,
subtitle: null,
description: null,
pubDate: null,
publishedAt: null
},
pubDateInput: null
}
},
watch: {
episode: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
selectedTab: 'details',
tabs: [
{
id: 'details',
title: 'Details',
component: 'modals-podcast-tabs-episode-details'
},
{
id: 'match',
title: 'Match',
component: 'modals-podcast-tabs-episode-match'
}
]
}
},
computed: {
@@ -77,67 +52,29 @@ export default {
episode() {
return this.$store.state.globals.selectedEpisode
},
episodeId() {
return this.episode ? this.episode.id : null
},
title() {
if (!this.libraryItem) return ''
return this.libraryItem.media.metadata.title || 'Unknown'
},
tabComponentName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
}
},
methods: {
updatePubDate(val) {
if (val) {
this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx')
this.newEpisode.publishedAt = new Date(val).valueOf()
} else {
this.newEpisode.pubDate = null
this.newEpisode.publishedAt = null
}
},
init() {
this.newEpisode.season = this.episode.season || ''
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 || ''
this.newEpisode.publishedAt = this.episode.publishedAt
this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), "yyyy-MM-dd'T'HH:mm") : null
},
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)
})
selectTab(tab) {
this.selectedTab = tab
}
},
mounted() {}
}
</script>
<style scoped>
.tab {
height: 40px;
}
.tab.tab-selected {
height: 41px;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div>
<div class="flex flex-wrap">
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
</div>
<div class="w-2/5 p-1">
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" 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 default-style">
<ui-rich-text-editor label="Description" v-model="newEpisode.description" />
</div>
</div>
<div class="flex justify-end pt-4">
<ui-btn @click="submit">Submit</ui-btn>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
newEpisode: {
season: null,
episode: null,
episodeType: null,
title: null,
subtitle: null,
description: null,
pubDate: null,
publishedAt: null
},
pubDateInput: null
}
},
watch: {
episode: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
episodeId() {
return this.episode ? this.episode.id : null
}
},
methods: {
updatePubDate(val) {
if (val) {
this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx')
this.newEpisode.publishedAt = new Date(val).valueOf()
} else {
this.newEpisode.pubDate = null
this.newEpisode.publishedAt = null
}
},
init() {
this.newEpisode.season = this.episode.season || ''
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 || ''
this.newEpisode.publishedAt = this.episode.publishedAt
this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), "yyyy-MM-dd'T'HH:mm") : null
},
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.isProcessing = true
this.$axios
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
.then(() => {
this.isProcessing = false
this.$toast.success('Podcast episode updated')
this.$emit('close')
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
console.error('Failed update episode', error)
this.isProcessing = false
this.$toast.error(errorMsg)
})
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,156 @@
<template>
<div style="min-height: 200px">
<template v-if="!podcastFeedUrl">
<div class="py-8">
<widgets-alert type="error">Podcast has no RSS feed url to use for matching</widgets-alert>
</div>
</template>
<template v-else>
<form @submit.prevent="submitForm">
<div class="flex mb-2">
<ui-text-input-with-label v-model="episodeTitle" :disabled="isProcessing" label="Episode Title" class="pr-1" />
<ui-btn class="mt-5 ml-1" :loading="isProcessing" type="submit">Search</ui-btn>
</div>
</form>
<div v-if="!isProcessing && searchedTitle && !episodesFound.length" class="w-full py-8">
<p class="text-center text-lg">No episode matches found</p>
</div>
<div v-for="(episode, index) in episodesFound" :key="index" class="w-full py-4 border-b border-white border-opacity-5 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer px-2" @click.stop="selectEpisode(episode)">
<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-400">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
</div>
</template>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
episodeTitle: '',
searchedTitle: '',
episodesFound: []
}
},
watch: {
episode: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
episodeId() {
return this.episode ? this.episode.id : null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
podcastFeedUrl() {
return this.mediaMetadata.feedUrl
}
},
methods: {
getUpdatePayload(episodeData) {
var updatePayload = {}
for (const key in episodeData) {
if (key === 'enclosure') {
if (!this.episode.enclosure || JSON.stringify(this.episode.enclosure) !== JSON.stringify(episodeData.enclosure)) {
updatePayload[key] = {
...episodeData.enclosure
}
}
} else if (episodeData[key] != this.episode[key]) {
updatePayload[key] = episodeData[key]
}
}
return updatePayload
},
selectEpisode(episode) {
const episodeData = {
title: episode.title || '',
subtitle: episode.subtitle || '',
description: episode.description || '',
enclosure: episode.enclosure || null,
episode: episode.episode || '',
episodeType: episode.episodeType || '',
season: episode.season || '',
pubDate: episode.pubDate || '',
publishedAt: episode.publishedAt
}
const updatePayload = this.getUpdatePayload(episodeData)
if (!Object.keys(updatePayload).length) {
return this.$toast.info('No updates are necessary')
}
console.log('Episode update payload', updatePayload)
this.isProcessing = true
this.$axios
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatePayload)
.then(() => {
this.isProcessing = false
this.$toast.success('Podcast episode updated')
this.$emit('selectTab', 'details')
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
console.error('Failed update episode', error)
this.isProcessing = false
this.$toast.error(errorMsg)
})
},
submitForm() {
if (!this.episodeTitle || !this.episodeTitle.length) {
this.$toast.error('Must enter an episode title')
return
}
this.searchedTitle = this.episodeTitle
this.isProcessing = true
this.$axios
.$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${this.$encodeUriPath(this.episodeTitle)}`)
.then((results) => {
this.episodesFound = results.episodes.map((ep) => ep.episode)
console.log('Episodes found', this.episodesFound)
this.isProcessing = false
})
.catch((error) => {
console.error('Failed to search for episode', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to search for episode')
this.isProcessing = false
})
},
init() {
this.searchedTitle = null
this.episodesFound = []
this.episodeTitle = this.episode ? this.episode.title || '' : ''
}
},
mounted() {}
}
</script>

View File

@@ -1,12 +1,13 @@
<template>
<div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 bg-primary">
<p>Collection List</p>
<div class="w-6 h-6 bg-white bg-opacity-10 flex items-center justify-center rounded-full ml-2">
<p class="font-mono text-sm">{{ books.length }}</p>
<div class="w-full h-14 flex items-center px-4 md:px-6 py-2 bg-primary">
<p class="pr-4">Collection List</p>
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<span class="text-xs md:text-sm font-mono leading-none">{{ books.length }}</span>
</div>
<div class="flex-grow" />
<!-- <p v-if="totalDuration">{{ totalDurationPretty }}</p> -->
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
</div>
<draggable v-model="booksCopy" 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 ? 'collection-book' : null">
@@ -56,6 +57,16 @@ export default {
},
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
},
totalDuration() {
var _total = 0
this.books.forEach((book) => {
_total += book.media.duration
})
return _total
},
totalDurationPretty() {
return this.$elapsedPrettyExtended(this.totalDuration)
}
},
methods: {

View File

@@ -1,12 +1,12 @@
<template>
<div class="w-full px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
<div v-if="book" class="flex h-20">
<div class="w-16 max-w-16 h-full">
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
<div v-if="book" class="flex h-16 md:h-20">
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-xl">menu</span>
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
</div>
</div>
<div class="h-full relative" :style="{ width: coverWidth + 'px' }">
<div class="h-full relative" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
@@ -107,9 +107,12 @@ export default {
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
},
coverSize() {
return this.$store.state.globals.isMobile ? 40 : 50
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.6
return 50
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return this.coverSize
}
},
methods: {

View File

@@ -89,6 +89,9 @@ export default {
// this.currentSearch = this.textInput
}, 100)
},
setFocus() {
if (this.$refs.input && this.editable) this.$refs.input.focus()
},
inputFocus() {
this.isFocused = true
},

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="currentLibrary" class="relative h-8 max-w-52" v-click-outside="clickOutsideObj">
<div v-if="currentLibrary" class="relative h-8 max-w-52 min-w-32" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<div class="flex items-center justify-center sm:justify-start">
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />

View File

@@ -80,6 +80,9 @@ export default {
blur() {
if (this.$refs.input) this.$refs.input.blur()
},
setFocus() {
if (this.$refs.input) this.$refs.input.focus()
},
mouseover() {
this.isHovering = true
},

View File

@@ -34,6 +34,11 @@ export default {
}
},
methods: {
setFocus() {
if (this.$refs.input && this.$refs.input.setFocus) {
this.$refs.input.setFocus()
}
},
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()

View File

@@ -211,6 +211,12 @@ export default {
libraryItemUpdated(libraryItem) {
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
this.$store.commit('setSelectedLibraryItem', libraryItem)
if (this.$store.state.globals.selectedEpisode && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find((ep) => ep.id === this.$store.state.globals.selectedEpisode.id)
if (episode) {
this.$store.commit('globals/setSelectedEpisode', episode)
}
}
}
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
@@ -355,11 +361,11 @@ export default {
download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download)
},
showErrorToast(message) {
this.$toast.error(message)
rssFeedOpen(data) {
this.$store.commit('feeds/addFeed', data)
},
showSuccessToast(message) {
this.$toast.success(message)
rssFeedClosed(data) {
this.$store.commit('feeds/removeFeed', data)
},
backupApplied() {
// Force refresh
@@ -431,9 +437,9 @@ export default {
this.socket.on('abmerge_killed', this.abmergeKilled)
this.socket.on('abmerge_expired', this.abmergeExpired)
// Toast Listeners
this.socket.on('show_error_toast', this.showErrorToast)
this.socket.on('show_success_toast', this.showSuccessToast)
// Feed Listeners
this.socket.on('rss_feed_open', this.rssFeedOpen)
this.socket.on('rss_feed_closed', this.rssFeedClosed)
this.socket.on('backup_applied', this.backupApplied)
},

View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.1.1",
"version": "2.1.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.1.1",
"version": "2.1.2",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.1.1",
"version": "2.1.2",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {

View File

@@ -8,8 +8,8 @@
</div>
</div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="flex sm:items-end flex-col sm:flex-row">
<h1 class="text-2xl md:text-3xl font-sans">
<div class="flex items-end flex-row flex-wrap md:flex-nowrap">
<h1 class="text-2xl md:text-3xl font-sans w-full md:w-fit mb-4 md:mb-0">
{{ collectionName }}
</h1>
<div class="flex-grow" />

View File

@@ -71,6 +71,11 @@ export default {
.configContent.page-library-stats {
width: 1200px;
}
@media (max-width: 1550px) {
.configContent.page-library-stats {
margin-left: 176px;
}
}
@media (max-width: 1240px) {
.configContent {
margin-left: 176px;
@@ -82,5 +87,8 @@ export default {
width: 100%;
max-width: 100%;
}
.configContent.page-library-stats {
margin-left: 0px;
}
}
</style>

View File

@@ -14,6 +14,12 @@
</ui-tooltip>
</div>
<!-- <div class="flex items-center py-2">
<ui-text-input v-model="cronExpression" :disabled="updatingServerSettings" class="w-32" @change="changedCronExpression" />
<p class="pl-4 text-lg">Cron expression</p>
</div> -->
<div class="flex items-center py-2">
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
@@ -41,6 +47,7 @@ export default {
dailyBackups: true,
backupsToKeep: 2,
maxBackupSize: 1,
// cronExpression: '',
newServerSettings: {}
}
},
@@ -64,6 +71,18 @@ export default {
}
},
methods: {
// changedCronExpression() {
// this.$axios
// .$post('/api/validate-cron', { expression: this.cronExpression })
// .then(() => {
// console.log('Cron is valid')
// })
// .catch((error) => {
// console.error('Cron validation failed', error)
// const msg = (error.response ? error.response.data : null) || 'Unknown cron validation error'
// this.$toast.error(msg)
// })
// },
updateBackupsSettings() {
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
this.$toast.error('Invalid maximum backup size')
@@ -99,6 +118,7 @@ export default {
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.dailyBackups = !!this.newServerSettings.backupSchedule
this.maxBackupSize = this.newServerSettings.maxBackupSize || 1
// this.cronExpression = '30 1 * * *'
}
},
mounted() {

View File

@@ -3,7 +3,7 @@
<h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-8">
<div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
<p v-if="!top5Genres.length">No Genres</p>

View File

@@ -7,7 +7,7 @@
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- Item Progress Bar -->
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
<!-- Item Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>

View File

@@ -124,9 +124,10 @@ export default {
location.reload()
},
setUser({ user, userDefaultLibraryId, serverSettings, Source }) {
setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) {
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source)
this.$store.commit('feeds/setFeeds', feeds)
if (serverSettings.chromecastEnabled) {
console.log('Chromecast enabled import script')

28
client/store/feeds.js Normal file
View File

@@ -0,0 +1,28 @@
export const state = () => ({
feeds: []
})
export const getters = {
getFeedForItem: state => id => {
return state.feeds.find(feed => feed.id === id)
}
}
export const actions = {
}
export const mutations = {
addFeed(state, feed) {
var index = state.feeds.findIndex(f => f.id === feed.id)
if (index >= 0) state.feeds.splice(index, 1, feed)
else state.feeds.push(feed)
},
removeFeed(state, feed) {
state.feeds = state.feeds.filter(f => f.id !== feed.id)
},
setFeeds(state, feeds) {
state.feeds = feeds || []
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.1.1",
"version": "2.1.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.1.1",
"version": "2.1.2",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.26.1",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.1.1",
"version": "2.1.2",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {

View File

@@ -114,16 +114,17 @@ class Auth {
})
}
getUserLoginResponsePayload(user) {
getUserLoginResponsePayload(user, feeds) {
return {
user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSONForBrowser(),
feeds,
Source: global.Source
}
}
async login(req, res) {
async login(req, res, feeds) {
var username = (req.body.username || '').toLowerCase()
var password = req.body.password || ''
@@ -142,14 +143,14 @@ class Auth {
if (password) {
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
return res.json(this.getUserLoginResponsePayload(user))
return res.json(this.getUserLoginResponsePayload(user, feeds))
}
}
// Check password match
var compare = await bcrypt.compare(password, user.pash)
if (compare) {
res.json(this.getUserLoginResponsePayload(user))
res.json(this.getUserLoginResponsePayload(user, feeds))
} else {
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
if (req.rateLimit.remaining <= 2) {

View File

@@ -10,6 +10,7 @@ const Author = require('./objects/entities/Author')
const Series = require('./objects/entities/Series')
const ServerSettings = require('./objects/settings/ServerSettings')
const PlaybackSession = require('./objects/PlaybackSession')
const Feed = require('./objects/Feed')
class Db {
constructor() {

View File

@@ -230,7 +230,7 @@ class Server {
]
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res, this.rssFeedManager.feedsArray))
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
app.post('/init', (req, res) => {
if (this.db.hasRootUser) {

View File

@@ -163,7 +163,7 @@ class LibraryController {
// If filtering by series, will include seriesName and seriesSequence on media metadata
filterSeries = (payload.mediaType == 'book' && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user)
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
payload.total = libraryItems.length
}

View File

@@ -2,7 +2,7 @@ const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const filePerms = require('../utils/filePerms')
const patternValidation = require('../libs/nodeCron/pattern-validation')
const { isObject } = require('../utils/index')
//
@@ -239,12 +239,7 @@ class MiscController {
Logger.error('Invalid user in authorize')
return res.sendStatus(401)
}
const userResponse = {
user: req.user,
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSONForBrowser(),
Source: global.Source
}
const userResponse = this.auth.getUserLoginResponsePayload(req.user, this.rssFeedManager.feedsArray)
res.json(userResponse)
}
@@ -263,5 +258,20 @@ class MiscController {
})
res.json(tags)
}
validateCronExpression(req, res) {
const expression = req.body.expression
if (!expression) {
return res.sendStatus(400)
}
try {
patternValidation(expression)
res.sendStatus(200)
} catch (error) {
Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message)
res.status(400).send(error.message)
}
}
}
module.exports = new MiscController()

View File

@@ -164,6 +164,25 @@ class PodcastController {
})
}
async findEpisode(req, res) {
const rssFeedUrl = req.libraryItem.media.metadata.feedUrl
if (!rssFeedUrl) {
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`)
return res.status(500).send('Podcast does not have an RSS feed URL')
}
var searchTitle = req.query.title
if (!searchTitle) {
return res.sendStatus(500)
}
searchTitle = searchTitle.toLowerCase().trim()
const episodes = await this.podcastManager.findEpisode(rssFeedUrl, searchTitle)
res.json({
episodes: episodes || []
})
}
async downloadEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
@@ -185,7 +204,7 @@ class PodcastController {
var episodeId = req.params.episodeId
if (!libraryItem.media.checkHasEpisode(episodeId)) {
return res.status(500).send('Episode not found')
return res.status(404).send('Episode not found')
}
var wasUpdated = libraryItem.media.updateEpisode(episodeId, req.body)

View File

@@ -6,6 +6,7 @@ const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
const Logger = require('../Logger')
const { downloadFile } = require('../utils/fileUtils')
const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML')
const prober = require('../utils/prober')
const LibraryFile = require('../objects/files/LibraryFile')
@@ -23,7 +24,8 @@ class PodcastManager {
this.currentDownload = null
this.episodeScheduleTask = null
this.failedCheckMap = {}
this.failedCheckMap = {},
this.MaxFailedEpisodeChecks = 24
}
get serverSettings() {
@@ -199,8 +201,8 @@ class PodcastManager {
// Allow up to 3 failed attempts before disabling auto download
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
this.failedCheckMap[libraryItem.id]++
if (this.failedCheckMap[libraryItem.id] > 2) {
Logger.error(`[PodcastManager] checkForNewEpisodes 3 failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
Logger.error(`[PodcastManager] checkForNewEpisodes ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
libraryItem.media.autoDownloadEpisodes = false
delete this.failedCheckMap[libraryItem.id]
} else {
@@ -259,6 +261,37 @@ class PodcastManager {
return newEpisodes
}
async findEpisode(rssFeedUrl, searchTitle) {
const feed = await this.getPodcastFeed(rssFeedUrl).catch(() => {
return null
})
if (!feed || !feed.episodes) {
return null
}
const matches = []
feed.episodes.forEach(ep => {
if (!ep.title) return
const epTitle = ep.title.toLowerCase().trim()
if (epTitle === searchTitle) {
matches.push({
episode: ep,
levenshtein: 0
})
} else {
const levenshtein = levenshteinDistance(searchTitle, epTitle, true)
if (levenshtein <= 6 && epTitle.length > levenshtein) {
matches.push({
episode: ep,
levenshtein
})
}
}
})
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
}
getPodcastFeed(feedUrl, excludeEpisodeMetadata = false) {
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`)
return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => {
@@ -273,7 +306,7 @@ class PodcastManager {
}
return payload.podcast
}).catch((error) => {
console.error('Failed', error)
Logger.error('[PodcastManager] getPodcastFeed Error', error)
return false
})
}

View File

@@ -11,6 +11,10 @@ class RssFeedManager {
this.feeds = {}
}
get feedsArray() {
return Object.values(this.feeds)
}
async init() {
var feedObjects = await this.db.getAllEntities('feed')
if (feedObjects && feedObjects.length) {
@@ -91,7 +95,7 @@ class RssFeedManager {
Logger.debug(`[RssFeedManager] Opened RSS feed ${feed.feedUrl}`)
await this.db.insertEntity('feed', feed)
this.emitter('rss_feed_open', { entityType: feed.entityType, entityId: feed.entityId, feedUrl: feed.feedUrl })
this.emitter('rss_feed_open', { id: feed.id, entityType: feed.entityType, entityId: feed.entityId, feedUrl: feed.feedUrl })
return feed
}
@@ -105,7 +109,7 @@ class RssFeedManager {
if (!this.feeds[id]) return
var feed = this.feeds[id]
await this.db.removeEntity('feed', id)
this.emitter('rss_feed_closed', { entityType: feed.entityType, entityId: feed.entityId, feedUrl: feed.feedUrl })
this.emitter('rss_feed_closed', { id: feed.id, entityType: feed.entityType, entityId: feed.entityId, feedUrl: feed.feedUrl })
delete this.feeds[id]
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`)
}

View File

@@ -85,7 +85,8 @@ class FeedEpisode {
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
const audiobookPubDate = date.format(new Date(libraryItem.addedAt), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order
const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentUrl = `/feed/${slug}/item/${audioTrack.index}/${audioTrack.metadata.filename}`
const media = libraryItem.media

View File

@@ -1,3 +1,4 @@
const Path = require('path')
const { getId } = require('../../utils/index')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
@@ -126,7 +127,7 @@ class PodcastEpisode {
setDataFromAudioFile(audioFile, index) {
this.id = getId('ep')
this.audioFile = audioFile
this.title = audioFile.metadata.filename
this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename))
this.index = index
this.addedAt = Date.now()
this.updatedAt = Date.now()

View File

@@ -402,9 +402,12 @@ class Book {
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
// If overdrive media markers are present and preferred, use those instead
if (preferOverdriveMediaMarker && overdriveMediaMarkersExist(includedAudioFiles)) {
Logger.info('[Book] Overdrive Media Markers and preference found! Using these for chapter definitions')
return this.chapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
if (preferOverdriveMediaMarker) {
var overdriveChapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
if (overdriveChapters) {
Logger.info('[Book] Overdrive Media Markers and preference found! Using these for chapter definitions')
return this.chapters = overdriveChapters
}
}
if (includedAudioFiles.length === 1) {

View File

@@ -6,7 +6,7 @@ class Audible {
constructor() { }
cleanResult(item) {
var { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language } = item
var { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin } = item
var series = []
if (seriesPrimary) series.push(seriesPrimary)
@@ -28,7 +28,8 @@ class Audible {
genres: genresFiltered.length > 0 ? genresFiltered.map(({ name }) => name).join(', ') : null,
tags: tagsFiltered.length > 0 ? tagsFiltered.map(({ name }) => name).join(', ') : null,
series: series != [] ? series.map(({ name, position }) => ({ series: name, volumeNumber: position })) : null,
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0
}
}
@@ -77,7 +78,6 @@ class Audible {
return []
})
}
return items ? items.map(item => this.cleanResult(item)) : []
}
}

View File

@@ -189,6 +189,7 @@ class ApiRouter {
this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
this.router.get('/podcasts/:id/search-episode', PodcastController.middleware.bind(this), PodcastController.findEpisode.bind(this))
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this))
@@ -210,6 +211,7 @@ class ApiRouter {
this.router.get('/search/authors', MiscController.findAuthor.bind(this))
this.router.get('/search/chapters', MiscController.findChapters.bind(this))
this.router.get('/tags', MiscController.getAllTags.bind(this))
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
}
async getDirectories(dir, relpath, excludedDirs, level = 0) {

View File

@@ -10,7 +10,7 @@ module.exports = {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
},
getFilteredLibraryItems(libraryItems, filterBy, user) {
getFilteredLibraryItems(libraryItems, filterBy, user, feedsArray) {
var filtered = libraryItems
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'missing', 'languages']
@@ -61,6 +61,8 @@ module.exports = {
}
} else if (filterBy === 'issues') {
filtered = filtered.filter(li => li.hasIssues)
} else if (filterBy === 'feed-open') {
filtered = filtered.filter(li => feedsArray.some(feed => feed.entityId === li.id))
}
return filtered
@@ -450,7 +452,7 @@ module.exports = {
if (bookInProgress) { // Update if this series is in progress
seriesMap[librarySeries.id].inProgress = true
if (seriesMap[librarySeries.id].bookInProgressLastUpdate > mediaProgress.lastUpdate) {
if (seriesMap[librarySeries.id].bookInProgressLastUpdate < mediaProgress.lastUpdate) {
seriesMap[librarySeries.id].bookInProgressLastUpdate = mediaProgress.lastUpdate
}
} else if (!seriesMap[librarySeries.id].firstBookUnread) {

View File

@@ -3,7 +3,7 @@ const Logger = require('../../Logger')
// given a list of audio files, extract all of the Overdrive Media Markers metaTags, and return an array of them as XML
function extractOverdriveMediaMarkers(includedAudioFiles) {
Logger.debug('[parseOverdriveMediaMarkers] Extracting overdrive media markers')
var markers = includedAudioFiles.map((af) => af.metaTags.tagOverdriveMediaMarker).filter(notUndefined => notUndefined !== undefined).filter(elem => { return elem !== null }) || []
var markers = includedAudioFiles.map((af) => af.metaTags.tagOverdriveMediaMarker).filter(af => af) || []
return markers
}
@@ -29,11 +29,11 @@ function cleanOverdriveMediaMarkers(overdriveMediaMarkers) {
]
*/
var parseString = require('xml2js').parseString; // function to convert xml to JSON
var parseString = require('xml2js').parseString // function to convert xml to JSON
var parsedOverdriveMediaMarkers = []
overdriveMediaMarkers.forEach(function (item, index) {
var parsed_result
overdriveMediaMarkers.forEach((item, index) => {
var parsed_result = null
parseString(item, function (err, result) {
/*
result.Markers.Marker is the result of parsing the XML for the MediaMarker tags for the MP3 file (Part##.mp3)
@@ -54,10 +54,14 @@ function cleanOverdriveMediaMarkers(overdriveMediaMarkers) {
*/
// The values for Name and Time in results.Markers.Marker are returned as Arrays from parseString and should be strings
parsed_result = objectValuesArrayToString(result.Markers.Marker)
if (result && result.Markers && result.Markers.Marker) {
parsed_result = objectValuesArrayToString(result.Markers.Marker)
}
})
parsedOverdriveMediaMarkers.push(parsed_result)
if (parsed_result) {
parsedOverdriveMediaMarkers.push(parsed_result)
}
})
return removeExtraChapters(parsedOverdriveMediaMarkers)
@@ -114,6 +118,7 @@ function generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers
// cleanedOverdriveMediaMarkers is an array of array of objects, where the inner array matches to the included audio files tracks
// this allows us to leverage the individual track durations when calculating the start times of chapters in tracks after the first (using length)
// TODO: can we guarantee the inner array matches the included audio files?
includedAudioFiles.forEach((track, track_index) => {
cleanedOverdriveMediaMarkers[track_index].forEach((chapter) => {
time = chapter.Time.split(":")
@@ -141,7 +146,13 @@ module.exports.parseOverdriveMediaMarkersAsChapters = (includedAudioFiles) => {
Logger.info('[parseOverdriveMediaMarkers] Parsing of Overdrive Media Markers started')
var overdriveMediaMarkers = extractOverdriveMediaMarkers(includedAudioFiles)
if (!overdriveMediaMarkers.length) return null
var cleanedOverdriveMediaMarkers = cleanOverdriveMediaMarkers(overdriveMediaMarkers)
// TODO: generateParsedChapters requires overdrive media markers and included audio files length to be the same
// so if not equal then we must exit
if (cleanedOverdriveMediaMarkers.length !== includedAudioFiles.length) return null
var parsedChapters = generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers)
return parsedChapters