mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-05 22:19:53 -05:00
Compare commits
39 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 |
@@ -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 {
|
||||
|
||||
@@ -158,7 +158,7 @@ export default {
|
||||
var newIsFinished = !this.selectedIsFinished
|
||||
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
||||
return {
|
||||
id: lid,
|
||||
libraryItemId: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
@@ -138,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)
|
||||
@@ -313,6 +347,7 @@ export default {
|
||||
}
|
||||
},
|
||||
sessionOpen(session) {
|
||||
// For opening session on init (temporarily unused)
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
@@ -376,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()
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -85,6 +85,9 @@ export default {
|
||||
},
|
||||
books() {
|
||||
return this.collection.books || []
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -133,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) {
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
||||
<div class="flex-grow" />
|
||||
<template v-if="isSelectionMode">
|
||||
<ui-btn color="error" small @click="removeSelectedEpisodes">Remove {{ selectedEpisodes.length }} episode{{ selectedEpisodes.length > 1 ? 's' : '' }}</ui-btn>
|
||||
<ui-btn small class="ml-2" @click="clearSelected">Cancel</ui-btn>
|
||||
<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 ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" />
|
||||
<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" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
|
||||
@@ -34,7 +37,8 @@ export default {
|
||||
selectedEpisode: null,
|
||||
showPodcastRemoveModal: false,
|
||||
selectedEpisodes: [],
|
||||
episodesToRemove: []
|
||||
episodesToRemove: [],
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -65,9 +69,40 @@ 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 = []
|
||||
},
|
||||
@@ -91,6 +126,33 @@ export default {
|
||||
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.episodesToRemove = [episode]
|
||||
this.showPodcastRemoveModal = true
|
||||
|
||||
@@ -55,9 +55,10 @@ export default {
|
||||
},
|
||||
labelClassname() {
|
||||
if (this.labelClass) return this.labelClass
|
||||
var classes = ['pl-1']
|
||||
if (this.small) classes.push('text-xs md:text-sm')
|
||||
else if (this.medium) classes.push('text-base md:text-lg')
|
||||
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() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{ 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" @blur="inputBlurred" />
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,7 +20,8 @@ export default {
|
||||
default: 'text'
|
||||
},
|
||||
readonly: Boolean,
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
inputClass: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.1.3",
|
||||
"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.3",
|
||||
"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>
|
||||
@@ -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">
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
@@ -128,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>
|
||||
@@ -150,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>
|
||||
|
||||
@@ -429,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
|
||||
},
|
||||
@@ -515,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) {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ export const state = () => ({
|
||||
streamLibraryItem: null,
|
||||
streamEpisodeId: null,
|
||||
streamIsPlaying: false,
|
||||
playerQueueItems: [],
|
||||
playerQueueAutoPlay: true,
|
||||
playerIsFullscreen: false,
|
||||
editModalTab: 'details',
|
||||
showEditModal: false,
|
||||
@@ -144,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
|
||||
|
||||
@@ -11,6 +11,7 @@ module.exports = {
|
||||
safelist: [
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'bg-yellow-400',
|
||||
'text-green-500',
|
||||
'py-1.5',
|
||||
'bg-info',
|
||||
@@ -18,7 +19,8 @@ module.exports = {
|
||||
'min-w-5',
|
||||
'w-3.5',
|
||||
'h-3.5',
|
||||
'border-warning'
|
||||
'border-warning',
|
||||
'mb-px'
|
||||
],
|
||||
},
|
||||
theme: {
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.1.3",
|
||||
"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.3",
|
||||
"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')
|
||||
|
||||
@@ -440,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))
|
||||
|
||||
@@ -470,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()
|
||||
@@ -165,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())
|
||||
|
||||
@@ -96,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}`)
|
||||
|
||||
@@ -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 || []
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -221,7 +221,7 @@ class PodcastManager {
|
||||
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
|
||||
@@ -234,15 +234,18 @@ 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, false)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -228,6 +228,7 @@ class Podcast {
|
||||
|
||||
addPodcastEpisode(podcastEpisode) {
|
||||
this.episodes.push(podcastEpisode)
|
||||
this.reorderEpisodes()
|
||||
}
|
||||
|
||||
addNewEpisodeFromAudioFile(audioFile, index) {
|
||||
@@ -241,15 +242,13 @@ class Podcast {
|
||||
reorderEpisodes() {
|
||||
var hasUpdates = false
|
||||
|
||||
// TODO: Sort by published date
|
||||
this.episodes = naturalSort(this.episodes).asc((ep) => ep.bestFilename)
|
||||
this.episodes = naturalSort(this.episodes).desc((ep) => ep.publishedAt)
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
if (this.episodes[i].index !== (i + 1)) {
|
||||
this.episodes[i].index = i + 1
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
this.episodes.sort((a, b) => b.index - a.index)
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
|
||||
@@ -116,16 +116,16 @@ class ApiRouter {
|
||||
//
|
||||
// Collection Routes
|
||||
//
|
||||
this.router.post('/collections', CollectionController.create.bind(this))
|
||||
this.router.post('/collections', CollectionController.middleware.bind(this), CollectionController.create.bind(this))
|
||||
this.router.get('/collections', CollectionController.findAll.bind(this))
|
||||
this.router.get('/collections/:id', CollectionController.findOne.bind(this))
|
||||
this.router.patch('/collections/:id', CollectionController.update.bind(this))
|
||||
this.router.delete('/collections/:id', CollectionController.delete.bind(this))
|
||||
this.router.get('/collections/:id', CollectionController.middleware.bind(this), CollectionController.findOne.bind(this))
|
||||
this.router.patch('/collections/:id', CollectionController.middleware.bind(this), CollectionController.update.bind(this))
|
||||
this.router.delete('/collections/:id', CollectionController.middleware.bind(this), CollectionController.delete.bind(this))
|
||||
|
||||
this.router.post('/collections/:id/book', CollectionController.addBook.bind(this))
|
||||
this.router.delete('/collections/:id/book/:bookId', CollectionController.removeBook.bind(this))
|
||||
this.router.post('/collections/:id/batch/add', CollectionController.addBatch.bind(this))
|
||||
this.router.post('/collections/:id/batch/remove', CollectionController.removeBatch.bind(this))
|
||||
this.router.post('/collections/:id/book', CollectionController.middleware.bind(this), CollectionController.addBook.bind(this))
|
||||
this.router.delete('/collections/:id/book/:bookId', CollectionController.middleware.bind(this), CollectionController.removeBook.bind(this))
|
||||
this.router.post('/collections/:id/batch/add', CollectionController.middleware.bind(this), CollectionController.addBatch.bind(this))
|
||||
this.router.post('/collections/:id/batch/remove', CollectionController.middleware.bind(this), CollectionController.removeBatch.bind(this))
|
||||
|
||||
//
|
||||
// Current User Routes (Me)
|
||||
|
||||
@@ -10,6 +10,7 @@ const { ScanResult, LogLevel } = require('../utils/constants')
|
||||
|
||||
const MediaFileScanner = require('./MediaFileScanner')
|
||||
const BookFinder = require('../finders/BookFinder')
|
||||
const PodcastFinder = require('../finders/PodcastFinder')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
const LibraryScan = require('./LibraryScan')
|
||||
const ScanOptions = require('./ScanOptions')
|
||||
@@ -28,7 +29,12 @@ class Scanner {
|
||||
this.cancelLibraryScan = {}
|
||||
this.librariesScanning = []
|
||||
|
||||
// Watcher file update scan vars
|
||||
this.pendingFileUpdatesToScan = []
|
||||
this.scanningFilesChanged = false
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
this.podcastFinder = new PodcastFinder()
|
||||
}
|
||||
|
||||
isLibraryScanning(libraryId) {
|
||||
@@ -494,7 +500,16 @@ class Scanner {
|
||||
}
|
||||
|
||||
async scanFilesChanged(fileUpdates) {
|
||||
if (!fileUpdates.length) return
|
||||
if (!fileUpdates || !fileUpdates.length) return
|
||||
|
||||
// If already scanning files from watcher then add these updates to queue
|
||||
if (this.scanningFilesChanged) {
|
||||
this.pendingFileUpdatesToScan.push(fileUpdates)
|
||||
Logger.debug(`[Scanner] Already scanning files from watcher - file updates pushed to queue (size ${this.pendingFileUpdatesToScan.length})`)
|
||||
return
|
||||
}
|
||||
this.scanningFilesChanged = true
|
||||
|
||||
// files grouped by folder
|
||||
var folderGroups = this.getFileUpdatesGrouped(fileUpdates)
|
||||
|
||||
@@ -520,6 +535,13 @@ class Scanner {
|
||||
var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
|
||||
Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
|
||||
}
|
||||
|
||||
this.scanningFilesChanged = false
|
||||
|
||||
if (this.pendingFileUpdatesToScan.length) {
|
||||
Logger.debug(`[Scanner] File updates finished scanning with more updates in queue (${this.pendingFileUpdatesToScan.length})`)
|
||||
this.scanFilesChanged(this.pendingFileUpdatesToScan.shift())
|
||||
}
|
||||
}
|
||||
|
||||
async scanFolderUpdates(library, folder, fileUpdateGroup) {
|
||||
@@ -652,16 +674,6 @@ class Scanner {
|
||||
var provider = options.provider || 'google'
|
||||
var searchTitle = options.title || libraryItem.media.metadata.title
|
||||
var searchAuthor = options.author || libraryItem.media.metadata.authorName
|
||||
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
||||
var searchASIN = options.asin || libraryItem.media.metadata.asin
|
||||
|
||||
var results = await this.bookFinder.search(provider, searchTitle, searchAuthor, searchISBN, searchASIN)
|
||||
if (!results.length) {
|
||||
return {
|
||||
warning: `No ${provider} match found`
|
||||
}
|
||||
}
|
||||
var matchData = results[0]
|
||||
|
||||
// Set to override existing metadata if scannerPreferMatchedMetadata setting is true
|
||||
if (this.db.serverSettings.scannerPreferMatchedMetadata) {
|
||||
@@ -669,18 +681,110 @@ class Scanner {
|
||||
options.overrideDetails = true
|
||||
}
|
||||
|
||||
// Update cover if not set OR overrideCover flag
|
||||
var updatePayload = {}
|
||||
var hasUpdated = false
|
||||
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
||||
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
|
||||
var coverResult = await this.coverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
|
||||
if (!coverResult || coverResult.error || !coverResult.cover) {
|
||||
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
|
||||
} else {
|
||||
|
||||
if (libraryItem.mediaType === 'book') {
|
||||
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
||||
var searchASIN = options.asin || libraryItem.media.metadata.asin
|
||||
|
||||
var results = await this.bookFinder.search(provider, searchTitle, searchAuthor, searchISBN, searchASIN)
|
||||
if (!results.length) {
|
||||
return {
|
||||
warning: `No ${provider} match found`
|
||||
}
|
||||
}
|
||||
var matchData = results[0]
|
||||
|
||||
// Update cover if not set OR overrideCover flag
|
||||
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
||||
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
|
||||
var coverResult = await this.coverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
|
||||
if (!coverResult || coverResult.error || !coverResult.cover) {
|
||||
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
|
||||
} else {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options)
|
||||
} else { // Podcast quick match
|
||||
var results = await this.podcastFinder.search(searchTitle)
|
||||
if (!results.length) {
|
||||
return {
|
||||
warning: `No ${provider} match found`
|
||||
}
|
||||
}
|
||||
var matchData = results[0]
|
||||
|
||||
// Update cover if not set OR overrideCover flag
|
||||
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
||||
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
|
||||
var coverResult = await this.coverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
|
||||
if (!coverResult || coverResult.error || !coverResult.cover) {
|
||||
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
|
||||
} else {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
updatePayload = this.quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options)
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
Logger.debug('[Scanner] Updating details', updatePayload)
|
||||
if (libraryItem.media.update(updatePayload)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
}
|
||||
|
||||
return {
|
||||
updated: hasUpdated,
|
||||
libraryItem: libraryItem.toJSONExpanded()
|
||||
}
|
||||
}
|
||||
|
||||
quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options) {
|
||||
const updatePayload = {}
|
||||
updatePayload.metadata = {}
|
||||
|
||||
const matchDataTransformed = {
|
||||
title: matchData.title || null,
|
||||
author: matchData.artistName || null,
|
||||
genres: matchData.genres || [],
|
||||
itunesId: matchData.id || null,
|
||||
itunesPageUrl: matchData.pageUrl || null,
|
||||
itunesArtistId: matchData.artistId || null,
|
||||
releaseDate: matchData.releaseDate || null,
|
||||
imageUrl: matchData.cover || null,
|
||||
description: matchData.descriptionPlain || null
|
||||
}
|
||||
|
||||
for (const key in matchDataTransformed) {
|
||||
if (matchDataTransformed[key]) {
|
||||
if (key === 'genres') {
|
||||
if ((!libraryItem.media.metadata.genres || options.overrideDetails)) {
|
||||
updatePayload.metadata[key] = matchDataTransformed[key].split(',').map(v => v.trim()).filter(v => !!v)
|
||||
}
|
||||
} else if (!libraryItem.media.metadata[key] || options.overrideDetails) {
|
||||
updatePayload.metadata[key] = matchDataTransformed[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.keys(updatePayload.metadata).length) {
|
||||
delete updatePayload.metadata
|
||||
}
|
||||
|
||||
return updatePayload
|
||||
}
|
||||
|
||||
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
|
||||
// Update media metadata if not set OR overrideDetails flag
|
||||
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'asin', 'isbn']
|
||||
const updatePayload = {}
|
||||
@@ -743,22 +847,11 @@ class Scanner {
|
||||
updatePayload.metadata.series = seriesPayload
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
Logger.debug('[Scanner] Updating details', updatePayload)
|
||||
if (libraryItem.media.update(updatePayload)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
if (!Object.keys(updatePayload.metadata).length) {
|
||||
delete updatePayload.metadata
|
||||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
}
|
||||
|
||||
return {
|
||||
updated: hasUpdated,
|
||||
libraryItem: libraryItem.toJSONExpanded()
|
||||
}
|
||||
return updatePayload
|
||||
}
|
||||
|
||||
async matchLibraryItems(library) {
|
||||
|
||||
2235
server/utils/htmlEntities.js
Normal file
2235
server/utils/htmlEntities.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
const sanitizeHtml = require('../libs/sanitizeHtml')
|
||||
const {entities} = require("./htmlEntities");
|
||||
|
||||
function sanitize(html) {
|
||||
const sanitizerOptions = {
|
||||
@@ -17,12 +18,22 @@ function sanitize(html) {
|
||||
}
|
||||
module.exports.sanitize = sanitize
|
||||
|
||||
function stripAllTags(html) {
|
||||
function stripAllTags(html, shouldDecodeEntities = true) {
|
||||
const sanitizerOptions = {
|
||||
allowedTags: [],
|
||||
disallowedTagsMode: 'discard'
|
||||
}
|
||||
|
||||
return sanitizeHtml(html, sanitizerOptions)
|
||||
let sanitized = sanitizeHtml(html, sanitizerOptions)
|
||||
return shouldDecodeEntities ? decodeHTMLEntities(sanitized) : sanitized
|
||||
}
|
||||
module.exports.stripAllTags = stripAllTags
|
||||
|
||||
function decodeHTMLEntities(strToDecode) {
|
||||
return strToDecode.replace(/\&([^;]+);?/g, function (entity) {
|
||||
if (entity in entities) {
|
||||
return entities[entity]
|
||||
}
|
||||
return entity;
|
||||
})
|
||||
}
|
||||
module.exports.stripAllTags = stripAllTags
|
||||
Reference in New Issue
Block a user