mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-05 10:12:44 +02:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f702c02859 | |||
| ad88de0571 | |||
| b64a651b27 | |||
| 06b8d1194c | |||
| 377ae7ab19 | |||
| 53cf6edd6a | |||
| 92bedeac15 | |||
| 3cf8b9dca9 | |||
| bcc2f847f9 | |||
| f1421f351b | |||
| ed23feaf3f | |||
| 668ebf8550 | |||
| a8c7905f6d | |||
| 45cd39ac0c | |||
| 21e1f62c65 | |||
| 8416f2d6be | |||
| 3b4ac3a230 | |||
| 6244909332 | |||
| 5db949e4a7 | |||
| c453d3e8c7 | |||
| 9d7ffdfcd0 | |||
| 976427b0b3 | |||
| 6cbfd8679b | |||
| 217bbb4a8e | |||
| 9916a1e8f6 | |||
| 372101592c | |||
| 18123664ee | |||
| 2e6e4f970c | |||
| 1c9e56ce2e | |||
| 9e7b84f289 | |||
| 7b83ab8970 | |||
| 86ee4dcff2 |
+1
-1
@@ -25,5 +25,5 @@ HEALTHCHECK \
|
|||||||
--interval=30s \
|
--interval=30s \
|
||||||
--timeout=3s \
|
--timeout=3s \
|
||||||
--start-period=10s \
|
--start-period=10s \
|
||||||
CMD curl -f http://127.0.0.1/ping || exit 1
|
CMD curl -f http://127.0.0.1/healthcheck || exit 1
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
.flip-list-move {
|
.flip-list-move {
|
||||||
transition: transform 0.5s;
|
transition: transform 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-move {
|
.no-move {
|
||||||
transition: transform 0s;
|
transition: transform 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
.ghost {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
background-color: rgba(255, 255, 255, 0.25);
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group {
|
.list-group {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
}
|
}
|
||||||
#librariesTable .item {
|
|
||||||
cursor: n-resize;
|
|
||||||
}
|
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
cursor: n-resize;
|
cursor: n-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:not(.exclude) {
|
.list-group-item:not(.exclude) {
|
||||||
cursor: n-resize;
|
cursor: n-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item.exclude {
|
.list-group-item.exclude {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:not(.ghost):not(.exclude):hover {
|
.list-group-item:not(.ghost):not(.exclude):hover {
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
@@ -36,6 +42,7 @@
|
|||||||
.list-group-item.exclude:not(.ghost) {
|
.list-group-item.exclude:not(.ghost) {
|
||||||
background-color: rgba(255, 0, 0, 0.25);
|
background-color: rgba(255, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item.exclude:not(.ghost):hover {
|
.list-group-item.exclude:not(.ghost):hover {
|
||||||
background-color: rgba(223, 0, 0, 0.25);
|
background-color: rgba(223, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
<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 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">
|
<div class="flex h-full items-center">
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
|
<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" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<ui-libraries-dropdown />
|
<ui-libraries-dropdown class="mr-2" />
|
||||||
|
|
||||||
<controls-global-search v-if="currentLibrary" class="" />
|
<controls-global-search v-if="currentLibrary" class="" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|||||||
@@ -364,7 +364,11 @@ export default {
|
|||||||
var episodeId = payload.episodeId || null
|
var episodeId = payload.episodeId || null
|
||||||
|
|
||||||
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
||||||
this.playerHandler.play()
|
if (payload.startTime !== null && !isNaN(payload.startTime)) {
|
||||||
|
this.seek(payload.startTime)
|
||||||
|
} else {
|
||||||
|
this.playerHandler.play()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +385,7 @@ export default {
|
|||||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
|
||||||
},
|
},
|
||||||
pauseItem() {
|
pauseItem() {
|
||||||
this.playerHandler.pause()
|
this.playerHandler.pause()
|
||||||
@@ -393,11 +397,13 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||||
|
this.$eventBus.$on('playback-seek', this.seek)
|
||||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||||
|
this.$eventBus.$off('playback-seek', this.seek)
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,11 +249,9 @@ export default {
|
|||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
if (this.recentEpisode) return this.recentEpisode.title
|
if (this.recentEpisode) return this.recentEpisode.title
|
||||||
if (this.collapsedSeries) return this.collapsedSeries.name
|
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||||
return this.mediaMetadata.titleIgnorePrefix
|
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
|
||||||
}
|
|
||||||
return this.title
|
|
||||||
},
|
},
|
||||||
displayLineTwo() {
|
displayLineTwo() {
|
||||||
if (this.recentEpisode) return this.title
|
if (this.recentEpisode) return this.title
|
||||||
@@ -502,7 +500,21 @@ export default {
|
|||||||
}
|
}
|
||||||
this.$emit('edit', this.libraryItem)
|
this.$emit('edit', this.libraryItem)
|
||||||
},
|
},
|
||||||
toggleFinished() {
|
toggleFinished(confirmed = false) {
|
||||||
|
if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {
|
||||||
|
const payload = {
|
||||||
|
message: `Are you sure you want to mark "${this.displayTitle}" as finished?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.toggleFinished(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
isFinished: !this.itemIsFinished
|
isFinished: !this.itemIsFinished
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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="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">
|
<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="title" :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" :group-to="seriesBooksRoute" />
|
||||||
</div>
|
</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>
|
<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>
|
||||||
@@ -10,16 +10,16 @@
|
|||||||
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
||||||
|
|
||||||
<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" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
<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" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -39,7 +39,8 @@ export default {
|
|||||||
seriesMount: {
|
seriesMount: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
}
|
},
|
||||||
|
sortingIgnorePrefix: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -65,6 +66,13 @@ export default {
|
|||||||
title() {
|
title() {
|
||||||
return this.series ? this.series.name : ''
|
return this.series ? this.series.name : ''
|
||||||
},
|
},
|
||||||
|
nameIgnorePrefix() {
|
||||||
|
return this.series ? this.series.nameIgnorePrefix : ''
|
||||||
|
},
|
||||||
|
displayTitle() {
|
||||||
|
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
|
||||||
|
return this.title
|
||||||
|
},
|
||||||
books() {
|
books() {
|
||||||
return this.series ? this.series.books || [] : []
|
return this.series ? this.series.books || [] : []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="sm:w-80 w-full sm:ml-6 relative">
|
<div class="sm:w-80 w-full relative">
|
||||||
<form @submit.prevent="submitSearch">
|
<form @submit.prevent="submitSearch">
|
||||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ export default {
|
|||||||
watch: {
|
watch: {
|
||||||
show: {
|
show: {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
console.log('accoutn modal show change', newVal)
|
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
@@ -162,6 +161,9 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
user() {
|
||||||
|
return this.$store.state.user.user
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
||||||
},
|
},
|
||||||
@@ -250,6 +252,12 @@ export default {
|
|||||||
this.$toast.error(`Failed to update account: ${data.error}`)
|
this.$toast.error(`Failed to update account: ${data.error}`)
|
||||||
} else {
|
} else {
|
||||||
console.log('Account updated', data.user)
|
console.log('Account updated', data.user)
|
||||||
|
|
||||||
|
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
||||||
|
console.log('Current user token was updated')
|
||||||
|
this.$store.commit('user/setUserToken', data.user.token)
|
||||||
|
}
|
||||||
|
|
||||||
this.$toast.success('Account updated')
|
this.$toast.success('Account updated')
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
@@ -305,7 +313,6 @@ export default {
|
|||||||
|
|
||||||
this.isNew = !this.account
|
this.isNew = !this.account
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
console.log(this.account)
|
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: this.account.username,
|
username: this.account.username,
|
||||||
password: this.account.password,
|
password: this.account.password,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
|
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
|
||||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<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">Your Bookmarks</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<div v-if="show" class="w-full h-full">
|
<div v-if="show" class="w-full h-full">
|
||||||
<template v-for="bookmark in bookmarks">
|
<template v-for="bookmark in bookmarks">
|
||||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
|
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
|
||||||
@@ -8,8 +13,8 @@
|
|||||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||||
<p class="text-xl">No Bookmarks</p>
|
<p class="text-xl">No Bookmarks</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-px bg-white bg-opacity-10" />
|
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
|
||||||
<form @submit.prevent="submitCreateBookmark">
|
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
|
||||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||||
<div class="w-16 max-w-16 text-center">
|
<div class="w-16 max-w-16 text-center">
|
||||||
<p class="text-sm font-mono text-gray-400">
|
<p class="text-sm font-mono text-gray-400">
|
||||||
@@ -39,7 +44,8 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
libraryItemId: String
|
libraryItemId: String,
|
||||||
|
hideCreate: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export default {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.content.style.transform = 'scale(1)'
|
this.content.style.transform = 'scale(1)'
|
||||||
}, 10)
|
}, 10)
|
||||||
document.documentElement.classList.add('modal-open')
|
|
||||||
|
|
||||||
this.$store.commit('setInnerModalOpen', true)
|
this.$store.commit('setInnerModalOpen', true)
|
||||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
@@ -97,7 +96,6 @@ export default {
|
|||||||
setHide() {
|
setHide() {
|
||||||
if (this.content) this.content.style.transform = 'scale(0)'
|
if (this.content) this.content.style.transform = 'scale(0)'
|
||||||
if (this.el) this.el.remove()
|
if (this.el) this.el.remove()
|
||||||
document.documentElement.classList.remove('modal-open')
|
|
||||||
|
|
||||||
this.$store.commit('setInnerModalOpen', false)
|
this.$store.commit('setInnerModalOpen', false)
|
||||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
el: null,
|
el: null,
|
||||||
content: null,
|
content: null,
|
||||||
preventClickoutside: false
|
preventClickoutside: false,
|
||||||
|
isShowingPrompt: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -93,7 +94,7 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
clickBg(ev) {
|
clickBg(ev) {
|
||||||
if (!this.show) return
|
if (!this.show || this.isShowingPrompt) return
|
||||||
if (this.preventClickoutside) {
|
if (this.preventClickoutside) {
|
||||||
this.preventClickoutside = false
|
this.preventClickoutside = false
|
||||||
return
|
return
|
||||||
@@ -147,8 +148,16 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
console.warn('Invalid modal init', this.name)
|
console.warn('Invalid modal init', this.name)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
showingPrompt(isShowing) {
|
||||||
|
this.isShowingPrompt = isShowing
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
this.$eventBus.$on('showing-prompt', this.showingPrompt)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('showing-prompt', this.showingPrompt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -116,6 +116,9 @@ export default {
|
|||||||
if (result.updated) {
|
if (result.updated) {
|
||||||
this.$toast.success('Author updated')
|
this.$toast.success('Author updated')
|
||||||
this.show = false
|
this.show = false
|
||||||
|
} else if (result.merged) {
|
||||||
|
this.$toast.success('Author merged')
|
||||||
|
this.show = false
|
||||||
} else this.$toast.info('No updates were needed')
|
} else this.$toast.info('No updates were needed')
|
||||||
}
|
}
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-bg" :class="wrapperClass" @click="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
|
||||||
<div class="w-16 max-w-16 text-center">
|
<div class="w-16 max-w-16 text-center">
|
||||||
<p class="text-sm font-mono text-gray-400">
|
<p class="text-sm font-mono text-gray-400">
|
||||||
{{ this.$secondsToTimestamp(bookmark.time) }}
|
{{ this.$secondsToTimestamp(bookmark.time) }}
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ export default {
|
|||||||
if (prevBook) {
|
if (prevBook) {
|
||||||
this.unregisterListeners()
|
this.unregisterListeners()
|
||||||
this.libraryItem = prevBook
|
this.libraryItem = prevBook
|
||||||
this.selectedTab = 'details'
|
|
||||||
this.$store.commit('setSelectedLibraryItem', prevBook)
|
this.$store.commit('setSelectedLibraryItem', prevBook)
|
||||||
this.$nextTick(this.registerListeners)
|
this.$nextTick(this.registerListeners)
|
||||||
} else {
|
} else {
|
||||||
@@ -210,7 +209,6 @@ export default {
|
|||||||
if (nextBook) {
|
if (nextBook) {
|
||||||
this.unregisterListeners()
|
this.unregisterListeners()
|
||||||
this.libraryItem = nextBook
|
this.libraryItem = nextBook
|
||||||
this.selectedTab = 'details'
|
|
||||||
this.$store.commit('setSelectedLibraryItem', nextBook)
|
this.$store.commit('setSelectedLibraryItem', nextBook)
|
||||||
this.$nextTick(this.registerListeners)
|
this.$nextTick(this.registerListeners)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-60 opacity-0">
|
||||||
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||||
|
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||||
|
<p class="text-base mb-8 mt-2 px-1">{{ message }}</p>
|
||||||
|
<div class="flex px-1 items-center">
|
||||||
|
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">Cancel</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="isYesNo" color="success" @click="confirm">Yes</ui-btn>
|
||||||
|
<ui-btn v-else color="primary" @click="confirm">Ok</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
el: null,
|
||||||
|
content: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.setShow()
|
||||||
|
} else {
|
||||||
|
this.setHide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showConfirmPrompt
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowConfirmPrompt', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmPromptOptions() {
|
||||||
|
return this.$store.state.globals.confirmPromptOptions || {}
|
||||||
|
},
|
||||||
|
message() {
|
||||||
|
return this.confirmPromptOptions.message || ''
|
||||||
|
},
|
||||||
|
callback() {
|
||||||
|
return this.confirmPromptOptions.callback
|
||||||
|
},
|
||||||
|
type() {
|
||||||
|
return this.confirmPromptOptions.type || 'ok'
|
||||||
|
},
|
||||||
|
persistent() {
|
||||||
|
return !!this.confirmPromptOptions.persistent
|
||||||
|
},
|
||||||
|
isYesNo() {
|
||||||
|
return this.type === 'yesNo'
|
||||||
|
},
|
||||||
|
modalHeight() {
|
||||||
|
return 'unset'
|
||||||
|
},
|
||||||
|
modalWidth() {
|
||||||
|
return '500px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickedOutside(evt) {
|
||||||
|
if (!this.show) return
|
||||||
|
if (evt) {
|
||||||
|
evt.stopPropagation()
|
||||||
|
evt.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.persistent) return
|
||||||
|
if (this.callback) this.callback(false)
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
|
nevermind() {
|
||||||
|
if (this.callback) this.callback(false)
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
|
confirm() {
|
||||||
|
if (this.callback) this.callback(true)
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
|
setShow() {
|
||||||
|
this.$eventBus.$emit('showing-prompt', true)
|
||||||
|
document.body.appendChild(this.el)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.content.style.transform = 'scale(1)'
|
||||||
|
}, 10)
|
||||||
|
},
|
||||||
|
setHide() {
|
||||||
|
this.$eventBus.$emit('showing-prompt', false)
|
||||||
|
this.content.style.transform = 'scale(0)'
|
||||||
|
this.el.remove()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.el = this.$refs.wrapper
|
||||||
|
this.content = this.$refs.content
|
||||||
|
this.content.style.transform = 'scale(0)'
|
||||||
|
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||||
|
this.el.style.opacity = 1
|
||||||
|
this.el.remove()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this.show) {
|
||||||
|
this.$eventBus.$emit('showing-prompt', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -65,12 +65,10 @@ export default {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.content.style.transform = 'scale(1)'
|
this.content.style.transform = 'scale(1)'
|
||||||
}, 10)
|
}, 10)
|
||||||
document.documentElement.classList.add('modal-open')
|
|
||||||
},
|
},
|
||||||
setHide() {
|
setHide() {
|
||||||
this.content.style.transform = 'scale(0)'
|
this.content.style.transform = 'scale(0)'
|
||||||
this.el.remove()
|
this.el.remove()
|
||||||
document.documentElement.classList.remove('modal-open')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -24,10 +24,10 @@
|
|||||||
<td class="font-book">
|
<td class="font-book">
|
||||||
{{ chapter.title }}
|
{{ chapter.title }}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono text-center">
|
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
|
||||||
{{ $secondsToTimestamp(chapter.start) }}
|
{{ $secondsToTimestamp(chapter.start) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono text-center">
|
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
|
||||||
{{ $secondsToTimestamp(chapter.end) }}
|
{{ $secondsToTimestamp(chapter.end) }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -57,6 +57,9 @@ export default {
|
|||||||
media() {
|
media() {
|
||||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
},
|
},
|
||||||
|
metadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
@@ -67,6 +70,30 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
clickBar() {
|
clickBar() {
|
||||||
this.expanded = !this.expanded
|
this.expanded = !this.expanded
|
||||||
|
},
|
||||||
|
goToTimestamp(time) {
|
||||||
|
if (this.$store.getters['getIsMediaStreaming'](this.libraryItemId)) {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: null,
|
||||||
|
startTime: time
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const payload = {
|
||||||
|
message: `Start playback for "${this.metadata.title}" at ${this.$secondsToTimestamp(time)}?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: null,
|
||||||
|
startTime: time
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||||
<template v-for="library in libraryCopies">
|
<template v-for="library in libraryCopies">
|
||||||
<div :key="library.id" class="item">
|
<div :key="library.id" class="item">
|
||||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="material-icons text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
|
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
|
||||||
|
|
||||||
<!-- For mobile -->
|
<!-- For mobile -->
|
||||||
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
|
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
|
||||||
@@ -105,7 +105,7 @@ export default {
|
|||||||
},
|
},
|
||||||
matchAll() {
|
matchAll() {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/libraries/${this.library.id}/matchall`)
|
.$get(`/api/libraries/${this.library.id}/matchall`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Starting scan for matches')
|
console.log('Starting scan for matches')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default {
|
|||||||
return this.$secondsToTimestamp(this.episode.duration)
|
return this.$secondsToTimestamp(this.episode.duration)
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
|
||||||
},
|
},
|
||||||
streamIsPlaying() {
|
streamIsPlaying() {
|
||||||
return this.$store.state.streamIsPlaying && this.isStreaming
|
return this.$store.state.streamIsPlaying && this.isStreaming
|
||||||
@@ -124,7 +124,21 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleFinished() {
|
toggleFinished(confirmed = false) {
|
||||||
|
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
|
||||||
|
const payload = {
|
||||||
|
message: `Are you sure you want to mark "${this.title}" as finished?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.toggleFinished(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
isFinished: !this.userIsFinished
|
isFinished: !this.userIsFinished
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
|
<div v-if="currentLibrary" class="relative h-8 max-w-52" v-click-outside="clickOutsideObj">
|
||||||
<button type="button" :disabled="disabled" class="w-10 sm:w-36 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">
|
<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">
|
||||||
<span class="flex items-center justify-center sm:justify-start">
|
<div class="flex items-center justify-center sm:justify-start">
|
||||||
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
|
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
|
||||||
<span class="hidden sm:block">{{ currentLibrary.name }}</span>
|
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
|
||||||
</span>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-36 bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||||
<template v-for="library in librariesFiltered">
|
<template v-for="library in librariesFiltered">
|
||||||
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
||||||
<div class="flex items-center px-3">
|
<div class="flex items-center px-2">
|
||||||
<widgets-library-icon :icon="library.icon" class="mr-2" />
|
<widgets-library-icon :icon="library.icon" class="mr-1.5 text-gray-400" />
|
||||||
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="rounded text-gray-200 border w-full px-3 py-2" :class="focusedDigit ? 'bg-primary bg-opacity-50 border-gray-300' : 'bg-primary border-gray-600'" @click="clickInput" v-click-outside="clickOutsideObj">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<template v-for="(digit, index) in digitDisplay">
|
||||||
|
<div v-if="digit == ':'" :key="index" class="px-px" @click.stop="clickMedian(index)">:</div>
|
||||||
|
<div v-else :key="index" class="px-px" :class="{ 'digit-focused': focusedDigit == digit }" @click.stop="focusDigit(digit)">{{ digits[digit] }}</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: [String, Number],
|
||||||
|
showThreeDigitHour: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
clickOutsideObj: {
|
||||||
|
handler: this.clickOutside,
|
||||||
|
events: ['mousedown'],
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
digitDisplay: ['hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0'],
|
||||||
|
focusedDigit: null,
|
||||||
|
digits: {
|
||||||
|
hour2: 0,
|
||||||
|
hour1: 0,
|
||||||
|
hour0: 0,
|
||||||
|
minute1: 0,
|
||||||
|
minute0: 0,
|
||||||
|
second1: 0,
|
||||||
|
second0: 0
|
||||||
|
},
|
||||||
|
isOver99Hours: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
immediate: true,
|
||||||
|
handler() {
|
||||||
|
this.initDigits()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
initDigits() {
|
||||||
|
var totalSeconds = !this.value || isNaN(this.value) ? 0 : Number(this.value)
|
||||||
|
totalSeconds = Math.round(totalSeconds)
|
||||||
|
|
||||||
|
var minutes = Math.floor(totalSeconds / 60)
|
||||||
|
var seconds = totalSeconds - minutes * 60
|
||||||
|
var hours = Math.floor(minutes / 60)
|
||||||
|
minutes -= hours * 60
|
||||||
|
|
||||||
|
this.digits.second1 = seconds <= 9 ? 0 : Number(String(seconds)[0])
|
||||||
|
this.digits.second0 = seconds <= 9 ? seconds : Number(String(seconds)[1])
|
||||||
|
|
||||||
|
this.digits.minute1 = minutes <= 9 ? 0 : Number(String(minutes)[0])
|
||||||
|
this.digits.minute0 = minutes <= 9 ? minutes : Number(String(minutes)[1])
|
||||||
|
|
||||||
|
if (hours > 99) {
|
||||||
|
this.digits.hour2 = Number(String(hours)[0])
|
||||||
|
this.digits.hour1 = Number(String(hours)[1])
|
||||||
|
this.digits.hour0 = Number(String(hours)[2])
|
||||||
|
this.isOver99Hours = true
|
||||||
|
} else {
|
||||||
|
this.digits.hour1 = hours <= 9 ? 0 : Number(String(hours)[0])
|
||||||
|
this.digits.hour0 = hours <= 9 ? hours : Number(String(hours)[1])
|
||||||
|
this.isOver99Hours = this.showThreeDigitHour
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isOver99Hours) {
|
||||||
|
this.digitDisplay = ['hour2', 'hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0']
|
||||||
|
} else {
|
||||||
|
this.digitDisplay = ['hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateSeconds() {
|
||||||
|
var seconds = this.digits.second0 + this.digits.second1 * 10
|
||||||
|
seconds += this.digits.minute0 * 60 + this.digits.minute1 * 600
|
||||||
|
seconds += this.digits.hour0 * 3600 + this.digits.hour1 * 36000
|
||||||
|
if (this.isOver99Hours) seconds += this.digits.hour2 * 360000
|
||||||
|
|
||||||
|
if (Number(this.value) !== seconds) {
|
||||||
|
this.$emit('input', seconds)
|
||||||
|
this.$emit('change', seconds)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clickMedian(index) {
|
||||||
|
// Click colon select digit to right
|
||||||
|
if (index >= 5) {
|
||||||
|
this.focusedDigit = 'second1'
|
||||||
|
} else {
|
||||||
|
this.focusedDigit = 'minute1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clickOutside() {
|
||||||
|
this.removeFocus()
|
||||||
|
},
|
||||||
|
removeFocus() {
|
||||||
|
this.focusedDigit = null
|
||||||
|
this.removeListeners()
|
||||||
|
},
|
||||||
|
focusDigit(digit) {
|
||||||
|
if (this.focusedDigit == null || isNaN(this.focusedDigit)) this.initListeners()
|
||||||
|
this.focusedDigit = digit
|
||||||
|
},
|
||||||
|
clickInput() {
|
||||||
|
if (this.focusedDigit) return
|
||||||
|
this.focusDigit('second0')
|
||||||
|
},
|
||||||
|
shiftFocusLeft() {
|
||||||
|
if (!this.focusedDigit) return
|
||||||
|
if (this.focusedDigit.endsWith('2')) return
|
||||||
|
|
||||||
|
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||||
|
if (!isDigit1) {
|
||||||
|
const digit1Key = this.focusedDigit.replace('0', '1')
|
||||||
|
this.focusedDigit = digit1Key
|
||||||
|
} else if (this.focusedDigit.startsWith('second')) {
|
||||||
|
this.focusedDigit = 'minute0'
|
||||||
|
} else if (this.focusedDigit.startsWith('minute')) {
|
||||||
|
this.focusedDigit = 'hour0'
|
||||||
|
} else if (this.isOver99Hours && this.focusedDigit.startsWith('hour')) {
|
||||||
|
this.focusedDigit = 'hour2'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shiftFocusRight() {
|
||||||
|
if (!this.focusedDigit) return
|
||||||
|
if (this.focusedDigit.endsWith('2')) {
|
||||||
|
// Must be hour2
|
||||||
|
this.focusedDigit = 'hour1'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||||
|
if (isDigit1) {
|
||||||
|
const digit0Key = this.focusedDigit.replace('1', '0')
|
||||||
|
this.focusedDigit = digit0Key
|
||||||
|
} else if (this.focusedDigit.startsWith('hour')) {
|
||||||
|
this.focusedDigit = 'minute1'
|
||||||
|
} else if (this.focusedDigit.startsWith('minute')) {
|
||||||
|
this.focusedDigit = 'second1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
increaseFocused() {
|
||||||
|
if (!this.focusedDigit) return
|
||||||
|
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||||
|
const digit = Number(this.digits[this.focusedDigit])
|
||||||
|
if (isDigit1 && !this.focusedDigit.startsWith('hour')) this.digits[this.focusedDigit] = (digit + 1) % 6
|
||||||
|
else this.digits[this.focusedDigit] = (digit + 1) % 10
|
||||||
|
this.updateSeconds()
|
||||||
|
},
|
||||||
|
decreaseFocused() {
|
||||||
|
if (!this.focusedDigit) return
|
||||||
|
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||||
|
const digit = Number(this.digits[this.focusedDigit])
|
||||||
|
if (isDigit1 && !this.focusedDigit.startsWith('hour')) this.digits[this.focusedDigit] = digit - 1 < 0 ? 5 : digit - 1
|
||||||
|
else this.digits[this.focusedDigit] = digit - 1 < 0 ? 9 : digit - 1
|
||||||
|
this.updateSeconds()
|
||||||
|
},
|
||||||
|
keydown(evt) {
|
||||||
|
if (!this.focusedDigit || !evt.key) return
|
||||||
|
|
||||||
|
if (evt.key === 'ArrowLeft') {
|
||||||
|
return this.shiftFocusLeft()
|
||||||
|
} else if (evt.key === 'ArrowRight') {
|
||||||
|
return this.shiftFocusRight()
|
||||||
|
} else if (evt.key === 'ArrowUp') {
|
||||||
|
return this.increaseFocused()
|
||||||
|
} else if (evt.key === 'ArrowDown') {
|
||||||
|
return this.decreaseFocused()
|
||||||
|
} else if (evt.key === 'Enter' || evt.key === 'Escape') {
|
||||||
|
return this.removeFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(evt.key)) return
|
||||||
|
|
||||||
|
var digit = Number(evt.key)
|
||||||
|
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||||
|
if (isDigit1 && !this.focusedDigit.startsWith('hour') && digit >= 6) {
|
||||||
|
digit = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
this.digits[this.focusedDigit] = digit
|
||||||
|
|
||||||
|
this.updateSeconds()
|
||||||
|
this.shiftFocusRight()
|
||||||
|
},
|
||||||
|
initListeners() {
|
||||||
|
window.addEventListener('keydown', this.keydown)
|
||||||
|
},
|
||||||
|
removeListeners() {
|
||||||
|
window.removeEventListener('keydown', this.keydown)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.removeListeners()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.digit-focused {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="`h-${size} w-${size}`">
|
<div :class="`h-${size} w-${size} min-w-${size}`">
|
||||||
<component :is="iconComponentName" />
|
<component :is="iconComponentName" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
||||||
<template v-for="(item, index) in items">
|
<template v-for="(item, index) in items">
|
||||||
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click="clickAction(item.func)">
|
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.func)">
|
||||||
<p>{{ item.text }}</p>
|
<p>{{ item.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<modals-podcast-edit-episode />
|
<modals-podcast-edit-episode />
|
||||||
<modals-podcast-view-episode />
|
<modals-podcast-view-episode />
|
||||||
<modals-authors-edit-modal />
|
<modals-authors-edit-modal />
|
||||||
|
<prompt-confirm />
|
||||||
<readers-reader />
|
<readers-reader />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ export default {
|
|||||||
width: this.entityWidth,
|
width: this.entityWidth,
|
||||||
height: this.entityHeight,
|
height: this.entityHeight,
|
||||||
bookCoverAspectRatio: this.bookCoverAspectRatio,
|
bookCoverAspectRatio: this.bookCoverAspectRatio,
|
||||||
bookshelfView: this.bookshelfView
|
bookshelfView: this.bookshelfView,
|
||||||
|
sortingIgnorePrefix: !!this.sortingIgnorePrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.entityName === 'books') {
|
if (this.entityName === 'books') {
|
||||||
props.filterBy = this.filterBy
|
props.filterBy = this.filterBy
|
||||||
props.orderBy = this.orderBy
|
props.orderBy = this.orderBy
|
||||||
props.sortingIgnorePrefix = !!this.sortingIgnorePrefix
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _this = this
|
var _this = this
|
||||||
|
|||||||
@@ -108,15 +108,15 @@ module.exports = {
|
|||||||
background_color: '#373838',
|
background_color: '#373838',
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: '/icon64.png',
|
src: '/icon.svg',
|
||||||
sizes: "64x64"
|
sizes: "64x64"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/icon192.png',
|
src: '/icon.svg',
|
||||||
sizes: "192x192"
|
sizes: "192x192"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/Logo.png',
|
src: '/icon.svg',
|
||||||
sizes: "512x512"
|
sizes: "512x512"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.24",
|
"version": "2.1.1",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.23",
|
"version": "2.1.1",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.24",
|
"version": "2.1.1",
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
|
||||||
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
||||||
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
||||||
<div class="w-40" />
|
<div class="w-40" />
|
||||||
@@ -32,7 +33,8 @@
|
|||||||
<div :key="chapter.id" class="flex py-1">
|
<div :key="chapter.id" class="flex py-1">
|
||||||
<div class="w-12">#{{ chapter.id + 1 }}</div>
|
<div class="w-12">#{{ chapter.id + 1 }}</div>
|
||||||
<div class="w-32 px-1">
|
<div class="w-32 px-1">
|
||||||
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
<ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
||||||
|
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-1">
|
<div class="flex-grow px-1">
|
||||||
<ui-text-input v-model="chapter.title" class="text-xs" />
|
<ui-text-input v-model="chapter.title" class="text-xs" />
|
||||||
@@ -136,7 +138,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ store, params, app, redirect, route }) {
|
async asyncData({ store, params, app, redirect, from }) {
|
||||||
if (!store.getters['user/getUserCanUpdate']) {
|
if (!store.getters['user/getUserCanUpdate']) {
|
||||||
return redirect('/?error=unauthorized')
|
return redirect('/?error=unauthorized')
|
||||||
}
|
}
|
||||||
@@ -152,8 +154,12 @@ export default {
|
|||||||
console.error('Invalid media type')
|
console.error('Invalid media type')
|
||||||
return redirect('/')
|
return redirect('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var previousRoute = from ? from.fullPath : null
|
||||||
|
if (from && from.path === '/login') previousRoute = null
|
||||||
return {
|
return {
|
||||||
libraryItem
|
libraryItem,
|
||||||
|
previousRoute
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -168,7 +174,8 @@ export default {
|
|||||||
asinInput: null,
|
asinInput: null,
|
||||||
findingChapters: false,
|
findingChapters: false,
|
||||||
showFindChaptersModal: false,
|
showFindChaptersModal: false,
|
||||||
chapterData: null
|
chapterData: null,
|
||||||
|
showSecondInputs: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -339,7 +346,6 @@ export default {
|
|||||||
|
|
||||||
this.saving = true
|
this.saving = true
|
||||||
|
|
||||||
console.log('udpated chapters', this.newChapters)
|
|
||||||
const payload = {
|
const payload = {
|
||||||
chapters: this.newChapters
|
chapters: this.newChapters
|
||||||
}
|
}
|
||||||
@@ -349,7 +355,11 @@ export default {
|
|||||||
this.saving = false
|
this.saving = false
|
||||||
if (data.updated) {
|
if (data.updated) {
|
||||||
this.$toast.success('Chapters updated')
|
this.$toast.success('Chapters updated')
|
||||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
if (this.previousRoute) {
|
||||||
|
this.$router.push(this.previousRoute)
|
||||||
|
} else {
|
||||||
|
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info('No changes needed updating')
|
this.$toast.info('No changes needed updating')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,16 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="flex items-center py-2">
|
||||||
|
<ui-text-input type="number" v-model="newServerSettings.scannerMaxThreads" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateScannerMaxThreads" />
|
||||||
|
<ui-tooltip :text="tooltips.scannerMaxThreads">
|
||||||
|
<p class="pl-4">
|
||||||
|
Max # of threads to use
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<h2 class="font-semibold">Experimental Features</h2>
|
<h2 class="font-semibold">Experimental Features</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,6 +194,16 @@
|
|||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.scannerUseSingleThreadedProber" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseSingleThreadedProber', val)" />
|
||||||
|
<ui-tooltip :text="tooltips.scannerUseSingleThreadedProber">
|
||||||
|
<p class="pl-4">
|
||||||
|
Scanner use old single threaded audio prober
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,7 +288,9 @@ export default {
|
|||||||
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
|
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
|
||||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
|
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
|
||||||
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
|
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
|
||||||
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically'
|
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically',
|
||||||
|
scannerUseSingleThreadedProber: 'The old scanner used a single thread. Leaving it in to use as a comparison for now.',
|
||||||
|
scannerMaxThreads: 'Number of concurrent media files to scan at a time. Value of 1 will be a slower scan but less CPU usage. <br><br>Value of 0 defaults to # of CPU cores for this server times 2 (i.e. 4-core CPU will be 8)'
|
||||||
},
|
},
|
||||||
showConfirmPurgeCache: false
|
showConfirmPurgeCache: false
|
||||||
}
|
}
|
||||||
@@ -300,6 +322,26 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updateScannerMaxThreads(val) {
|
||||||
|
if (!val || isNaN(val)) {
|
||||||
|
this.$toast.error('Invalid max threads must be a number')
|
||||||
|
this.newServerSettings.scannerMaxThreads = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Number(val) < 0) {
|
||||||
|
this.$toast.error('Max threads must be >= 0')
|
||||||
|
this.newServerSettings.scannerMaxThreads = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Math.round(Number(val)) !== Number(val)) {
|
||||||
|
this.$toast.error('Max threads must be an integer')
|
||||||
|
this.newServerSettings.scannerMaxThreads = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.updateServerSettings({
|
||||||
|
scannerMaxThreads: Number(val)
|
||||||
|
})
|
||||||
|
},
|
||||||
updateSortingPrefixes(val) {
|
updateSortingPrefixes(val) {
|
||||||
if (!val || !val.length) {
|
if (!val || !val.length) {
|
||||||
this.$toast.error('Must have at least 1 prefix')
|
this.$toast.error('Must have at least 1 prefix')
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center hidden sm:table-cell">
|
<td class="text-center hidden sm:table-cell">
|
||||||
@@ -89,7 +89,8 @@ export default {
|
|||||||
total: 0,
|
total: 0,
|
||||||
currentPage: 0,
|
currentPage: 0,
|
||||||
userFilter: null,
|
userFilter: null,
|
||||||
selectedUser: ''
|
selectedUser: '',
|
||||||
|
processingGoToTimestamp: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -110,6 +111,41 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async clickCurrentTime(session) {
|
||||||
|
if (this.processingGoToTimestamp) return
|
||||||
|
this.processingGoToTimestamp = true
|
||||||
|
const libraryItem = await this.$axios.$get(`/api/items/${session.libraryItemId}`).catch((error) => {
|
||||||
|
console.error('Failed to get library item', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!libraryItem) {
|
||||||
|
this.$toast.error('Failed to get library item')
|
||||||
|
this.processingGoToTimestamp = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) {
|
||||||
|
this.$toast.error('Failed to get podcast episode')
|
||||||
|
this.processingGoToTimestamp = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
message: `Start playback for "${session.displayTitle}" at ${this.$secondsToTimestamp(session.currentTime)}?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: libraryItem.id,
|
||||||
|
episodeId: session.episodeId || null,
|
||||||
|
startTime: session.currentTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.processingGoToTimestamp = false
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
updateUserFilter() {
|
updateUserFilter() {
|
||||||
this.loadSessions(0)
|
this.loadSessions(0)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,11 +13,12 @@
|
|||||||
<widgets-online-indicator :value="!!userOnline" />
|
<widgets-online-indicator :value="!!userOnline" />
|
||||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)">
|
<div v-if="userToken" class="flex text-xs mt-4">
|
||||||
<p v-if="userToken" class="py-2 text-xs">
|
<ui-text-input-with-label label="API Token" :value="userToken" readonly />
|
||||||
<strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span
|
|
||||||
><span class="material-icons pl-2 text-base">content_copy</span>
|
<div class="px-1 mt-8 cursor-pointer" @click="copyToClipboard(userToken)">
|
||||||
</p>
|
<span class="material-icons pl-2 text-base">content_copy</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
@@ -138,12 +139,15 @@ export default {
|
|||||||
this.$copyToClipboard(str, this)
|
this.$copyToClipboard(str, this)
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`).then((data) => {
|
this.listeningSessions = await this.$axios
|
||||||
return data.sessions || []
|
.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`)
|
||||||
}).catch((err) => {
|
.then((data) => {
|
||||||
console.error('Failed to load listening sesions', err)
|
return data.sessions || []
|
||||||
return []
|
})
|
||||||
})
|
.catch((err) => {
|
||||||
|
console.error('Failed to load listening sesions', err)
|
||||||
|
return []
|
||||||
|
})
|
||||||
this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => {
|
this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => {
|
||||||
console.error('Failed to load listening sesions', err)
|
console.error('Failed to load listening sesions', err)
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center hidden sm:table-cell">
|
<td class="text-center hidden sm:table-cell">
|
||||||
@@ -85,7 +85,8 @@ export default {
|
|||||||
listeningSessions: [],
|
listeningSessions: [],
|
||||||
numPages: 0,
|
numPages: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
currentPage: 0
|
currentPage: 0,
|
||||||
|
processingGoToTimestamp: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -97,6 +98,41 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async clickCurrentTime(session) {
|
||||||
|
if (this.processingGoToTimestamp) return
|
||||||
|
this.processingGoToTimestamp = true
|
||||||
|
const libraryItem = await this.$axios.$get(`/api/items/${session.libraryItemId}`).catch((error) => {
|
||||||
|
console.error('Failed to get library item', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!libraryItem) {
|
||||||
|
this.$toast.error('Failed to get library item')
|
||||||
|
this.processingGoToTimestamp = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) {
|
||||||
|
this.$toast.error('Failed to get podcast episode')
|
||||||
|
this.processingGoToTimestamp = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
message: `Start playback for "${session.displayTitle}" at ${this.$secondsToTimestamp(session.currentTime)}?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: libraryItem.id,
|
||||||
|
episodeId: session.episodeId || null,
|
||||||
|
startTime: session.currentTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.processingGoToTimestamp = false
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
prevPage() {
|
prevPage() {
|
||||||
this.loadSessions(this.currentPage - 1)
|
this.loadSessions(this.currentPage - 1)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<!-- Item Cover Overlay -->
|
<!-- 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 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 && !streaming" class="h-full flex items-center justify-center pointer-events-none">
|
<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="startStream">
|
||||||
<span class="material-icons text-4xl">play_circle_filled</span>
|
<span class="material-icons text-4xl">play_circle_filled</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,12 +129,12 @@
|
|||||||
|
|
||||||
<!-- Icon buttons -->
|
<!-- Icon buttons -->
|
||||||
<div class="flex items-center justify-center md:justify-start pt-4">
|
<div class="flex items-center justify-center md:justify-start pt-4">
|
||||||
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||||
{{ streaming ? 'Playing' : 'Play' }}
|
{{ isStreaming ? 'Playing' : 'Play' }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
|
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">error</span>
|
||||||
{{ isMissing ? 'Missing' : 'Incomplete' }}
|
{{ isMissing ? 'Missing' : 'Incomplete' }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
@@ -160,7 +160,11 @@
|
|||||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<!-- Experimental RSS feed open -->
|
<ui-tooltip v-if="bookmarks.length" text="Your Bookmarks" direction="top">
|
||||||
|
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" />
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<!-- RSS feed -->
|
||||||
<ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top">
|
<ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top">
|
||||||
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
|
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -189,6 +193,7 @@
|
|||||||
|
|
||||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||||
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
|
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
|
||||||
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -222,7 +227,8 @@ export default {
|
|||||||
podcastFeedEpisodes: [],
|
podcastFeedEpisodes: [],
|
||||||
episodesDownloading: [],
|
episodesDownloading: [],
|
||||||
episodeDownloadsQueued: [],
|
episodeDownloadsQueued: [],
|
||||||
showRssFeedModal: false
|
showRssFeedModal: false,
|
||||||
|
showBookmarksModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -296,6 +302,10 @@ export default {
|
|||||||
chapters() {
|
chapters() {
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
|
bookmarks() {
|
||||||
|
if (this.isPodcast) return []
|
||||||
|
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
|
||||||
|
},
|
||||||
tracks() {
|
tracks() {
|
||||||
return this.media.tracks || []
|
return this.media.tracks || []
|
||||||
},
|
},
|
||||||
@@ -389,7 +399,7 @@ export default {
|
|||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
streaming() {
|
isStreaming() {
|
||||||
return this.streamLibraryItem && this.streamLibraryItem.id === this.libraryItemId
|
return this.streamLibraryItem && this.streamLibraryItem.id === this.libraryItemId
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
@@ -409,6 +419,31 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickBookmarksBtn() {
|
||||||
|
this.showBookmarksModal = true
|
||||||
|
},
|
||||||
|
selectBookmark(bookmark) {
|
||||||
|
if (!bookmark) return
|
||||||
|
if (this.isStreaming) {
|
||||||
|
this.$eventBus.$emit('playback-seek', bookmark.time)
|
||||||
|
} else if (this.streamLibraryItem) {
|
||||||
|
this.showBookmarksModal = false
|
||||||
|
console.log('Already streaming library item so ask about it')
|
||||||
|
const payload = {
|
||||||
|
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(bookmark.time)}?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.startStream(bookmark.time)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
} else {
|
||||||
|
this.startStream(bookmark.time)
|
||||||
|
}
|
||||||
|
this.showBookmarksModal = false
|
||||||
|
},
|
||||||
clearDownloadQueue() {
|
clearDownloadQueue() {
|
||||||
if (confirm('Are you sure you want to clear episode download queue?')) {
|
if (confirm('Are you sure you want to clear episode download queue?')) {
|
||||||
this.$axios
|
this.$axios
|
||||||
@@ -453,7 +488,21 @@ export default {
|
|||||||
openEbook() {
|
openEbook() {
|
||||||
this.$store.commit('showEReader', this.libraryItem)
|
this.$store.commit('showEReader', this.libraryItem)
|
||||||
},
|
},
|
||||||
toggleFinished() {
|
toggleFinished(confirmed = false) {
|
||||||
|
if (!this.userIsFinished && this.progressPercent > 0 && !confirmed) {
|
||||||
|
const payload = {
|
||||||
|
message: `Are you sure you want to mark "${this.title}" as finished?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.toggleFinished(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
isFinished: !this.userIsFinished
|
isFinished: !this.userIsFinished
|
||||||
}
|
}
|
||||||
@@ -470,7 +519,7 @@ export default {
|
|||||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
startStream() {
|
startStream(startTime = null) {
|
||||||
var episodeId = null
|
var episodeId = null
|
||||||
if (this.isPodcast) {
|
if (this.isPodcast) {
|
||||||
var episode = this.podcastEpisodes.find((ep) => {
|
var episode = this.podcastEpisodes.find((ep) => {
|
||||||
@@ -483,7 +532,8 @@ export default {
|
|||||||
|
|
||||||
this.$eventBus.$emit('play-item', {
|
this.$eventBus.$emit('play-item', {
|
||||||
libraryItemId: this.libraryItem.id,
|
libraryItemId: this.libraryItem.id,
|
||||||
episodeId
|
episodeId,
|
||||||
|
startTime
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
editClick() {
|
editClick() {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default class PlayerHandler {
|
|||||||
this.isHlsTranscode = false
|
this.isHlsTranscode = false
|
||||||
this.isVideo = false
|
this.isVideo = false
|
||||||
this.currentSessionId = null
|
this.currentSessionId = null
|
||||||
|
this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page)
|
||||||
this.startTime = 0
|
this.startTime = 0
|
||||||
|
|
||||||
this.failedProgressSyncs = 0
|
this.failedProgressSyncs = 0
|
||||||
@@ -51,12 +52,13 @@ export default class PlayerHandler {
|
|||||||
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
|
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
|
||||||
this.libraryItem = libraryItem
|
this.libraryItem = libraryItem
|
||||||
this.episodeId = episodeId
|
this.episodeId = episodeId
|
||||||
this.playWhenReady = playWhenReady
|
this.playWhenReady = playWhenReady
|
||||||
this.initialPlaybackRate = playbackRate
|
this.initialPlaybackRate = playbackRate
|
||||||
this.isVideo = libraryItem.mediaType === 'video'
|
this.isVideo = libraryItem.mediaType === 'video'
|
||||||
|
this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride)
|
||||||
|
|
||||||
if (!this.player) this.switchPlayer(playWhenReady)
|
if (!this.player) this.switchPlayer(playWhenReady)
|
||||||
else this.prepare()
|
else this.prepare()
|
||||||
@@ -142,11 +144,13 @@ export default class PlayerHandler {
|
|||||||
} else {
|
} else {
|
||||||
this.stopPlayInterval()
|
this.stopPlayInterval()
|
||||||
}
|
}
|
||||||
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
|
if (this.player) {
|
||||||
this.ctx.setDuration(this.getDuration())
|
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
|
||||||
}
|
this.ctx.setDuration(this.getDuration())
|
||||||
if (this.playerState !== 'LOADING') {
|
}
|
||||||
this.ctx.setCurrentTime(this.player.getCurrentTime())
|
if (this.playerState !== 'LOADING') {
|
||||||
|
this.ctx.setCurrentTime(this.player.getCurrentTime())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ctx.setPlaying(this.playerState === 'PLAYING')
|
this.ctx.setPlaying(this.playerState === 'PLAYING')
|
||||||
@@ -183,13 +187,14 @@ export default class PlayerHandler {
|
|||||||
this.isVideo = session.libraryItem.mediaType === 'video'
|
this.isVideo = session.libraryItem.mediaType === 'video'
|
||||||
this.playWhenReady = false
|
this.playWhenReady = false
|
||||||
this.initialPlaybackRate = playbackRate
|
this.initialPlaybackRate = playbackRate
|
||||||
|
this.startTimeOverride = undefined
|
||||||
|
|
||||||
this.prepareSession(session)
|
this.prepareSession(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareSession(session) {
|
prepareSession(session) {
|
||||||
this.failedProgressSyncs = 0
|
this.failedProgressSyncs = 0
|
||||||
this.startTime = session.currentTime
|
this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime
|
||||||
this.currentSessionId = session.id
|
this.currentSessionId = session.id
|
||||||
this.displayTitle = session.displayTitle
|
this.displayTitle = session.displayTitle
|
||||||
this.displayAuthor = session.displayAuthor
|
this.displayAuthor = session.displayAuthor
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 1235.7 1235.4" style="enable-background:new 0 0 6702.7 1277.4;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#FFFFFF;}
|
||||||
|
.st1{fill:url(#SVGID_1_);}
|
||||||
|
.st2{fill:#C9C9C9;}
|
||||||
|
.st3{font-family:'GentiumBookBasic';}
|
||||||
|
.st4{font-size:800px;}
|
||||||
|
.st5{fill:#474747;}
|
||||||
|
</style>
|
||||||
|
<title>bgAsset 6</title>
|
||||||
|
<g id="Layer_2_1_">
|
||||||
|
<g id="Layer_2-2">
|
||||||
|
<g id="Layer_4">
|
||||||
|
<g id="Layer_5">
|
||||||
|
<circle class="st0" cx="618.6" cy="618.6" r="618.6"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="617.37" y1="1257.3" x2="617.37" y2="61.4399" gradientTransform="matrix(1 0 0 -1 0 1278)">
|
||||||
|
<stop offset="0.32" style="stop-color:#CD9D49"/>
|
||||||
|
<stop offset="0.99" style="stop-color:#875D27"/>
|
||||||
|
</linearGradient>
|
||||||
|
<circle class="st1" cx="617.4" cy="618.6" r="597.9"/>
|
||||||
|
</g>
|
||||||
|
<path class="st0" d="M1005.6,574.1c-4.8-4-12.4-10-22.6-17v-79.2c0-201.9-163.7-365.6-365.6-365.6l0,0
|
||||||
|
c-201.9,0-365.6,163.7-365.6,365.6v79.2c-10.2,7-17.7,13-22.6,17c-4.1,3.4-6.5,8.5-6.5,13.9v94.9c0,5.4,2.4,10.5,6.5,14
|
||||||
|
c11.3,9.4,37.2,29.1,77.5,49.3v9.2c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45V527.8c0-24.9-16-45-35.8-45l0,0
|
||||||
|
c-19,0-34.5,18.5-35.8,41.9h-0.1v-46.9c0-171.6,139.1-310.7,310.7-310.7l0,0C789,167.2,928,306.3,928,477.9v46.9H928
|
||||||
|
c-1.3-23.4-16.8-41.9-35.8-41.9l0,0c-19.8,0-35.8,20.2-35.8,45v227.6c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45v-9.2
|
||||||
|
c40.3-20.2,66.2-39.9,77.5-49.3c4.2-3.5,6.5-8.6,6.5-14V588C1012.1,582.6,1009.7,577.5,1005.6,574.1z"/>
|
||||||
|
<path class="st0" d="M489.9,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
|
||||||
|
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L489.9,969.7z M418.2,514.6h98.7v10.3h-98.7V514.6z"/>
|
||||||
|
<path class="st0" d="M639.7,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3H595c-23.9,0-43.3,19.4-43.3,43.3
|
||||||
|
v484.8c0,23.9,19.4,43.3,43.3,43.3H639.7z M568,514.6h98.7v10.3H568V514.6z"/>
|
||||||
|
<path class="st0" d="M789.6,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
|
||||||
|
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L789.6,969.7z M717.9,514.6h98.7v10.3h-98.7V514.6z"/>
|
||||||
|
<path class="st0" d="M327.1,984.7h580.5c18,0,32.6,14.6,32.6,32.6v0c0,18-14.6,32.6-32.6,32.6H327.1c-18,0-32.6-14.6-32.6-32.6v0
|
||||||
|
C294.5,999.3,309.1,984.7,327.1,984.7z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -6,6 +6,8 @@ export const state = () => ({
|
|||||||
showEditCollectionModal: false,
|
showEditCollectionModal: false,
|
||||||
showEditPodcastEpisode: false,
|
showEditPodcastEpisode: false,
|
||||||
showViewPodcastEpisodeModal: false,
|
showViewPodcastEpisodeModal: false,
|
||||||
|
showConfirmPrompt: false,
|
||||||
|
confirmPromptOptions: null,
|
||||||
showEditAuthorModal: false,
|
showEditAuthorModal: false,
|
||||||
selectedEpisode: null,
|
selectedEpisode: null,
|
||||||
selectedCollection: null,
|
selectedCollection: null,
|
||||||
@@ -69,6 +71,13 @@ export const mutations = {
|
|||||||
setShowViewPodcastEpisodeModal(state, val) {
|
setShowViewPodcastEpisodeModal(state, val) {
|
||||||
state.showViewPodcastEpisodeModal = val
|
state.showViewPodcastEpisodeModal = val
|
||||||
},
|
},
|
||||||
|
setShowConfirmPrompt(state, val) {
|
||||||
|
state.showConfirmPrompt = val
|
||||||
|
},
|
||||||
|
setConfirmPrompt(state, options) {
|
||||||
|
state.confirmPromptOptions = options
|
||||||
|
state.showConfirmPrompt = true
|
||||||
|
},
|
||||||
setEditCollection(state, collection) {
|
setEditCollection(state, collection) {
|
||||||
state.selectedCollection = collection
|
state.selectedCollection = collection
|
||||||
state.showEditCollectionModal = true
|
state.showEditCollectionModal = true
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ export const getters = {
|
|||||||
getLibraryItemIdStreaming: state => {
|
getLibraryItemIdStreaming: state => {
|
||||||
return state.streamLibraryItem ? state.streamLibraryItem.id : null
|
return state.streamLibraryItem ? state.streamLibraryItem.id : null
|
||||||
},
|
},
|
||||||
getIsEpisodeStreaming: state => (libraryItemId, episodeId) => {
|
getIsMediaStreaming: state => (libraryItemId, episodeId) => {
|
||||||
if (!state.streamLibraryItem) return null
|
if (!state.streamLibraryItem) return null
|
||||||
|
if (!episodeId) return state.streamLibraryItem.id == libraryItemId
|
||||||
return state.streamLibraryItem.id == libraryItemId && state.streamEpisodeId == episodeId
|
return state.streamLibraryItem.id == libraryItemId && state.streamEpisodeId == episodeId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ export const mutations = {
|
|||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setUserToken(state, token) {
|
||||||
|
state.user.token = token
|
||||||
|
localStorage.setItem('token', user.token)
|
||||||
|
},
|
||||||
updateMediaProgress(state, { id, data }) {
|
updateMediaProgress(state, { id, data }) {
|
||||||
if (!state.user) return
|
if (!state.user) return
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ module.exports = {
|
|||||||
'text-green-500',
|
'text-green-500',
|
||||||
'py-1.5',
|
'py-1.5',
|
||||||
'bg-info',
|
'bg-info',
|
||||||
'px-1.5'
|
'px-1.5',
|
||||||
|
'min-w-5'
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
@@ -36,11 +37,14 @@ module.exports = {
|
|||||||
'32': '8rem',
|
'32': '8rem',
|
||||||
'40': '10rem',
|
'40': '10rem',
|
||||||
'48': '12rem',
|
'48': '12rem',
|
||||||
|
'52': '13rem',
|
||||||
'64': '16rem',
|
'64': '16rem',
|
||||||
'80': '20rem'
|
'80': '20rem'
|
||||||
},
|
},
|
||||||
minWidth: {
|
minWidth: {
|
||||||
|
'5': '1.25rem',
|
||||||
'6': '1.5rem',
|
'6': '1.5rem',
|
||||||
|
'10': '2.5rem',
|
||||||
'12': '3rem',
|
'12': '3rem',
|
||||||
'16': '4rem',
|
'16': '4rem',
|
||||||
'20': '5rem',
|
'20': '5rem',
|
||||||
@@ -87,7 +91,8 @@ module.exports = {
|
|||||||
'2.5xl': '1.6875rem'
|
'2.5xl': '1.6875rem'
|
||||||
},
|
},
|
||||||
zIndex: {
|
zIndex: {
|
||||||
'50': 50
|
'50': 50,
|
||||||
|
'60': 60
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-3
@@ -7,6 +7,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 13378:80
|
- 13378:80
|
||||||
volumes:
|
volumes:
|
||||||
- /audiobooks:/audiobooks
|
- ./audiobooks:/audiobooks
|
||||||
- /metadata:/metadata
|
- ./metadata:/metadata
|
||||||
- /config:/config
|
- ./config:/config
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
|
||||||
const server = require('./server/Server')
|
const server = require('./server/Server')
|
||||||
global.appRoot = __dirname
|
global.appRoot = __dirname
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.24",
|
"version": "2.1.1",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.23",
|
"version": "2.1.1",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.26.1",
|
"axios": "^0.26.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.24",
|
"version": "2.1.1",
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+24
-4
@@ -31,6 +31,26 @@ class Auth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async initTokenSecret() {
|
||||||
|
if (process.env.TOKEN_SECRET) { // User can supply their own token secret
|
||||||
|
Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`)
|
||||||
|
this.db.serverSettings.tokenSecret = process.env.TOKEN_SECRET
|
||||||
|
} else {
|
||||||
|
Logger.debug(`[Auth] Setting token secret - using random bytes`)
|
||||||
|
this.db.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||||
|
}
|
||||||
|
await this.db.updateServerSettings()
|
||||||
|
|
||||||
|
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
||||||
|
if (this.db.users.length) {
|
||||||
|
for (const user of this.db.users) {
|
||||||
|
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
|
||||||
|
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
|
||||||
|
}
|
||||||
|
await this.db.updateEntities('user', this.db.users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async authMiddleware(req, res, next) {
|
async authMiddleware(req, res, next) {
|
||||||
var token = null
|
var token = null
|
||||||
|
|
||||||
@@ -74,7 +94,7 @@ class Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
generateAccessToken(payload) {
|
generateAccessToken(payload) {
|
||||||
return jwt.sign(payload, process.env.TOKEN_SECRET);
|
return jwt.sign(payload, global.ServerSettings.tokenSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticateUser(token) {
|
authenticateUser(token) {
|
||||||
@@ -83,12 +103,12 @@ class Auth {
|
|||||||
|
|
||||||
verifyToken(token) {
|
verifyToken(token) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
|
jwt.verify(token, global.ServerSettings.tokenSecret, (err, payload) => {
|
||||||
if (!payload || err) {
|
if (!payload || err) {
|
||||||
Logger.error('JWT Verify Token Failed', err)
|
Logger.error('JWT Verify Token Failed', err)
|
||||||
return resolve(null)
|
return resolve(null)
|
||||||
}
|
}
|
||||||
var user = this.users.find(u => u.id === payload.userId)
|
var user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
|
||||||
resolve(user || null)
|
resolve(user || null)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -98,7 +118,7 @@ class Auth {
|
|||||||
return {
|
return {
|
||||||
user: user.toJSONForBrowser(),
|
user: user.toJSONForBrowser(),
|
||||||
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
||||||
serverSettings: this.db.serverSettings.toJSON(),
|
serverSettings: this.db.serverSettings.toJSONForBrowser(),
|
||||||
Source: global.Source
|
Source: global.Source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -10,7 +10,6 @@ const Author = require('./objects/entities/Author')
|
|||||||
const Series = require('./objects/entities/Series')
|
const Series = require('./objects/entities/Series')
|
||||||
const ServerSettings = require('./objects/settings/ServerSettings')
|
const ServerSettings = require('./objects/settings/ServerSettings')
|
||||||
const PlaybackSession = require('./objects/PlaybackSession')
|
const PlaybackSession = require('./objects/PlaybackSession')
|
||||||
const Feed = require('./objects/Feed')
|
|
||||||
|
|
||||||
class Db {
|
class Db {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -414,6 +413,23 @@ class Db {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeEntities(entityName, selectFunc) {
|
||||||
|
var entityDb = this.getEntityDb(entityName)
|
||||||
|
return entityDb.delete(selectFunc).then((results) => {
|
||||||
|
Logger.debug(`[DB] Deleted entities ${entityName}: ${results.deleted}`)
|
||||||
|
var arrayKey = this.getEntityArrayKey(entityName)
|
||||||
|
if (this[arrayKey]) {
|
||||||
|
this[arrayKey] = this[arrayKey].filter(e => {
|
||||||
|
return !selectFunc(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results.deleted
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[DB] Remove entities ${entityName} Failed: ${error}`)
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
recreateLibraryItemsDb() {
|
recreateLibraryItemsDb() {
|
||||||
return this.libraryItemsDb.drop().then((results) => {
|
return this.libraryItemsDb.drop().then((results) => {
|
||||||
Logger.info(`[DB] Dropped library items db`, results)
|
Logger.info(`[DB] Dropped library items db`, results)
|
||||||
@@ -426,8 +442,8 @@ class Db {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllSessions() {
|
getAllSessions(selectFunc = () => true) {
|
||||||
return this.sessionsDb.select(() => true).then((results) => {
|
return this.sessionsDb.select(selectFunc).then((results) => {
|
||||||
return results.data || []
|
return results.data || []
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
Logger.error('[Db] Failed to select sessions', error)
|
Logger.error('[Db] Failed to select sessions', error)
|
||||||
|
|||||||
+9
-5
@@ -136,8 +136,14 @@ class Server {
|
|||||||
await this.db.init()
|
await this.db.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create token secret if does not exist (Added v2.1.0)
|
||||||
|
if (!this.db.serverSettings.tokenSecret) {
|
||||||
|
await this.auth.initTokenSecret()
|
||||||
|
}
|
||||||
|
|
||||||
await this.checkUserMediaProgress() // Remove invalid user item progress
|
await this.checkUserMediaProgress() // Remove invalid user item progress
|
||||||
await this.purgeMetadata() // Remove metadata folders without library item
|
await this.purgeMetadata() // Remove metadata folders without library item
|
||||||
|
await this.playbackSessionManager.removeInvalidSessions()
|
||||||
await this.cacheManager.ensureCachePaths()
|
await this.cacheManager.ensureCachePaths()
|
||||||
await this.abMergeManager.ensureDownloadDirPath()
|
await this.abMergeManager.ensureDownloadDirPath()
|
||||||
|
|
||||||
@@ -171,7 +177,6 @@ class Server {
|
|||||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||||
app.use(express.static(distPath))
|
app.use(express.static(distPath))
|
||||||
|
|
||||||
|
|
||||||
// Metadata folder static path
|
// Metadata folder static path
|
||||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
||||||
|
|
||||||
@@ -247,9 +252,10 @@ class Server {
|
|||||||
res.json(payload)
|
res.json(payload)
|
||||||
})
|
})
|
||||||
app.get('/ping', (req, res) => {
|
app.get('/ping', (req, res) => {
|
||||||
Logger.info('Recieved ping')
|
Logger.info('Received ping')
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
})
|
})
|
||||||
|
app.get('/healthcheck', (req, res) => res.sendStatus(200))
|
||||||
|
|
||||||
this.server.listen(this.Port, this.Host, () => {
|
this.server.listen(this.Port, this.Host, () => {
|
||||||
Logger.info(`Listening on http://${this.Host}:${this.Port}`)
|
Logger.info(`Listening on http://${this.Host}:${this.Port}`)
|
||||||
@@ -314,7 +320,7 @@ class Server {
|
|||||||
const newRoot = req.body.newRoot
|
const newRoot = req.body.newRoot
|
||||||
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
||||||
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||||
let rootToken = await this.auth.generateAccessToken({ userId: 'root' })
|
let rootToken = await this.auth.generateAccessToken({ userId: 'root', username: newRoot.username })
|
||||||
await this.db.createRootUser(newRoot.username, rootPash, rootToken)
|
await this.db.createRootUser(newRoot.username, rootPash, rootToken)
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
@@ -459,8 +465,6 @@ class Server {
|
|||||||
await this.db.updateEntity('user', user)
|
await this.db.updateEntity('user', user)
|
||||||
|
|
||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
// TODO: this is sent with user auth now, update mobile app to use that then remove this
|
|
||||||
serverSettings: this.db.serverSettings.toJSON(),
|
|
||||||
metadataPath: global.MetadataPath,
|
metadataPath: global.MetadataPath,
|
||||||
configPath: global.ConfigPath,
|
configPath: global.ConfigPath,
|
||||||
user: client.user.toJSONForBrowser(),
|
user: client.user.toJSONForBrowser(),
|
||||||
|
|||||||
@@ -82,31 +82,59 @@ class AuthorController {
|
|||||||
|
|
||||||
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
||||||
|
|
||||||
var hasUpdated = req.author.update(payload)
|
// Check if author name matches another author and merge the authors
|
||||||
|
var existingAuthor = authorNameUpdate ? this.db.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
|
||||||
if (hasUpdated) {
|
if (existingAuthor) {
|
||||||
if (authorNameUpdate) { // Update author name on all books
|
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
||||||
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
|
||||||
itemsWithAuthor.forEach(libraryItem => {
|
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
|
||||||
libraryItem.media.metadata.updateAuthor(req.author)
|
})
|
||||||
})
|
if (itemsWithAuthor.length) {
|
||||||
if (itemsWithAuthor.length) {
|
await this.db.updateLibraryItems(itemsWithAuthor)
|
||||||
await this.db.updateLibraryItems(itemsWithAuthor)
|
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
|
||||||
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.updateEntity('author', req.author)
|
// Remove old author
|
||||||
var numBooks = this.db.libraryItems.filter(li => {
|
await this.db.removeEntity('author', req.author.id)
|
||||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
this.emitter('author_removed', req.author.toJSON())
|
||||||
}).length
|
|
||||||
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
// Send updated num books for merged author
|
||||||
author: req.author.toJSON(),
|
var numBooks = this.db.libraryItems.filter(li => {
|
||||||
updated: hasUpdated
|
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id)
|
||||||
})
|
}).length
|
||||||
|
this.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
author: existingAuthor.toJSON(),
|
||||||
|
merged: true
|
||||||
|
})
|
||||||
|
} else { // Regular author update
|
||||||
|
var hasUpdated = req.author.update(payload)
|
||||||
|
|
||||||
|
if (hasUpdated) {
|
||||||
|
if (authorNameUpdate) { // Update author name on all books
|
||||||
|
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
||||||
|
itemsWithAuthor.forEach(libraryItem => {
|
||||||
|
libraryItem.media.metadata.updateAuthor(req.author)
|
||||||
|
})
|
||||||
|
if (itemsWithAuthor.length) {
|
||||||
|
await this.db.updateLibraryItems(itemsWithAuthor)
|
||||||
|
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.updateEntity('author', req.author)
|
||||||
|
var numBooks = this.db.libraryItems.filter(li => {
|
||||||
|
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||||
|
}).length
|
||||||
|
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
author: req.author.toJSON(),
|
||||||
|
updated: hasUpdated
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(req, res) {
|
async search(req, res) {
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class LibraryController {
|
|||||||
// When collapsing by series and sorting by title use the series name instead of the book title
|
// When collapsing by series and sorting by title use the series name instead of the book title
|
||||||
if (payload.mediaType === 'book' && payload.collapseseries && li.media.metadata.seriesName) {
|
if (payload.mediaType === 'book' && payload.collapseseries && li.media.metadata.seriesName) {
|
||||||
if (sortByTitle) {
|
if (sortByTitle) {
|
||||||
return li.media.metadata.seriesName
|
return this.db.serverSettings.sortingIgnorePrefix ? li.media.metadata.seriesNameIgnorePrefix : li.media.metadata.seriesName
|
||||||
} else {
|
} else {
|
||||||
// When not sorting by title always show the collapsed series at the end
|
// When not sorting by title always show the collapsed series at the end
|
||||||
return direction === 'desc' ? -1 : 'zzzz'
|
return direction === 'desc' ? -1 : 'zzzz'
|
||||||
@@ -273,7 +273,7 @@ class LibraryController {
|
|||||||
|
|
||||||
var series = libraryHelpers.getSeriesFromBooks(libraryItems, payload.minified)
|
var series = libraryHelpers.getSeriesFromBooks(libraryItems, payload.minified)
|
||||||
series = sort(series).asc(s => {
|
series = sort(series).asc(s => {
|
||||||
return s.name
|
return this.db.serverSettings.sortingIgnorePrefix ? s.nameIgnorePrefix : s.name
|
||||||
})
|
})
|
||||||
payload.total = series.length
|
payload.total = series.length
|
||||||
|
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ class MiscController {
|
|||||||
const userResponse = {
|
const userResponse = {
|
||||||
user: req.user,
|
user: req.user,
|
||||||
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
|
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
|
||||||
serverSettings: this.db.serverSettings.toJSON(),
|
serverSettings: this.db.serverSettings.toJSONForBrowser(),
|
||||||
Source: global.Source
|
Source: global.Source
|
||||||
}
|
}
|
||||||
res.json(userResponse)
|
res.json(userResponse)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class UserController {
|
|||||||
account.id = getId('usr')
|
account.id = getId('usr')
|
||||||
account.pash = await this.auth.hashPass(account.password)
|
account.pash = await this.auth.hashPass(account.password)
|
||||||
delete account.password
|
delete account.password
|
||||||
account.token = await this.auth.generateAccessToken({ userId: account.id })
|
account.token = await this.auth.generateAccessToken({ userId: account.id, username })
|
||||||
account.createdAt = Date.now()
|
account.createdAt = Date.now()
|
||||||
var newUser = new User(account)
|
var newUser = new User(account)
|
||||||
var success = await this.db.insertEntity('user', newUser)
|
var success = await this.db.insertEntity('user', newUser)
|
||||||
@@ -74,12 +74,14 @@ class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var account = req.body
|
var account = req.body
|
||||||
|
var shouldUpdateToken = false
|
||||||
|
|
||||||
if (account.username !== undefined && account.username !== user.username) {
|
if (account.username !== undefined && account.username !== user.username) {
|
||||||
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
|
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
|
||||||
if (usernameExists) {
|
if (usernameExists) {
|
||||||
return res.status(500).send('Username already taken')
|
return res.status(500).send('Username already taken')
|
||||||
}
|
}
|
||||||
|
shouldUpdateToken = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updating password
|
// Updating password
|
||||||
@@ -90,6 +92,10 @@ class UserController {
|
|||||||
|
|
||||||
var hasUpdated = user.update(account)
|
var hasUpdated = user.update(account)
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
|
if (shouldUpdateToken) {
|
||||||
|
user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username })
|
||||||
|
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
|
||||||
|
}
|
||||||
await this.db.updateEntity('user', user)
|
await this.db.updateEntity('user', user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,5 +244,16 @@ class PlaybackSessionManager {
|
|||||||
Logger.error(`[PlaybackSessionManager] cleanOrphanStreams failed`, error)
|
Logger.error(`[PlaybackSessionManager] cleanOrphanStreams failed`, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android app v0.9.54 and below had a bug where listening time was sending unix timestamp
|
||||||
|
// See https://github.com/advplyr/audiobookshelf/issues/868
|
||||||
|
// Remove playback sessions with listening time too high
|
||||||
|
async removeInvalidSessions() {
|
||||||
|
const selectFunc = (session) => isNaN(session.timeListening) || Number(session.timeListening) > 3600000000
|
||||||
|
const numSessionsRemoved = await this.db.removeEntities('session', selectFunc)
|
||||||
|
if (numSessionsRemoved) {
|
||||||
|
Logger.info(`[PlaybackSessionManager] Removed ${numSessionsRemoved} invalid playback sessions`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = PlaybackSessionManager
|
module.exports = PlaybackSessionManager
|
||||||
@@ -68,9 +68,9 @@ class RssFeedManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const extname = Path.extname(feedData.coverPath).toLowerCase().slice(1)
|
const extname = Path.extname(feed.coverPath).toLowerCase().slice(1)
|
||||||
res.type(`image/${extname}`)
|
res.type(`image/${extname}`)
|
||||||
var readStream = fs.createReadStream(feedData.coverPath)
|
var readStream = fs.createReadStream(feed.coverPath)
|
||||||
readStream.pipe(res)
|
readStream.pipe(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
const { AudioMimeType } = require('../utils/constants')
|
||||||
|
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||||
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
|
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
|
||||||
const DEFAULT_TIMEOUT = 1000 * 60 * 30 // 30 minutes
|
const DEFAULT_TIMEOUT = 1000 * 60 * 30 // 30 minutes
|
||||||
|
|
||||||
class Download {
|
class Download {
|
||||||
constructor(download) {
|
constructor(download) {
|
||||||
this.id = null
|
this.id = null
|
||||||
@@ -32,16 +35,7 @@ class Download {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get mimeType() {
|
get mimeType() {
|
||||||
if (this.ext === '.mp3' || this.ext === '.m4b' || this.ext === '.m4a') {
|
return getAudioMimeTypeFromExtname(this.ext) || AudioMimeType.MP3
|
||||||
return 'audio/mpeg'
|
|
||||||
} else if (this.ext === '.mp4') {
|
|
||||||
return 'audio/mp4'
|
|
||||||
} else if (this.ext === '.ogg') {
|
|
||||||
return 'audio/ogg'
|
|
||||||
} else if (this.ext === '.aac' || this.ext === '.m4p') {
|
|
||||||
return 'audio/aac'
|
|
||||||
}
|
|
||||||
return 'audio/mpeg'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
const { isNullOrNaN } = require('../../utils/index')
|
|
||||||
const { AudioMimeType } = require('../../utils/constants')
|
const { AudioMimeType } = require('../../utils/constants')
|
||||||
const AudioMetaTags = require('../metadata/AudioMetaTags')
|
const AudioMetaTags = require('../metadata/AudioMetaTags')
|
||||||
const FileMetadata = require('../metadata/FileMetadata')
|
const FileMetadata = require('../metadata/FileMetadata')
|
||||||
@@ -136,23 +135,6 @@ class AudioFile {
|
|||||||
this.embeddedCoverArt = probeData.embeddedCoverArt
|
this.embeddedCoverArt = probeData.embeddedCoverArt
|
||||||
}
|
}
|
||||||
|
|
||||||
validateTrackIndex() {
|
|
||||||
var numFromMeta = isNullOrNaN(this.trackNumFromMeta) ? null : Number(this.trackNumFromMeta)
|
|
||||||
var numFromFilename = isNullOrNaN(this.trackNumFromFilename) ? null : Number(this.trackNumFromFilename)
|
|
||||||
|
|
||||||
if (numFromMeta !== null) return numFromMeta
|
|
||||||
if (numFromFilename !== null) return numFromFilename
|
|
||||||
|
|
||||||
this.invalid = true
|
|
||||||
this.error = 'Failed to get track number'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
setDuplicateTrackNumber(num) {
|
|
||||||
this.invalid = true
|
|
||||||
this.error = 'Duplicate track number "' + num + '"'
|
|
||||||
}
|
|
||||||
|
|
||||||
syncChapters(updatedChapters) {
|
syncChapters(updatedChapters) {
|
||||||
if (this.chapters.length !== updatedChapters.length) {
|
if (this.chapters.length !== updatedChapters.length) {
|
||||||
this.chapters = updatedChapters.map(ch => ({ ...ch }))
|
this.chapters = updatedChapters.map(ch => ({ ...ch }))
|
||||||
|
|||||||
@@ -420,10 +420,10 @@ class Book {
|
|||||||
// If audio file has chapters use chapters
|
// If audio file has chapters use chapters
|
||||||
if (file.chapters && file.chapters.length) {
|
if (file.chapters && file.chapters.length) {
|
||||||
file.chapters.forEach((chapter) => {
|
file.chapters.forEach((chapter) => {
|
||||||
if (chapter.start > this.duration) {
|
if (currStartTime > this.duration) {
|
||||||
Logger.warn(`[Book] Invalid chapter start time > duration`)
|
Logger.warn(`[Book] Invalid chapter start time > duration`)
|
||||||
} else {
|
} else {
|
||||||
var chapterAlreadyExists = this.chapters.find(ch => ch.start === chapter.start)
|
var chapterAlreadyExists = this.chapters.find(ch => ch.start === currStartTime)
|
||||||
if (!chapterAlreadyExists) {
|
if (!chapterAlreadyExists) {
|
||||||
var chapterDuration = chapter.end - chapter.start
|
var chapterDuration = chapter.end - chapter.start
|
||||||
if (chapterDuration > 0) {
|
if (chapterDuration > 0) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const Logger = require('../../Logger')
|
const Logger = require('../../Logger')
|
||||||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix } = require('../../utils/index')
|
||||||
const parseNameString = require('../../utils/parsers/parseNameString')
|
const parseNameString = require('../../utils/parsers/parseNameString')
|
||||||
class BookMetadata {
|
class BookMetadata {
|
||||||
constructor(metadata) {
|
constructor(metadata) {
|
||||||
@@ -109,15 +109,7 @@ class BookMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get titleIgnorePrefix() {
|
get titleIgnorePrefix() {
|
||||||
if (!this.title) return ''
|
return getTitleIgnorePrefix(this.title)
|
||||||
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
|
|
||||||
for (const prefix of prefixesToIgnore) {
|
|
||||||
// e.g. for prefix "the". If title is "The Book Title" return "Book Title, The"
|
|
||||||
if (this.title.toLowerCase().startsWith(`${prefix} `)) {
|
|
||||||
return this.title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.title
|
|
||||||
}
|
}
|
||||||
get authorName() {
|
get authorName() {
|
||||||
if (!this.authors.length) return ''
|
if (!this.authors.length) return ''
|
||||||
@@ -134,6 +126,13 @@ class BookMetadata {
|
|||||||
return `${se.name} #${se.sequence}`
|
return `${se.name} #${se.sequence}`
|
||||||
}).join(', ')
|
}).join(', ')
|
||||||
}
|
}
|
||||||
|
get seriesNameIgnorePrefix() {
|
||||||
|
if (!this.series.length) return ''
|
||||||
|
return this.series.map(se => {
|
||||||
|
if (!se.sequence) return getTitleIgnorePrefix(se.name)
|
||||||
|
return `${getTitleIgnorePrefix(se.name)} #${se.sequence}`
|
||||||
|
}).join(', ')
|
||||||
|
}
|
||||||
get narratorName() {
|
get narratorName() {
|
||||||
return this.narrators.join(', ')
|
return this.narrators.join(', ')
|
||||||
}
|
}
|
||||||
@@ -191,6 +190,14 @@ class BookMetadata {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replaceAuthor(oldAuthor, newAuthor) {
|
||||||
|
this.authors = this.authors.filter(au => au.id !== oldAuthor.id) // Remove old author
|
||||||
|
this.authors.push({
|
||||||
|
id: newAuthor.id,
|
||||||
|
name: newAuthor.name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
setData(scanMediaData = {}) {
|
setData(scanMediaData = {}) {
|
||||||
this.title = scanMediaData.title || null
|
this.title = scanMediaData.title || null
|
||||||
this.subtitle = scanMediaData.subtitle || null
|
this.subtitle = scanMediaData.subtitle || null
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
const { BookCoverAspectRatio, BookshelfView } = require('../../utils/constants')
|
const { BookCoverAspectRatio, BookshelfView } = require('../../utils/constants')
|
||||||
|
const { isNullOrNaN } = require('../../utils')
|
||||||
const Logger = require('../../Logger')
|
const Logger = require('../../Logger')
|
||||||
|
|
||||||
class ServerSettings {
|
class ServerSettings {
|
||||||
constructor(settings) {
|
constructor(settings) {
|
||||||
this.id = 'server-settings'
|
this.id = 'server-settings'
|
||||||
|
this.tokenSecret = null
|
||||||
|
|
||||||
// Scanner
|
// Scanner
|
||||||
this.scannerParseSubtitle = false
|
this.scannerParseSubtitle = false
|
||||||
@@ -14,6 +16,8 @@ class ServerSettings {
|
|||||||
this.scannerPreferMatchedMetadata = false
|
this.scannerPreferMatchedMetadata = false
|
||||||
this.scannerDisableWatcher = false
|
this.scannerDisableWatcher = false
|
||||||
this.scannerPreferOverdriveMediaMarker = false
|
this.scannerPreferOverdriveMediaMarker = false
|
||||||
|
this.scannerUseSingleThreadedProber = true
|
||||||
|
this.scannerMaxThreads = 0 // 0 = defaults to CPUs * 2
|
||||||
|
|
||||||
// Metadata - choose to store inside users library item folder
|
// Metadata - choose to store inside users library item folder
|
||||||
this.storeCoverWithItem = false
|
this.storeCoverWithItem = false
|
||||||
@@ -60,6 +64,7 @@ class ServerSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
construct(settings) {
|
construct(settings) {
|
||||||
|
this.tokenSecret = settings.tokenSecret
|
||||||
this.scannerFindCovers = !!settings.scannerFindCovers
|
this.scannerFindCovers = !!settings.scannerFindCovers
|
||||||
this.scannerCoverProvider = settings.scannerCoverProvider || 'google'
|
this.scannerCoverProvider = settings.scannerCoverProvider || 'google'
|
||||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||||
@@ -68,6 +73,11 @@ class ServerSettings {
|
|||||||
this.scannerPreferMatchedMetadata = !!settings.scannerPreferMatchedMetadata
|
this.scannerPreferMatchedMetadata = !!settings.scannerPreferMatchedMetadata
|
||||||
this.scannerDisableWatcher = !!settings.scannerDisableWatcher
|
this.scannerDisableWatcher = !!settings.scannerDisableWatcher
|
||||||
this.scannerPreferOverdriveMediaMarker = !!settings.scannerPreferOverdriveMediaMarker
|
this.scannerPreferOverdriveMediaMarker = !!settings.scannerPreferOverdriveMediaMarker
|
||||||
|
this.scannerUseSingleThreadedProber = !!settings.scannerUseSingleThreadedProber
|
||||||
|
if (settings.scannerUseSingleThreadedProber === undefined) { // Default to original scanner
|
||||||
|
this.scannerUseSingleThreadedProber = true
|
||||||
|
}
|
||||||
|
this.scannerMaxThreads = isNullOrNaN(settings.scannerMaxThreads) ? 0 : Number(settings.scannerMaxThreads)
|
||||||
|
|
||||||
this.storeCoverWithItem = !!settings.storeCoverWithItem
|
this.storeCoverWithItem = !!settings.storeCoverWithItem
|
||||||
if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was old name of setting < v2
|
if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was old name of setting < v2
|
||||||
@@ -105,9 +115,10 @@ class ServerSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() { // Use toJSONForBrowser if sending to client
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
|
tokenSecret: this.tokenSecret, // Do not return to client
|
||||||
scannerFindCovers: this.scannerFindCovers,
|
scannerFindCovers: this.scannerFindCovers,
|
||||||
scannerCoverProvider: this.scannerCoverProvider,
|
scannerCoverProvider: this.scannerCoverProvider,
|
||||||
scannerParseSubtitle: this.scannerParseSubtitle,
|
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||||
@@ -116,6 +127,8 @@ class ServerSettings {
|
|||||||
scannerPreferMatchedMetadata: this.scannerPreferMatchedMetadata,
|
scannerPreferMatchedMetadata: this.scannerPreferMatchedMetadata,
|
||||||
scannerDisableWatcher: this.scannerDisableWatcher,
|
scannerDisableWatcher: this.scannerDisableWatcher,
|
||||||
scannerPreferOverdriveMediaMarker: this.scannerPreferOverdriveMediaMarker,
|
scannerPreferOverdriveMediaMarker: this.scannerPreferOverdriveMediaMarker,
|
||||||
|
scannerUseSingleThreadedProber: this.scannerUseSingleThreadedProber,
|
||||||
|
scannerMaxThreads: this.scannerMaxThreads,
|
||||||
storeCoverWithItem: this.storeCoverWithItem,
|
storeCoverWithItem: this.storeCoverWithItem,
|
||||||
storeMetadataWithItem: this.storeMetadataWithItem,
|
storeMetadataWithItem: this.storeMetadataWithItem,
|
||||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||||
@@ -138,6 +151,12 @@ class ServerSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toJSONForBrowser() {
|
||||||
|
const json = this.toJSON()
|
||||||
|
delete json.tokenSecret
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
update(payload) {
|
update(payload) {
|
||||||
var hasUpdates = false
|
var hasUpdates = false
|
||||||
for (const key in payload) {
|
for (const key in payload) {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class ApiRouter {
|
|||||||
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
||||||
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
||||||
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
||||||
this.router.post('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
|
this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
|
||||||
this.router.get('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this)) // Root only
|
this.router.get('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this)) // Root only
|
||||||
|
|
||||||
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||||
|
|
||||||
class StaticRouter {
|
class StaticRouter {
|
||||||
constructor(db) {
|
constructor(db) {
|
||||||
@@ -20,7 +20,16 @@ class StaticRouter {
|
|||||||
var fullPath = null
|
var fullPath = null
|
||||||
if (item.isFile) fullPath = item.path
|
if (item.isFile) fullPath = item.path
|
||||||
else fullPath = Path.join(item.path, remainingPath)
|
else fullPath = Path.join(item.path, remainingPath)
|
||||||
res.sendFile(fullPath)
|
|
||||||
|
var opts = {}
|
||||||
|
|
||||||
|
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
|
||||||
|
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(fullPath))
|
||||||
|
if (audioMimeType) {
|
||||||
|
opts = { headers: { 'Content-Type': audioMimeType } }
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendFile(fullPath, opts)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const Path = require('path')
|
|||||||
const AudioFile = require('../objects/files/AudioFile')
|
const AudioFile = require('../objects/files/AudioFile')
|
||||||
const VideoFile = require('../objects/files/VideoFile')
|
const VideoFile = require('../objects/files/VideoFile')
|
||||||
|
|
||||||
|
const MediaProbePool = require('./MediaProbePool')
|
||||||
|
|
||||||
const prober = require('../utils/prober')
|
const prober = require('../utils/prober')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { LogLevel } = require('../utils/constants')
|
const { LogLevel } = require('../utils/constants')
|
||||||
@@ -100,19 +102,38 @@ class MediaFileScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns array of { MediaFile, elapsed, averageScanDuration } from audio file scan objects
|
// Returns array of { MediaFile, elapsed, averageScanDuration } from audio file scan objects
|
||||||
async executeMediaFileScans(mediaType, mediaLibraryFiles, scanData) {
|
async executeMediaFileScans(libraryItem, mediaLibraryFiles, scanData) {
|
||||||
var mediaMetadataFromScan = scanData.media.metadata || null
|
const mediaType = libraryItem.mediaType
|
||||||
var proms = []
|
|
||||||
for (let i = 0; i < mediaLibraryFiles.length; i++) {
|
if (!global.ServerSettings.scannerUseSingleThreadedProber) { // New multi-threaded scanner
|
||||||
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadataFromScan))
|
var scanStart = Date.now()
|
||||||
}
|
const probeResults = await new Promise((resolve) => {
|
||||||
var scanStart = Date.now()
|
// const probePool = new MediaProbePool(mediaType, mediaLibraryFiles, scanData, global.ServerSettings.scannerMaxThreads)
|
||||||
var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))
|
const itemBatch = MediaProbePool.initBatch(libraryItem, mediaLibraryFiles, scanData)
|
||||||
return {
|
itemBatch.on('done', resolve)
|
||||||
audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile),
|
MediaProbePool.runBatch(itemBatch)
|
||||||
videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile),
|
})
|
||||||
elapsed: Date.now() - scanStart,
|
|
||||||
averageScanDuration: this.getAverageScanDurationMs(results)
|
return {
|
||||||
|
audioFiles: probeResults.audioFiles || [],
|
||||||
|
videoFiles: probeResults.videoFiles || [],
|
||||||
|
elapsed: Date.now() - scanStart,
|
||||||
|
averageScanDuration: probeResults.averageTimePerMb
|
||||||
|
}
|
||||||
|
} else { // Old single threaded scanner
|
||||||
|
var scanStart = Date.now()
|
||||||
|
var mediaMetadataFromScan = scanData.media.metadata || null
|
||||||
|
var proms = []
|
||||||
|
for (let i = 0; i < mediaLibraryFiles.length; i++) {
|
||||||
|
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadataFromScan))
|
||||||
|
}
|
||||||
|
var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))
|
||||||
|
return {
|
||||||
|
audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile),
|
||||||
|
videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile),
|
||||||
|
elapsed: Date.now() - scanStart,
|
||||||
|
averageScanDuration: this.getAverageScanDurationMs(results)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +170,6 @@ class MediaFileScanner {
|
|||||||
if (af.discNumFromMeta !== null) discsFromMeta.push(af.discNumFromMeta)
|
if (af.discNumFromMeta !== null) discsFromMeta.push(af.discNumFromMeta)
|
||||||
if (af.trackNumFromFilename !== null) tracksFromFilename.push(af.trackNumFromFilename)
|
if (af.trackNumFromFilename !== null) tracksFromFilename.push(af.trackNumFromFilename)
|
||||||
if (af.trackNumFromMeta !== null) tracksFromMeta.push(af.trackNumFromMeta)
|
if (af.trackNumFromMeta !== null) tracksFromMeta.push(af.trackNumFromMeta)
|
||||||
af.validateTrackIndex() // Sets error if no valid track number
|
|
||||||
})
|
})
|
||||||
discsFromFilename.sort((a, b) => a - b)
|
discsFromFilename.sort((a, b) => a - b)
|
||||||
discsFromMeta.sort((a, b) => a - b)
|
discsFromMeta.sort((a, b) => a - b)
|
||||||
@@ -198,7 +218,8 @@ class MediaFileScanner {
|
|||||||
async scanMediaFiles(mediaLibraryFiles, scanData, libraryItem, preferAudioMetadata, preferOverdriveMediaMarker, libraryScan = null) {
|
async scanMediaFiles(mediaLibraryFiles, scanData, libraryItem, preferAudioMetadata, preferOverdriveMediaMarker, libraryScan = null) {
|
||||||
var hasUpdated = false
|
var hasUpdated = false
|
||||||
|
|
||||||
var mediaScanResult = await this.executeMediaFileScans(libraryItem.mediaType, mediaLibraryFiles, scanData)
|
var mediaScanResult = await this.executeMediaFileScans(libraryItem, mediaLibraryFiles, scanData)
|
||||||
|
|
||||||
if (libraryItem.mediaType === 'video') {
|
if (libraryItem.mediaType === 'video') {
|
||||||
if (mediaScanResult.videoFiles.length) {
|
if (mediaScanResult.videoFiles.length) {
|
||||||
// TODO: Check for updates etc
|
// TODO: Check for updates etc
|
||||||
@@ -207,9 +228,9 @@ class MediaFileScanner {
|
|||||||
}
|
}
|
||||||
} else if (mediaScanResult.audioFiles.length) {
|
} else if (mediaScanResult.audioFiles.length) {
|
||||||
if (libraryScan) {
|
if (libraryScan) {
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Library Item "${scanData.path}" Audio file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms`)
|
libraryScan.addLog(LogLevel.DEBUG, `Library Item "${scanData.path}" Media file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms per MB`)
|
||||||
Logger.debug(`Library Item "${scanData.path}" Audio file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms`)
|
|
||||||
}
|
}
|
||||||
|
Logger.debug(`Library Item "${scanData.path}" Media file scan took ${mediaScanResult.elapsed}ms with ${mediaScanResult.audioFiles.length} audio files averaging ${mediaScanResult.averageScanDuration}ms per MB`)
|
||||||
|
|
||||||
var newAudioFiles = mediaScanResult.audioFiles.filter(af => {
|
var newAudioFiles = mediaScanResult.audioFiles.filter(af => {
|
||||||
return !libraryItem.media.findFileWithInode(af.ino)
|
return !libraryItem.media.findFileWithInode(af.ino)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const AudioFileMetadata = require('../objects/metadata/AudioMetaTags')
|
const AudioFileMetadata = require('../objects/metadata/AudioMetaTags')
|
||||||
|
|
||||||
class MediaProbeData {
|
class MediaProbeData {
|
||||||
constructor() {
|
constructor(probeData) {
|
||||||
this.embeddedCoverArt = null
|
this.embeddedCoverArt = null
|
||||||
this.format = null
|
this.format = null
|
||||||
this.duration = null
|
this.duration = null
|
||||||
@@ -26,6 +26,20 @@ class MediaProbeData {
|
|||||||
|
|
||||||
this.discNumber = null
|
this.discNumber = null
|
||||||
this.discTotal = null
|
this.discTotal = null
|
||||||
|
|
||||||
|
if (probeData) {
|
||||||
|
this.construct(probeData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
construct(probeData) {
|
||||||
|
for (const key in probeData) {
|
||||||
|
if (key === 'audioFileMetadata' && probeData[key]) {
|
||||||
|
this[key] = new AudioFileMetadata(probeData[key])
|
||||||
|
} else if (this[key] !== undefined) {
|
||||||
|
this[key] = probeData[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getEmbeddedCoverArt(videoStream) {
|
getEmbeddedCoverArt(videoStream) {
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
const os = require('os')
|
||||||
|
const Path = require('path')
|
||||||
|
const { EventEmitter } = require('events')
|
||||||
|
const { Worker } = require("worker_threads")
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
const AudioFile = require('../objects/files/AudioFile')
|
||||||
|
const VideoFile = require('../objects/files/VideoFile')
|
||||||
|
const MediaProbeData = require('./MediaProbeData')
|
||||||
|
|
||||||
|
class LibraryItemBatch extends EventEmitter {
|
||||||
|
constructor(libraryItem, libraryFiles, scanData) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.id = libraryItem.id
|
||||||
|
this.mediaType = libraryItem.mediaType
|
||||||
|
this.mediaMetadataFromScan = scanData.media.metadata || null
|
||||||
|
this.libraryFilesToScan = libraryFiles
|
||||||
|
|
||||||
|
// Results
|
||||||
|
this.totalElapsed = 0
|
||||||
|
this.totalProbed = 0
|
||||||
|
this.audioFiles = []
|
||||||
|
this.videoFiles = []
|
||||||
|
}
|
||||||
|
|
||||||
|
done() {
|
||||||
|
this.emit('done', {
|
||||||
|
videoFiles: this.videoFiles,
|
||||||
|
audioFiles: this.audioFiles,
|
||||||
|
averageTimePerMb: Math.round(this.totalElapsed / this.totalProbed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaProbePool {
|
||||||
|
constructor() {
|
||||||
|
this.MaxThreads = 0
|
||||||
|
this.probeWorkerScript = null
|
||||||
|
|
||||||
|
this.itemBatchMap = {}
|
||||||
|
|
||||||
|
this.probesRunning = []
|
||||||
|
this.probeQueue = []
|
||||||
|
}
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
if (this.probesRunning.length < this.MaxThreads) {
|
||||||
|
if (this.probeQueue.length > 0) {
|
||||||
|
const pw = this.probeQueue.shift()
|
||||||
|
// console.log('Unqueued probe - Remaining is', this.probeQueue.length, 'Currently running is', this.probesRunning.length)
|
||||||
|
this.startTask(pw)
|
||||||
|
} else if (!this.probesRunning.length) {
|
||||||
|
// console.log('No more probes to run')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startTask(task) {
|
||||||
|
this.probesRunning.push(task)
|
||||||
|
|
||||||
|
const itemBatch = this.itemBatchMap[task.batchId]
|
||||||
|
|
||||||
|
await task.start().then((taskResult) => {
|
||||||
|
itemBatch.libraryFilesToScan = itemBatch.libraryFilesToScan.filter(lf => lf.ino !== taskResult.libraryFile.ino)
|
||||||
|
|
||||||
|
var fileSizeMb = taskResult.libraryFile.metadata.size / (1024 * 1024)
|
||||||
|
var elapsedPerMb = Math.round(taskResult.elapsed / fileSizeMb)
|
||||||
|
|
||||||
|
const probeData = new MediaProbeData(taskResult.data)
|
||||||
|
|
||||||
|
if (itemBatch.mediaType === 'video') {
|
||||||
|
if (!probeData.videoStream) {
|
||||||
|
Logger.error('[MediaProbePool] Invalid video file no video stream')
|
||||||
|
} else {
|
||||||
|
itemBatch.totalElapsed += elapsedPerMb
|
||||||
|
itemBatch.totalProbed++
|
||||||
|
|
||||||
|
var videoFile = new VideoFile()
|
||||||
|
videoFile.setDataFromProbe(libraryFile, probeData)
|
||||||
|
itemBatch.videoFiles.push(videoFile)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!probeData.audioStream) {
|
||||||
|
Logger.error('[MediaProbePool] Invalid audio file no audio stream')
|
||||||
|
} else {
|
||||||
|
itemBatch.totalElapsed += elapsedPerMb
|
||||||
|
itemBatch.totalProbed++
|
||||||
|
|
||||||
|
var audioFile = new AudioFile()
|
||||||
|
audioFile.trackNumFromMeta = probeData.trackNumber
|
||||||
|
audioFile.discNumFromMeta = probeData.discNumber
|
||||||
|
if (itemBatch.mediaType === 'book') {
|
||||||
|
const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(itemBatch.mediaMetadataFromScan, taskResult.libraryFile)
|
||||||
|
audioFile.trackNumFromFilename = trackNumber
|
||||||
|
audioFile.discNumFromFilename = discNumber
|
||||||
|
}
|
||||||
|
audioFile.setDataFromProbe(taskResult.libraryFile, probeData)
|
||||||
|
|
||||||
|
itemBatch.audioFiles.push(audioFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.probesRunning = this.probesRunning.filter(tq => tq.mediaPath !== task.mediaPath)
|
||||||
|
this.tick()
|
||||||
|
}).catch((error) => {
|
||||||
|
itemBatch.libraryFilesToScan = itemBatch.libraryFilesToScan.filter(lf => lf.ino !== taskResult.libraryFile.ino)
|
||||||
|
|
||||||
|
Logger.error('[MediaProbePool] Task failed', error)
|
||||||
|
this.probesRunning = this.probesRunning.filter(tq => tq.mediaPath !== task.mediaPath)
|
||||||
|
this.tick()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!itemBatch.libraryFilesToScan.length) {
|
||||||
|
itemBatch.done()
|
||||||
|
delete this.itemBatchMap[itemBatch.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTask(libraryFile, batchId) {
|
||||||
|
return {
|
||||||
|
batchId,
|
||||||
|
mediaPath: libraryFile.metadata.path,
|
||||||
|
start: () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
const worker = new Worker(this.probeWorkerScript)
|
||||||
|
worker.on("message", ({ data }) => {
|
||||||
|
if (data.error) {
|
||||||
|
reject(data.error)
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
data,
|
||||||
|
elapsed: Date.now() - startTime,
|
||||||
|
libraryFile
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
worker.postMessage({
|
||||||
|
mediaPath: libraryFile.metadata.path
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initBatch(libraryItem, libraryFiles, scanData) {
|
||||||
|
this.MaxThreads = global.ServerSettings.scannerMaxThreads || (os.cpus().length * 2)
|
||||||
|
this.probeWorkerScript = Path.join(global.appRoot, 'server/utils/probeWorker.js')
|
||||||
|
|
||||||
|
Logger.debug(`[MediaProbePool] Run item batch ${libraryItem.id} with`, libraryFiles.length, 'files and max concurrent of', this.MaxThreads)
|
||||||
|
|
||||||
|
const itemBatch = new LibraryItemBatch(libraryItem, libraryFiles, scanData)
|
||||||
|
this.itemBatchMap[itemBatch.id] = itemBatch
|
||||||
|
|
||||||
|
return itemBatch
|
||||||
|
}
|
||||||
|
|
||||||
|
runBatch(itemBatch) {
|
||||||
|
for (const libraryFile of itemBatch.libraryFilesToScan) {
|
||||||
|
const probeTask = this.buildTask(libraryFile, itemBatch.id)
|
||||||
|
|
||||||
|
if (this.probesRunning.length < this.MaxThreads) {
|
||||||
|
this.startTask(probeTask)
|
||||||
|
} else {
|
||||||
|
this.probeQueue.push(probeTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) {
|
||||||
|
const { title, author, series, publishedYear } = mediaMetadataFromScan
|
||||||
|
const { filename, path } = audioLibraryFile.metadata
|
||||||
|
var partbasename = Path.basename(filename, Path.extname(filename))
|
||||||
|
|
||||||
|
// Remove title, author, series, and publishedYear from filename if there
|
||||||
|
if (title) partbasename = partbasename.replace(title, '')
|
||||||
|
if (author) partbasename = partbasename.replace(author, '')
|
||||||
|
if (series) partbasename = partbasename.replace(series, '')
|
||||||
|
if (publishedYear) partbasename = partbasename.replace(publishedYear)
|
||||||
|
|
||||||
|
// Look for disc number
|
||||||
|
var discNumber = null
|
||||||
|
var discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i)
|
||||||
|
if (discMatch && discMatch.length > 2 && discMatch[2]) {
|
||||||
|
if (!isNaN(discMatch[2])) {
|
||||||
|
discNumber = Number(discMatch[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove disc number from filename
|
||||||
|
partbasename = partbasename.replace(/\b(disc|cd) ?(\d\d?)\b/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
|
||||||
|
var pathdir = Path.dirname(path).split('/').pop()
|
||||||
|
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
|
||||||
|
var discFromFolder = Number(pathdir.replace(/cd/i, ''))
|
||||||
|
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
var numbersinpath = partbasename.match(/\d{1,4}/g)
|
||||||
|
var trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null
|
||||||
|
return {
|
||||||
|
trackNumber,
|
||||||
|
discNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = new MediaProbePool()
|
||||||
+38
-28
@@ -205,20 +205,25 @@ class Scanner {
|
|||||||
checkRes.libraryItem = libraryItem
|
checkRes.libraryItem = libraryItem
|
||||||
checkRes.scanData = dataFound
|
checkRes.scanData = dataFound
|
||||||
|
|
||||||
// If this item will go over max size then push current chunk
|
if (global.ServerSettings.scannerUseSingleThreadedProber) {
|
||||||
if (libraryItem.audioFileTotalSize + itemDataToRescanSize > MaxSizePerChunk && itemDataToRescan.length > 0) {
|
// If this item will go over max size then push current chunk
|
||||||
itemDataToRescanChunks.push(itemDataToRescan)
|
if (libraryItem.audioFileTotalSize + itemDataToRescanSize > MaxSizePerChunk && itemDataToRescan.length > 0) {
|
||||||
itemDataToRescanSize = 0
|
itemDataToRescanChunks.push(itemDataToRescan)
|
||||||
itemDataToRescan = []
|
itemDataToRescanSize = 0
|
||||||
|
itemDataToRescan = []
|
||||||
|
}
|
||||||
|
|
||||||
|
itemDataToRescan.push(checkRes)
|
||||||
|
itemDataToRescanSize += libraryItem.audioFileTotalSize
|
||||||
|
if (itemDataToRescanSize >= MaxSizePerChunk) {
|
||||||
|
itemDataToRescanChunks.push(itemDataToRescan)
|
||||||
|
itemDataToRescanSize = 0
|
||||||
|
itemDataToRescan = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
itemDataToRescan.push(checkRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
itemDataToRescan.push(checkRes)
|
|
||||||
itemDataToRescanSize += libraryItem.audioFileTotalSize
|
|
||||||
if (itemDataToRescanSize >= MaxSizePerChunk) {
|
|
||||||
itemDataToRescanChunks.push(itemDataToRescan)
|
|
||||||
itemDataToRescanSize = 0
|
|
||||||
itemDataToRescan = []
|
|
||||||
}
|
|
||||||
} else if (libraryScan.findCovers && libraryItem.media.shouldSearchForCover) { // Search cover
|
} else if (libraryScan.findCovers && libraryItem.media.shouldSearchForCover) { // Search cover
|
||||||
libraryScan.resultsUpdated++
|
libraryScan.resultsUpdated++
|
||||||
itemsToFindCovers.push(libraryItem)
|
itemsToFindCovers.push(libraryItem)
|
||||||
@@ -240,22 +245,26 @@ class Scanner {
|
|||||||
if (!hasMediaFile) {
|
if (!hasMediaFile) {
|
||||||
libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`)
|
libraryScan.addLog(LogLevel.WARN, `Item found "${libraryItemDataFound.path}" has no media files`)
|
||||||
} else {
|
} else {
|
||||||
var mediaFileSize = 0
|
if (global.ServerSettings.scannerUseSingleThreadedProber) {
|
||||||
dataFound.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video').forEach(lf => mediaFileSize += lf.metadata.size)
|
// If this item will go over max size then push current chunk
|
||||||
|
var mediaFileSize = 0
|
||||||
|
dataFound.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video').forEach(lf => mediaFileSize += lf.metadata.size)
|
||||||
|
if (mediaFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) {
|
||||||
|
newItemDataToScanChunks.push(newItemDataToScan)
|
||||||
|
newItemDataToScanSize = 0
|
||||||
|
newItemDataToScan = []
|
||||||
|
}
|
||||||
|
|
||||||
// If this item will go over max size then push current chunk
|
newItemDataToScan.push(dataFound)
|
||||||
if (mediaFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) {
|
newItemDataToScanSize += mediaFileSize
|
||||||
newItemDataToScanChunks.push(newItemDataToScan)
|
|
||||||
newItemDataToScanSize = 0
|
|
||||||
newItemDataToScan = []
|
|
||||||
}
|
|
||||||
|
|
||||||
newItemDataToScan.push(dataFound)
|
if (newItemDataToScanSize >= MaxSizePerChunk) {
|
||||||
newItemDataToScanSize += mediaFileSize
|
newItemDataToScanChunks.push(newItemDataToScan)
|
||||||
if (newItemDataToScanSize >= MaxSizePerChunk) {
|
newItemDataToScanSize = 0
|
||||||
newItemDataToScanChunks.push(newItemDataToScan)
|
newItemDataToScan = []
|
||||||
newItemDataToScanSize = 0
|
}
|
||||||
newItemDataToScan = []
|
} else { // Chunking is not necessary for new scanner
|
||||||
|
newItemDataToScan.push(dataFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,14 +281,14 @@ class Scanner {
|
|||||||
await this.updateLibraryItemChunk(itemsToUpdate)
|
await this.updateLibraryItemChunk(itemsToUpdate)
|
||||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chunking will be removed when legacy single threaded scanner is removed
|
||||||
for (let i = 0; i < itemDataToRescanChunks.length; i++) {
|
for (let i = 0; i < itemDataToRescanChunks.length; i++) {
|
||||||
await this.rescanLibraryItemDataChunk(itemDataToRescanChunks[i], libraryScan)
|
await this.rescanLibraryItemDataChunk(itemDataToRescanChunks[i], libraryScan)
|
||||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||||
// console.log('Rescan chunk done', i, 'of', itemDataToRescanChunks.length)
|
|
||||||
}
|
}
|
||||||
for (let i = 0; i < newItemDataToScanChunks.length; i++) {
|
for (let i = 0; i < newItemDataToScanChunks.length; i++) {
|
||||||
await this.scanNewLibraryItemDataChunk(newItemDataToScanChunks[i], libraryScan)
|
await this.scanNewLibraryItemDataChunk(newItemDataToScanChunks[i], libraryScan)
|
||||||
// console.log('New scan chunk done', i, 'of', newItemDataToScanChunks.length)
|
|
||||||
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,6 +512,7 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
|
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
|
||||||
var fileUpdateGroup = groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths)
|
var fileUpdateGroup = groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths)
|
||||||
|
|
||||||
if (!Object.keys(fileUpdateGroup).length) {
|
if (!Object.keys(fileUpdateGroup).length) {
|
||||||
Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`)
|
Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`)
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const rra = require('../libs/recursiveReaddirAsync')
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const { AudioMimeType } = require('./constants')
|
||||||
|
|
||||||
async function getFileStat(path) {
|
async function getFileStat(path) {
|
||||||
try {
|
try {
|
||||||
@@ -211,3 +212,11 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
|||||||
|
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns null if extname is not in our defined list of audio extnames
|
||||||
|
module.exports.getAudioMimeTypeFromExtname = (extname) => {
|
||||||
|
if (!extname || !extname.length) return null
|
||||||
|
const formatUpper = extname.slice(1).toUpperCase()
|
||||||
|
if (AudioMimeType[formatUpper]) return AudioMimeType[formatUpper]
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -136,3 +136,15 @@ module.exports.cleanStringForSearch = (str) => {
|
|||||||
// Remove ' . ` " ,
|
// Remove ' . ` " ,
|
||||||
return str.toLowerCase().replace(/[\'\.\`\",]/g, '').trim()
|
return str.toLowerCase().replace(/[\'\.\`\",]/g, '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports.getTitleIgnorePrefix = (title) => {
|
||||||
|
if (!title) return ''
|
||||||
|
var prefixesToIgnore = global.ServerSettings.sortingPrefixes || []
|
||||||
|
for (const prefix of prefixesToIgnore) {
|
||||||
|
// e.g. for prefix "the". If title is "The Book" return "Book, The"
|
||||||
|
if (title.toLowerCase().startsWith(`${prefix} `)) {
|
||||||
|
return title.substr(prefix.length + 1) + `, ${prefix.substr(0, 1).toUpperCase() + prefix.substr(1)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const { sort, createNewSortInstance } = require('../libs/fastSort')
|
const { sort, createNewSortInstance } = require('../libs/fastSort')
|
||||||
|
const { getTitleIgnorePrefix } = require('../utils/index')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const naturalSort = createNewSortInstance({
|
const naturalSort = createNewSortInstance({
|
||||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||||
@@ -123,6 +124,7 @@ module.exports = {
|
|||||||
_series[series.id] = {
|
_series[series.id] = {
|
||||||
id: series.id,
|
id: series.id,
|
||||||
name: series.name,
|
name: series.name,
|
||||||
|
nameIgnorePrefix: getTitleIgnorePrefix(series.name),
|
||||||
type: 'series',
|
type: 'series',
|
||||||
books: [abJson]
|
books: [abJson]
|
||||||
}
|
}
|
||||||
@@ -228,6 +230,7 @@ module.exports = {
|
|||||||
libraryItemJson.collapsedSeries = {
|
libraryItemJson.collapsedSeries = {
|
||||||
id: seriesToUse[li.id].id,
|
id: seriesToUse[li.id].id,
|
||||||
name: seriesToUse[li.id].name,
|
name: seriesToUse[li.id].name,
|
||||||
|
nameIgnorePrefix: seriesToUse[li.id].nameIgnorePrefix,
|
||||||
numBooks: seriesToUse[li.id].books.length
|
numBooks: seriesToUse[li.id].books.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const { parentPort } = require("worker_threads")
|
||||||
|
const prober = require('./prober')
|
||||||
|
|
||||||
|
parentPort.on("message", async ({ mediaPath }) => {
|
||||||
|
const results = await prober.probe(mediaPath)
|
||||||
|
parentPort.postMessage({
|
||||||
|
data: results,
|
||||||
|
})
|
||||||
|
})
|
||||||
+10
-5
@@ -220,19 +220,18 @@ function getDefaultAudioStream(audioStreams) {
|
|||||||
function parseProbeData(data, verbose = false) {
|
function parseProbeData(data, verbose = false) {
|
||||||
try {
|
try {
|
||||||
var { format, streams, chapters } = data
|
var { format, streams, chapters } = data
|
||||||
var { format_long_name, duration, size, bit_rate } = format
|
|
||||||
|
|
||||||
var sizeBytes = !isNaN(size) ? Number(size) : null
|
var sizeBytes = !isNaN(format.size) ? Number(format.size) : null
|
||||||
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
|
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
|
||||||
|
|
||||||
// Logger.debug('Parsing Data for', Path.basename(format.filename))
|
// Logger.debug('Parsing Data for', Path.basename(format.filename))
|
||||||
var tags = parseTags(format, verbose)
|
var tags = parseTags(format, verbose)
|
||||||
var cleanedData = {
|
var cleanedData = {
|
||||||
format: format_long_name,
|
format: format.format_long_name || format.name || 'Unknown',
|
||||||
duration: !isNaN(duration) ? Number(duration) : null,
|
duration: !isNaN(format.duration) ? Number(format.duration) : null,
|
||||||
size: sizeBytes,
|
size: sizeBytes,
|
||||||
sizeMb,
|
sizeMb,
|
||||||
bit_rate: !isNaN(bit_rate) ? Number(bit_rate) : null,
|
bit_rate: !isNaN(format.bit_rate) ? Number(format.bit_rate) : null,
|
||||||
...tags
|
...tags
|
||||||
}
|
}
|
||||||
if (verbose && format.tags) {
|
if (verbose && format.tags) {
|
||||||
@@ -278,6 +277,12 @@ function probe(filepath, verbose = false) {
|
|||||||
|
|
||||||
return ffprobe(filepath)
|
return ffprobe(filepath)
|
||||||
.then(raw => {
|
.then(raw => {
|
||||||
|
if (raw.error) {
|
||||||
|
return {
|
||||||
|
error: raw.error.string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var rawProbeData = parseProbeData(raw, verbose)
|
var rawProbeData = parseProbeData(raw, verbose)
|
||||||
if (!rawProbeData || (!rawProbeData.audio_stream && !rawProbeData.video_stream)) {
|
if (!rawProbeData || (!rawProbeData.audio_stream && !rawProbeData.video_stream)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+23
-1
@@ -20,11 +20,22 @@ function isMediaFile(mediaType, ext) {
|
|||||||
// Output: map of files grouped into potential item dirs
|
// Output: map of files grouped into potential item dirs
|
||||||
function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
||||||
// Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir
|
// Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir
|
||||||
|
var nonMediaFilePaths = []
|
||||||
var pathsFiltered = paths.map(path => {
|
var pathsFiltered = paths.map(path => {
|
||||||
return path.startsWith('/') ? path.slice(1) : path
|
return path.startsWith('/') ? path.slice(1) : path
|
||||||
}).filter(path => {
|
}).filter(path => {
|
||||||
let parsedPath = Path.parse(path)
|
let parsedPath = Path.parse(path)
|
||||||
return parsedPath.dir || (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext))
|
// Is not in root dir OR is a book media file
|
||||||
|
if (parsedPath.dir) {
|
||||||
|
if (!isMediaFile(mediaType, parsedPath.ext)) { // Seperate out non-media files
|
||||||
|
nonMediaFilePaths.push(path)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext)) { // (book media type supports single file audiobooks/ebooks in root dir)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 2: Sort by least number of directories
|
// Step 2: Sort by least number of directories
|
||||||
@@ -64,6 +75,17 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Step 4: Add in non-media files if they fit into item group
|
||||||
|
if (nonMediaFilePaths.length) {
|
||||||
|
for (const nonMediaFilePath of nonMediaFilePaths) {
|
||||||
|
const pathDir = Path.dirname(nonMediaFilePath)
|
||||||
|
if (itemGroup[pathDir]) {
|
||||||
|
itemGroup[pathDir].push(nonMediaFilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return itemGroup
|
return itemGroup
|
||||||
}
|
}
|
||||||
module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
|
module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
|
||||||
|
|||||||
Reference in New Issue
Block a user