mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-02 20:48:18 -05:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174decf8da | ||
|
|
0700f12896 | ||
|
|
3dc848a106 | ||
|
|
c17612a233 | ||
|
|
7313d151f8 | ||
|
|
97dc9fbccf | ||
|
|
9a87e4af73 | ||
|
|
4ccb4243f7 | ||
|
|
eb25ca7af5 | ||
|
|
872d5178e6 | ||
|
|
d11501b2c6 | ||
|
|
7e05804bcf | ||
|
|
a73b72a07b | ||
|
|
8ec4bd4279 | ||
|
|
e362456895 | ||
|
|
8cd7de25ad | ||
|
|
99ea7866c5 | ||
|
|
3194b4cd87 | ||
|
|
149f52b33c | ||
|
|
575ec9d00b | ||
|
|
40e999fcae | ||
|
|
ac57b2b867 | ||
|
|
3cafa87eda | ||
|
|
dee4ca3559 | ||
|
|
772c7b3217 | ||
|
|
c0dd58a94e | ||
|
|
91e116969a | ||
|
|
1f37e32f91 | ||
|
|
221061ea30 | ||
|
|
1e8e45431d | ||
|
|
381a81e4bb | ||
|
|
be28b9899e | ||
|
|
37ca139195 | ||
|
|
6b02779e0f | ||
|
|
ff6d95dc4d | ||
|
|
e611d7a8fd | ||
|
|
67f6cd3c56 | ||
|
|
d0ab13865c | ||
|
|
33ae93e61e | ||
|
|
3b961c424f | ||
|
|
389b603d7d | ||
|
|
721de0a343 | ||
|
|
0aadf579f3 | ||
|
|
4ec217e5d0 | ||
|
|
0f01f21a0a | ||
|
|
46668854ad | ||
|
|
a690dfe671 | ||
|
|
7528e8df41 | ||
|
|
8224ca7650 | ||
|
|
a574d06e22 | ||
|
|
dd9a072231 | ||
|
|
2304f37cbe | ||
|
|
0c20988e18 | ||
|
|
9a57fcad40 | ||
|
|
01333b6401 | ||
|
|
8509ca3249 | ||
|
|
7a69afdcd9 | ||
|
|
2c0c53bbf1 | ||
|
|
9f200ece99 | ||
|
|
c5f91ec508 | ||
|
|
d06c61b329 | ||
|
|
be4f11a60e | ||
|
|
0c5db214d1 | ||
|
|
1ad9ea92b6 | ||
|
|
d15120eb5f | ||
|
|
b9deb32b20 |
@@ -5,7 +5,7 @@ set -o pipefail
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
||||
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
DEFAULT_PORT=7331
|
||||
DEFAULT_PORT=13378
|
||||
DEFAULT_HOST="0.0.0.0"
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
#bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||
|
||||
/* For Firefox */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #855620 rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
||||
<div class="flex h-full items-center">
|
||||
<nuxt-link to="/">
|
||||
<img src="/icon.svg" class="w-10 min-w-10 h-10 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
|
||||
<img src="/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/">
|
||||
@@ -12,10 +12,10 @@
|
||||
|
||||
<ui-libraries-dropdown class="mr-2" />
|
||||
|
||||
<controls-global-search v-if="currentLibrary" class="" />
|
||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-2xl md:text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||
|
||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
|
||||
@@ -158,7 +158,7 @@ export default {
|
||||
var newIsFinished = !this.selectedIsFinished
|
||||
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
||||
return {
|
||||
id: lid,
|
||||
libraryItemId: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- Regular bookshelf view -->
|
||||
<div v-else class="w-full">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,11 +70,8 @@ export default {
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
return this.$store.getters['getHomeBookshelfView'] === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverWidth() {
|
||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
@@ -82,13 +79,10 @@ export default {
|
||||
return coverSize
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
||||
return this.coverAspectRatio == 1
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-group-card :key="entity.name" :group="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||
|
||||
@@ -89,7 +89,7 @@ export default {
|
||||
authors: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -215,6 +215,7 @@ export default {
|
||||
this.$toast.success('Removed library items with issues')
|
||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
this.processingIssues = false
|
||||
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove library items with issues', error)
|
||||
@@ -228,7 +229,7 @@ export default {
|
||||
this.processingSeries = true
|
||||
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||
return {
|
||||
id: lid,
|
||||
libraryItemId: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
@@ -302,4 +303,4 @@ export default {
|
||||
#toolbar {
|
||||
box-shadow: 0px 8px 6px #111111aa;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -110,24 +110,20 @@ export default {
|
||||
return this.$store.getters['user/getUserSetting']('collapseSeries')
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
sortingIgnorePrefix() {
|
||||
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
return this.coverAspectRatio == 1
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getBookshelfView']
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
// if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
||||
},
|
||||
hasFilter() {
|
||||
return this.filterBy && this.filterBy !== 'all'
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||
<div id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
|
||||
<div>
|
||||
@@ -44,11 +44,14 @@
|
||||
@close="closePlayer"
|
||||
@showBookmarks="showBookmarks"
|
||||
@showSleepTimer="showSleepTimerModal = true"
|
||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||
/>
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
|
||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,6 +69,7 @@ export default {
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
showSleepTimerModal: false,
|
||||
showPlayerQueueItemsModal: false,
|
||||
sleepTimerSet: false,
|
||||
sleepTimerTime: 0,
|
||||
sleepTimerRemaining: 0,
|
||||
@@ -77,16 +81,13 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return 88
|
||||
},
|
||||
bookCoverPosTop() {
|
||||
if (this.bookCoverAspectRatio === 1) return -10
|
||||
if (this.coverAspectRatio == 1) return -10
|
||||
return -64
|
||||
},
|
||||
cover() {
|
||||
@@ -141,9 +142,39 @@ export default {
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
return this.mediaMetadata.author || 'Unknown'
|
||||
},
|
||||
playerQueueItems() {
|
||||
return this.$store.state.playerQueueItems || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mediaFinished(libraryItemId, episodeId) {
|
||||
// Play next item in queue
|
||||
if (!this.playerQueueItems.length || !this.$store.state.playerQueueAutoPlay) {
|
||||
// TODO: Set media finished flag so play button will play next queue item
|
||||
return
|
||||
}
|
||||
var currentQueueIndex = this.playerQueueItems.findIndex((i) => {
|
||||
if (episodeId) return i.libraryItemId === libraryItemId && i.episodeId === episodeId
|
||||
return i.libraryItemId === libraryItemId
|
||||
})
|
||||
if (currentQueueIndex < 0) {
|
||||
console.error('Media finished not found in queue', this.playerQueueItems)
|
||||
return
|
||||
}
|
||||
if (currentQueueIndex === this.playerQueueItems.length - 1) {
|
||||
console.log('Finished last item in queue')
|
||||
return
|
||||
}
|
||||
const nextItemInQueue = this.playerQueueItems[currentQueueIndex + 1]
|
||||
if (nextItemInQueue) {
|
||||
this.playLibraryItem({
|
||||
libraryItemId: nextItemInQueue.libraryItemId,
|
||||
episodeId: nextItemInQueue.episodeId || null,
|
||||
queueItems: this.playerQueueItems
|
||||
})
|
||||
}
|
||||
},
|
||||
setPlaying(isPlaying) {
|
||||
this.isPlaying = isPlaying
|
||||
this.$store.commit('setIsPlaying', isPlaying)
|
||||
@@ -316,6 +347,7 @@ export default {
|
||||
}
|
||||
},
|
||||
sessionOpen(session) {
|
||||
// For opening session on init (temporarily unused)
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
@@ -367,7 +399,7 @@ export default {
|
||||
if (payload.startTime !== null && !isNaN(payload.startTime)) {
|
||||
this.seek(payload.startTime)
|
||||
} else {
|
||||
this.playerHandler.play()
|
||||
this.playerHandler.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -379,7 +411,8 @@ export default {
|
||||
if (!libraryItem) return
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem,
|
||||
episodeId
|
||||
episodeId,
|
||||
queueItems: payload.queueItems || []
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</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>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration * 60) }}</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">
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm h-full relative" :style="{ padding: `0px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<div class="rounded-sm h-full relative" :style="{ width: width + 'px', height: height + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
||||
<covers-group-cover ref="groupcover" :id="seriesId" :name="groupName" :group-to="groupTo" :type="groupType" :book-items="bookItems" :width="coverWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'">
|
||||
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
|
||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
||||
</div>
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,11 +23,8 @@ export default {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
isCategorized: Boolean,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
@@ -43,23 +32,7 @@ export default {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
width(newVal) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.groupcover) {
|
||||
this.$refs.groupcover.init()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
seriesId() {
|
||||
return this.groupEncode
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.coverWidth < 160) return 0.75
|
||||
return 0.875
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
@@ -70,29 +43,11 @@ export default {
|
||||
return this._group.type
|
||||
},
|
||||
groupTo() {
|
||||
if (this.groupType === 'series') {
|
||||
return `/library/${this.currentLibraryId}/series/${this._group.id}`
|
||||
} else if (this.groupType === 'collection') {
|
||||
return `/collection/${this._group.id}`
|
||||
} else {
|
||||
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
|
||||
}
|
||||
},
|
||||
squareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
coverWidth() {
|
||||
return this.width * 2
|
||||
},
|
||||
coverHeight() {
|
||||
return this.width * this.bookCoverAspectRatio
|
||||
return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||
return this.width / baseSize
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
bookItems() {
|
||||
return this._group.books || []
|
||||
|
||||
@@ -31,7 +31,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Overlay is not shown if collapsing series in library -->
|
||||
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
@@ -66,6 +66,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing/loading spinner overlay -->
|
||||
<div v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
|
||||
<widgets-loading-spinner size="la-lg" />
|
||||
</div>
|
||||
|
||||
<!-- Series name overlay -->
|
||||
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||
@@ -88,7 +93,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !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` }">
|
||||
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode && !processing" 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' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||
</div>
|
||||
|
||||
@@ -129,10 +134,9 @@ export default {
|
||||
return {
|
||||
isHovering: false,
|
||||
isMoreMenuOpen: false,
|
||||
isProcessingReadUpdate: false,
|
||||
processing: false,
|
||||
libraryItem: null,
|
||||
imageReady: false,
|
||||
rescanning: false,
|
||||
selected: false,
|
||||
isSelectionMode: false,
|
||||
showCoverBg: false
|
||||
@@ -382,12 +386,14 @@ export default {
|
||||
{
|
||||
func: 'toggleFinished',
|
||||
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||
},
|
||||
{
|
||||
func: 'openCollections',
|
||||
text: 'Add to Collection'
|
||||
}
|
||||
]
|
||||
if (this.userCanUpdate) {
|
||||
items.push({
|
||||
func: 'openCollections',
|
||||
text: 'Add to Collection'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (this.userCanUpdate) {
|
||||
items.push({
|
||||
@@ -490,6 +496,7 @@ export default {
|
||||
this.libraryItem = libraryItem
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.processing) return
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
@@ -526,7 +533,7 @@ export default {
|
||||
var updatePayload = {
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.processing = true
|
||||
|
||||
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
|
||||
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
|
||||
@@ -536,12 +543,12 @@ export default {
|
||||
axios
|
||||
.$patch(apiEndpoint, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.processing = false
|
||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.processing = false
|
||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
@@ -549,11 +556,12 @@ export default {
|
||||
this.$emit('editPodcast', this.libraryItem)
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
if (this.processing) return
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
this.processing = true
|
||||
axios
|
||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
this.rescanning = false
|
||||
var result = data.result
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
@@ -564,11 +572,12 @@ export default {
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete item was removed`)
|
||||
}
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to scan library item', error)
|
||||
this.$toast.error('Failed to scan library item')
|
||||
this.rescanning = false
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
showEditModalFiles() {
|
||||
@@ -647,11 +656,50 @@ export default {
|
||||
this.selected = !this.selected
|
||||
this.$emit('select', this.libraryItem)
|
||||
},
|
||||
play() {
|
||||
async play() {
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
|
||||
const queueItems = []
|
||||
// Podcast episode load queue items
|
||||
if (this.recentEpisode) {
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
this.processing = true
|
||||
const fullLibraryItem = await axios.$get(`/api/items/${this.libraryItemId}`).catch((err) => {
|
||||
console.error('Failed to fetch library item', err)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
|
||||
if (fullLibraryItem && fullLibraryItem.media.episodes) {
|
||||
const episodes = fullLibraryItem.media.episodes || []
|
||||
// Sort from least recent to most recent
|
||||
episodes.sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
|
||||
|
||||
const episodeIndex = episodes.findIndex((ep) => ep.id === this.recentEpisode.id)
|
||||
if (episodeIndex >= 0) {
|
||||
for (let i = episodeIndex; i < episodes.length; i++) {
|
||||
const episode = episodes[i]
|
||||
const podcastProgress = this.store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
||||
if (!podcastProgress || !podcastProgress.isFinished) {
|
||||
queueItems.push({
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.recentEpisode ? this.recentEpisode.id : null
|
||||
episodeId: this.recentEpisode ? this.recentEpisode.id : null,
|
||||
queueItems
|
||||
})
|
||||
},
|
||||
mouseover() {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
</div>
|
||||
@@ -30,7 +30,12 @@ export default {
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
collectionMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
isTag: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -64,6 +69,9 @@ export default {
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -99,6 +107,10 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
if (this.collectionMount) {
|
||||
this.setEntity(this.collectionMount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
name() {
|
||||
return this.series.name
|
||||
|
||||
@@ -44,6 +44,10 @@ export default {
|
||||
text: 'Author (Last, First)',
|
||||
value: 'media.metadata.authorNameLF'
|
||||
},
|
||||
{
|
||||
text: 'Published Year',
|
||||
value: 'media.metadata.publishedYear'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
value: 'addedAt'
|
||||
|
||||
@@ -17,7 +17,6 @@ export default {
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
groupTo: String,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</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>
|
||||
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +31,11 @@ export default {
|
||||
default: 120
|
||||
},
|
||||
showOpenNewTab: Boolean,
|
||||
bookCoverAspectRatio: Number
|
||||
bookCoverAspectRatio: Number,
|
||||
showResolution: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
91
client/components/modals/BackupScheduleModal.vue
Normal file
91
client/components/modals/BackupScheduleModal.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="backup-scheduler" :width="700" :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">Set Backup Schedule</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="show && newCronExpression" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? 'Save Backup Schedule' : 'No update necessary' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
cronExpression: {
|
||||
type: String,
|
||||
default: '* * * * *'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newCronExpression: null,
|
||||
isUpdated: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
expressionUpdated() {
|
||||
this.isUpdated = this.newCronExpression !== this.cronExpression
|
||||
},
|
||||
init() {
|
||||
this.newCronExpression = this.cronExpression
|
||||
this.isUpdated = false
|
||||
},
|
||||
submit() {
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.expressionBuilder && this.$refs.expressionBuilder.checkBlurExpressionInput) {
|
||||
if (this.$refs.expressionBuilder.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
|
||||
var updatePayload = {
|
||||
backupSchedule: this.newCronExpression
|
||||
}
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', updatePayload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$emit('update:cronExpression', this.newCronExpression)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
||||
<ui-btn small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
|
||||
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Save</ui-btn>
|
||||
</div>
|
||||
@@ -75,7 +75,7 @@ export default {
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
collection() {
|
||||
return this.$store.state.globals.selectedCollection || {}
|
||||
@@ -85,6 +85,9 @@ export default {
|
||||
},
|
||||
books() {
|
||||
return this.collection.books || []
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="listening-session-modal" :width="700" :height="'unset'">
|
||||
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||
<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">Session {{ _session.id }}</p>
|
||||
@@ -96,6 +96,10 @@
|
||||
<p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<ui-btn small color="error" @click.stop="deleteSessionClick">Delete</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -110,7 +114,9 @@ export default {
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
@@ -147,7 +153,37 @@ export default {
|
||||
return 'Unknown'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
deleteSessionClick() {
|
||||
const payload = {
|
||||
message: `Are you sure you want to delete this session?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteSession()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteSession() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/sessions/${this._session.id}`)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Session deleted successfully')
|
||||
this.$emit('removedSession')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to delete session', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || 'Failed to delete session')
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
|
||||
@@ -46,12 +46,14 @@ export default {
|
||||
{
|
||||
id: 'chapters',
|
||||
title: 'Chapters',
|
||||
component: 'modals-item-tabs-chapters'
|
||||
component: 'modals-item-tabs-chapters',
|
||||
mediaType: 'book'
|
||||
},
|
||||
{
|
||||
id: 'episodes',
|
||||
title: 'Episodes',
|
||||
component: 'modals-item-tabs-episodes'
|
||||
component: 'modals-item-tabs-episodes',
|
||||
mediaType: 'podcast'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
@@ -66,7 +68,16 @@ export default {
|
||||
{
|
||||
id: 'manage',
|
||||
title: 'Manage',
|
||||
component: 'modals-item-tabs-manage'
|
||||
component: 'modals-item-tabs-manage',
|
||||
mediaType: 'book',
|
||||
admin: true
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
title: 'Schedule',
|
||||
component: 'modals-item-tabs-schedule',
|
||||
mediaType: 'podcast',
|
||||
admin: true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -120,13 +131,17 @@ export default {
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||
if (tab.id === 'manage' && (this.isMissing || this.mediaType !== 'book')) return false
|
||||
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
|
||||
if (this.mediaType == 'book' && tab.id == 'episodes') return false
|
||||
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
|
||||
if (tab.admin && !this.userIsAdminOrUp) return false
|
||||
|
||||
if (tab.id === 'manage' && this.isMissing) return false
|
||||
|
||||
if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
|
||||
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||
|
||||
@@ -129,11 +129,8 @@ export default {
|
||||
else if (this.provider == 'itunes') return 'Search Term'
|
||||
return 'Search Title'
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-tooltip :disabled="!!quickMatching" :text="`Populate empty ${mediaType} details & cover with first ${mediaType} result from '${libraryProvider}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.`" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-tooltip :disabled="!!libraryScan" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
|
||||
<!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
|
||||
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
|
||||
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" label="Max episodes" class="w-16 mr-2" input-class="h-10">
|
||||
<div class="flex -mb-0.5">
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">Limit</p>
|
||||
<ui-tooltip direction="top" text="Max # of episodes to download. Use 0 for unlimited.">
|
||||
<span class="material-icons text-base">info_outlined</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</ui-text-input-with-label>
|
||||
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +59,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
checkingNewEpisodes: false,
|
||||
lastEpisodeCheckInput: null
|
||||
lastEpisodeCheckInput: null,
|
||||
maxEpisodesToDownload: 3
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -89,6 +97,16 @@ export default {
|
||||
if (this.$refs.lastCheckInput) {
|
||||
this.$refs.lastCheckInput.blur()
|
||||
}
|
||||
if (this.$refs.maxEpisodesInput) {
|
||||
this.$refs.maxEpisodesInput.blur()
|
||||
}
|
||||
|
||||
if (this.maxEpisodesToDownload < 0) {
|
||||
this.maxEpisodesToDownload = 3
|
||||
this.$toast.error('Invalid max episodes to download')
|
||||
return
|
||||
}
|
||||
|
||||
this.checkingNewEpisodes = true
|
||||
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
|
||||
|
||||
@@ -102,7 +120,7 @@ export default {
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/checknew?limit=${this.maxEpisodesToDownload}`)
|
||||
.then((response) => {
|
||||
if (response.episodes && response.episodes.length) {
|
||||
console.log('New episodes', response.episodes.length)
|
||||
|
||||
@@ -25,59 +25,65 @@
|
||||
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-2">
|
||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-4">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-3">Update Book Details</p>
|
||||
</div>
|
||||
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatch.cover" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly label="Cover" class="flex-grow mx-4" />
|
||||
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
|
||||
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
|
||||
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" />
|
||||
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.title || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" />
|
||||
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.subtitle || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.author" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" />
|
||||
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.authorName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.narrator" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" />
|
||||
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.narratorName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publisher" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" />
|
||||
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publisher || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.publishedYear" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishedYear" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" label="Published Year" />
|
||||
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publishedYear || '' }}</p>
|
||||
@@ -85,46 +91,46 @@
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<widgets-series-input-widget v-model="selectedMatch.series" />
|
||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" @input="checkboxToggled" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.genres" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.genres" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
|
||||
<p v-if="mediaMetadata.genresList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genresList || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.tags" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.tags" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" label="Tags" />
|
||||
<p v-if="mediaMetadata.tagsList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.tagsList || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.language" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.language" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" label="Language" />
|
||||
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.language || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
||||
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.isbn || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.asin" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.asin" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
||||
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.asin || '' }}</p>
|
||||
@@ -132,28 +138,28 @@
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatch.itunesId" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesId" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
||||
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesId || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.feedUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.feedUrl" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
||||
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.feedUrl || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.itunesPageUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
||||
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesPageUrl || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.releaseDate" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.releaseDate" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" label="Release Date" />
|
||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.releaseDate || '' }}</p>
|
||||
@@ -209,7 +215,8 @@ export default {
|
||||
itunesId: true,
|
||||
feedUrl: true,
|
||||
releaseDate: true
|
||||
}
|
||||
},
|
||||
selectAll: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -246,7 +253,7 @@ export default {
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
@@ -271,6 +278,14 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectAllToggled(val) {
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
this.selectedMatchUsage[key] = val
|
||||
}
|
||||
},
|
||||
checkboxToggled() {
|
||||
this.selectAll = Object.values(this.selectedMatchUsage).findIndex((v) => v == false) < 0
|
||||
},
|
||||
persistProvider() {
|
||||
try {
|
||||
localStorage.setItem('book-provider', this.provider)
|
||||
|
||||
155
client/components/modals/item/tabs/Schedule.vue
Normal file
155
client/components/modals/item/tabs/Schedule.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6">
|
||||
<template v-if="!feedUrl">
|
||||
<widgets-alert type="warning" class="text-base mb-4">No RSS feed URL is set for this podcast</widgets-alert>
|
||||
</template>
|
||||
<template v-if="feedUrl || autoDownloadEpisodes">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-base md:text-xl font-semibold">Schedule Automatic Episode Downloads</p>
|
||||
<ui-checkbox v-model="enableAutoDownloadEpisodes" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||
</div>
|
||||
|
||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxEpisodesToKeep" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updatedMaxEpisodesToKeep" />
|
||||
<ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download.">
|
||||
<p class="pl-4 text-base">
|
||||
Max episodes to keep
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoDownloadEpisodes" v-model="cronExpression" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5">
|
||||
<div class="flex items-center px-2 md:px-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? 'Save' : 'No update necessary' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
enableAutoDownloadEpisodes: false,
|
||||
cronExpression: null,
|
||||
newMaxEpisodesToKeep: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
feedUrl() {
|
||||
return this.mediaMetadata.feedUrl
|
||||
},
|
||||
autoDownloadEpisodes() {
|
||||
return !!this.media.autoDownloadEpisodes
|
||||
},
|
||||
autoDownloadSchedule() {
|
||||
return this.media.autoDownloadSchedule
|
||||
},
|
||||
maxEpisodesToKeep() {
|
||||
return this.media.maxEpisodesToKeep
|
||||
},
|
||||
isUpdated() {
|
||||
return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes || this.maxEpisodesToKeep !== Number(this.newMaxEpisodesToKeep)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updatedMaxEpisodesToKeep() {
|
||||
if (isNaN(this.newMaxEpisodesToKeep) || this.newMaxEpisodesToKeep < 0) {
|
||||
this.newMaxEpisodesToKeep = 0
|
||||
} else {
|
||||
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
|
||||
}
|
||||
},
|
||||
save() {
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.cronExpressionBuilder && this.$refs.cronExpressionBuilder.checkBlurExpressionInput) {
|
||||
if (this.$refs.cronExpressionBuilder.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.$refs.maxEpisodesInput && this.$refs.maxEpisodesInput.isFocused) {
|
||||
this.$refs.maxEpisodesInput.blur()
|
||||
return
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
autoDownloadEpisodes: this.enableAutoDownloadEpisodes
|
||||
}
|
||||
if (this.enableAutoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadSchedule = this.cronExpression
|
||||
}
|
||||
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
|
||||
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
|
||||
}
|
||||
|
||||
this.updateDetails(updatePayload)
|
||||
},
|
||||
async updateDetails(updatePayload) {
|
||||
this.isProcessing = true
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success('Item details updated')
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info('No updates were necessary')
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
init() {
|
||||
this.enableAutoDownloadEpisodes = this.autoDownloadEpisodes
|
||||
this.cronExpression = this.autoDownloadSchedule
|
||||
this.newMaxEpisodesToKeep = this.maxEpisodesToKeep
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#scheduleWrapper {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-4 py-2 mb-4">
|
||||
<div class="w-full h-full px-1 md:px-4 py-2 mb-4">
|
||||
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
||||
<div class="flex flex-wrap md:flex-nowrap -mx-1">
|
||||
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="edit-library" :width="700" :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>
|
||||
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
@@ -11,10 +11,10 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<component v-if="libraryCopy && show" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||
<div class="px-2 md:px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-opacity-10">
|
||||
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
|
||||
<div class="flex justify-end">
|
||||
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
||||
</div>
|
||||
@@ -46,6 +46,11 @@ export default {
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
component: 'modals-libraries-library-settings'
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
title: 'Schedule',
|
||||
component: 'modals-libraries-schedule-scan'
|
||||
}
|
||||
],
|
||||
libraryCopy: null
|
||||
@@ -84,6 +89,7 @@ export default {
|
||||
},
|
||||
updateLibrary(library) {
|
||||
this.mapLibraryToCopy(library)
|
||||
console.log('Updated library', this.libraryCopy)
|
||||
},
|
||||
getNewLibraryData() {
|
||||
return {
|
||||
@@ -93,9 +99,11 @@ export default {
|
||||
icon: 'database',
|
||||
mediaType: 'book',
|
||||
settings: {
|
||||
coverAspectRatio: this.$constants.BookCoverAspectRatio.SQUARE,
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
autoScanCronExpression: null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -112,7 +120,9 @@ export default {
|
||||
if (key === 'folders') {
|
||||
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
|
||||
} else if (key === 'settings') {
|
||||
this.libraryCopy.settings = { ...library.settings }
|
||||
for (const settingKey in library.settings) {
|
||||
this.libraryCopy.settings[settingKey] = library.settings[settingKey]
|
||||
}
|
||||
} else {
|
||||
this.libraryCopy[key] = library[key]
|
||||
}
|
||||
@@ -134,6 +144,13 @@ export default {
|
||||
submit() {
|
||||
if (!this.validate()) return
|
||||
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.tabComponent && this.$refs.tabComponent.checkBlurExpressionInput) {
|
||||
if (this.$refs.tabComponent.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.library) {
|
||||
this.submitUpdateLibrary()
|
||||
} else {
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-4 py-1 mb-4">
|
||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
|
||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||
<p class="pl-4 text-base">
|
||||
Use square book covers
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
|
||||
<ui-toggle-switch v-else disabled :value="false" />
|
||||
<p class="pl-4 text-lg">Disable folder watcher for library</p>
|
||||
<p class="pl-4 text-base">Disable folder watcher for library</p>
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||
</div>
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
||||
<p class="pl-4 text-base">Skip matching books that already have an ASIN</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
||||
<p class="pl-4 text-base">Skip matching books that already have an ISBN</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,9 +44,13 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
provider: null,
|
||||
useSquareBookCovers: false,
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
tooltips: {
|
||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -59,6 +72,7 @@ export default {
|
||||
getLibraryData() {
|
||||
return {
|
||||
settings: {
|
||||
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
||||
disableWatcher: !!this.disableWatcher,
|
||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
|
||||
@@ -69,6 +83,7 @@ export default {
|
||||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
init() {
|
||||
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
this.disableWatcher = !!this.librarySettings.disableWatcher
|
||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||
|
||||
56
client/components/modals/libraries/ScheduleScan.vue
Normal file
56
client/components/modals/libraries/ScheduleScan.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-base md:text-xl font-semibold">Schedule Automatic Library Scans</p>
|
||||
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||
</div>
|
||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
cronExpression: null,
|
||||
enableAutoScan: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
checkBlurExpressionInput() {
|
||||
// returns true if advanced cron input is focused
|
||||
if (!this.$refs.cronExpressionBuilder) return false
|
||||
return this.$refs.cronExpressionBuilder.checkBlurExpressionInput()
|
||||
},
|
||||
toggleEnableAutoScan(v) {
|
||||
if (!v) this.updatedCron(null)
|
||||
else if (!this.cronExpression) {
|
||||
this.cronExpression = '0 0 * * 1'
|
||||
this.updatedCron(this.cronExpression)
|
||||
}
|
||||
},
|
||||
updatedCron(expression) {
|
||||
this.$emit('update', {
|
||||
settings: {
|
||||
autoScanCronExpression: expression
|
||||
}
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.cronExpression = this.library.settings.autoScanCronExpression
|
||||
this.enableAutoScan = !!this.cronExpression
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
102
client/components/modals/player/QueueItemRow.vue
Normal file
102
client/components/modals/player/QueueItemRow.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div v-if="item" class="w-full flex items-center px-4 py-2" :class="wrapperClass" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<covers-preview-cover :src="coverUrl" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" />
|
||||
<div class="flex-grow px-2 py-1 queue-item-row-content truncate">
|
||||
<p class="text-gray-200 text-sm truncate">{{ title }}</p>
|
||||
<p class="text-gray-300 text-sm">{{ subtitle }}</p>
|
||||
<p v-if="caption" class="text-gray-400 text-xs">{{ caption }}</p>
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">Streaming</p>
|
||||
<div v-else-if="isHovering" class="flex items-center justify-end -mx-1">
|
||||
<button class="outline-none mx-1 flex items-center" @click.stop="playClick">
|
||||
<span class="material-icons text-success">play_arrow</span>
|
||||
</button>
|
||||
<button class="outline-none mx-1 flex items-center" @click.stop="removeClick">
|
||||
<span class="material-icons text-error">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-gray-400 text-sm text-right">{{ durationPretty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
index: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.item.title || ''
|
||||
},
|
||||
subtitle() {
|
||||
return this.item.subtitle || ''
|
||||
},
|
||||
caption() {
|
||||
return this.item.caption
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.item.libraryItemId
|
||||
},
|
||||
episodeId() {
|
||||
return this.item.episodeId
|
||||
},
|
||||
coverPath() {
|
||||
return this.item.coverPath
|
||||
},
|
||||
coverUrl() {
|
||||
if (!this.coverPath) return '/book_placeholder.jpg'
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
duration() {
|
||||
return this.item.duration
|
||||
},
|
||||
durationPretty() {
|
||||
if (!this.duration) return 'N/A'
|
||||
return this.$elapsedPretty(this.duration)
|
||||
},
|
||||
isOpenInPlayer() {
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)
|
||||
},
|
||||
wrapperClass() {
|
||||
if (this.isOpenInPlayer) return 'bg-yellow-400 bg-opacity-10'
|
||||
if (this.index % 2 === 0) return 'bg-gray-300 bg-opacity-5 hover:bg-opacity-10'
|
||||
return 'bg-bg hover:bg-gray-300 hover:bg-opacity-10'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
playClick() {
|
||||
this.$emit('play', this.item)
|
||||
},
|
||||
removeClick() {
|
||||
this.$emit('remove', this.item)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.queue-item-row-content {
|
||||
max-width: calc(100% - 48px - 128px);
|
||||
}
|
||||
</style>
|
||||
70
client/components/modals/player/QueueItemsModal.vue
Normal file
70
client/components/modals/player/QueueItemsModal.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'">
|
||||
<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">Player Queue</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden py-4" style="max-height: 80vh">
|
||||
<div v-if="show" class="w-full h-full">
|
||||
<div class="pb-4 px-4 flex items-center">
|
||||
<p class="text-base text-gray-200">Player Queue</p>
|
||||
<p class="text-base text-gray-400 px-4">{{ playerQueueItems.length }} Items</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-checkbox v-model="playerQueueAutoPlay" label="Auto Play" medium checkbox-bg="primary" border-color="gray-600" label-class="pl-2 mb-px" />
|
||||
</div>
|
||||
<modals-player-queue-item-row v-for="(item, index) in playerQueueItems" :key="index" :item="item" :index="index" @play="playItem" @remove="removeItem" />
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
playerQueueAutoPlay: {
|
||||
get() {
|
||||
return this.$store.state.playerQueueAutoPlay
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setPlayerQueueAutoPlay', val)
|
||||
}
|
||||
},
|
||||
playerQueueItems() {
|
||||
return this.$store.state.playerQueueItems || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
playItem(item) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: item.libraryItemId,
|
||||
episodeId: item.episodeId || null,
|
||||
queueItems: this.playerQueueItems
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
removeItem(item) {
|
||||
const updatedQueue = this.playerQueueItems.filter((i) => {
|
||||
if (!i.episodeId) return i.libraryItemId !== item.libraryItemId
|
||||
return i.libraryItemId !== item.libraryItemId || i.episodeId !== item.episodeId
|
||||
})
|
||||
this.$store.commit('setPlayerQueueItems', updatedQueue)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -11,11 +11,11 @@
|
||||
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)"
|
||||
:class="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, episode)"
|
||||
>
|
||||
<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>
|
||||
<span v-if="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">
|
||||
@@ -23,20 +23,13 @@
|
||||
<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">
|
||||
<div class="relative">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" :disabled="allDownloaded" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
<p :class="!allDownloaded ? 'font-semibold text-gray-200' : 'text-gray-400'">Select all episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
<ui-btn :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" label="Select all episodes" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
|
||||
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -58,7 +51,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
selectedEpisodes: {}
|
||||
selectedEpisodes: {},
|
||||
selectAll: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -78,22 +72,12 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectAll: {
|
||||
get() {
|
||||
return this.episodesSelected.length == this.episodes.filter((_, index) => !(this.episodes[index].enclosure && this.itemEpisodeMap[this.episodes[index].enclosure.url])).length
|
||||
},
|
||||
set(val) {
|
||||
for (const key in this.selectedEpisodes) {
|
||||
this.selectedEpisodes[key] = val
|
||||
}
|
||||
}
|
||||
},
|
||||
title() {
|
||||
if (!this.libraryItem) return ''
|
||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||
},
|
||||
allDownloaded() {
|
||||
return Object.values(this.episodes).filter((episode) => !(episode.enclosure && this.itemEpisodeMap[episode.enclosure.url])).length === 0
|
||||
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
|
||||
},
|
||||
episodesSelected() {
|
||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||
@@ -115,8 +99,27 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleSelectEpisode(index) {
|
||||
toggleSelectAll(val) {
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
const episode = this.episodes[i]
|
||||
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
|
||||
else this.$set(this.selectedEpisodes, String(i), val)
|
||||
}
|
||||
},
|
||||
checkSetIsSelectedAll() {
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
const episode = this.episodes[i]
|
||||
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
|
||||
this.selectAll = false
|
||||
return
|
||||
}
|
||||
}
|
||||
this.selectAll = true
|
||||
},
|
||||
toggleSelectEpisode(index, episode) {
|
||||
if (this.itemEpisodeMap[episode.enclosure.url]) return
|
||||
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
||||
this.checkSetIsSelectedAll()
|
||||
},
|
||||
submit() {
|
||||
var episodesToDownload = []
|
||||
@@ -145,17 +148,15 @@ export default {
|
||||
console.error('Failed to download episodes', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
|
||||
this.selectedEpisodes = {}
|
||||
this.selectAll = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
var episode = this.episodes[i]
|
||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
||||
// Do not include episodes already downloaded
|
||||
this.$set(this.selectedEpisodes, String(i), false)
|
||||
}
|
||||
}
|
||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||
this.selectAll = false
|
||||
this.selectedEpisodes = {}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
@@ -170,4 +171,4 @@ export default {
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
</template>
|
||||
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="mb-4">
|
||||
<p class="text-lg text-gray-200 mb-4">
|
||||
<p v-if="episode" class="text-lg text-gray-200 mb-4">
|
||||
Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
|
||||
>?
|
||||
</p>
|
||||
<p v-else class="text-lg text-gray-200 mb-4">Are you sure you want to remove {{ episodes.length }} episodes?</p>
|
||||
<p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||
|
||||
<ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn>
|
||||
<ui-btn @click="submit">{{ btnText }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -30,9 +31,9 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -55,34 +56,49 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
episode() {
|
||||
if (this.episodes.length === 1) return this.episodes[0]
|
||||
return null
|
||||
},
|
||||
title() {
|
||||
if (this.episodes.length > 1) return `Remove ${this.episodes.length} episodes`
|
||||
return 'Remove Episode'
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
btnText() {
|
||||
if (this.episodes.length > 1) return this.hardDeleteFile ? `Delete ${this.episodes.length} episodes` : `Remove ${this.episodes.length} episodes`
|
||||
return this.hardDeleteFile ? 'Delete episode' : 'Remove episode'
|
||||
},
|
||||
episodeTitle() {
|
||||
return this.episode ? this.episode.title : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
async submit() {
|
||||
this.processing = true
|
||||
|
||||
var queryString = this.hardDeleteFile ? '?hard=1' : ''
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`)
|
||||
.then(() => {
|
||||
for (const episode of this.episodes) {
|
||||
const success = await this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${episode.id}${queryString}`)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to remove episode'
|
||||
console.error('Failed to remove episode', error)
|
||||
this.$toast.error(errorMsg)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
this.processing = false
|
||||
this.$toast.success('Podcast episode removed')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
this.$emit('clearSelected')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = false
|
||||
this.$toast.success(`${this.episodes.length} episode${this.episodes.length > 1 ? 's' : ''} removed`)
|
||||
this.show = false
|
||||
this.$emit('clearSelected')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -66,7 +66,7 @@ export default {
|
||||
return this.mediaMetadata.author
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -23,9 +23,16 @@
|
||||
<ui-rich-text-editor label="Description" v-model="newEpisode.description" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<div class="flex items-center justify-end pt-4">
|
||||
<ui-btn @click="submit">Submit</ui-btn>
|
||||
</div>
|
||||
<div v-if="enclosureUrl" class="py-4">
|
||||
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
|
||||
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
|
||||
</div>
|
||||
<div v-else class="py-4">
|
||||
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,6 +83,12 @@ export default {
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
},
|
||||
enclosure() {
|
||||
return this.episode ? this.episode.enclosure || {} : {}
|
||||
},
|
||||
enclosureUrl() {
|
||||
return this.enclosure.url
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
|
||||
<button v-if="playerQueueItems.length" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
|
||||
<span class="material-icons text-2xl sm:text-3xl">playlist_play</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
@@ -138,6 +142,9 @@ export default {
|
||||
hasNextChapter() {
|
||||
if (!this.chapters.length) return false
|
||||
return this.currentChapterIndex < this.chapters.length - 1
|
||||
},
|
||||
playerQueueItems() {
|
||||
return this.$store.state.playerQueueItems || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
||||
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
|
||||
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories. If you have enabled server settings to store cover art and metadata in your library folders then those are not backup up or overwritten.</p>
|
||||
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.</p>
|
||||
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
|
||||
|
||||
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
|
||||
|
||||
@@ -52,11 +52,8 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
totalDuration() {
|
||||
var _total = 0
|
||||
|
||||
@@ -164,8 +164,8 @@ export default {
|
||||
}
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('user_added', this.newUserAdded)
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
this.$root.socket.off('user_added', this.addUpdateUser)
|
||||
this.$root.socket.off('user_updated', this.addUpdateUser)
|
||||
this.$root.socket.off('user_removed', this.userRemoved)
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,6 @@ export default {
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #272727
|
||||
background-color: #272727;
|
||||
}
|
||||
</style>
|
||||
@@ -27,15 +27,15 @@
|
||||
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
|
||||
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : translateDistance">
|
||||
<div class="flex h-full items-center">
|
||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
||||
</div>
|
||||
<div class="mx-1">
|
||||
<div v-if="userCanDelete" class="mx-1">
|
||||
<ui-icon-btn icon="close" borderless @click="removeClick" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,6 +71,11 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
translateDistance() {
|
||||
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
|
||||
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
|
||||
return '-translate-x-24'
|
||||
},
|
||||
media() {
|
||||
return this.book.media || {}
|
||||
},
|
||||
@@ -113,6 +118,12 @@ export default {
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
|
||||
return this.coverSize
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div v-if="episode" class="flex items-center h-24 cursor-pointer" @click="$emit('view', episode)">
|
||||
<div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode">
|
||||
<div class="flex-grow px-2">
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
|
||||
|
||||
<div class="flex justify-between pt-2 max-w-xl">
|
||||
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
@@ -17,20 +23,18 @@
|
||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
<p v-if="episode.season" class="px-4 text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="px-4 text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||
|
||||
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
|
||||
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24 min-w-24" />
|
||||
<div v-if="isHovering || isSelected || selectionMode" class="hidden md:block w-12 min-w-12" />
|
||||
</div>
|
||||
<div class="w-24 min-w-24 -right-0 absolute top-0 h-full transform transition-transform" :class="!isHovering ? 'translate-x-32' : 'translate-x-0'">
|
||||
<div v-if="isSelected || selectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
|
||||
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !selectionMode ? 'translate-x-24' : 'translate-x-0'">
|
||||
<div class="flex h-full items-center">
|
||||
<div class="mx-1">
|
||||
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
|
||||
</div>
|
||||
<div class="mx-1">
|
||||
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
|
||||
<ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,13 +50,15 @@ export default {
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
selectionMode: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
processingRemove: false,
|
||||
isHovering: false
|
||||
isHovering: false,
|
||||
isSelected: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -104,8 +110,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedEpisode() {
|
||||
this.$emit('view', this.episode)
|
||||
},
|
||||
clickedSelectionBg() {
|
||||
this.isSelected = !this.isSelected
|
||||
this.selectedUpdated(this.isSelected)
|
||||
},
|
||||
selectedUpdated(value) {
|
||||
this.$emit('selected', { isSelected: value, episode: this.episode })
|
||||
},
|
||||
mouseover() {
|
||||
// if (this.isDragging) return
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
@@ -118,10 +133,7 @@ export default {
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episode.id
|
||||
})
|
||||
this.$emit('play', this.episode)
|
||||
}
|
||||
},
|
||||
toggleFinished(confirmed = false) {
|
||||
|
||||
@@ -3,14 +3,21 @@
|
||||
<div class="flex items-center mb-4">
|
||||
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
||||
<div class="flex-grow" />
|
||||
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||
<template v-if="isSelectionMode">
|
||||
<ui-tooltip :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">Remove {{ selectedEpisodes.length }} episode{{ selectedEpisodes.length > 1 ? 's' : '' }}</ui-btn>
|
||||
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">Cancel</ui-btn>
|
||||
</template>
|
||||
<controls-episode-sort-select v-else v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||
</div>
|
||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||
<template v-for="episode in episodesSorted">
|
||||
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" />
|
||||
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" />
|
||||
</template>
|
||||
|
||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,7 +35,10 @@ export default {
|
||||
sortKey: 'publishedAt',
|
||||
sortDesc: true,
|
||||
selectedEpisode: null,
|
||||
showPodcastRemoveModal: false
|
||||
showPodcastRemoveModal: false,
|
||||
selectedEpisodes: [],
|
||||
episodesToRemove: [],
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -37,6 +47,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelectionMode() {
|
||||
return this.selectedEpisodes.length > 0
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
@@ -56,11 +69,92 @@ export default {
|
||||
}
|
||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
},
|
||||
selectedIsFinished() {
|
||||
// Find an item that is not finished, if none then all items finished
|
||||
return !this.selectedEpisodes.find((episode) => {
|
||||
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
||||
return !itemProgress || !itemProgress.isFinished
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleBatchFinished() {
|
||||
this.processing = true
|
||||
var newIsFinished = !this.selectedIsFinished
|
||||
var updateProgressPayloads = this.selectedEpisodes.map((episode) => {
|
||||
return {
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId: episode.id,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
|
||||
this.$axios
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success('Batch update success!')
|
||||
this.processing = false
|
||||
this.clearSelected()
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch update failed')
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
removeEpisodeModalToggled(val) {
|
||||
if (!val) this.episodesToRemove = []
|
||||
},
|
||||
clearSelected() {
|
||||
const episodeRows = this.$refs.episodeRow
|
||||
if (episodeRows && episodeRows.length) {
|
||||
for (const epRow of episodeRows) {
|
||||
if (epRow) epRow.isSelected = false
|
||||
}
|
||||
}
|
||||
this.selectedEpisodes = []
|
||||
},
|
||||
removeSelectedEpisodes() {
|
||||
this.episodesToRemove = this.selectedEpisodes
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
episodeSelected({ isSelected, episode }) {
|
||||
if (isSelected) {
|
||||
this.selectedEpisodes.push(episode)
|
||||
} else {
|
||||
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
|
||||
}
|
||||
},
|
||||
playEpisode(episode) {
|
||||
const queueItems = []
|
||||
|
||||
const episodesInListeningOrder = this.episodesCopy.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
|
||||
const episodeIndex = episodesInListeningOrder.findIndex((e) => e.id === episode.id)
|
||||
for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {
|
||||
const episode = episodesInListeningOrder[i]
|
||||
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
||||
if (!podcastProgress || !podcastProgress.isFinished) {
|
||||
queueItems.push({
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId: episode.id,
|
||||
queueItems
|
||||
})
|
||||
},
|
||||
removeEpisode(episode) {
|
||||
this.selectedEpisode = episode
|
||||
this.episodesToRemove = [episode]
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
editEpisode(episode) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
value: Boolean,
|
||||
label: String,
|
||||
small: Boolean,
|
||||
medium: Boolean,
|
||||
checkboxBg: {
|
||||
type: String,
|
||||
default: 'white'
|
||||
@@ -47,19 +48,23 @@ export default {
|
||||
wrapperClass() {
|
||||
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
|
||||
if (this.small) classes.push('w-4 h-4')
|
||||
else if (this.medium) classes.push('w-5 h-5')
|
||||
else classes.push('w-6 h-6')
|
||||
|
||||
return classes.join(' ')
|
||||
},
|
||||
labelClassname() {
|
||||
if (this.labelClass) return this.labelClass
|
||||
var classes = ['pl-1']
|
||||
if (this.small) classes.push('text-xs md:text-sm')
|
||||
var classes = []
|
||||
if (this.small) classes.push('text-xs md:text-sm pl-1')
|
||||
else if (this.medium) classes.push('text-base md:text-lg pl-2')
|
||||
else classes.push('pl-2')
|
||||
return classes.join(' ')
|
||||
},
|
||||
svgClass() {
|
||||
var classes = [`text-${this.checkColor}`]
|
||||
if (this.small) classes.push('w-3 h-3')
|
||||
else if (this.medium) classes.push('w-3.5 h-3.5')
|
||||
else classes.push('w-4 h-4')
|
||||
|
||||
return classes.join(' ')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="currentLibrary" class="relative h-8 max-w-52 min-w-32" v-click-outside="clickOutsideObj">
|
||||
<div v-if="currentLibrary" class="relative h-8 max-w-52 md: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" />
|
||||
|
||||
@@ -36,7 +36,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showPassword: false,
|
||||
isHovering: false
|
||||
isHovering: false,
|
||||
isFocused: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -66,9 +67,11 @@ export default {
|
||||
this.inputValue = ''
|
||||
},
|
||||
focused() {
|
||||
this.isFocused = true
|
||||
this.$emit('focus')
|
||||
},
|
||||
blurred() {
|
||||
this.isFocused = false
|
||||
this.$emit('blur')
|
||||
},
|
||||
change(e) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
|
||||
<slot>
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
</slot>
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,7 +20,8 @@ export default {
|
||||
default: 'text'
|
||||
},
|
||||
readonly: Boolean,
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
inputClass: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -41,13 +41,6 @@ export default {
|
||||
this.setTooltipPosition(this.tooltip)
|
||||
}
|
||||
},
|
||||
getTextWidth() {
|
||||
var styles = {
|
||||
'font-size': '0.75rem'
|
||||
}
|
||||
var size = this.$calculateTextSize(this.text, styles)
|
||||
return size.width
|
||||
},
|
||||
createTooltip() {
|
||||
if (!this.$refs.box) return
|
||||
var tooltip = document.createElement('div')
|
||||
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
||||
312
client/components/widgets/CronExpressionBuilder.vue
Normal file
312
client/components/widgets/CronExpressionBuilder.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="w-full py-2">
|
||||
<div class="flex -mb-px">
|
||||
<div class="w-1/2 h-8 rounded-tl-md relative border border-black-200 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = false">
|
||||
<p class="text-sm">Scheduler</p>
|
||||
</div>
|
||||
<div class="w-1/2 h-8 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = true">
|
||||
<p class="text-sm">Advanced</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-4 md:p-4 border border-black-200 rounded-b-md mr-px" style="min-height: 280px">
|
||||
<template v-if="!showAdvancedView">
|
||||
<ui-dropdown v-model="selectedInterval" @input="updateCron" label="Interval" :items="intervalOptions" class="mb-2" />
|
||||
|
||||
<ui-multi-select-dropdown v-if="selectedInterval === 'custom'" v-model="selectedWeekdays" @input="updateCron" label="Weekdays to run" :items="weekdays" />
|
||||
|
||||
<div v-if="(selectedWeekdays.length && selectedInterval === 'custom') || selectedInterval === 'daily'" class="flex items-center py-2">
|
||||
<ui-text-input-with-label v-model="selectedHour" @input="updateCron" @blur="hourBlur" type="number" label="Hour" class="max-w-20" />
|
||||
<p class="text-xl px-2 mt-4">:</p>
|
||||
<ui-text-input-with-label v-model="selectedMinute" @input="updateCron" @blur="minuteBlur" type="number" label="Minute" class="max-w-20" />
|
||||
</div>
|
||||
|
||||
<div v-if="description" class="w-full bg-primary bg-opacity-75 rounded-xl p-2 md:p-4 text-center mt-2">
|
||||
<p class="text-base md:text-lg text-gray-200" v-html="description" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="px-1 text-sm font-semibold">Cron Expression</p>
|
||||
<ui-text-input ref="customExpressionInput" v-model="customCronExpression" @blur="cronExpressionBlur" label="Cron Expression" :padding-y="2" text-center class="w-full text-2xl md:text-4xl -tracking-widest mb-4 font-mono" />
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<widgets-loading-spinner v-if="isValidating" class="mr-2" />
|
||||
<span v-else class="material-icons-outlined mr-2 text-xl" :class="isValid ? 'text-success' : 'text-error'">{{ isValid ? 'check_circle_outline' : 'error_outline' }}</span>
|
||||
<p v-if="isValidating" class="text-gray-300 text-base md:text-lg text-center">Checking cron...</p>
|
||||
<p v-else-if="customCronError" class="text-error text-base md:text-lg text-center">{{ customCronError }}</p>
|
||||
<p v-else class="text-success text-base md:text-lg text-center">Valid cron expression</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedInterval: 'custom',
|
||||
showAdvancedView: false,
|
||||
selectedHour: 0,
|
||||
selectedMinute: 0,
|
||||
selectedWeekdays: [],
|
||||
cronExpression: '0 0 * * *',
|
||||
customCronExpression: '0 0 * * *',
|
||||
customCronError: '',
|
||||
isValidating: false,
|
||||
validatedCron: null,
|
||||
isValid: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
minuteIsValid() {
|
||||
return !(isNaN(this.selectedMinute) || this.selectedMinute === '' || this.selectedMinute < 0 || this.selectedMinute > 59)
|
||||
},
|
||||
hourIsValid() {
|
||||
return !(isNaN(this.selectedHour) || this.selectedHour === '' || this.selectedHour < 0 || this.selectedHour > 23)
|
||||
},
|
||||
description() {
|
||||
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
|
||||
|
||||
if (!this.hourIsValid) {
|
||||
return `<span class="text-error">Invalid hour must be 0-23 | ${this.selectedHour < 0 || this.selectedHour > 23}</span>`
|
||||
}
|
||||
if (!this.minuteIsValid) {
|
||||
return `<span class="text-error">Invalid minute must be 0-59</span>`
|
||||
}
|
||||
|
||||
var description = 'Run every '
|
||||
var weekdayTexts = ''
|
||||
if (this.selectedWeekdays.length === 7 || this.selectedInterval === 'daily') {
|
||||
weekdayTexts = 'day'
|
||||
} else {
|
||||
weekdayTexts = this.selectedWeekdays
|
||||
.map((weekday) => {
|
||||
return this.weekdays.find((w) => w.value === weekday).text
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
description += `<span class="font-bold text-white">${weekdayTexts}</span>`
|
||||
|
||||
const hourString = this.selectedHour.toString()
|
||||
const minuteString = this.selectedMinute.toString().padStart(2, '0')
|
||||
description += ` at <span class="font-bold text-white">${hourString}:${minuteString}</span>`
|
||||
return description
|
||||
},
|
||||
intervalOptions() {
|
||||
return [
|
||||
{
|
||||
text: 'Custom daily/weekly',
|
||||
value: 'custom'
|
||||
},
|
||||
{
|
||||
text: 'Every day',
|
||||
value: 'daily'
|
||||
},
|
||||
{
|
||||
text: 'Every 12 hours',
|
||||
value: '0 */12 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 6 hours',
|
||||
value: '0 */6 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 2 hours',
|
||||
value: '0 */2 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every hour',
|
||||
value: '0 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 30 minutes',
|
||||
value: '*/30 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 15 minutes',
|
||||
value: '*/15 * * * *'
|
||||
}
|
||||
]
|
||||
},
|
||||
weekdays() {
|
||||
return [
|
||||
{
|
||||
text: 'Sunday',
|
||||
value: 0
|
||||
},
|
||||
{
|
||||
text: 'Monday',
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
text: 'Tuesday',
|
||||
value: 2
|
||||
},
|
||||
{
|
||||
text: 'Wednesday',
|
||||
value: 3
|
||||
},
|
||||
{
|
||||
text: 'Thursday',
|
||||
value: 4
|
||||
},
|
||||
{
|
||||
text: 'Friday',
|
||||
value: 5
|
||||
},
|
||||
{
|
||||
text: 'Saturday',
|
||||
value: 6
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkBlurExpressionInput() {
|
||||
if (!this.showAdvancedView || !this.$refs.customExpressionInput) return false
|
||||
if (this.$refs.customExpressionInput.isFocused) {
|
||||
this.$refs.customExpressionInput.blur()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
updateCron() {
|
||||
if (this.selectedInterval === 'custom') {
|
||||
if (!this.minuteIsValid || !this.hourIsValid || !this.selectedWeekdays.length) {
|
||||
this.cronExpression = null
|
||||
return
|
||||
}
|
||||
this.selectedWeekdays.sort()
|
||||
|
||||
const daysOfWeekPiece = this.selectedWeekdays.length === 7 ? '*' : this.selectedWeekdays.join(',')
|
||||
this.cronExpression = `${this.selectedMinute} ${this.selectedHour} * * ${daysOfWeekPiece}`
|
||||
} else if (this.selectedInterval === 'daily') {
|
||||
if (!this.minuteIsValid || !this.hourIsValid) {
|
||||
this.cronExpression = null
|
||||
return
|
||||
}
|
||||
this.cronExpression = `${this.selectedMinute} ${this.selectedHour} * * *`
|
||||
} else {
|
||||
this.cronExpression = this.selectedInterval
|
||||
}
|
||||
|
||||
this.customCronExpression = this.cronExpression
|
||||
this.validatedCron = this.cronExpression
|
||||
this.isValid = true
|
||||
this.customCronError = ''
|
||||
this.$emit('input', this.cronExpression)
|
||||
},
|
||||
minuteBlur() {
|
||||
const v = this.selectedMinute
|
||||
if (v === '' || v === null || isNaN(v) || v < 0) {
|
||||
this.selectedMinute = 0
|
||||
} else if (v > 59) {
|
||||
this.selectedMinute = 59
|
||||
} else {
|
||||
this.selectedMinute = Number(v)
|
||||
}
|
||||
this.updateCron()
|
||||
},
|
||||
hourBlur() {
|
||||
const v = this.selectedHour
|
||||
if (v === '' || v === null || isNaN(v) || v < 0) {
|
||||
this.selectedHour = 0
|
||||
} else if (v > 23) {
|
||||
this.selectedHour = 23
|
||||
} else {
|
||||
this.selectedHour = Number(v)
|
||||
}
|
||||
this.updateCron()
|
||||
},
|
||||
async cronExpressionBlur() {
|
||||
this.customCronError = ''
|
||||
if (!this.customCronExpression || this.customCronExpression.split(' ').length !== 5) {
|
||||
this.customCronError = 'Invalid cron expression'
|
||||
this.isValid = false
|
||||
return
|
||||
}
|
||||
|
||||
if (this.customCronExpression !== this.cronExpression) {
|
||||
this.selectedWeekdays = []
|
||||
this.selectedHour = 0
|
||||
this.selectedMinute = 0
|
||||
this.cronExpression = this.customCronExpression
|
||||
}
|
||||
|
||||
if (!this.validatedCron || this.validatedCron !== this.cronExpression) {
|
||||
const validationPayload = await this.validateCron()
|
||||
this.isValid = validationPayload.isValid
|
||||
this.validatedCron = this.cronExpression
|
||||
this.customCronError = validationPayload.error || ''
|
||||
}
|
||||
|
||||
if (this.isValid) {
|
||||
this.$emit('input', this.cronExpression)
|
||||
}
|
||||
},
|
||||
validateCron() {
|
||||
this.isValidating = true
|
||||
return this.$axios
|
||||
.$post('/api/validate-cron', { expression: this.customCronExpression })
|
||||
.then(() => {
|
||||
this.isValidating = false
|
||||
return {
|
||||
isValid: true
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Invalid cron', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.isValidating = false
|
||||
return {
|
||||
isValid: false,
|
||||
error: errMsg || 'Invalid cron expression'
|
||||
}
|
||||
})
|
||||
},
|
||||
init() {
|
||||
if (!this.value) return
|
||||
const pieces = this.value.split(' ')
|
||||
if (pieces.length !== 5) {
|
||||
console.error('Invalid cron expression input', this.value)
|
||||
return
|
||||
}
|
||||
|
||||
const intervalMatch = this.intervalOptions.find((opt) => opt.value === this.value)
|
||||
if (intervalMatch) {
|
||||
this.selectedInterval = this.value
|
||||
} else {
|
||||
var isCustomCron = false
|
||||
if (isNaN(pieces[0]) || isNaN(pieces[1])) {
|
||||
isCustomCron = true
|
||||
} else if (pieces[2] !== '*' || pieces[3] !== '*') {
|
||||
isCustomCron = true
|
||||
} else if (pieces[4] !== '*' && pieces[4].split(',').some((num) => isNaN(num))) {
|
||||
isCustomCron = true
|
||||
}
|
||||
|
||||
if (isCustomCron) {
|
||||
this.showAdvancedView = true
|
||||
} else {
|
||||
if (pieces[4] === '*') this.selectedInterval = 'daily'
|
||||
|
||||
this.selectedWeekdays = pieces[4] === '*' ? [0, 1, 2, 3, 4, 5, 6] : pieces[4].split(',').map((num) => Number(num))
|
||||
this.selectedHour = pieces[1]
|
||||
this.selectedMinute = pieces[0]
|
||||
}
|
||||
}
|
||||
this.cronExpression = this.value
|
||||
this.customCronExpression = this.value
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
||||
@@ -147,6 +147,16 @@ export default {
|
||||
margin-top: -2px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-lg > div {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -4px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-2x {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
|
||||
@@ -39,10 +39,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -71,7 +67,6 @@ export default {
|
||||
explicit: false,
|
||||
language: null
|
||||
},
|
||||
autoDownloadEpisodes: false,
|
||||
newTags: []
|
||||
}
|
||||
},
|
||||
@@ -196,10 +191,6 @@ export default {
|
||||
updatePayload.tags = [...this.newTags]
|
||||
}
|
||||
|
||||
if (this.media.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadEpisodes = !!this.autoDownloadEpisodes
|
||||
}
|
||||
|
||||
return {
|
||||
updatePayload,
|
||||
hasChanges: !!Object.keys(updatePayload).length
|
||||
@@ -219,7 +210,6 @@ export default {
|
||||
this.details.language = this.mediaMetadata.language || ''
|
||||
this.details.explicit = !!this.mediaMetadata.explicit
|
||||
|
||||
this.autoDownloadEpisodes = !!this.media.autoDownloadEpisodes
|
||||
this.newTags = [...(this.media.tags || [])]
|
||||
},
|
||||
submitForm() {
|
||||
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
||||
@@ -111,21 +111,8 @@ export default {
|
||||
reconnectFailed() {
|
||||
console.error('[SOCKET] reconnect failed')
|
||||
},
|
||||
init(payload, count = 0) {
|
||||
if (!this.$refs.streamContainer) {
|
||||
if (count > 20) {
|
||||
console.error('Stream container never mounted')
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init(payload, ++count)
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
init(payload) {
|
||||
console.log('Init Payload', payload)
|
||||
if (payload.session) {
|
||||
this.$refs.streamContainer.sessionOpen(payload.session)
|
||||
}
|
||||
|
||||
// Start scans currently running
|
||||
if (payload.librariesScanning) {
|
||||
@@ -535,6 +522,17 @@ export default {
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
},
|
||||
initLocalStorage() {
|
||||
// If experimental features set in local storage
|
||||
var experimentalFeaturesSaved = localStorage.getItem('experimental')
|
||||
if (experimentalFeaturesSaved === '1') {
|
||||
this.$store.commit('setExperimentalFeatures', true)
|
||||
}
|
||||
|
||||
// Queue auto play
|
||||
var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')
|
||||
this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
@@ -548,11 +546,7 @@ export default {
|
||||
|
||||
this.$store.dispatch('libraries/load')
|
||||
|
||||
// If experimental features set in local storage
|
||||
var experimentalFeaturesSaved = localStorage.getItem('experimental')
|
||||
if (experimentalFeaturesSaved === '1') {
|
||||
this.$store.commit('setExperimentalFeatures', true)
|
||||
}
|
||||
this.initLocalStorage()
|
||||
|
||||
this.checkVersionUpdate()
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export default {
|
||||
index,
|
||||
width: this.entityWidth,
|
||||
height: this.entityHeight,
|
||||
bookCoverAspectRatio: this.bookCoverAspectRatio,
|
||||
bookCoverAspectRatio: this.coverAspectRatio,
|
||||
bookshelfView: this.bookshelfView,
|
||||
sortingIgnorePrefix: !!this.sortingIgnorePrefix
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ module.exports = {
|
||||
'@/plugins/constants.js',
|
||||
'@/plugins/init.client.js',
|
||||
'@/plugins/axios.js',
|
||||
'@/plugins/toast.js'
|
||||
'@/plugins/toast.js',
|
||||
'@/plugins/utils.js'
|
||||
],
|
||||
|
||||
// Auto import components: https://go.nuxtjs.dev/config-components
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.5",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.5",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -256,7 +256,6 @@ export default {
|
||||
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
|
||||
if (this.isLoadingChapter) return
|
||||
if (this.isPlayingChapter) {
|
||||
console.log('Destroying chapter')
|
||||
this.destroyAudioEl()
|
||||
return
|
||||
}
|
||||
@@ -427,6 +426,9 @@ export default {
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.destroyAudioEl()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -144,12 +144,6 @@ export default {
|
||||
isPodcastLibrary() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</ui-btn>
|
||||
|
||||
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
||||
<ui-icon-btn v-if="userCanUpdate" icon="edit" class="mx-0.5" @click="editClick" />
|
||||
|
||||
<ui-icon-btn icon="delete" class="mx-0.5" @click="removeClick" />
|
||||
<ui-icon-btn v-if="userCanDelete" icon="delete" class="mx-0.5" @click="removeClick" />
|
||||
</div>
|
||||
|
||||
<div class="my-8 max-w-2xl">
|
||||
@@ -65,7 +65,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
@@ -92,6 +92,12 @@ export default {
|
||||
},
|
||||
showPlayButton() {
|
||||
return this.playableBooks.length
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -5,20 +5,22 @@
|
||||
<h1 class="text-xl">Backups</h1>
|
||||
</div>
|
||||
|
||||
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/items</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
|
||||
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, library item details, server settings, and images stored in <span class="font-mono text-gray-100">/metadata/items</span> & <span class="font-mono text-gray-100">/metadata/authors</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
|
||||
<ui-tooltip :text="dailyBackupsTooltip">
|
||||
<p class="pl-4 text-lg">Run daily backups <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<ui-toggle-switch v-model="enableBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
|
||||
<ui-tooltip :text="backupsTooltip">
|
||||
<p class="pl-4 text-lg">Enable automatic backups <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</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 v-if="enableBackups" class="mb-6">
|
||||
<div class="flex items-center pl-6">
|
||||
<span class="material-icons-outlined text-black-50">schedule</span>
|
||||
<p class="text-gray-100 px-2">{{ scheduleDescription }}</p>
|
||||
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer" @click="showCronBuilder = !showCronBuilder">edit</span>
|
||||
</div>
|
||||
</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" />
|
||||
@@ -36,6 +38,8 @@
|
||||
|
||||
<tables-backups-table />
|
||||
</div>
|
||||
|
||||
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,11 +48,12 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
updatingServerSettings: false,
|
||||
dailyBackups: true,
|
||||
enableBackups: true,
|
||||
backupsToKeep: 2,
|
||||
maxBackupSize: 1,
|
||||
// cronExpression: '',
|
||||
newServerSettings: {}
|
||||
cronExpression: '',
|
||||
newServerSettings: {},
|
||||
showCronBuilder: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -60,29 +65,22 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dailyBackupsTooltip() {
|
||||
return 'Runs at 1:30am every day (your server time). Saved in /metadata/backups.'
|
||||
backupsTooltip() {
|
||||
return 'Backups saved in /metadata/backups'
|
||||
},
|
||||
maxBackupSizeTooltip() {
|
||||
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
scheduleDescription() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$parseCronExpression(this.cronExpression)
|
||||
return parsed ? parsed.description : 'Custom cron expression ' + this.cronExpression
|
||||
}
|
||||
},
|
||||
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')
|
||||
@@ -93,7 +91,7 @@ export default {
|
||||
return
|
||||
}
|
||||
var updatePayload = {
|
||||
backupSchedule: this.dailyBackups ? '30 1 * * *' : false,
|
||||
backupSchedule: this.enableBackups ? this.cronExpression : false,
|
||||
backupsToKeep: Number(this.backupsToKeep),
|
||||
maxBackupSize: Number(this.maxBackupSize)
|
||||
}
|
||||
@@ -116,9 +114,9 @@ export default {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||
this.enableBackups = !!this.newServerSettings.backupSchedule
|
||||
this.maxBackupSize = this.newServerSettings.maxBackupSize || 1
|
||||
// this.cronExpression = '30 1 * * *'
|
||||
this.cronExpression = this.newServerSettings.backupSchedule || '30 1 * * *'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -53,10 +53,10 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||
<ui-toggle-switch v-model="homeUseAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateHomeAlternativeBookshelfView" />
|
||||
<ui-tooltip :text="tooltips.bookshelfView">
|
||||
<p class="pl-4">
|
||||
Square book covers
|
||||
Alternative bookshelf view for home page
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
@@ -270,7 +270,7 @@ export default {
|
||||
return {
|
||||
isResettingLibraryItems: false,
|
||||
updatingServerSettings: false,
|
||||
useSquareBookCovers: false,
|
||||
homeUseAlternativeBookshelfView: false,
|
||||
useAlternativeBookshelfView: false,
|
||||
isPurgingCache: false,
|
||||
newServerSettings: {},
|
||||
@@ -362,6 +362,11 @@ export default {
|
||||
coverAspectRatio: val ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD
|
||||
})
|
||||
},
|
||||
updateHomeAlternativeBookshelfView(val) {
|
||||
this.updateServerSettings({
|
||||
homeBookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD
|
||||
})
|
||||
},
|
||||
updateAlternativeBookshelfView(val) {
|
||||
this.updateServerSettings({
|
||||
bookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD
|
||||
@@ -391,8 +396,7 @@ export default {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
|
||||
|
||||
this.useSquareBookCovers = this.newServerSettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
|
||||
this.homeUseAlternativeBookshelfView = this.newServerSettings.homeBookshelfView === this.$constants.BookshelfView.TITLES
|
||||
this.useAlternativeBookshelfView = this.newServerSettings.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
resetLibraryItems() {
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
|
||||
</div>
|
||||
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,6 +111,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removedSession() {
|
||||
this.loadSessions(this.currentPage)
|
||||
},
|
||||
async clickCurrentTime(session) {
|
||||
if (this.processingGoToTimestamp) return
|
||||
this.processingGoToTimestamp = true
|
||||
|
||||
@@ -105,11 +105,8 @@ export default {
|
||||
userToken() {
|
||||
return this.user.token
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
username() {
|
||||
return this.user.username
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -98,6 +98,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removedSession() {
|
||||
this.loadSessions(this.currentPage)
|
||||
},
|
||||
async clickCurrentTime(session) {
|
||||
if (this.processingGoToTimestamp) return
|
||||
this.processingGoToTimestamp = true
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- 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>
|
||||
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="startStream">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem">
|
||||
<span class="material-icons text-4xl">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,12 +24,13 @@
|
||||
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
|
||||
<div class="flex justify-center">
|
||||
<div class="mb-4">
|
||||
<div class="flex sm:items-end flex-col sm:flex-row">
|
||||
<h1 class="text-2xl md:text-3xl font-sans">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||
</div>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||
|
||||
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
|
||||
|
||||
<template v-if="!isVideo">
|
||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||
@@ -39,8 +40,6 @@
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
</template>
|
||||
|
||||
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
|
||||
|
||||
<div v-if="narrator" class="flex py-0.5 mt-4">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span>
|
||||
@@ -129,7 +128,7 @@
|
||||
|
||||
<!-- Icon buttons -->
|
||||
<div class="flex items-center justify-center md:justify-start pt-4">
|
||||
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem">
|
||||
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ isStreaming ? 'Playing' : 'Play' }}
|
||||
</ui-btn>
|
||||
@@ -151,7 +150,7 @@
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="!isPodcast" text="Collections" direction="top">
|
||||
<ui-tooltip v-if="!isPodcast && userCanUpdate" text="Collections" direction="top">
|
||||
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -247,11 +246,8 @@ export default {
|
||||
isFile() {
|
||||
return this.libraryItem.isFile
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return 208
|
||||
@@ -433,14 +429,14 @@ export default {
|
||||
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(bookmark.time)}?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.startStream(bookmark.time)
|
||||
this.playItem(bookmark.time)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
} else {
|
||||
this.startStream(bookmark.time)
|
||||
this.playItem(bookmark.time)
|
||||
}
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
@@ -519,21 +515,43 @@ export default {
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
startStream(startTime = null) {
|
||||
playItem(startTime = null) {
|
||||
var episodeId = null
|
||||
const queueItems = []
|
||||
if (this.isPodcast) {
|
||||
var episode = this.podcastEpisodes.find((ep) => {
|
||||
const episodesInListeningOrder = this.podcastEpisodes.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
|
||||
|
||||
// Find most recent episode unplayed
|
||||
var episodeIndex = episodesInListeningOrder.findLastIndex((ep) => {
|
||||
var podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
|
||||
return !podcastProgress || !podcastProgress.isFinished
|
||||
})
|
||||
if (!episode) episode = this.podcastEpisodes[0]
|
||||
episodeId = episode.id
|
||||
if (episodeIndex < 0) episodeIndex = 0
|
||||
|
||||
episodeId = episodesInListeningOrder[episodeIndex].id
|
||||
|
||||
for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {
|
||||
const episode = episodesInListeningOrder[i]
|
||||
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
||||
if (!podcastProgress || !podcastProgress.isFinished) {
|
||||
queueItems.push({
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.libraryItem.media.coverPath || null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId,
|
||||
startTime
|
||||
startTime,
|
||||
queueItems
|
||||
})
|
||||
},
|
||||
editClick() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
|
||||
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode"/>
|
||||
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -42,4 +42,4 @@ export default {
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -133,6 +133,8 @@ export default class PlayerHandler {
|
||||
|
||||
// TODO: Add listening time between last sync and now?
|
||||
this.sendProgressSync(currentTime)
|
||||
|
||||
this.ctx.mediaFinished(this.libraryItemId, this.episodeId)
|
||||
}
|
||||
|
||||
playerStateChange(state) {
|
||||
|
||||
@@ -75,6 +75,9 @@ const Hotkeys = {
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
Constants
|
||||
}
|
||||
export default ({ app }, inject) => {
|
||||
inject('constants', Constants)
|
||||
inject('keynames', KeyNames)
|
||||
|
||||
@@ -30,92 +30,6 @@ Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
|
||||
return date
|
||||
}
|
||||
|
||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
if (isNaN(bytes) || bytes == 0) {
|
||||
return '0 Bytes'
|
||||
}
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
||||
if (seconds < 60) {
|
||||
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
|
||||
}
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 70) {
|
||||
return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
|
||||
}
|
||||
var hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
if (!minutes) {
|
||||
return `${hours} ${useFullNames ? 'hours' : 'hr'}`
|
||||
}
|
||||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
if (!seconds) return '0:00'
|
||||
var _seconds = seconds
|
||||
var _minutes = Math.floor(seconds / 60)
|
||||
_seconds -= _minutes * 60
|
||||
var _hours = Math.floor(_minutes / 60)
|
||||
_minutes -= _hours * 60
|
||||
_seconds = Math.floor(_seconds)
|
||||
if (!_hours) {
|
||||
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
|
||||
if (isNaN(seconds) || seconds === null) return ''
|
||||
seconds = Math.round(seconds)
|
||||
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
seconds -= minutes * 60
|
||||
var hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
|
||||
var days = 0
|
||||
if (useDays || Math.floor(hours / 24) >= 100) {
|
||||
days = Math.floor(hours / 24)
|
||||
hours -= days * 24
|
||||
}
|
||||
|
||||
var strs = []
|
||||
if (days) strs.push(`${days}d`)
|
||||
if (hours) strs.push(`${hours}h`)
|
||||
if (minutes) strs.push(`${minutes}m`)
|
||||
if (seconds) strs.push(`${seconds}s`)
|
||||
return strs.join(' ')
|
||||
}
|
||||
|
||||
Vue.prototype.$calculateTextSize = (text, styles = {}) => {
|
||||
const el = document.createElement('p')
|
||||
|
||||
let attr = 'margin:0px;opacity:1;position:absolute;top:100px;left:100px;z-index:99;'
|
||||
for (const key in styles) {
|
||||
if (styles[key] && String(styles[key]).length > 0) {
|
||||
attr += `${key}:${styles[key]};`
|
||||
}
|
||||
}
|
||||
|
||||
el.setAttribute('style', attr)
|
||||
el.innerText = text
|
||||
|
||||
document.body.appendChild(el)
|
||||
const boundingBox = el.getBoundingClientRect()
|
||||
el.remove()
|
||||
return {
|
||||
height: boundingBox.height,
|
||||
width: boundingBox.width
|
||||
}
|
||||
}
|
||||
|
||||
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
|
||||
if (typeof input !== 'string') {
|
||||
return false
|
||||
|
||||
128
client/plugins/utils.js
Normal file
128
client/plugins/utils.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import Vue from 'vue'
|
||||
|
||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
if (isNaN(bytes) || bytes == 0) {
|
||||
return '0 Bytes'
|
||||
}
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
||||
if (seconds < 60) {
|
||||
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
|
||||
}
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 70) {
|
||||
return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
|
||||
}
|
||||
var hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
if (!minutes) {
|
||||
return `${hours} ${useFullNames ? 'hours' : 'hr'}`
|
||||
}
|
||||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
if (!seconds) return '0:00'
|
||||
var _seconds = seconds
|
||||
var _minutes = Math.floor(seconds / 60)
|
||||
_seconds -= _minutes * 60
|
||||
var _hours = Math.floor(_minutes / 60)
|
||||
_minutes -= _hours * 60
|
||||
_seconds = Math.floor(_seconds)
|
||||
if (!_hours) {
|
||||
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
|
||||
if (isNaN(seconds) || seconds === null) return ''
|
||||
seconds = Math.round(seconds)
|
||||
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
seconds -= minutes * 60
|
||||
var hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
|
||||
var days = 0
|
||||
if (useDays || Math.floor(hours / 24) >= 100) {
|
||||
days = Math.floor(hours / 24)
|
||||
hours -= days * 24
|
||||
}
|
||||
|
||||
var strs = []
|
||||
if (days) strs.push(`${days}d`)
|
||||
if (hours) strs.push(`${hours}h`)
|
||||
if (minutes) strs.push(`${minutes}m`)
|
||||
if (seconds) strs.push(`${seconds}s`)
|
||||
return strs.join(' ')
|
||||
}
|
||||
|
||||
Vue.prototype.$parseCronExpression = (expression) => {
|
||||
if (!expression) return null
|
||||
const pieces = expression.split(' ')
|
||||
if (pieces.length !== 5) {
|
||||
return null
|
||||
}
|
||||
|
||||
const commonPatterns = [
|
||||
{
|
||||
text: 'Every 12 hours',
|
||||
value: '0 */12 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 6 hours',
|
||||
value: '0 */6 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 2 hours',
|
||||
value: '0 */2 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every hour',
|
||||
value: '0 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 30 minutes',
|
||||
value: '*/30 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 15 minutes',
|
||||
value: '*/15 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every minute',
|
||||
value: '* * * * *'
|
||||
}
|
||||
]
|
||||
const patternMatch = commonPatterns.find(p => p.value === expression)
|
||||
if (patternMatch) {
|
||||
return {
|
||||
description: patternMatch.text
|
||||
}
|
||||
}
|
||||
|
||||
if (isNaN(pieces[0]) || isNaN(pieces[1])) {
|
||||
return null
|
||||
}
|
||||
if (pieces[2] !== '*' || pieces[3] !== '*') {
|
||||
return null
|
||||
}
|
||||
if (pieces[4] !== '*' && pieces[4].split(',').some(p => isNaN(p))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
var weekdayText = 'day'
|
||||
if (pieces[4] !== '*') weekdayText = pieces[4].split(',').map(p => weekdays[p]).join(', ')
|
||||
|
||||
return {
|
||||
description: `Run every ${weekdayText} at ${pieces[1]}:${pieces[0].padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,14 @@ export const getters = {
|
||||
return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
}
|
||||
return `/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
},
|
||||
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = '/book_placeholder.jpg') => {
|
||||
if (!libraryItemId) return placeholder
|
||||
var userToken = rootGetters['user/getToken']
|
||||
if (process.env.NODE_ENV !== 'production') { // Testing
|
||||
return `http://localhost:3333/api/items/${libraryItemId}/cover?token=${userToken}`
|
||||
}
|
||||
return `/api/items/${libraryItemId}/cover?token=${userToken}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { checkForUpdate, currentVersion } from '@/plugins/version'
|
||||
import Vue from 'vue'
|
||||
const { Constants } = require('../plugins/constants')
|
||||
|
||||
export const state = () => ({
|
||||
Source: null,
|
||||
@@ -8,6 +9,8 @@ export const state = () => ({
|
||||
streamLibraryItem: null,
|
||||
streamEpisodeId: null,
|
||||
streamIsPlaying: false,
|
||||
playerQueueItems: [],
|
||||
playerQueueAutoPlay: true,
|
||||
playerIsFullscreen: false,
|
||||
editModalTab: 'details',
|
||||
showEditModal: false,
|
||||
@@ -45,6 +48,14 @@ export const getters = {
|
||||
if (!state.streamLibraryItem) return null
|
||||
if (!episodeId) return state.streamLibraryItem.id == libraryItemId
|
||||
return state.streamLibraryItem.id == libraryItemId && state.streamEpisodeId == episodeId
|
||||
},
|
||||
getBookshelfView: state => {
|
||||
if (!state.serverSettings || isNaN(state.serverSettings.bookshelfView)) return Constants.BookshelfView.STANDARD
|
||||
return state.serverSettings.bookshelfView
|
||||
},
|
||||
getHomeBookshelfView: state => {
|
||||
if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD
|
||||
return state.serverSettings.homeBookshelfView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,14 +146,23 @@ export const mutations = {
|
||||
state.streamLibraryItem = null
|
||||
state.streamEpisodeId = null
|
||||
state.streamIsPlaying = false
|
||||
state.playerQueueItems = []
|
||||
} else {
|
||||
state.streamLibraryItem = payload.libraryItem
|
||||
state.streamEpisodeId = payload.episodeId || null
|
||||
state.playerQueueItems = payload.queueItems || []
|
||||
}
|
||||
},
|
||||
setIsPlaying(state, isPlaying) {
|
||||
state.streamIsPlaying = isPlaying
|
||||
},
|
||||
setPlayerQueueItems(state, items) {
|
||||
state.playerQueueItems = items || []
|
||||
},
|
||||
setPlayerQueueAutoPlay(state, autoPlay) {
|
||||
state.playerQueueAutoPlay = !!autoPlay
|
||||
localStorage.setItem('playerQueueAutoPlay', !!autoPlay ? '1' : '0')
|
||||
},
|
||||
showEditModal(state, libraryItem) {
|
||||
state.editModalTab = 'details'
|
||||
state.selectedLibraryItem = libraryItem
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const { Constants } = require('../plugins/constants')
|
||||
|
||||
export const state = () => ({
|
||||
libraries: [],
|
||||
lastLoad: 0,
|
||||
@@ -42,6 +44,14 @@ export const getters = {
|
||||
})
|
||||
if (!librariesSorted.length) return null
|
||||
return librariesSorted[0]
|
||||
},
|
||||
getCurrentLibrarySettings: (state, getters) => {
|
||||
if (!getters.getCurrentLibrary) return null
|
||||
return getters.getCurrentLibrary.settings
|
||||
},
|
||||
getBookCoverAspectRatio: (state, getters) => {
|
||||
if (!getters.getCurrentLibrarySettings || isNaN(getters.getCurrentLibrarySettings.coverAspectRatio)) return 1
|
||||
return getters.getCurrentLibrarySettings.coverAspectRatio === Constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ export const actions = {
|
||||
if (state.settings.orderBy == 'media.duration') {
|
||||
settingsUpdate.orderBy = 'media.numTracks'
|
||||
}
|
||||
if (state.settings.orderBy == 'media.metadata.publishedYear') {
|
||||
settingsUpdate.orderBy = 'media.metadata.title'
|
||||
}
|
||||
var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
|
||||
var filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
|
||||
if (invalidFilters.includes(filterByFirstPart)) {
|
||||
|
||||
@@ -11,11 +11,16 @@ module.exports = {
|
||||
safelist: [
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'bg-yellow-400',
|
||||
'text-green-500',
|
||||
'py-1.5',
|
||||
'bg-info',
|
||||
'px-1.5',
|
||||
'min-w-5'
|
||||
'min-w-5',
|
||||
'w-3.5',
|
||||
'h-3.5',
|
||||
'border-warning',
|
||||
'mb-px'
|
||||
],
|
||||
},
|
||||
theme: {
|
||||
@@ -44,6 +49,7 @@ module.exports = {
|
||||
minWidth: {
|
||||
'5': '1.25rem',
|
||||
'6': '1.5rem',
|
||||
'8': '2rem',
|
||||
'10': '2.5rem',
|
||||
'12': '3rem',
|
||||
'16': '4rem',
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.5",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.26.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.5",
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
1
prod.js
1
prod.js
@@ -10,7 +10,6 @@ const commandLineArgs = require('./server/libs/commandLineArgs')
|
||||
const options = commandLineArgs(optionDefinitions)
|
||||
|
||||
const Path = require('path')
|
||||
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
const server = require('./server/Server')
|
||||
|
||||
56
server/Db.js
56
server/Db.js
@@ -121,7 +121,7 @@ class Db {
|
||||
async init() {
|
||||
await this.load()
|
||||
|
||||
if (!this.serverSettings) {
|
||||
if (!this.serverSettings) { // Create first load server settings
|
||||
this.serverSettings = new ServerSettings()
|
||||
await this.insertEntity('settings', this.serverSettings)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ class Db {
|
||||
this.libraries.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
Logger.info(`[DB] ${this.libraries.length} Libraries Loaded`)
|
||||
})
|
||||
var p4 = this.settingsDb.select(() => true).then((results) => {
|
||||
var p4 = this.settingsDb.select(() => true).then(async (results) => {
|
||||
if (results.data && results.data.length) {
|
||||
this.settings = results.data
|
||||
var serverSettings = this.settings.find(s => s.id === 'server-settings')
|
||||
@@ -152,6 +152,18 @@ class Db {
|
||||
// Check if server was upgraded
|
||||
if (!this.serverSettings.version || this.serverSettings.version !== version) {
|
||||
this.previousVersion = this.serverSettings.version || '1.0.0'
|
||||
|
||||
// Library settings and server settings updated in 2.1.3 - run migration
|
||||
if (this.previousVersion.localeCompare('2.1.3') < 0) {
|
||||
Logger.info(`[Db] Running servers & library settings migration`)
|
||||
for (const library of this.libraries) {
|
||||
if (library.settings.coverAspectRatio !== serverSettings.coverAspectRatio) {
|
||||
library.settings.coverAspectRatio = serverSettings.coverAspectRatio
|
||||
await this.updateEntity('library', library)
|
||||
Logger.debug(`[Db] Library ${library.name} migrated`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,17 +195,6 @@ class Db {
|
||||
getLibraryItemsInLibrary(libraryId) {
|
||||
return this.libraryItems.filter(li => li.libraryId === libraryId)
|
||||
}
|
||||
getPlaybackSession(id) {
|
||||
return this.sessionsDb.select((pb) => pb.id == id).then((results) => {
|
||||
if (results.data.length) {
|
||||
return new PlaybackSession(results.data[0])
|
||||
}
|
||||
return null
|
||||
}).catch((error) => {
|
||||
Logger.error('Failed to get session', error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
async updateLibraryItem(libraryItem) {
|
||||
return this.updateLibraryItems([libraryItem])
|
||||
@@ -368,22 +369,7 @@ class Db {
|
||||
}
|
||||
|
||||
return entityDb.update((record) => record.id === entity.id, () => jsonEntity).then((results) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
Logger.debug(`[DB] Updated ${entityName}: ${results.updated} | Selected: ${results.selected}`)
|
||||
|
||||
if (!results.selected) {
|
||||
entityDb.select(match => match.id == jsonEntity.id).then((results) => {
|
||||
if (results.data.length) {
|
||||
console.log('Said selected 0 but found it right here...', results.data[0].id)
|
||||
} else {
|
||||
console.log('Said selected 0 and no results for json entity id', jsonEntity.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[DB] Updated ${entityName}: ${results.updated}`)
|
||||
}
|
||||
|
||||
Logger.debug(`[DB] Updated ${entityName}: ${results.updated}`)
|
||||
var arrayKey = this.getEntityArrayKey(entityName)
|
||||
if (this[arrayKey]) {
|
||||
this[arrayKey] = this[arrayKey].map(e => {
|
||||
@@ -452,6 +438,18 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
getPlaybackSession(id) {
|
||||
return this.sessionsDb.select((pb) => pb.id == id).then((results) => {
|
||||
if (results.data.length) {
|
||||
return new PlaybackSession(results.data[0])
|
||||
}
|
||||
return null
|
||||
}).catch((error) => {
|
||||
Logger.error('Failed to get session', error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
selectUserSessions(userId) {
|
||||
return this.sessionsDb.select((session) => session.userId === userId).then((results) => {
|
||||
return results.data || []
|
||||
|
||||
@@ -32,6 +32,7 @@ const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
||||
const PodcastManager = require('./managers/PodcastManager')
|
||||
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
const CronManager = require('./managers/CronManager')
|
||||
|
||||
class Server {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH) {
|
||||
@@ -74,9 +75,10 @@ class Server {
|
||||
this.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this))
|
||||
|
||||
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
|
||||
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
|
||||
|
||||
// Routers
|
||||
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.cronManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
|
||||
this.staticRouter = new StaticRouter(this.db)
|
||||
|
||||
@@ -150,7 +152,7 @@ class Server {
|
||||
await this.backupManager.init()
|
||||
await this.logManager.init()
|
||||
await this.rssFeedManager.init()
|
||||
this.podcastManager.init()
|
||||
this.cronManager.init()
|
||||
|
||||
if (this.db.serverSettings.scannerDisableWatcher) {
|
||||
Logger.info(`[Server] Watcher is disabled`)
|
||||
@@ -438,26 +440,7 @@ class Server {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has session open
|
||||
var session = this.playbackSessionManager.getUserSession(user.id)
|
||||
if (session) {
|
||||
Logger.debug(`[Server] User Online "${client.user.username}" with session open "${session.id}"`)
|
||||
var sessionLibraryItem = this.db.libraryItems.find(li => li.id === session.libraryItemId)
|
||||
if (!sessionLibraryItem) {
|
||||
Logger.error(`[Server] Library Item for session "${session.id}" does not exist "${session.libraryItemId}"`)
|
||||
this.playbackSessionManager.removeSession(session.id)
|
||||
session = null
|
||||
} else if (session.mediaType === 'podcast' && !sessionLibraryItem.media.checkHasEpisode(session.episodeId)) {
|
||||
Logger.error(`[Server] Library Item for session "${session.id}" episode ${session.episodeId} does not exist "${session.libraryItemId}"`)
|
||||
this.playbackSessionManager.removeSession(session.id)
|
||||
session = null
|
||||
}
|
||||
if (session) {
|
||||
session = session.toJSONForClient(sessionLibraryItem)
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[Server] User Online ${client.user.username}`)
|
||||
}
|
||||
Logger.debug(`[Server] User Online ${client.user.username}`)
|
||||
|
||||
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
|
||||
|
||||
@@ -468,7 +451,6 @@ class Server {
|
||||
metadataPath: global.MetadataPath,
|
||||
configPath: global.ConfigPath,
|
||||
user: client.user.toJSONForBrowser(),
|
||||
session,
|
||||
librariesScanning: this.scanner.librariesScanning,
|
||||
backups: (this.backupManager.backups || []).map(b => b.toJSON())
|
||||
}
|
||||
|
||||
@@ -24,18 +24,11 @@ class CollectionController {
|
||||
}
|
||||
|
||||
findOne(req, res) {
|
||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
res.json(collection.toJSONExpanded(this.db.libraryItems))
|
||||
res.json(req.collection.toJSONExpanded(this.db.libraryItems))
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
const collection = req.collection
|
||||
var wasUpdated = collection.update(req.body)
|
||||
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
||||
if (wasUpdated) {
|
||||
@@ -46,10 +39,7 @@ class CollectionController {
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
const collection = req.collection
|
||||
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
||||
await this.db.removeEntity('collection', collection.id)
|
||||
this.emitter('collection_removed', jsonExpanded)
|
||||
@@ -57,10 +47,7 @@ class CollectionController {
|
||||
}
|
||||
|
||||
async addBook(req, res) {
|
||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
const collection = req.collection
|
||||
var libraryItem = this.db.libraryItems.find(li => li.id === req.body.id)
|
||||
if (!libraryItem) {
|
||||
return res.status(500).send('Book not found')
|
||||
@@ -80,11 +67,7 @@ class CollectionController {
|
||||
|
||||
// DELETE: api/collections/:id/book/:bookId
|
||||
async removeBook(req, res) {
|
||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
|
||||
const collection = req.collection
|
||||
if (collection.books.includes(req.params.bookId)) {
|
||||
collection.removeBook(req.params.bookId)
|
||||
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
||||
@@ -96,10 +79,7 @@ class CollectionController {
|
||||
|
||||
// POST: api/collections/:id/batch/add
|
||||
async addBatch(req, res) {
|
||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
const collection = req.collection
|
||||
if (!req.body.books || !req.body.books.length) {
|
||||
return res.status(500).send('Invalid request body')
|
||||
}
|
||||
@@ -120,10 +100,7 @@ class CollectionController {
|
||||
|
||||
// POST: api/collections/:id/batch/remove
|
||||
async removeBatch(req, res) {
|
||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
const collection = req.collection
|
||||
if (!req.body.books || !req.body.books.length) {
|
||||
return res.status(500).send('Invalid request body')
|
||||
}
|
||||
@@ -141,5 +118,25 @@ class CollectionController {
|
||||
}
|
||||
res.json(collection.toJSONExpanded(this.db.libraryItems))
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (req.params.id) {
|
||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
||||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
req.collection = collection
|
||||
}
|
||||
|
||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
Logger.warn(`[CollectionController] User attempted to delete without permission`, req.user.username)
|
||||
return res.sendStatus(403)
|
||||
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
|
||||
Logger.warn('[CollectionController] User attempted to update without permission', req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new CollectionController()
|
||||
@@ -108,6 +108,9 @@ class LibraryController {
|
||||
// Update watcher
|
||||
this.watcher.updateLibrary(library)
|
||||
|
||||
// Update auto scan cron
|
||||
this.cronManager.updateLibraryScanCron(library)
|
||||
|
||||
// Remove libraryItems no longer in library
|
||||
var itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
|
||||
if (itemsToRemove.length) {
|
||||
@@ -162,6 +165,7 @@ class LibraryController {
|
||||
if (payload.filterBy) {
|
||||
// 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
|
||||
if (filterSeries === 'No Series') filterSeries = null
|
||||
|
||||
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
|
||||
payload.total = libraryItems.length
|
||||
|
||||
@@ -51,11 +51,6 @@ class LibraryItemController {
|
||||
|
||||
var hasUpdates = libraryItem.update(req.body)
|
||||
if (hasUpdates) {
|
||||
// Turn on podcast auto download cron if not already on
|
||||
if (libraryItem.mediaType == 'podcast' && req.body.media.autoDownloadEpisodes && !this.podcastManager.episodeScheduleTask) {
|
||||
this.podcastManager.schedulePodcastEpisodeCron()
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryItemController] Updated now saving`)
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
@@ -79,12 +74,27 @@ class LibraryItemController {
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
}
|
||||
|
||||
// Book specific
|
||||
if (libraryItem.isBook) {
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
|
||||
}
|
||||
|
||||
// Podcast specific
|
||||
var isPodcastAutoDownloadUpdated = false
|
||||
if (libraryItem.isPodcast) {
|
||||
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
|
||||
isPodcastAutoDownloadUpdated = true
|
||||
} else if (mediaPayload.autoDownloadSchedule !== undefined && libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
|
||||
isPodcastAutoDownloadUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
var hasUpdates = libraryItem.media.update(mediaPayload)
|
||||
if (hasUpdates) {
|
||||
if (isPodcastAutoDownloadUpdated) {
|
||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const Logger = require('../Logger')
|
||||
const { sort } = require('../libs/fastSort')
|
||||
const { isObject, toNumber } = require('../utils/index')
|
||||
|
||||
class MeController {
|
||||
@@ -95,9 +96,9 @@ class MeController {
|
||||
|
||||
var shouldUpdate = false
|
||||
itemProgressPayloads.forEach((itemProgress) => {
|
||||
var libraryItem = this.db.libraryItems.find(li => li.id === itemProgress.id) // Make sure this library item exists
|
||||
var libraryItem = this.db.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists
|
||||
if (libraryItem) {
|
||||
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, itemProgress)
|
||||
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)
|
||||
if (wasUpdated) shouldUpdate = true
|
||||
} else {
|
||||
Logger.error(`[MeController] batchUpdateMediaProgress: Library Item does not exist ${itemProgress.id}`)
|
||||
@@ -240,5 +241,40 @@ class MeController {
|
||||
localProgressUpdates: updatedLocalMediaProgress
|
||||
})
|
||||
}
|
||||
|
||||
// GET: api/me/items-in-progress
|
||||
async getAllLibraryItemsInProgress(req, res) {
|
||||
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
|
||||
|
||||
var itemsInProgress = []
|
||||
for (const mediaProgress of req.user.mediaProgress) {
|
||||
if (!mediaProgress.isFinished && mediaProgress.progress > 0) {
|
||||
const libraryItem = await this.db.getLibraryItem(mediaProgress.libraryItemId)
|
||||
if (libraryItem) {
|
||||
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
||||
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
|
||||
if (episode) {
|
||||
const libraryItemWithEpisode = {
|
||||
...libraryItem.toJSONMinified(),
|
||||
recentEpisode: episode.toJSON(),
|
||||
progressLastUpdate: mediaProgress.lastUpdate
|
||||
}
|
||||
itemsInProgress.push(libraryItemWithEpisode)
|
||||
}
|
||||
} else if (!mediaProgress.episodeId) {
|
||||
itemsInProgress.push({
|
||||
...libraryItem.toJSONMinified(),
|
||||
progressLastUpdate: mediaProgress.lastUpdate
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemsInProgress = sort(itemsInProgress).desc(li => li.progressLastUpdate).slice(0, limit)
|
||||
res.json({
|
||||
libraryItems: itemsInProgress
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = new MeController()
|
||||
@@ -86,8 +86,8 @@ class PodcastController {
|
||||
}
|
||||
|
||||
// Turn on podcast auto download cron if not already on
|
||||
if (libraryItem.media.autoDownloadEpisodes && !this.podcastManager.episodeScheduleTask) {
|
||||
this.podcastManager.schedulePodcastEpisodeCron()
|
||||
if (libraryItem.media.autoDownloadEpisodes) {
|
||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,9 @@ class PodcastController {
|
||||
return res.status(500).send('Podcast has no rss feed url')
|
||||
}
|
||||
|
||||
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem)
|
||||
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
|
||||
|
||||
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
|
||||
res.json({
|
||||
episodes: newEpisodes || []
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ class SessionController {
|
||||
res.json(payload)
|
||||
}
|
||||
|
||||
getSession(req, res) {
|
||||
getOpenSession(req, res) {
|
||||
var libraryItem = this.db.getLibraryItem(req.session.libraryItemId)
|
||||
var sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||
res.json(sessionForClient)
|
||||
@@ -58,12 +58,24 @@ class SessionController {
|
||||
this.playbackSessionManager.closeSessionRequest(req.user, req.session, req.body, res)
|
||||
}
|
||||
|
||||
// DELETE: api/session/:id
|
||||
async delete(req, res) {
|
||||
// if session is open then remove it
|
||||
const openSession = this.playbackSessionManager.getSession(req.session.id)
|
||||
if (openSession) {
|
||||
await this.playbackSessionManager.removeSession(req.session.id)
|
||||
}
|
||||
|
||||
await this.db.removeEntity('session', req.session.id)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/session/local
|
||||
syncLocal(req, res) {
|
||||
this.playbackSessionManager.syncLocalSessionRequest(req.user, req.body, res)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
openSessionMiddleware(req, res, next) {
|
||||
var playbackSession = this.playbackSessionManager.getSession(req.params.id)
|
||||
if (!playbackSession) return res.sendStatus(404)
|
||||
|
||||
@@ -75,5 +87,21 @@ class SessionController {
|
||||
req.session = playbackSession
|
||||
next()
|
||||
}
|
||||
|
||||
async middleware(req, res, next) {
|
||||
var playbackSession = await this.db.getPlaybackSession(req.params.id)
|
||||
if (!playbackSession) return res.sendStatus(404)
|
||||
|
||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
Logger.warn(`[SessionController] User attempted to delete without permission`, req.user)
|
||||
return res.sendStatus(403)
|
||||
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
|
||||
Logger.warn('[SessionController] User attempted to update without permission', req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
req.session = playbackSession
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new SessionController()
|
||||
@@ -3,14 +3,14 @@
|
||||
const EventEmitter = require('events');
|
||||
const TimeMatcher = require('./time-matcher');
|
||||
|
||||
class Scheduler extends EventEmitter{
|
||||
constructor(pattern, timezone, autorecover){
|
||||
class Scheduler extends EventEmitter {
|
||||
constructor(pattern, timezone, autorecover) {
|
||||
super();
|
||||
this.timeMatcher = new TimeMatcher(pattern, timezone);
|
||||
this.autorecover = autorecover;
|
||||
}
|
||||
|
||||
start(){
|
||||
start() {
|
||||
// clear timeout if exists
|
||||
this.stop();
|
||||
|
||||
@@ -22,11 +22,11 @@ class Scheduler extends EventEmitter{
|
||||
const elapsedTime = process.hrtime(lastCheck);
|
||||
const elapsedMs = (elapsedTime[0] * 1e9 + elapsedTime[1]) / 1e6;
|
||||
const missedExecutions = Math.floor(elapsedMs / 1000);
|
||||
|
||||
for(let i = missedExecutions; i >= 0; i--){
|
||||
|
||||
for (let i = missedExecutions; i >= 0; i--) {
|
||||
const date = new Date(new Date().getTime() - i * 1000);
|
||||
let date_tmp = this.timeMatcher.apply(date);
|
||||
if(lastExecution.getTime() < date_tmp.getTime() && (i === 0 || this.autorecover) && this.timeMatcher.match(date)){
|
||||
if (lastExecution.getTime() < date_tmp.getTime() && (i === 0 || this.autorecover) && this.timeMatcher.match(date)) {
|
||||
this.emit('scheduled-time-matched', date_tmp);
|
||||
date_tmp.setMilliseconds(0);
|
||||
lastExecution = date_tmp;
|
||||
@@ -38,8 +38,8 @@ class Scheduler extends EventEmitter{
|
||||
matchTime();
|
||||
}
|
||||
|
||||
stop(){
|
||||
if(this.timeout){
|
||||
stop() {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
this.timeout = null;
|
||||
|
||||
@@ -16,6 +16,7 @@ class BackupManager {
|
||||
constructor(db, emitter) {
|
||||
this.BackupPath = Path.join(global.MetadataPath, 'backups')
|
||||
this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items')
|
||||
this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors')
|
||||
|
||||
this.db = db
|
||||
this.emitter = emitter
|
||||
@@ -56,14 +57,14 @@ class BackupManager {
|
||||
updateCronSchedule() {
|
||||
if (this.scheduleTask && !this.serverSettings.backupSchedule) {
|
||||
Logger.info(`[BackupManager] Disabling backup schedule`)
|
||||
if (this.scheduleTask.destroy) this.scheduleTask.destroy()
|
||||
if (this.scheduleTask.stop) this.scheduleTask.stop()
|
||||
this.scheduleTask = null
|
||||
} else if (!this.scheduleTask && this.serverSettings.backupSchedule) {
|
||||
Logger.info(`[BackupManager] Starting backup schedule ${this.serverSettings.backupSchedule}`)
|
||||
this.scheduleCron()
|
||||
} else if (this.serverSettings.backupSchedule) {
|
||||
Logger.info(`[BackupManager] Restarting backup schedule ${this.serverSettings.backupSchedule}`)
|
||||
if (this.scheduleTask.destroy) this.scheduleTask.destroy()
|
||||
if (this.scheduleTask.stop) this.scheduleTask.stop()
|
||||
this.scheduleCron()
|
||||
}
|
||||
}
|
||||
@@ -119,6 +120,7 @@ class BackupManager {
|
||||
await zip.extract('config/', global.ConfigPath)
|
||||
if (backup.backupMetadataCovers) {
|
||||
await zip.extract('metadata-items/', this.ItemsMetadataPath)
|
||||
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
|
||||
}
|
||||
await this.db.reinit()
|
||||
this.emitter('backup_applied')
|
||||
@@ -178,8 +180,6 @@ class BackupManager {
|
||||
async runBackup() {
|
||||
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
|
||||
Logger.info(`[BackupManager] Running Backup`)
|
||||
var metadataItemsPath = this.serverSettings.backupMetadataCovers ? this.ItemsMetadataPath : null
|
||||
|
||||
var newBackup = new Backup()
|
||||
|
||||
const newBackData = {
|
||||
@@ -188,7 +188,10 @@ class BackupManager {
|
||||
}
|
||||
newBackup.setData(newBackData)
|
||||
|
||||
var zipResult = await this.zipBackup(metadataItemsPath, newBackup).then(() => true).catch((error) => {
|
||||
var metadataAuthorsPath = this.AuthorsMetadataPath
|
||||
if (!await fs.pathExists(metadataAuthorsPath)) metadataAuthorsPath = null
|
||||
|
||||
var zipResult = await this.zipBackup(metadataAuthorsPath, newBackup).then(() => true).catch((error) => {
|
||||
Logger.error(`[BackupManager] Backup Failed ${error}`)
|
||||
return false
|
||||
})
|
||||
@@ -228,7 +231,7 @@ class BackupManager {
|
||||
}
|
||||
}
|
||||
|
||||
zipBackup(metadataItemsPath, backup) {
|
||||
zipBackup(metadataAuthorsPath, backup) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// create a file to stream archive data to
|
||||
const output = fs.createWriteStream(backup.fullPath)
|
||||
@@ -299,10 +302,16 @@ class BackupManager {
|
||||
archive.directory(this.db.AuthorsPath, 'config/authors')
|
||||
archive.directory(this.db.SeriesPath, 'config/series')
|
||||
|
||||
if (metadataItemsPath) {
|
||||
Logger.debug(`[BackupManager] Backing up Metadata Items "${metadataItemsPath}"`)
|
||||
archive.directory(metadataItemsPath, 'metadata-items')
|
||||
if (this.serverSettings.backupMetadataCovers) {
|
||||
Logger.debug(`[BackupManager] Backing up Metadata Items "${this.ItemsMetadataPath}"`)
|
||||
archive.directory(this.ItemsMetadataPath, 'metadata-items')
|
||||
|
||||
if (metadataAuthorsPath) {
|
||||
Logger.debug(`[BackupManager] Backing up Metadata Authors "${metadataAuthorsPath}"`)
|
||||
archive.directory(metadataAuthorsPath, 'metadata-authors')
|
||||
}
|
||||
}
|
||||
|
||||
archive.append(backup.detailsString, { name: 'details' })
|
||||
|
||||
archive.finalize()
|
||||
|
||||
177
server/managers/CronManager.js
Normal file
177
server/managers/CronManager.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const cron = require('../libs/nodeCron')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class CronManager {
|
||||
constructor(db, scanner, podcastManager) {
|
||||
this.db = db
|
||||
this.scanner = scanner
|
||||
this.podcastManager = podcastManager
|
||||
|
||||
this.libraryScanCrons = []
|
||||
this.podcastCrons = []
|
||||
|
||||
this.podcastCronExpressionsExecuting = []
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initLibraryScanCrons()
|
||||
this.initPodcastCrons()
|
||||
}
|
||||
|
||||
initLibraryScanCrons() {
|
||||
for (const library of this.db.libraries) {
|
||||
if (library.settings.autoScanCronExpression) {
|
||||
this.startCronForLibrary(library)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startCronForLibrary(library) {
|
||||
Logger.debug(`[CronManager] Init library scan cron for ${library.name} on schedule ${library.settings.autoScanCronExpression}`)
|
||||
const libScanCron = cron.schedule(library.settings.autoScanCronExpression, () => {
|
||||
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
|
||||
this.scanner.scan(library)
|
||||
})
|
||||
this.libraryScanCrons.push({
|
||||
libraryId: library.id,
|
||||
expression: library.settings.autoScanCronExpression,
|
||||
task: libScanCron
|
||||
})
|
||||
}
|
||||
|
||||
removeCronForLibrary(library) {
|
||||
Logger.debug(`[CronManager] Removing library scan cron for ${library.name}`)
|
||||
this.libraryScanCrons = this.libraryScanCrons.filter(lsc => lsc.libraryId !== library.id)
|
||||
}
|
||||
|
||||
updateLibraryScanCron(library) {
|
||||
const expression = library.settings.autoScanCronExpression
|
||||
const existingCron = this.libraryScanCrons.find(lsc => lsc.libraryId === library.id)
|
||||
|
||||
if (!expression && existingCron) {
|
||||
if (existingCron.task.stop) existingCron.task.stop()
|
||||
|
||||
this.removeCronForLibrary(library)
|
||||
} else if (!existingCron && expression) {
|
||||
this.startCronForLibrary(library)
|
||||
} else if (existingCron && existingCron.expression !== expression) {
|
||||
if (existingCron.task.stop) existingCron.task.stop()
|
||||
|
||||
this.removeCronForLibrary(library)
|
||||
this.startCronForLibrary(library)
|
||||
}
|
||||
}
|
||||
|
||||
initPodcastCrons() {
|
||||
const cronExpressionMap = {}
|
||||
this.db.libraryItems.forEach((li) => {
|
||||
if (li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) {
|
||||
if (!li.media.autoDownloadSchedule) {
|
||||
Logger.error(`[CronManager] Podcast auto download schedule is not set for ${li.media.metadata.title}`)
|
||||
} else {
|
||||
if (!cronExpressionMap[li.media.autoDownloadSchedule]) {
|
||||
cronExpressionMap[li.media.autoDownloadSchedule] = {
|
||||
expression: li.media.autoDownloadSchedule,
|
||||
libraryItemIds: []
|
||||
}
|
||||
}
|
||||
cronExpressionMap[li.media.autoDownloadSchedule].libraryItemIds.push(li.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!Object.keys(cronExpressionMap).length) return
|
||||
|
||||
Logger.debug(`[CronManager] Found ${Object.keys(cronExpressionMap).length} podcast episode schedules to start`)
|
||||
for (const expression in cronExpressionMap) {
|
||||
this.startPodcastCron(expression, cronExpressionMap[expression].libraryItemIds)
|
||||
}
|
||||
}
|
||||
|
||||
startPodcastCron(expression, libraryItemIds) {
|
||||
try {
|
||||
Logger.debug(`[CronManager] Scheduling podcast episode check cron "${expression}" for ${libraryItemIds.length} item(s)`)
|
||||
const task = cron.schedule(expression, () => {
|
||||
if (this.podcastCronExpressionsExecuting.includes(expression)) {
|
||||
Logger.warn(`[CronManager] Podcast cron "${expression}" is already executing`)
|
||||
} else {
|
||||
this.executePodcastCron(expression, libraryItemIds)
|
||||
}
|
||||
})
|
||||
this.podcastCrons.push({
|
||||
libraryItemIds,
|
||||
expression,
|
||||
task
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async executePodcastCron(expression, libraryItemIds) {
|
||||
Logger.debug(`[CronManager] Start executing podcast cron ${expression} for ${libraryItemIds.length} item(s)`)
|
||||
const podcastCron = this.podcastCrons.find(cron => cron.expression === expression)
|
||||
if (!podcastCron) {
|
||||
Logger.error(`[CronManager] Podcast cron not found for expression ${expression}`)
|
||||
return
|
||||
}
|
||||
this.podcastCronExpressionsExecuting.push(expression)
|
||||
|
||||
// Get podcast library items to check
|
||||
const libraryItems = []
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
const libraryItem = this.db.libraryItems.find(li => li.id === libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
|
||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
|
||||
} else {
|
||||
libraryItems.push(libraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
// Run episode checks
|
||||
for (const libraryItem of libraryItems) {
|
||||
const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
|
||||
if (!keepAutoDownloading) { // auto download was disabled
|
||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
|
||||
}
|
||||
}
|
||||
|
||||
// Stop and remove cron if no more library items
|
||||
if (!podcastCron.libraryItemIds.length) {
|
||||
this.removePodcastEpisodeCron(podcastCron)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug(`[CronManager] Finished executing podcast cron ${expression} for ${libraryItems.length} item(s)`)
|
||||
this.podcastCronExpressionsExecuting = this.podcastCronExpressionsExecuting.filter(exp => exp !== expression)
|
||||
}
|
||||
|
||||
removePodcastEpisodeCron(podcastCron) {
|
||||
Logger.info(`[CronManager] Stopping & removing podcast episode cron for ${podcastCron.expression}`)
|
||||
if (podcastCron.task) podcastCron.task.stop()
|
||||
this.podcastCrons = this.podcastCrons.filter(pc => pc.expression !== podcastCron.expression)
|
||||
}
|
||||
|
||||
checkUpdatePodcastCron(libraryItem) {
|
||||
// Remove from old cron by library item id
|
||||
const existingCron = this.podcastCrons.find(pc => pc.libraryItemIds.includes(libraryItem.id))
|
||||
if (existingCron) {
|
||||
existingCron.libraryItemIds = existingCron.libraryItemIds.filter(lid => lid !== libraryItem.id)
|
||||
if (!existingCron.libraryItemIds.length) {
|
||||
this.removePodcastEpisodeCron(existingCron)
|
||||
}
|
||||
}
|
||||
|
||||
// Add to cron or start new cron
|
||||
if (libraryItem.media.autoDownloadEpisodes && libraryItem.media.autoDownloadSchedule) {
|
||||
const cronMatchingExpression = this.podcastCrons.find(pc => pc.expression === libraryItem.media.autoDownloadSchedule)
|
||||
if (cronMatchingExpression) {
|
||||
cronMatchingExpression.libraryItemIds.push(libraryItem.id)
|
||||
Logger.info(`[CronManager] Added podcast "${libraryItem.media.metadata.title}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
|
||||
} else {
|
||||
this.startPodcastCron(libraryItem.media.autoDownloadSchedule, [libraryItem.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = CronManager
|
||||
@@ -19,6 +19,7 @@ class PlaybackSessionManager {
|
||||
this.clientEmitter = clientEmitter
|
||||
|
||||
this.sessions = []
|
||||
this.localSessionLock = {}
|
||||
}
|
||||
|
||||
getSession(sessionId) {
|
||||
@@ -58,18 +59,26 @@ class PlaybackSessionManager {
|
||||
}
|
||||
|
||||
async syncLocalSessionRequest(user, sessionJson, res) {
|
||||
if (this.localSessionLock[sessionJson.id]) {
|
||||
Logger.debug(`[PlaybackSessionManager] syncLocalSessionRequest: Local session is locked and already syncing`)
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
|
||||
var libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`)
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
|
||||
this.localSessionLock[sessionJson.id] = true // Lock local session
|
||||
|
||||
var session = await this.db.getPlaybackSession(sessionJson.id)
|
||||
if (!session) {
|
||||
// New session from local
|
||||
session = new PlaybackSession(sessionJson)
|
||||
await this.db.insertEntity('session', session)
|
||||
} else {
|
||||
session.currentTime = sessionJson.currentTime
|
||||
session.timeListening = sessionJson.timeListening
|
||||
session.updatedAt = sessionJson.updatedAt
|
||||
session.date = date.format(new Date(), 'YYYY-MM-DD')
|
||||
@@ -94,6 +103,9 @@ class PlaybackSessionManager {
|
||||
data: itemProgress.toJSON()
|
||||
})
|
||||
}
|
||||
|
||||
delete this.localSessionLock[sessionJson.id] // Unlock local session
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -256,4 +268,4 @@ class PlaybackSessionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = PlaybackSessionManager
|
||||
module.exports = PlaybackSessionManager
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
const fs = require('../libs/fsExtra')
|
||||
const cron = require('../libs/nodeCron')
|
||||
const axios = require('axios')
|
||||
|
||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const { downloadFile } = require('../utils/fileUtils')
|
||||
const { downloadFile, removeFile } = require('../utils/fileUtils')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
const opmlParser = require('../utils/parsers/parseOPML')
|
||||
const prober = require('../utils/prober')
|
||||
@@ -23,22 +22,14 @@ class PodcastManager {
|
||||
this.downloadQueue = []
|
||||
this.currentDownload = null
|
||||
|
||||
this.episodeScheduleTask = null
|
||||
this.failedCheckMap = {},
|
||||
this.MaxFailedEpisodeChecks = 24
|
||||
this.failedCheckMap = {}
|
||||
this.MaxFailedEpisodeChecks = 24
|
||||
}
|
||||
|
||||
get serverSettings() {
|
||||
return this.db.serverSettings || {}
|
||||
}
|
||||
|
||||
init() {
|
||||
var podcastsWithAutoDownload = this.db.libraryItems.some(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
|
||||
if (podcastsWithAutoDownload) {
|
||||
this.schedulePodcastEpisodeCron()
|
||||
}
|
||||
}
|
||||
|
||||
getEpisodeDownloadsInQueue(libraryItemId) {
|
||||
return this.downloadQueue.filter(d => d.libraryItemId === libraryItemId)
|
||||
}
|
||||
@@ -56,14 +47,14 @@ class PodcastManager {
|
||||
}
|
||||
}
|
||||
|
||||
async downloadPodcastEpisodes(libraryItem, episodesToDownload) {
|
||||
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
|
||||
var index = libraryItem.media.episodes.length + 1
|
||||
episodesToDownload.forEach((ep) => {
|
||||
var newPe = new PodcastEpisode()
|
||||
newPe.setData(ep, index++)
|
||||
newPe.libraryItemId = libraryItem.id
|
||||
var newPeDl = new PodcastEpisodeDownload()
|
||||
newPeDl.setData(newPe, libraryItem)
|
||||
newPeDl.setData(newPe, libraryItem, isAutoDownload)
|
||||
this.startPodcastEpisodeDownload(newPeDl)
|
||||
})
|
||||
}
|
||||
@@ -131,12 +122,46 @@ class PodcastManager {
|
||||
libraryItem.isInvalid = false
|
||||
}
|
||||
libraryItem.libraryFiles.push(libraryFile)
|
||||
|
||||
// Check setting maxEpisodesToKeep and remove episode if necessary
|
||||
if (this.currentDownload.isAutoDownload) { // only applies for auto-downloaded episodes
|
||||
if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) {
|
||||
Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
|
||||
await this.removeOldestEpisode(libraryItem, podcastEpisode.id)
|
||||
}
|
||||
}
|
||||
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
return true
|
||||
}
|
||||
|
||||
async removeOldestEpisode(libraryItem, episodeIdJustDownloaded) {
|
||||
var smallestPublishedAt = 0
|
||||
var oldestEpisode = null
|
||||
libraryItem.media.episodesWithPubDate.filter(ep => ep.id !== episodeIdJustDownloaded).forEach((ep) => {
|
||||
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
|
||||
smallestPublishedAt = ep.publishedAt
|
||||
oldestEpisode = ep
|
||||
}
|
||||
})
|
||||
// TODO: Should we check for open playback sessions for this episode?
|
||||
// TODO: remove all user progress for this episode
|
||||
if (oldestEpisode && oldestEpisode.audioFile) {
|
||||
Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`)
|
||||
const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path)
|
||||
if (successfullyDeleted) {
|
||||
libraryItem.media.removeEpisode(oldestEpisode.id)
|
||||
libraryItem.removeLibraryFile(oldestEpisode.audioFile.ino)
|
||||
return true
|
||||
} else {
|
||||
Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async getLibraryFile(path, relPath) {
|
||||
var newLibFile = new LibraryFile()
|
||||
await newLibFile.setDataFromPath(path, relPath)
|
||||
@@ -155,76 +180,48 @@ class PodcastManager {
|
||||
return newAudioFile
|
||||
}
|
||||
|
||||
schedulePodcastEpisodeCron() {
|
||||
try {
|
||||
Logger.debug(`[PodcastManager] Scheduled podcast episode check cron "${this.serverSettings.podcastEpisodeSchedule}"`)
|
||||
this.episodeScheduleTask = cron.schedule(this.serverSettings.podcastEpisodeSchedule, () => {
|
||||
Logger.debug(`[PodcastManager] Running cron`)
|
||||
this.checkForNewEpisodes()
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
||||
}
|
||||
}
|
||||
// Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
|
||||
async runEpisodeCheck(libraryItem) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
|
||||
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
||||
|
||||
cancelCron() {
|
||||
Logger.debug(`[PodcastManager] Canceled new podcast episode check cron`)
|
||||
if (this.episodeScheduleTask) {
|
||||
this.episodeScheduleTask.destroy()
|
||||
this.episodeScheduleTask = null
|
||||
}
|
||||
}
|
||||
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
|
||||
// lastEpisodeCheckDate will be the current time when adding a new podcast
|
||||
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
|
||||
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
||||
|
||||
async checkForNewEpisodes() {
|
||||
var podcastsWithAutoDownload = this.db.libraryItems.filter(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
|
||||
if (!podcastsWithAutoDownload.length) {
|
||||
Logger.info(`[PodcastManager] checkForNewEpisodes - No podcasts with auto download set`)
|
||||
this.cancelCron()
|
||||
return
|
||||
}
|
||||
Logger.debug(`[PodcastManager] checkForNewEpisodes - Checking ${podcastsWithAutoDownload.length} Podcasts`)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter)
|
||||
Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes ? newEpisodes.length : 'N/A'} episodes found`)
|
||||
|
||||
for (const libraryItem of podcastsWithAutoDownload) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
|
||||
Logger.info(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
||||
|
||||
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
|
||||
// lastEpisodeCheckDate will be the current time when adding a new podcast
|
||||
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
|
||||
Logger.debug(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
||||
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter)
|
||||
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
|
||||
|
||||
if (!newEpisodes) { // Failed
|
||||
// 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] >= 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 {
|
||||
Logger.warn(`[PodcastManager] checkForNewEpisodes ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
|
||||
}
|
||||
} else if (newEpisodes.length) {
|
||||
if (!newEpisodes) { // Failed
|
||||
// Allow up to MaxFailedEpisodeChecks 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] >= this.MaxFailedEpisodeChecks) {
|
||||
Logger.error(`[PodcastManager] runEpisodeCheck ${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]
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
||||
} else {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
||||
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
|
||||
}
|
||||
|
||||
libraryItem.media.lastEpisodeCheck = Date.now()
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
} else if (newEpisodes.length) {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)
|
||||
} else {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
||||
}
|
||||
|
||||
libraryItem.media.lastEpisodeCheck = Date.now()
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
return libraryItem.media.autoDownloadEpisodes
|
||||
}
|
||||
|
||||
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter) {
|
||||
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {
|
||||
if (!podcastLibraryItem.media.metadata.feedUrl) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
||||
return false
|
||||
@@ -237,18 +234,21 @@ class PodcastManager {
|
||||
|
||||
// Filter new and not already has
|
||||
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
|
||||
// Max new episodes for safety = 3
|
||||
newEpisodes = newEpisodes.slice(0, 3)
|
||||
|
||||
if (maxNewEpisodes > 0) {
|
||||
newEpisodes = newEpisodes.slice(0, maxNewEpisodes)
|
||||
}
|
||||
|
||||
return newEpisodes
|
||||
}
|
||||
|
||||
async checkAndDownloadNewEpisodes(libraryItem) {
|
||||
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload)
|
||||
if (newEpisodes.length) {
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes, false)
|
||||
} else {
|
||||
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class RssFeedManager {
|
||||
return Object.values(this.feeds).find(feed => feed.entityId === libraryItemId)
|
||||
}
|
||||
|
||||
getFeed(req, res) {
|
||||
async getFeed(req, res) {
|
||||
var feed = this.feeds[req.params.id]
|
||||
if (!feed) {
|
||||
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
|
||||
@@ -38,6 +38,15 @@ class RssFeedManager {
|
||||
return
|
||||
}
|
||||
|
||||
if (feed.entityType === 'item') {
|
||||
const libraryItem = this.db.getLibraryItem(feed.entityId)
|
||||
if (libraryItem && (!feed.entityUpdatedAt || libraryItem.updatedAt > feed.entityUpdatedAt)) {
|
||||
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
|
||||
feed.updateFromItem(libraryItem)
|
||||
await this.db.updateEntity('feed', feed)
|
||||
}
|
||||
}
|
||||
|
||||
var xml = feed.buildXml()
|
||||
res.set('Content-Type', 'text/xml')
|
||||
res.send(xml)
|
||||
|
||||
@@ -9,6 +9,7 @@ class Feed {
|
||||
this.userId = null
|
||||
this.entityType = null
|
||||
this.entityId = null
|
||||
this.entityUpdatedAt = null
|
||||
|
||||
this.coverPath = null
|
||||
this.serverAddress = null
|
||||
@@ -79,6 +80,7 @@ class Feed {
|
||||
this.userId = userId
|
||||
this.entityType = 'item'
|
||||
this.entityId = libraryItem.id
|
||||
this.entityUpdatedAt = libraryItem.updatedAt
|
||||
this.coverPath = media.coverPath || null
|
||||
this.serverAddress = serverAddress
|
||||
this.feedUrl = feedUrl
|
||||
@@ -111,6 +113,39 @@ class Feed {
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
updateFromItem(libraryItem) {
|
||||
const media = libraryItem.media
|
||||
const mediaMetadata = media.metadata
|
||||
const isPodcast = libraryItem.mediaType === 'podcast'
|
||||
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
|
||||
|
||||
this.entityUpdatedAt = libraryItem.updatedAt
|
||||
|
||||
this.meta.title = mediaMetadata.title
|
||||
this.meta.description = mediaMetadata.description
|
||||
this.meta.author = author
|
||||
this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png`
|
||||
this.meta.explicit = !!mediaMetadata.explicit
|
||||
|
||||
this.episodes = []
|
||||
if (isPodcast) { // PODCAST EPISODES
|
||||
media.episodes.forEach((episode) => {
|
||||
var feedEpisode = new FeedEpisode()
|
||||
feedEpisode.setFromPodcastEpisode(libraryItem, this.serverAddress, this.slug, episode, this.meta)
|
||||
this.episodes.push(feedEpisode)
|
||||
})
|
||||
} else { // AUDIOBOOK EPISODES
|
||||
media.tracks.forEach((audioTrack) => {
|
||||
var feedEpisode = new FeedEpisode()
|
||||
feedEpisode.setFromAudiobookTrack(libraryItem, this.serverAddress, this.slug, audioTrack, this.meta)
|
||||
this.episodes.push(feedEpisode)
|
||||
})
|
||||
}
|
||||
|
||||
this.updatedAt = Date.now()
|
||||
this.xml = null
|
||||
}
|
||||
|
||||
buildXml() {
|
||||
if (this.xml) return this.xml
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class FeedEpisode {
|
||||
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta) {
|
||||
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
|
||||
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 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user