mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-03 04:58:46 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dd8dc6dd4 | ||
|
|
8d9d5a8d1b | ||
|
|
0db34dcab5 | ||
|
|
47c6c1aaad | ||
|
|
d6cab8e591 | ||
|
|
dc18eb408e | ||
|
|
6f891208d0 | ||
|
|
643040e635 | ||
|
|
4c07f9ec25 | ||
|
|
0ba38d45bc | ||
|
|
0da327222e | ||
|
|
868e1af28a | ||
|
|
a343a1038c | ||
|
|
3e5338ec8e | ||
|
|
01fdca4bf9 | ||
|
|
ed96dd7c81 | ||
|
|
1ead5de9f5 |
@@ -11,7 +11,6 @@ RUN npm run generate
|
||||
### STAGE 2: Build server ###
|
||||
FROM node:12-alpine
|
||||
ENV NODE_ENV=production
|
||||
ENV LOG_LEVEL=INFO
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY --from=ffmpeg / /
|
||||
COPY index.js index.js
|
||||
|
||||
@@ -9,20 +9,38 @@
|
||||
height: calc(100% - 64px - 165px);
|
||||
max-height: calc(100% - 64px - 165px);
|
||||
}
|
||||
#bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
/* width */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
/* ::-webkit-scrollbar:horizontal { */
|
||||
/* height: 16px; */
|
||||
/* height: 24px;
|
||||
} */
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: rgba(0,0,0,0);
|
||||
}
|
||||
/* ::-webkit-scrollbar-track:horizontal { */
|
||||
/* background: rgb(149, 119, 90); */
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
} */
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #855620;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* ::-webkit-scrollbar-thumb:horizontal { */
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
/* box-shadow: 2px 14px 8px #111111aa;
|
||||
border-radius: 4px;
|
||||
} */
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #704922;
|
||||
|
||||
@@ -440,7 +440,7 @@ export default {
|
||||
})
|
||||
|
||||
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||
console.error('[HLS] Error', data.type, data.details)
|
||||
console.error('[HLS] Error', data.type, data.details, data)
|
||||
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||
console.error('[HLS] BUFFER STALLED ERROR')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-16 bg-primary relative">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-30">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-40">
|
||||
<div class="flex h-full items-center">
|
||||
<img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" />
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
|
||||
@@ -37,7 +37,9 @@
|
||||
|
||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
||||
<ui-btn small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn>
|
||||
<ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll"
|
||||
>{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ audiobooksShowing.length }})</span></ui-btn
|
||||
>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -62,8 +64,11 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isHome() {
|
||||
return this.$route.name === 'index'
|
||||
},
|
||||
showBack() {
|
||||
return this.$route.name !== 'library-id'
|
||||
return this.$route.name !== 'library-id' && !this.isHome
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
@@ -71,6 +76,7 @@ export default {
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
},
|
||||
@@ -87,7 +93,11 @@ export default {
|
||||
return this.$store.state.user.user.audiobooks || {}
|
||||
},
|
||||
audiobooksShowing() {
|
||||
return this.$store.getters['audiobooks/getFiltered']()
|
||||
// return this.$store.getters['audiobooks/getFiltered']()
|
||||
return this.$store.getters['audiobooks/getEntitiesShowing']()
|
||||
},
|
||||
selectedSeries() {
|
||||
return this.$store.state.audiobooks.selectedSeries
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
@@ -110,12 +120,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
back() {
|
||||
if (this.$route.name === 'audiobook-id-edit') {
|
||||
this.$router.push(`/audiobook/${this.$route.params.id}`)
|
||||
} else {
|
||||
this.$router.push('/library')
|
||||
}
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
var backTo = popped || '/'
|
||||
this.$router.push(backTo)
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<template v-for="entity in shelf">
|
||||
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
|
||||
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
|
||||
<cards-book-card v-else :key="entity.id" :show-volume-number="selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
|
||||
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
@@ -68,11 +68,13 @@ export default {
|
||||
},
|
||||
selectedSeries() {
|
||||
this.$nextTick(() => {
|
||||
this.$store.commit('audiobooks/setSelectedSeries', this.selectedSeries)
|
||||
this.setBookshelfEntities()
|
||||
})
|
||||
},
|
||||
searchResults() {
|
||||
this.$nextTick(() => {
|
||||
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
|
||||
this.setBookshelfEntities()
|
||||
})
|
||||
}
|
||||
@@ -100,7 +102,8 @@ export default {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookWidth() {
|
||||
return this.bookCoverWidth + this.paddingX * 2
|
||||
var _width = this.bookCoverWidth + this.paddingX * 2
|
||||
return this.showGroups ? _width * 1.6 : _width
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected']
|
||||
@@ -130,9 +133,6 @@ export default {
|
||||
clickGroup(group) {
|
||||
this.$emit('update:selectedSeries', group.name)
|
||||
},
|
||||
changeRotation() {
|
||||
this.rotation = 'show-right'
|
||||
},
|
||||
clearFilter() {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', null)
|
||||
if (this.filterBy !== 'all') {
|
||||
@@ -162,6 +162,7 @@ export default {
|
||||
setBookshelfEntities() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper.clientWidth
|
||||
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
|
||||
|
||||
var booksPerRow = Math.floor(width / this.bookWidth)
|
||||
|
||||
var entities = this.entities
|
||||
|
||||
162
client/components/app/BookShelfCategorized.vue
Normal file
162
client/components/app/BookShelfCategorized.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<div class="fixed bottom-2 right-4 z-40">
|
||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
|
||||
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
||||
selectedSizeIndex: 3,
|
||||
rowPaddingX: 40,
|
||||
keywordFilterTimeout: null,
|
||||
scannerParseSubtitle: false,
|
||||
wrapperClientWidth: 0,
|
||||
overflowingShelvesRight: {},
|
||||
overflowingShelvesLeft: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return this.availableSizes[this.selectedSizeIndex]
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
},
|
||||
signSizeMultiplier() {
|
||||
return (1 - this.sizeMultiplier) / 2 + this.sizeMultiplier
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookWidth() {
|
||||
return this.bookCoverWidth + this.paddingX * 2
|
||||
},
|
||||
mostRecentPlayed() {
|
||||
var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].lastUpdate > 0 && this.userAudiobooks[ab.id].progress > 0 && !this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab }))
|
||||
audiobooks.sort((a, b) => {
|
||||
return this.userAudiobooks[b.id].lastUpdate - this.userAudiobooks[a.id].lastUpdate
|
||||
})
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
mostRecentAdded() {
|
||||
var audiobooks = this.audiobooks.map((ab) => ({ ...ab })).sort((a, b) => b.addedAt - a.addedAt)
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
seriesGroups() {
|
||||
return this.$store.getters['audiobooks/getSeriesGroups']()
|
||||
},
|
||||
recentlyUpdatedSeries() {
|
||||
var mostRecentTime = 0
|
||||
var mostRecentSeries = null
|
||||
this.seriesGroups.forEach((series) => {
|
||||
if ((series.books.length && mostRecentSeries === null) || series.lastUpdate > mostRecentTime) {
|
||||
mostRecentTime = series.lastUpdate
|
||||
mostRecentSeries = series
|
||||
}
|
||||
})
|
||||
if (!mostRecentSeries) return null
|
||||
return mostRecentSeries.books
|
||||
},
|
||||
booksRecentlyRead() {
|
||||
var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab }))
|
||||
audiobooks.sort((a, b) => {
|
||||
return this.userAudiobooks[b.id].finishedAt - this.userAudiobooks[a.id].finishedAt
|
||||
})
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
shelves() {
|
||||
var shelves = []
|
||||
if (this.mostRecentPlayed.length) {
|
||||
shelves.push({ books: this.mostRecentPlayed, label: 'Continue Reading' })
|
||||
}
|
||||
|
||||
shelves.push({ books: this.mostRecentAdded, label: 'Recently Added' })
|
||||
|
||||
if (this.recentlyUpdatedSeries) {
|
||||
shelves.push({ books: this.recentlyUpdatedSeries, label: 'Newest Series' })
|
||||
}
|
||||
|
||||
if (this.booksRecentlyRead.length) {
|
||||
shelves.push({ books: this.booksRecentlyRead, label: 'Read Again' })
|
||||
}
|
||||
return shelves
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
increaseSize() {
|
||||
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
decreaseSize() {
|
||||
this.selectedSizeIndex = Math.max(0, this.selectedSizeIndex - 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
async init() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||
|
||||
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
|
||||
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
|
||||
|
||||
await this.$store.dispatch('audiobooks/load')
|
||||
},
|
||||
resize() {},
|
||||
audiobooksUpdated() {},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
||||
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
||||
if (index >= 0) {
|
||||
this.selectedSizeIndex = index
|
||||
this.resize()
|
||||
}
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/removeListener', 'bookshelf')
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
139
client/components/app/BookShelfRow.vue
Normal file
139
client/components/app/BookShelfRow.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="shelf" class="w-full max-w-full bookshelfRowCategorized relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: 2.5 * sizeMultiplier + 'rem' }" @scroll="scrolled">
|
||||
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
|
||||
<div class="flex items-center -mb-2">
|
||||
<template v-for="entity in shelf.books">
|
||||
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-8 w-36 rounded-md" style="height: 22px">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bookshelfDividerCategorized h-6 w-full absolute bottom-0 left-0 right-0 z-20"></div>
|
||||
|
||||
<div v-show="canScrollLeft && !isScrolling" class="absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left flex items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollLeft">
|
||||
<span class="material-icons text-8xl text-white">chevron_left</span>
|
||||
</div>
|
||||
<div v-show="canScrollRight && !isScrolling" class="absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right flex items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||
<span class="material-icons text-8xl text-white">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
shelf: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
sizeMultiplier: Number,
|
||||
bookCoverWidth: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canScrollRight: false,
|
||||
canScrollLeft: false,
|
||||
isScrolling: false,
|
||||
scrollTimer: null,
|
||||
updateTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
this.scrollTimer = setTimeout(() => {
|
||||
this.isScrolling = false
|
||||
this.$nextTick(this.checkCanScroll)
|
||||
}, 50)
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
this.isScrolling = true
|
||||
this.$refs.shelf.scrollLeft = 0
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
this.isScrolling = true
|
||||
this.$refs.shelf.scrollLeft = 999
|
||||
},
|
||||
updatedBookCard() {
|
||||
clearTimeout(this.updateTimer)
|
||||
this.updateTimer = setTimeout(() => {
|
||||
this.$nextTick(this.checkCanScroll)
|
||||
}, 100)
|
||||
},
|
||||
checkCanScroll() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
var clientWidth = this.$refs.shelf.clientWidth
|
||||
var scrollWidth = this.$refs.shelf.scrollWidth
|
||||
var scrollLeft = this.$refs.shelf.scrollLeft
|
||||
if (scrollWidth > clientWidth) {
|
||||
this.canScrollRight = scrollLeft === 0
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
} else {
|
||||
this.canScrollRight = false
|
||||
this.canScrollLeft = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bookshelfRowCategorized {
|
||||
scroll-behavior: smooth;
|
||||
width: calc(100vw - 80px);
|
||||
background-image: url(/wood_panels.jpg);
|
||||
}
|
||||
.bookshelfDividerCategorized {
|
||||
background: rgb(149, 119, 90);
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
||||
/* background: linear-gradient(180deg, rgb(114, 85, 59) 0%, rgb(73, 48, 22) 17%, rgb(71, 43, 15) 88%, rgb(61, 41, 20) 100%); */
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
}
|
||||
.categoryPlacard {
|
||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.shinyBlack {
|
||||
background-color: #2d3436;
|
||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||
border-color: rgba(255, 244, 182, 0.6);
|
||||
border-style: solid;
|
||||
color: #fce3a6;
|
||||
}
|
||||
.book-shelf-arrow-right {
|
||||
height: calc(100% - 24px);
|
||||
background: rgb(48, 48, 48);
|
||||
background: linear-gradient(90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
.book-shelf-arrow-left {
|
||||
height: calc(100% - 24px);
|
||||
background: rgb(48, 48, 48);
|
||||
background: linear-gradient(-90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-10 relative">
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
|
||||
<template v-if="page !== 'search'">
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
|
||||
<template v-if="page !== 'search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="flex items-center">
|
||||
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
@@ -18,7 +18,7 @@
|
||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
|
||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-else-if="!isHome">
|
||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">west</span>
|
||||
</div>
|
||||
@@ -35,6 +35,7 @@
|
||||
export default {
|
||||
props: {
|
||||
page: String,
|
||||
isHome: Boolean,
|
||||
selectedSeries: String,
|
||||
searchResults: {
|
||||
type: Array,
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<template>
|
||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-20" style="min-width: 80px">
|
||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
|
||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
<nuxt-link to="/library" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<nuxt-link to="/" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Home</p>
|
||||
|
||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/library" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' && !homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
|
||||
|
||||
<div v-show="paramId === ''" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/library/series" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
@@ -64,6 +74,9 @@ export default {
|
||||
},
|
||||
selectedClassName() {
|
||||
return ''
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'index'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
<div class="absolute -bottom-4 left-0 triangle-right" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @click.stop>
|
||||
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
|
||||
|
||||
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
|
||||
@@ -29,10 +29,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="volumeNumber && showVolumeNumber && !isHovering" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="true && hasEbook" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p>
|
||||
</div> -->
|
||||
|
||||
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
|
||||
@@ -78,8 +82,12 @@ export default {
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
hasEbook() {
|
||||
return this.audiobook.numEbooks
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected']
|
||||
// return this.$store.getters['getNumAudiobooksSelected']
|
||||
return !!this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
@@ -199,7 +207,6 @@ export default {
|
||||
this.selectBtnClick()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -162,7 +162,11 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Oops, something went wrong...')
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error('Oops, something went wrong...')
|
||||
}
|
||||
this.processingUpload = false
|
||||
})
|
||||
},
|
||||
@@ -204,20 +208,39 @@ export default {
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: {
|
||||
cover: cover
|
||||
var success = false
|
||||
|
||||
// Download cover from url and use
|
||||
if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||
success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
|
||||
console.error('Failed to download cover from url', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
// Update local cover url
|
||||
const updatePayload = {
|
||||
book: {
|
||||
cover: cover
|
||||
}
|
||||
}
|
||||
success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
if (success) {
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
} else {
|
||||
this.imageUrl = this.book.cover || ''
|
||||
}
|
||||
this.isProcessing = false
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
|
||||
|
||||
@@ -55,6 +55,15 @@
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||
<div class="flex px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
|
||||
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="ml-4">
|
||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="ml-4">
|
||||
<ui-btn v-if="isRootUser" :loading="rescanning" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
@@ -87,7 +96,9 @@ export default {
|
||||
},
|
||||
newTags: [],
|
||||
resettingProgress: false,
|
||||
isScrollable: false
|
||||
isScrollable: false,
|
||||
savingMetadata: false,
|
||||
rescanning: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -107,6 +118,9 @@ export default {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
@@ -127,6 +141,41 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
audiobookScanComplete(result) {
|
||||
this.rescanning = false
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete audiobook was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete audiobook was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete audiobook was removed`)
|
||||
}
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
|
||||
this.$root.socket.emit('scan_audiobook', this.audiobookId)
|
||||
},
|
||||
saveMetadataComplete(result) {
|
||||
this.savingMetadata = false
|
||||
if (result.error) {
|
||||
this.$toast.error(result.error)
|
||||
} else if (result.audiobookId) {
|
||||
var { savedPath } = result
|
||||
if (!savedPath) {
|
||||
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
|
||||
} else {
|
||||
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
|
||||
}
|
||||
}
|
||||
},
|
||||
saveMetadata() {
|
||||
this.savingMetadata = true
|
||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||
this.$root.socket.emit('save_metadata', this.audiobookId)
|
||||
},
|
||||
async submitForm() {
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
|
||||
72
client/components/ui/Dropdown.vue
Normal file
72
client/components/ui/Dropdown.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="relative w-44" v-click-outside="clickOutside">
|
||||
<p class="text-sm text-opacity-75 mb-1">{{ label }}</p>
|
||||
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate">{{ selectedText }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons text-gray-100">chevron_down</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectedItem() {
|
||||
return this.items.find((i) => i.value === this.selected)
|
||||
},
|
||||
selectedText() {
|
||||
return this.selectedItem ? this.selectedItem.text : ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
clickedOption(itemValue) {
|
||||
this.selected = itemValue
|
||||
this.showMenu = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
inputAccept: 'image/*'
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp'
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
|
||||
@@ -30,7 +30,7 @@ export default {
|
||||
}
|
||||
},
|
||||
className() {
|
||||
if (this.disabled) return 'bg-bg cursor-not-allowed'
|
||||
if (this.disabled) return this.toggleValue ? `bg-${this.onColor} cursor-not-allowed` : `bg-${this.offColor} cursor-not-allowed`
|
||||
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
|
||||
},
|
||||
switchClassName() {
|
||||
|
||||
@@ -190,6 +190,9 @@ export default {
|
||||
download.status = this.$constants.DownloadStatus.EXPIRED
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
logEvtReceived(payload) {
|
||||
this.$store.commit('logs/logEvt', payload)
|
||||
},
|
||||
initializeSocket() {
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
@@ -237,6 +240,8 @@ export default {
|
||||
this.socket.on('download_failed', this.downloadFailed)
|
||||
this.socket.on('download_killed', this.downloadKilled)
|
||||
this.socket.on('download_expired', this.downloadExpired)
|
||||
|
||||
this.socket.on('log', this.logEvtReceived)
|
||||
},
|
||||
showUpdateToast(versionData) {
|
||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default function ({ store, redirect, route, app }) {
|
||||
// If the user is not authenticated
|
||||
if (!store.state.user.user) {
|
||||
if (route.name === 'batch') return redirect('/login')
|
||||
if (route.name === 'batch' || route.name === 'index') return redirect('/login')
|
||||
return redirect(`/login?redirect=${route.fullPath}`)
|
||||
}
|
||||
}
|
||||
24
client/middleware/routed.js
Normal file
24
client/middleware/routed.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export default function (context) {
|
||||
if (process.client) {
|
||||
var route = context.route
|
||||
var from = context.from
|
||||
var store = context.store
|
||||
|
||||
if (route.name === 'login' || from.name === 'login') return
|
||||
|
||||
if (!route.name) {
|
||||
console.warn('No Route name', route)
|
||||
return
|
||||
}
|
||||
|
||||
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
|
||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && from.name !== 'config' && from.name !== 'config-log' && from.name !== 'upload' && from.name !== 'account') {
|
||||
var _history = [...store.state.routeHistory]
|
||||
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
||||
_history.push(from.fullPath)
|
||||
store.commit('setRouteHistory', _history)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,10 @@ module.exports = {
|
||||
]
|
||||
},
|
||||
|
||||
router: {
|
||||
middleware: ['routed']
|
||||
},
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: [
|
||||
'@/assets/app.css'
|
||||
|
||||
2
client/package-lock.json
generated
2
client/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.1.13",
|
||||
"version": "1.2.4",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.4",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
Missing
|
||||
</ui-btn>
|
||||
|
||||
<!-- <ui-btn v-if="ebooks.length" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
|
||||
Read
|
||||
</ui-btn> -->
|
||||
|
||||
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
|
||||
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
||||
</ui-tooltip>
|
||||
@@ -86,6 +91,8 @@
|
||||
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="area"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -223,6 +230,9 @@ export default {
|
||||
audioFiles() {
|
||||
return this.audiobook.audioFiles || []
|
||||
},
|
||||
ebooks() {
|
||||
return this.audiobook.ebooks
|
||||
},
|
||||
description() {
|
||||
return this.book.description || ''
|
||||
},
|
||||
@@ -261,6 +271,18 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openEbook() {
|
||||
var ebook = this.ebooks[0]
|
||||
console.log('Ebook', ebook)
|
||||
this.$axios
|
||||
.$get(`/ebook/open/${this.audiobookId}/${ebook.ino}`)
|
||||
.then(() => {
|
||||
console.log('opened')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed', error)
|
||||
})
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
isRead: !this.isRead
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-2xl">Users</h1>
|
||||
@@ -34,15 +34,17 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
<div class="py-4 mb-8">
|
||||
|
||||
<div class="py-4 mb-4">
|
||||
<p class="text-2xl">Scanner</p>
|
||||
<div class="flex items-start py-2">
|
||||
<div class="py-2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" @input="updateScannerParseSubtitle" />
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
||||
<ui-tooltip :text="parseSubtitleTooltip">
|
||||
<p class="pl-4 text-lg">Parse Subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<p class="pl-4 text-lg">Parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,8 +52,8 @@
|
||||
<div class="w-40 flex flex-col">
|
||||
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
|
||||
|
||||
<div class="w-full">
|
||||
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
|
||||
<div class="w-full mb-4">
|
||||
<ui-tooltip direction="bottom" text="(Warning: Long running task!) Attempts to lookup and match a cover with all audiobooks that don't have one." class="w-full">
|
||||
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -59,10 +61,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-4 mb-4">
|
||||
<p class="text-2xl">Metadata</p>
|
||||
<div class="flex items-start py-2">
|
||||
<div class="py-2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
|
||||
<ui-tooltip :text="coverDestinationTooltip">
|
||||
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="w-40 flex flex-col">
|
||||
<ui-tooltip :text="saveMetadataTooltip" direction="bottom" class="w-full">
|
||||
<ui-btn color="primary" small class="w-full" @click="saveMetadataFiles">Save Metadata</ui-btn>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn to="/config/log">View Logger</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
@@ -95,18 +119,21 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
storeCoversInAudiobookDir: false,
|
||||
isResettingAudiobooks: false,
|
||||
users: [],
|
||||
selectedAccount: null,
|
||||
showAccountModal: false,
|
||||
isDeletingUser: false,
|
||||
newServerSettings: {}
|
||||
newServerSettings: {},
|
||||
updatingServerSettings: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serverSettings(newVal, oldVal) {
|
||||
if (newVal && !oldVal) {
|
||||
this.newServerSettings = { ...this.serverSettings }
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -114,6 +141,12 @@ export default {
|
||||
parseSubtitleTooltip() {
|
||||
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
|
||||
},
|
||||
coverDestinationTooltip() {
|
||||
return 'By default covers are stored in /metadata/books, enabling this setting will store covers inside your audiobooks directory. Only one file named "cover" will be kept.'
|
||||
},
|
||||
saveMetadataTooltip() {
|
||||
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
@@ -128,17 +161,29 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateCoverStorageDestination(val) {
|
||||
this.newServerSettings.coverDestination = val ? this.$constants.CoverDestination.AUDIOBOOK : this.$constants.CoverDestination.METADATA
|
||||
this.updateServerSettings({
|
||||
coverDestination: this.newServerSettings.coverDestination
|
||||
})
|
||||
},
|
||||
updateScannerParseSubtitle(val) {
|
||||
var payload = {
|
||||
scannerParseSubtitle: val
|
||||
}
|
||||
this.updateServerSettings(payload)
|
||||
},
|
||||
updateServerSettings(payload) {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
},
|
||||
setDeveloperMode() {
|
||||
@@ -152,6 +197,16 @@ export default {
|
||||
scanCovers() {
|
||||
this.$root.socket.emit('scan_covers')
|
||||
},
|
||||
saveMetadataComplete(result) {
|
||||
this.savingMetadata = false
|
||||
if (!result) return
|
||||
this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
|
||||
},
|
||||
saveMetadataFiles() {
|
||||
this.savingMetadata = true
|
||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||
this.$root.socket.emit('save_metadata')
|
||||
},
|
||||
loadUsers() {
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
@@ -170,11 +225,12 @@ export default {
|
||||
.then(() => {
|
||||
this.isResettingAudiobooks = false
|
||||
this.$toast.success('Successfully reset audiobooks')
|
||||
location.reload()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed to reset audiobooks', error)
|
||||
this.isResettingAudiobooks = false
|
||||
this.$toast.error('Failed to reset audiobooks - stop docker and manually remove appdata')
|
||||
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -234,6 +290,7 @@ export default {
|
||||
this.$root.socket.on('user_removed', this.userRemoved)
|
||||
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
136
client/pages/config/log.vue
Normal file
136
client/pages/config/log.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p class="text-2xl">Logger</p>
|
||||
|
||||
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" />
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px">
|
||||
<template v-for="(log, index) in logs">
|
||||
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
||||
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p>
|
||||
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
||||
<p class="px-4 logmessage">{{ log.message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="!logs.length" class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center text-center">
|
||||
<p class="text-xl text-gray-200 mb-2">No Logs</p>
|
||||
<p class="text-base text-gray-400">Log listening starts when you login</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsRoot']) {
|
||||
redirect('/?error=unauthorized')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newServerSettings: {},
|
||||
logColors: ['yellow-200', 'gray-400', 'info', 'warning', 'error', 'red-800', 'blue-400'],
|
||||
logLevels: [
|
||||
{
|
||||
value: 1,
|
||||
text: 'Debug'
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
text: 'Info'
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
text: 'Warn'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serverSettings(newVal, oldVal) {
|
||||
if (newVal && !oldVal) {
|
||||
this.newServerSettings = { ...this.serverSettings }
|
||||
}
|
||||
},
|
||||
logs() {
|
||||
this.updateScroll()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
logLevelItems() {
|
||||
if (process.env.NODE_ENV === 'production') return this.logLevels
|
||||
this.logLevels.unshift({ text: 'Trace', value: 0 })
|
||||
return this.logLevels
|
||||
},
|
||||
logs() {
|
||||
return this.$store.state.logs.logs.filter((log) => {
|
||||
return log.level >= this.newServerSettings.logLevel
|
||||
})
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateScroll() {
|
||||
if (this.$refs.container) {
|
||||
this.$refs.container.scrollTop = this.$refs.container.scrollHeight - this.$refs.container.clientHeight
|
||||
}
|
||||
},
|
||||
logLevelUpdated(val) {
|
||||
var payload = {
|
||||
logLevel: Number(val)
|
||||
}
|
||||
this.updateServerSettings(payload)
|
||||
|
||||
this.$store.dispatch('logs/setLogListener', this.newServerSettings.logLevel)
|
||||
this.$nextTick(this.updateScroll)
|
||||
},
|
||||
updateServerSettings(payload) {
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
})
|
||||
},
|
||||
init(attempts = 0) {
|
||||
if (!this.$root.socket) {
|
||||
if (attempts > 10) {
|
||||
return console.error('Failed to setup socket listeners')
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init(++attempts)
|
||||
}, 250)
|
||||
return
|
||||
}
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.$nextTick(this.updateScroll)
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logmessage {
|
||||
width: calc(100% - 208px);
|
||||
}
|
||||
</style>
|
||||
@@ -7,14 +7,22 @@
|
||||
<!-- <app-book-shelf /> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar is-home />
|
||||
<app-book-shelf-categorized />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ redirect }) {
|
||||
redirect('/library')
|
||||
},
|
||||
// asyncData({ redirect }) {
|
||||
// redirect('/library')
|
||||
// },
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
@@ -24,12 +24,18 @@ export default {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
store.commit('audiobooks/setSearchResults', searchResults)
|
||||
}
|
||||
var selectedSeries = query.series ? app.$decode(query.series) : null
|
||||
store.commit('audiobooks/setSelectedSeries', selectedSeries)
|
||||
var libraryPage = params.id || ''
|
||||
store.commit('audiobooks/setLibraryPage', libraryPage)
|
||||
|
||||
return {
|
||||
id: params.id,
|
||||
id: libraryPage,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
selectedSeries: query.series ? app.$decode(query.series) : null
|
||||
selectedSeries
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -37,7 +37,7 @@ export default {
|
||||
if (this.$route.query.redirect) {
|
||||
this.$router.replace(this.$route.query.redirect)
|
||||
} else {
|
||||
this.$router.replace('/library')
|
||||
this.$router.replace('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,15 +57,14 @@ export default {
|
||||
password: this.password || ''
|
||||
}
|
||||
var authRes = await this.$axios.$post('/login', payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
console.error('Failed', error.response)
|
||||
if (error.response) this.error = error.response.data
|
||||
else this.error = 'Unknown Error'
|
||||
return false
|
||||
})
|
||||
console.log('Auth res', authRes)
|
||||
if (!authRes) {
|
||||
this.error = 'Unknown Failure'
|
||||
} else if (authRes.error) {
|
||||
if (authRes && authRes.error) {
|
||||
this.error = authRes.error
|
||||
} else {
|
||||
} else if (authRes) {
|
||||
this.$store.commit('user/setUser', authRes.user)
|
||||
}
|
||||
this.processing = false
|
||||
@@ -77,7 +76,6 @@ export default {
|
||||
if (token) {
|
||||
this.processing = true
|
||||
|
||||
console.log('Authorize', token)
|
||||
this.$axios
|
||||
.$post('/api/authorize', null, {
|
||||
headers: {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
|
||||
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept }}</p>
|
||||
</header>
|
||||
</section>
|
||||
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
|
||||
@@ -120,9 +120,9 @@ export default {
|
||||
title: null,
|
||||
author: null,
|
||||
series: null,
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a'],
|
||||
acceptedImageFormats: ['image/*'],
|
||||
inputAccept: ['image/*, .mp3, .m4b, .m4a'],
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac'],
|
||||
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac',
|
||||
isDragOver: false,
|
||||
showUploader: true,
|
||||
validAudioFiles: [],
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function ({ $axios, store }) {
|
||||
|
||||
$axios.onError(error => {
|
||||
const code = parseInt(error.response && error.response.status)
|
||||
console.error('Axios error code', code)
|
||||
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
console.error('Axios error', code, message)
|
||||
})
|
||||
}
|
||||
@@ -5,8 +5,14 @@ const DownloadStatus = {
|
||||
FAILED: 3
|
||||
}
|
||||
|
||||
const CoverDestination = {
|
||||
METADATA: 0,
|
||||
AUDIOBOOK: 1
|
||||
}
|
||||
|
||||
const Constants = {
|
||||
DownloadStatus
|
||||
DownloadStatus,
|
||||
CoverDestination
|
||||
}
|
||||
|
||||
export default ({ app }, inject) => {
|
||||
|
||||
@@ -10,13 +10,32 @@ export const state = () => ({
|
||||
genres: [...STANDARD_GENRES],
|
||||
tags: [],
|
||||
series: [],
|
||||
keywordFilter: null
|
||||
keywordFilter: null,
|
||||
selectedSeries: null,
|
||||
libraryPage: null,
|
||||
searchResults: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getAudiobook: (state) => id => {
|
||||
return state.audiobooks.find(ab => ab.id === id)
|
||||
},
|
||||
getEntitiesShowing: (state, getters, rootState, rootGetters) => () => {
|
||||
if (!state.libraryPage) {
|
||||
return getters.getFiltered()
|
||||
} else if (state.libraryPage === 'search') {
|
||||
return state.searchResults
|
||||
} else if (state.libraryPage === 'series') {
|
||||
var series = getters.getSeriesGroups()
|
||||
if (state.selectedSeries) {
|
||||
var _series = series.find(__series => __series.name === state.selectedSeries)
|
||||
if (!_series) return []
|
||||
return _series.books || []
|
||||
}
|
||||
return series
|
||||
}
|
||||
return []
|
||||
},
|
||||
getFiltered: (state, getters, rootState, rootGetters) => () => {
|
||||
var filtered = state.audiobooks
|
||||
var settings = rootState.user.settings || {}
|
||||
@@ -69,12 +88,15 @@ export const getters = {
|
||||
state.audiobooks.forEach((audiobook) => {
|
||||
if (audiobook.book && audiobook.book.series) {
|
||||
if (series[audiobook.book.series]) {
|
||||
var bookLastUpdate = audiobook.book.lastUpdate
|
||||
if (bookLastUpdate > series[audiobook.book.series].lastUpdate) series[audiobook.book.series].lastUpdate = bookLastUpdate
|
||||
series[audiobook.book.series].books.push(audiobook)
|
||||
} else {
|
||||
series[audiobook.book.series] = {
|
||||
type: 'series',
|
||||
name: audiobook.book.series || '',
|
||||
books: [audiobook]
|
||||
books: [audiobook],
|
||||
lastUpdate: audiobook.book.lastUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +181,15 @@ export const mutations = {
|
||||
setKeywordFilter(state, val) {
|
||||
state.keywordFilter = val
|
||||
},
|
||||
setSelectedSeries(state, val) {
|
||||
state.selectedSeries = val
|
||||
},
|
||||
setLibraryPage(state, val) {
|
||||
state.libraryPage = val
|
||||
},
|
||||
setSearchResults(state, val) {
|
||||
state.searchResults = val
|
||||
},
|
||||
set(state, audiobooks) {
|
||||
// GENRES
|
||||
var genres = [...state.genres]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { checkForUpdate } from '@/plugins/version'
|
||||
import Vue from 'vue'
|
||||
|
||||
export const state = () => ({
|
||||
versionData: null,
|
||||
@@ -14,7 +15,9 @@ export const state = () => ({
|
||||
coverScanProgress: null,
|
||||
developerMode: false,
|
||||
selectedAudiobooks: [],
|
||||
processingBatch: false
|
||||
processingBatch: false,
|
||||
previousPath: '/',
|
||||
routeHistory: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -51,10 +54,25 @@ export const actions = {
|
||||
console.error('Update check failed', error)
|
||||
return false
|
||||
})
|
||||
},
|
||||
popRoute({ commit, state }) {
|
||||
if (!state.routeHistory.length) {
|
||||
return null
|
||||
}
|
||||
var _history = [...state.routeHistory]
|
||||
var last = _history.pop()
|
||||
commit('setRouteHistory', _history)
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setRouteHistory(state, val) {
|
||||
state.routeHistory = val
|
||||
},
|
||||
setPreviousPath(state, val) {
|
||||
state.previousPath = val
|
||||
},
|
||||
setVersionData(state, versionData) {
|
||||
state.versionData = versionData
|
||||
},
|
||||
@@ -112,13 +130,18 @@ export const mutations = {
|
||||
state.developerMode = val
|
||||
},
|
||||
setSelectedAudiobooks(state, audiobooks) {
|
||||
state.selectedAudiobooks = audiobooks
|
||||
Vue.set(state, 'selectedAudiobooks', audiobooks)
|
||||
// state.selectedAudiobooks = audiobooks
|
||||
},
|
||||
toggleAudiobookSelected(state, audiobookId) {
|
||||
if (state.selectedAudiobooks.includes(audiobookId)) {
|
||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
||||
} else {
|
||||
state.selectedAudiobooks.push(audiobookId)
|
||||
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
||||
// state.selectedAudiobooks = newSel
|
||||
console.log('Setting toggle on sel', newSel)
|
||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
||||
// state.selectedAudiobooks.push(audiobookId)
|
||||
}
|
||||
},
|
||||
setProcessingBatch(state, val) {
|
||||
|
||||
31
client/store/logs.js
Normal file
31
client/store/logs.js
Normal file
@@ -0,0 +1,31 @@
|
||||
export const state = () => ({
|
||||
isListening: false,
|
||||
logs: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
setLogListener({ state, commit, dispatch }) {
|
||||
dispatch('$nuxtSocket/emit', {
|
||||
label: 'main',
|
||||
evt: 'set_log_listener',
|
||||
msg: 0
|
||||
}, { root: true })
|
||||
commit('setIsListening', true)
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setIsListening(state, val) {
|
||||
state.isListening = val
|
||||
},
|
||||
logEvt(state, payload) {
|
||||
state.logs.push(payload)
|
||||
if (state.logs.length > 500) {
|
||||
state.logs = state.logs.slice(50)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,12 @@ module.exports = {
|
||||
height: {
|
||||
'7.5': '1.75rem'
|
||||
},
|
||||
spacing: {
|
||||
'-54': '-13.5rem'
|
||||
},
|
||||
rotate: {
|
||||
'-60': '-60deg'
|
||||
},
|
||||
colors: {
|
||||
bg: '#373838',
|
||||
primary: '#232323',
|
||||
|
||||
54
package-lock.json
generated
54
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.1.13",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -568,6 +568,16 @@
|
||||
"busboy": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"express-rate-limit": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
|
||||
"integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew=="
|
||||
},
|
||||
"file-type": {
|
||||
"version": "10.11.0",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz",
|
||||
"integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw=="
|
||||
},
|
||||
"finalhandler": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
|
||||
@@ -718,6 +728,14 @@
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
|
||||
},
|
||||
"image-type": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/image-type/-/image-type-4.1.0.tgz",
|
||||
"integrity": "sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==",
|
||||
"requires": {
|
||||
"file-type": "^10.10.0"
|
||||
}
|
||||
},
|
||||
"inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@@ -1027,6 +1045,16 @@
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="
|
||||
},
|
||||
"p-finally": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
|
||||
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
|
||||
},
|
||||
"p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
|
||||
},
|
||||
"parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1042,6 +1070,11 @@
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
|
||||
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
|
||||
},
|
||||
"pify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
|
||||
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
|
||||
},
|
||||
"podcast": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/podcast/-/podcast-1.3.0.tgz",
|
||||
@@ -1119,6 +1152,15 @@
|
||||
"unpipe": "1.0.0"
|
||||
}
|
||||
},
|
||||
"read-chunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.1.0.tgz",
|
||||
"integrity": "sha512-ZdiZJXXoZYE08SzZvTipHhI+ZW0FpzxmFtLI3vIeMuRN9ySbIZ+SZawKogqJ7dxW9fJ/W73BNtxu4Zu/bZp+Ng==",
|
||||
"requires": {
|
||||
"pify": "^4.0.1",
|
||||
"with-open-file": "^0.1.5"
|
||||
}
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
||||
@@ -1419,6 +1461,16 @@
|
||||
"isexe": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"with-open-file": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz",
|
||||
"integrity": "sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==",
|
||||
"requires": {
|
||||
"p-finally": "^1.0.0",
|
||||
"p-try": "^2.1.0",
|
||||
"pify": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.4",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -29,14 +29,17 @@
|
||||
"cookie-parser": "^1.4.5",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"fs-extra": "^10.0.0",
|
||||
"image-type": "^4.1.0",
|
||||
"ip": "^1.1.5",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"libgen": "^2.1.0",
|
||||
"njodb": "^0.4.20",
|
||||
"node-dir": "^0.1.17",
|
||||
"podcast": "^1.3.0",
|
||||
"read-chunk": "^3.1.0",
|
||||
"socket.io": "^4.1.3",
|
||||
"watcher": "^1.2.0"
|
||||
},
|
||||
|
||||
64
readme.md
64
readme.md
@@ -2,7 +2,7 @@
|
||||
|
||||
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
|
||||
|
||||
See [Install guides](https://audiobookshelf.org/install) and docs coming soon to [audiobookshelf.org](https://audiobookshelf.org)
|
||||
See [Install guides](https://audiobookshelf.org/install) and [documentation](https://audiobookshelf.org/docs)
|
||||
|
||||
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
||||
@@ -11,63 +11,19 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
|
||||
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
|
||||
|
||||
|
||||
## Directory Structure
|
||||
## Organizing your audiobooks
|
||||
|
||||
Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure.
|
||||
#### Directory structure and folder names are critical to AudioBookshelf!
|
||||
|
||||
**Note**: Files in the root directory `/audiobooks` will be ignored, all audiobooks should be in a directory
|
||||
|
||||
**1 Folder:** `/Title/...`\
|
||||
**2 Folders:** `/Author/Title/...`\
|
||||
**3 Folders:** `/Author/Series/Title/...`
|
||||
|
||||
### Parsing publish year
|
||||
|
||||
`/1984 - Hackers/...`\
|
||||
Will save the publish year as `1984` and the title as `Hackers`
|
||||
|
||||
### Parsing volume number (only for series)
|
||||
|
||||
`/Book 3 - Hackers/...`\
|
||||
Will save the volume number as `3` and the title as `Hackers`
|
||||
|
||||
`Book` `Volume` `Vol` `Vol.` are all supported case insensitive
|
||||
|
||||
These combinations will also work:\
|
||||
`/Hackers - Vol. 3/...`\
|
||||
`/1984 - Volume 3 - Hackers/...`\
|
||||
`/1984 - Hackers Book 3/...`
|
||||
See [documentation](https://audiobookshelf.org/docs) for supported directory structure, folder naming conventions, and audio file metadata usage.
|
||||
|
||||
|
||||
### Parsing subtitles (optional in settings)
|
||||
|
||||
Title Folder: `/Hackers - Heroes of the Computer Revolution/...`
|
||||
|
||||
Will save the title as `Hackers` and the subtitle as `Heroes of the Computer Revolution`
|
||||
|
||||
|
||||
### Full example
|
||||
|
||||
`/Steven Levy/The Hacker Series/1984 - Hackers - Heroes of the Computer Revolution - Vol. 1/...`
|
||||
|
||||
**Becomes:**
|
||||
| Key | Value |
|
||||
|---------------|-----------------------------------|
|
||||
| Author | Steven Levy |
|
||||
| Series | The Hacker Series |
|
||||
| Publish Year | 1984 |
|
||||
| Title | Hackers |
|
||||
| Subtitle | Heroes of the Computer Revolution |
|
||||
| Volume Number | 1 |
|
||||
|
||||
|
||||
## Features coming soon
|
||||
|
||||
* Support different views to see more details of each audiobook
|
||||
* iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app))
|
||||
|
||||
## Installation
|
||||
|
||||
** Default username is "root" with no password
|
||||
|
||||
### Docker Install
|
||||
Available in Unraid Community Apps
|
||||
|
||||
@@ -113,14 +69,10 @@ curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | su
|
||||
|
||||
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
|
||||
|
||||
```bash
|
||||
wget https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf_1.2.3_amd64.deb
|
||||
|
||||
sudo apt install ./audiobookshelf_1.2.3_amd64.deb
|
||||
```
|
||||
See [instructions](https://www.audiobookshelf.org/install#debian)
|
||||
|
||||
|
||||
#### File locations
|
||||
#### Linux file locations
|
||||
|
||||
Project directory: `/usr/share/audiobookshelf/`
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@ const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const Logger = require('./Logger')
|
||||
const User = require('./objects/User')
|
||||
const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
|
||||
const { CoverDestination } = require('./utils/constants')
|
||||
const { isObject } = require('./utils/index')
|
||||
|
||||
class ApiController {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.scanner = scanner
|
||||
this.auth = auth
|
||||
this.streamManager = streamManager
|
||||
this.rssFeeds = rssFeeds
|
||||
this.downloadManager = downloadManager
|
||||
this.coverController = coverController
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
this.MetadataPath = MetadataPath
|
||||
@@ -37,7 +37,6 @@ class ApiController {
|
||||
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
|
||||
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
|
||||
|
||||
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
|
||||
this.router.patch('/match/:id', this.match.bind(this))
|
||||
|
||||
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
|
||||
@@ -70,11 +69,6 @@ class ApiController {
|
||||
this.scanner.findCovers(req, res)
|
||||
}
|
||||
|
||||
async getMetadata(req, res) {
|
||||
var metadata = await this.scanner.fetchMetadata(req.params.id, req.params.trackIndex)
|
||||
res.json(metadata)
|
||||
}
|
||||
|
||||
authorize(req, res) {
|
||||
if (!req.user) {
|
||||
Logger.error('Invalid user in authorize')
|
||||
@@ -227,77 +221,36 @@ class ApiController {
|
||||
Logger.warn('User attempted to upload a cover without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (!req.files || !req.files.cover) {
|
||||
return res.status(400).send('No files were uploaded')
|
||||
}
|
||||
|
||||
var audiobookId = req.params.id
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
if (!audiobook) {
|
||||
return res.status(404).send('Audiobook not found')
|
||||
}
|
||||
|
||||
var coverFile = req.files.cover
|
||||
var mimeType = coverFile.mimetype
|
||||
var extname = Path.extname(coverFile.name.toLowerCase()) || '.jpg'
|
||||
if (!isAcceptableCoverMimeType(mimeType)) {
|
||||
return res.status(400).send('Invalid image file type: ' + mimeType)
|
||||
}
|
||||
|
||||
var coverDestination = this.db.serverSettings ? this.db.serverSettings.coverDestination : CoverDestination.METADATA
|
||||
Logger.info(`[ApiController] Cover Upload destination ${coverDestination}`)
|
||||
|
||||
var coverDirpath = audiobook.fullPath
|
||||
var coverRelDirpath = Path.join('/local', audiobook.path)
|
||||
if (coverDestination === CoverDestination.METADATA) {
|
||||
coverDirpath = Path.join(this.MetadataPath, 'books', audiobookId)
|
||||
coverRelDirpath = Path.join('/metadata', 'books', audiobookId)
|
||||
Logger.debug(`[ApiController] storing in metadata | ${coverDirpath}`)
|
||||
await fs.ensureDir(coverDirpath)
|
||||
var result = null
|
||||
if (req.body && req.body.url) {
|
||||
Logger.debug(`[ApiController] Requesting download cover from url "${req.body.url}"`)
|
||||
result = await this.coverController.downloadCoverFromUrl(audiobook, req.body.url)
|
||||
} else if (req.files && req.files.cover) {
|
||||
Logger.debug(`[ApiController] Handling uploaded cover`)
|
||||
var coverFile = req.files.cover
|
||||
result = await this.coverController.uploadCover(audiobook, coverFile)
|
||||
} else {
|
||||
Logger.debug(`[ApiController] storing in audiobook | ${coverRelDirpath}`)
|
||||
return res.status(400).send('Invalid request no file or url')
|
||||
}
|
||||
|
||||
var coverFilename = `cover${extname}`
|
||||
var coverFullPath = Path.join(coverDirpath, coverFilename)
|
||||
var coverPath = Path.join(coverRelDirpath, coverFilename)
|
||||
|
||||
// If current cover is a metadata cover and does not match replacement, then remove it
|
||||
var currentBookCover = audiobook.book.cover
|
||||
if (currentBookCover && currentBookCover.startsWith(Path.sep + 'metadata')) {
|
||||
Logger.debug(`Current Book Cover is metadata ${currentBookCover}`)
|
||||
if (currentBookCover !== coverPath) {
|
||||
Logger.info(`[ApiController] removing old metadata cover "${currentBookCover}"`)
|
||||
var oldFullBookCoverPath = Path.join(this.MetadataPath, currentBookCover.replace(Path.sep + 'metadata', ''))
|
||||
|
||||
// Metadata path may have changed, check if exists first
|
||||
var exists = await fs.pathExists(oldFullBookCoverPath)
|
||||
if (exists) {
|
||||
try {
|
||||
await fs.remove(oldFullBookCoverPath)
|
||||
} catch (error) {
|
||||
Logger.error(`[ApiController] Failed to remove old metadata book cover ${oldFullBookCoverPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result && result.error) {
|
||||
return res.status(400).send(result.error)
|
||||
} else if (!result || !result.cover) {
|
||||
return res.status(500).send('Unknown error occurred')
|
||||
}
|
||||
|
||||
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
|
||||
Logger.error('Failed to move cover file', path, error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
return res.status(500).send('Failed to move cover into destination')
|
||||
}
|
||||
|
||||
Logger.info(`[ApiController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
|
||||
|
||||
audiobook.updateBookCover(coverPath)
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
res.json({
|
||||
success: true,
|
||||
cover: coverPath
|
||||
cover: result.cover
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -103,18 +103,18 @@ class Auth {
|
||||
|
||||
var user = this.users.find(u => u.username === username)
|
||||
|
||||
if (!user) {
|
||||
return res.json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return res.json({ error: 'User unavailable' })
|
||||
if (!user || !user.isActive) {
|
||||
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
|
||||
}
|
||||
return res.status(401).send('Invalid user or password')
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.id === 'root' && (!user.pash || user.pash === '')) {
|
||||
if (password) {
|
||||
return res.json({ error: 'Invalid root password (hint: there is none)' })
|
||||
return res.status(401).send('Invalid root password (hint: there is none)')
|
||||
} else {
|
||||
return res.json({ user: user.toJSONForBrowser() })
|
||||
}
|
||||
@@ -127,12 +127,24 @@ class Auth {
|
||||
user: user.toJSONForBrowser()
|
||||
})
|
||||
} else {
|
||||
res.json({
|
||||
error: 'Invalid Password'
|
||||
})
|
||||
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
Logger.error(`[Auth] Failed login attempt for user ${user.username}. Attempts: ${req.rateLimit.current}`)
|
||||
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
|
||||
}
|
||||
return res.status(401).send('Invalid user or password')
|
||||
}
|
||||
}
|
||||
|
||||
// Not in use now
|
||||
lockUser(user) {
|
||||
user.isLocked = true
|
||||
return this.db.updateEntity('user', user).catch((error) => {
|
||||
Logger.error('[Auth] Failed to lock user', user.username, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
if (!password || !user.pash) return false
|
||||
|
||||
184
server/CoverController.js
Normal file
184
server/CoverController.js
Normal file
@@ -0,0 +1,184 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const axios = require('axios')
|
||||
const Logger = require('./Logger')
|
||||
const readChunk = require('read-chunk')
|
||||
const imageType = require('image-type')
|
||||
|
||||
const globals = require('./utils/globals')
|
||||
const { CoverDestination } = require('./utils/constants')
|
||||
|
||||
class CoverController {
|
||||
constructor(db, MetadataPath, AudiobookPath) {
|
||||
this.db = db
|
||||
this.MetadataPath = MetadataPath
|
||||
this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
|
||||
this.AudiobookPath = AudiobookPath
|
||||
}
|
||||
|
||||
getCoverDirectory(audiobook) {
|
||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||
return {
|
||||
fullPath: audiobook.fullPath,
|
||||
relPath: Path.join('/local', audiobook.path)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
fullPath: Path.join(this.BookMetadataPath, audiobook.id),
|
||||
relPath: Path.join('/metadata', 'books', audiobook.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getFilesInDirectory(dir) {
|
||||
try {
|
||||
return fs.readdir(dir)
|
||||
} catch (error) {
|
||||
Logger.error(`[CoverController] Failed to get files in dir ${dir}`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(filepath) {
|
||||
try {
|
||||
return fs.pathExists(filepath).then((exists) => {
|
||||
if (!exists) Logger.warn(`[CoverController] Attempting to remove file that does not exist ${filepath}`)
|
||||
return exists ? fs.unlink(filepath) : false
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error(`[CoverController] Failed to remove file "${filepath}"`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Remove covers that dont have the same filename as the new cover
|
||||
async removeOldCovers(dirpath, newCoverExt) {
|
||||
var filesInDir = await this.getFilesInDirectory(dirpath)
|
||||
|
||||
for (let i = 0; i < filesInDir.length; i++) {
|
||||
var file = filesInDir[i]
|
||||
var _extname = Path.extname(file)
|
||||
var _filename = Path.basename(file, _extname)
|
||||
if (_filename === 'cover' && _extname !== newCoverExt) {
|
||||
var filepath = Path.join(dirpath, file)
|
||||
Logger.debug(`[CoverController] Removing old cover from metadata "${filepath}"`)
|
||||
await this.removeFile(filepath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async checkFileIsValidImage(imagepath) {
|
||||
const buffer = await readChunk(imagepath, 0, 12)
|
||||
const imgType = imageType(buffer)
|
||||
if (!imgType) {
|
||||
await this.removeFile(imagepath)
|
||||
return {
|
||||
error: 'Invalid image'
|
||||
}
|
||||
}
|
||||
|
||||
if (!globals.SupportedImageTypes.includes(imgType.ext)) {
|
||||
await this.removeFile(imagepath)
|
||||
return {
|
||||
error: `Invalid image type ${imgType.ext} (Supported: ${globals.SupportedImageTypes.join(',')})`
|
||||
}
|
||||
}
|
||||
return imgType
|
||||
}
|
||||
|
||||
async uploadCover(audiobook, coverFile) {
|
||||
var extname = Path.extname(coverFile.name.toLowerCase())
|
||||
if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {
|
||||
return {
|
||||
error: `Invalid image type ${extname} (Supported: ${globals.SupportedImageTypes.join(',')})`
|
||||
}
|
||||
}
|
||||
|
||||
var { fullPath, relPath } = this.getCoverDirectory(audiobook)
|
||||
await fs.ensureDir(fullPath)
|
||||
|
||||
var coverFilename = `cover${extname}`
|
||||
var coverFullPath = Path.join(fullPath, coverFilename)
|
||||
var coverPath = Path.join(relPath, coverFilename)
|
||||
|
||||
// Move cover from temp upload dir to destination
|
||||
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
|
||||
Logger.error('[CoverController] Failed to move cover file', path, error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
error: 'Failed to move cover into destination'
|
||||
}
|
||||
}
|
||||
|
||||
await this.removeOldCovers(fullPath, extname)
|
||||
|
||||
Logger.info(`[CoverController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
|
||||
|
||||
audiobook.updateBookCover(coverPath)
|
||||
return {
|
||||
cover: coverPath
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFile(url, filepath) {
|
||||
Logger.debug(`[CoverController] Starting file download to ${filepath}`)
|
||||
const writer = fs.createWriteStream(filepath)
|
||||
const response = await axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream'
|
||||
})
|
||||
response.data.pipe(writer)
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', resolve)
|
||||
writer.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async downloadCoverFromUrl(audiobook, url) {
|
||||
try {
|
||||
var { fullPath, relPath } = this.getCoverDirectory(audiobook)
|
||||
await fs.ensureDir(fullPath)
|
||||
|
||||
var temppath = Path.join(fullPath, 'cover')
|
||||
var success = await this.downloadFile(url, temppath).then(() => true).catch((err) => {
|
||||
Logger.error(`[CoverController] Download image file failed for "${url}"`, err)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
return {
|
||||
error: 'Failed to download image from url'
|
||||
}
|
||||
}
|
||||
|
||||
var imgtype = await this.checkFileIsValidImage(temppath)
|
||||
|
||||
if (imgtype.error) {
|
||||
return imgtype
|
||||
}
|
||||
|
||||
var coverFilename = `cover.${imgtype.ext}`
|
||||
var coverPath = Path.join(relPath, coverFilename)
|
||||
var coverFullPath = Path.join(fullPath, coverFilename)
|
||||
await fs.rename(temppath, coverFullPath)
|
||||
|
||||
await this.removeOldCovers(fullPath, '.' + imgtype.ext)
|
||||
|
||||
Logger.info(`[CoverController] Downloaded audiobook cover "${coverPath}" from url "${url}" for "${audiobook.title}"`)
|
||||
|
||||
audiobook.updateBookCover(coverPath)
|
||||
return {
|
||||
cover: coverPath
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[CoverController] Fetch cover image from url "${url}" failed`, error)
|
||||
return {
|
||||
error: 'Failed to fetch image from url'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = CoverController
|
||||
42
server/EbookReader.js
Normal file
42
server/EbookReader.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// const express = require('express')
|
||||
// const EPub = require('epub')
|
||||
// const Logger = require('./Logger')
|
||||
|
||||
// class EbookReader {
|
||||
// constructor(db, MetadataPath, AudiobookPath) {
|
||||
// this.db = db
|
||||
// this.MetadataPath = MetadataPath
|
||||
// this.AudiobookPath = AudiobookPath
|
||||
|
||||
// this.router = express()
|
||||
// this.init()
|
||||
// }
|
||||
|
||||
// init() {
|
||||
// this.router.get('/open/:id/:ino', this.openRequest.bind(this))
|
||||
// }
|
||||
|
||||
// openRequest(req, res) {
|
||||
// Logger.info('Open request received', req.params)
|
||||
// var audiobookId = req.params.id
|
||||
// var fileIno = req.params.ino
|
||||
// var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
// if (!audiobook) {
|
||||
// return res.sendStatus(404)
|
||||
// }
|
||||
// var ebook = audiobook.ebooks.find(eb => eb.ino === fileIno)
|
||||
// if (!ebook) {
|
||||
// Logger.error('Ebook file not found', fileIno)
|
||||
// return res.sendStatus(404)
|
||||
// }
|
||||
// Logger.info('Ebook found', ebook)
|
||||
// this.open(ebook.fullPath)
|
||||
// res.sendStatus(200)
|
||||
// }
|
||||
|
||||
// open(path) {
|
||||
// var epub = new EPub(path)
|
||||
// console.log('epub', epub)
|
||||
// }
|
||||
// }
|
||||
// module.exports = EbookReader
|
||||
@@ -21,7 +21,7 @@ class HlsController {
|
||||
}
|
||||
|
||||
parseSegmentFilename(filename) {
|
||||
var basename = Path.basename(filename, '.ts')
|
||||
var basename = Path.basename(filename, Path.extname(filename))
|
||||
var num_part = basename.split('-')[1]
|
||||
return Number(num_part)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class HlsController {
|
||||
Logger.warn('File path does not exist', fullFilePath)
|
||||
|
||||
var fileExt = Path.extname(req.params.file)
|
||||
if (fileExt === '.ts') {
|
||||
if (fileExt === '.ts' || fileExt === '.m4s') {
|
||||
var segNum = this.parseSegmentFilename(req.params.file)
|
||||
var stream = this.streamManager.getStream(streamId)
|
||||
if (!stream) {
|
||||
@@ -66,6 +66,7 @@ class HlsController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logger.info('Sending file', fullFilePath)
|
||||
res.sendFile(fullFilePath)
|
||||
}
|
||||
|
||||
@@ -1,55 +1,110 @@
|
||||
const LOG_LEVEL = {
|
||||
TRACE: 0,
|
||||
DEBUG: 1,
|
||||
INFO: 2,
|
||||
WARN: 3,
|
||||
ERROR: 4,
|
||||
FATAL: 5
|
||||
}
|
||||
const { LogLevel } = require('./utils/constants')
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
let env_log_level = process.env.LOG_LEVEL || 'TRACE'
|
||||
this.LogLevel = LOG_LEVEL[env_log_level] || LOG_LEVEL.TRACE
|
||||
this.info(`Log Level: ${this.LogLevel}`)
|
||||
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
|
||||
this.socketListeners = []
|
||||
}
|
||||
|
||||
get timestamp() {
|
||||
return (new Date()).toISOString()
|
||||
}
|
||||
|
||||
get levelString() {
|
||||
for (const key in LogLevel) {
|
||||
if (LogLevel[key] === this.logLevel) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
getLogLevelString(level) {
|
||||
for (const key in LogLevel) {
|
||||
if (LogLevel[key] === level) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
addSocketListener(socket, level) {
|
||||
var index = this.socketListeners.findIndex(s => s.id === socket.id)
|
||||
if (index >= 0) {
|
||||
this.socketListeners.splice(index, 1, {
|
||||
id: socket.id,
|
||||
socket,
|
||||
level
|
||||
})
|
||||
} else {
|
||||
this.socketListeners.push({
|
||||
id: socket.id,
|
||||
socket,
|
||||
level
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListener(socketId) {
|
||||
this.socketListeners = this.socketListeners.filter(s => s.id !== socketId)
|
||||
}
|
||||
|
||||
logToSockets(level, args) {
|
||||
this.socketListeners.forEach((socketListener) => {
|
||||
if (socketListener.level <= level) {
|
||||
socketListener.socket.emit('log', {
|
||||
timestamp: this.timestamp,
|
||||
message: args.join(' '),
|
||||
levelName: this.getLogLevelString(level),
|
||||
level
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setLogLevel(level) {
|
||||
this.logLevel = level
|
||||
this.debug(`Set Log Level to ${this.levelString}`)
|
||||
}
|
||||
|
||||
trace(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.TRACE) return
|
||||
if (this.logLevel > LogLevel.TRACE) return
|
||||
console.trace(`[${this.timestamp}] TRACE:`, ...args)
|
||||
this.logToSockets(LogLevel.TRACE, args)
|
||||
}
|
||||
|
||||
debug(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.DEBUG) return
|
||||
if (this.logLevel > LogLevel.DEBUG) return
|
||||
console.debug(`[${this.timestamp}] DEBUG:`, ...args)
|
||||
this.logToSockets(LogLevel.DEBUG, args)
|
||||
}
|
||||
|
||||
info(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.INFO) return
|
||||
if (this.logLevel > LogLevel.INFO) return
|
||||
console.info(`[${this.timestamp}] INFO:`, ...args)
|
||||
}
|
||||
|
||||
note(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.INFO) return
|
||||
console.log(`[${this.timestamp}] NOTE:`, ...args)
|
||||
this.logToSockets(LogLevel.INFO, args)
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.WARN) return
|
||||
if (this.logLevel > LogLevel.WARN) return
|
||||
console.warn(`[${this.timestamp}] WARN:`, ...args)
|
||||
this.logToSockets(LogLevel.WARN, args)
|
||||
}
|
||||
|
||||
error(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.ERROR) return
|
||||
if (this.logLevel > LogLevel.ERROR) return
|
||||
console.error(`[${this.timestamp}] ERROR:`, ...args)
|
||||
this.logToSockets(LogLevel.ERROR, args)
|
||||
}
|
||||
|
||||
fatal(...args) {
|
||||
console.error(`[${this.timestamp}] FATAL:`, ...args)
|
||||
this.logToSockets(LogLevel.FATAL, args)
|
||||
}
|
||||
|
||||
note(...args) {
|
||||
console.log(`[${this.timestamp}] NOTE:`, ...args)
|
||||
this.logToSockets(LogLevel.NOTE, args)
|
||||
}
|
||||
}
|
||||
module.exports = new Logger()
|
||||
@@ -7,13 +7,16 @@ const audioFileScanner = require('./utils/audioFileScanner')
|
||||
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
|
||||
const { comparePaths, getIno } = require('./utils/index')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
const { ScanResult, CoverDestination } = require('./utils/constants')
|
||||
|
||||
class Scanner {
|
||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
|
||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
|
||||
this.AudiobookPath = AUDIOBOOK_PATH
|
||||
this.MetadataPath = METADATA_PATH
|
||||
this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
|
||||
|
||||
this.db = db
|
||||
this.coverController = coverController
|
||||
this.emitter = emitter
|
||||
|
||||
this.cancelScan = false
|
||||
@@ -25,23 +28,18 @@ class Scanner {
|
||||
return this.db.audiobooks
|
||||
}
|
||||
|
||||
async setAudiobookDataInos(audiobookData) {
|
||||
for (let i = 0; i < audiobookData.length; i++) {
|
||||
var abd = audiobookData[i]
|
||||
var matchingAB = this.db.audiobooks.find(_ab => comparePaths(_ab.path, abd.path))
|
||||
if (matchingAB) {
|
||||
if (!matchingAB.ino) {
|
||||
matchingAB.ino = await getIno(matchingAB.fullPath)
|
||||
}
|
||||
abd.ino = matchingAB.ino
|
||||
} else {
|
||||
abd.ino = await getIno(abd.fullPath)
|
||||
if (!abd.ino) {
|
||||
Logger.error('[Scanner] Invalid ino - ignoring audiobook data', abd.path)
|
||||
}
|
||||
getCoverDirectory(audiobook) {
|
||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||
return {
|
||||
fullPath: audiobook.fullPath,
|
||||
relPath: Path.join('/local', audiobook.path)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
fullPath: Path.join(this.BookMetadataPath, audiobook.id),
|
||||
relPath: Path.join('/metadata', 'books', audiobook.id)
|
||||
}
|
||||
}
|
||||
return audiobookData.filter(abd => !!abd.ino)
|
||||
}
|
||||
|
||||
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
|
||||
@@ -63,11 +61,72 @@ class Scanner {
|
||||
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
|
||||
}
|
||||
|
||||
async scanAudiobookData(audiobookData) {
|
||||
// Only updates audio files with matching paths
|
||||
syncAudiobookInodeValues(audiobook, { audioFiles, otherFiles }) {
|
||||
var filesUpdated = 0
|
||||
|
||||
// Sync audio files & audio tracks with updated inodes
|
||||
audiobook._audioFiles.forEach((audioFile) => {
|
||||
var matchingAudioFile = audioFiles.find(af => af.ino !== audioFile.ino && af.path === audioFile.path)
|
||||
if (matchingAudioFile) {
|
||||
// Audio Track should always have the same ino as the equivalent audio file (not all audio files have a track)
|
||||
var audioTrack = audiobook.tracks.find(t => t.ino === audioFile.ino)
|
||||
if (audioTrack) {
|
||||
Logger.debug(`[Scanner] Found audio file & track with mismatched inode "${audioFile.filename}" - updating it`)
|
||||
audioTrack.ino = matchingAudioFile.ino
|
||||
filesUpdated++
|
||||
} else {
|
||||
Logger.debug(`[Scanner] Found audio file with mismatched inode "${audioFile.filename}" - updating it`)
|
||||
}
|
||||
|
||||
audioFile.ino = matchingAudioFile.ino
|
||||
filesUpdated++
|
||||
}
|
||||
})
|
||||
|
||||
// Sync other files with updated inodes
|
||||
audiobook._otherFiles.forEach((otherFile) => {
|
||||
var matchingOtherFile = otherFiles.find(of => of.ino !== otherFile.ino && of.path === otherFile.path)
|
||||
if (matchingOtherFile) {
|
||||
Logger.debug(`[Scanner] Found other file with mismatched inode "${otherFile.filename}" - updating it`)
|
||||
otherFile.ino = matchingOtherFile.ino
|
||||
filesUpdated++
|
||||
}
|
||||
})
|
||||
|
||||
return filesUpdated
|
||||
}
|
||||
|
||||
async scanAudiobookData(audiobookData, forceAudioFileScan = false) {
|
||||
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
|
||||
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
|
||||
|
||||
// inode value may change when using shared drives, update inode if matching path is found
|
||||
// Note: inode will not change on rename
|
||||
var hasUpdatedIno = false
|
||||
if (!existingAudiobook) {
|
||||
// check an audiobook exists with matching path, then update inodes
|
||||
existingAudiobook = this.audiobooks.find(a => a.path === audiobookData.path)
|
||||
if (existingAudiobook) {
|
||||
existingAudiobook.ino = audiobookData.ino
|
||||
hasUpdatedIno = true
|
||||
}
|
||||
}
|
||||
|
||||
if (existingAudiobook) {
|
||||
// Always sync files and inode values
|
||||
var filesInodeUpdated = this.syncAudiobookInodeValues(existingAudiobook, audiobookData)
|
||||
if (hasUpdatedIno || filesInodeUpdated > 0) {
|
||||
Logger.info(`[Scanner] Updating inode value for "${existingAudiobook.title}" - ${filesInodeUpdated} files updated`)
|
||||
hasUpdatedIno = true
|
||||
}
|
||||
|
||||
|
||||
// TEMP: Check if is older audiobook and needs force rescan
|
||||
if (!forceAudioFileScan && existingAudiobook.checkNeedsAudioFileRescan()) {
|
||||
Logger.info(`[Scanner] Re-Scanning all audio files for "${existingAudiobook.title}" (last scan <= 1.3.0)`)
|
||||
forceAudioFileScan = true
|
||||
}
|
||||
|
||||
|
||||
// REMOVE: No valid audio files
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
@@ -80,7 +139,8 @@ class Scanner {
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
|
||||
// ino is now set for every file in scandir
|
||||
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
|
||||
|
||||
// Check for audio files that were removed
|
||||
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
||||
@@ -90,6 +150,13 @@ class Scanner {
|
||||
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
|
||||
}
|
||||
|
||||
// Check for mismatched audio tracks - tracks with no matching audio file
|
||||
var removedAudioTracks = existingAudiobook.tracks.filter(track => !abdAudioFileInos.includes(track.ino))
|
||||
if (removedAudioTracks.length) {
|
||||
Logger.info(`[Scanner] ${removedAudioTracks.length} tracks removed no matching audio file for audiobook "${existingAudiobook.title}"`)
|
||||
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
|
||||
}
|
||||
|
||||
// Check for new audio files and sync existing audio files
|
||||
var newAudioFiles = []
|
||||
var hasUpdatedAudioFiles = false
|
||||
@@ -108,13 +175,35 @@ class Scanner {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Rescan audio file metadata
|
||||
if (forceAudioFileScan) {
|
||||
Logger.info(`[Scanner] Rescanning ${existingAudiobook.audioFiles.length} audio files for "${existingAudiobook.title}"`)
|
||||
var numAudioFilesUpdated = await audioFileScanner.rescanAudioFiles(existingAudiobook)
|
||||
if (numAudioFilesUpdated > 0) {
|
||||
Logger.info(`[Scanner] Rescan complete, ${numAudioFilesUpdated} audio files were updated for "${existingAudiobook.title}"`)
|
||||
hasUpdatedAudioFiles = true
|
||||
|
||||
// Use embedded cover art if audiobook has no cover
|
||||
if (existingAudiobook.hasEmbeddedCoverArt && !existingAudiobook.cover) {
|
||||
var outputCoverDirs = this.getCoverDirectory(existingAudiobook)
|
||||
var relativeDir = await existingAudiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
|
||||
if (relativeDir) {
|
||||
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.info(`[Scanner] Rescan complete, audio files were up to date for "${existingAudiobook.title}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan and add new audio files found and set tracks
|
||||
if (newAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
|
||||
// Scan new audio files found - sets tracks
|
||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||
}
|
||||
|
||||
// REMOVE: No valid audio tracks
|
||||
// If after a scan no valid audio tracks remain
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!existingAudiobook.tracks.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
||||
@@ -124,14 +213,17 @@ class Scanner {
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
var hasUpdates = hasUpdatedIno || removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
|
||||
// Check that audio tracks are in sequential order with no gaps
|
||||
if (existingAudiobook.checkUpdateMissingParts()) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) {
|
||||
// Sync other files (all files that are not audio files)
|
||||
var otherFilesUpdated = await existingAudiobook.syncOtherFiles(audiobookData.otherFiles, forceAudioFileScan)
|
||||
if (otherFilesUpdated) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
@@ -140,12 +232,14 @@ class Scanner {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// If audiobook was missing before, it is now found
|
||||
if (existingAudiobook.isMissing) {
|
||||
existingAudiobook.isMissing = false
|
||||
hasUpdates = true
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
||||
}
|
||||
|
||||
// Save changes and notify users
|
||||
if (hasUpdates) {
|
||||
existingAudiobook.setChapters()
|
||||
|
||||
@@ -174,6 +268,19 @@ class Scanner {
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
if (audiobook.hasDescriptionTextFile) {
|
||||
await audiobook.saveDescriptionFromTextFile()
|
||||
}
|
||||
|
||||
if (audiobook.hasEmbeddedCoverArt) {
|
||||
var outputCoverDirs = this.getCoverDirectory(audiobook)
|
||||
var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
|
||||
if (relativeDir) {
|
||||
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
|
||||
}
|
||||
}
|
||||
|
||||
audiobook.setDetailsFromFileMetadata()
|
||||
audiobook.checkUpdateMissingParts()
|
||||
audiobook.setChapters()
|
||||
|
||||
@@ -183,31 +290,29 @@ class Scanner {
|
||||
return ScanResult.ADDED
|
||||
}
|
||||
|
||||
async scan() {
|
||||
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos"
|
||||
// TEMP - fix relative file paths
|
||||
async scan(forceAudioFileScan = false) {
|
||||
// TODO: This temporary fix from pre-release should be removed soon, "checkUpdateInos"
|
||||
// TEMP - update ino for each audiobook
|
||||
// if (this.audiobooks.length) {
|
||||
// for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
// var ab = this.audiobooks[i]
|
||||
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
|
||||
|
||||
// // Update ino if an audio file has the same ino as the audiobook
|
||||
// var shouldUpdateIno = !ab.ino || (ab.audioFiles || []).find(abf => abf.ino === ab.ino)
|
||||
// if (shouldUpdateIno) {
|
||||
// await ab.checkUpdateInos()
|
||||
// }
|
||||
// if (shouldUpdate) {
|
||||
// await this.db.updateAudiobook(ab)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
if (this.audiobooks.length) {
|
||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
var ab = this.audiobooks[i]
|
||||
// Update ino if inos are not set
|
||||
var shouldUpdateIno = ab.hasMissingIno
|
||||
if (shouldUpdateIno) {
|
||||
Logger.debug(`Updating inos for ${ab.title}`)
|
||||
var hasUpdates = await ab.checkUpdateInos()
|
||||
if (hasUpdates) {
|
||||
await this.db.updateAudiobook(ab)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scanStart = Date.now()
|
||||
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
|
||||
|
||||
// Set ino for each ab data as a string
|
||||
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound)
|
||||
// Remove audiobooks with no inode
|
||||
audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
|
||||
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
@@ -241,8 +346,7 @@ class Scanner {
|
||||
|
||||
// Check for new and updated audiobooks
|
||||
for (let i = 0; i < audiobookDataFound.length; i++) {
|
||||
var audiobookData = audiobookDataFound[i]
|
||||
var result = await this.scanAudiobookData(audiobookData)
|
||||
var result = await this.scanAudiobookData(audiobookDataFound[i], forceAudioFileScan)
|
||||
if (result === ScanResult.ADDED) scanResults.added++
|
||||
if (result === ScanResult.REMOVED) scanResults.removed++
|
||||
if (result === ScanResult.UPDATED) scanResults.updated++
|
||||
@@ -266,14 +370,24 @@ class Scanner {
|
||||
return scanResults
|
||||
}
|
||||
|
||||
async scanAudiobook(audiobookPath) {
|
||||
async scanAudiobookById(audiobookId) {
|
||||
const audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
if (!audiobook) {
|
||||
Logger.error(`[Scanner] Scan audiobook by id not found ${audiobookId}`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
Logger.info(`[Scanner] Scanning Audiobook "${audiobook.title}"`)
|
||||
return this.scanAudiobook(audiobook.fullPath, true)
|
||||
}
|
||||
|
||||
async scanAudiobook(audiobookPath, forceAudioFileScan = false) {
|
||||
Logger.debug('[Scanner] scanAudiobook', audiobookPath)
|
||||
var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings)
|
||||
if (!audiobookData) {
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
audiobookData.ino = await getIno(audiobookData.fullPath)
|
||||
return this.scanAudiobookData(audiobookData)
|
||||
return this.scanAudiobookData(audiobookData, forceAudioFileScan)
|
||||
}
|
||||
|
||||
// Files were modified in this directory, check it out
|
||||
@@ -323,7 +437,7 @@ class Scanner {
|
||||
async filesChanged(filepaths) {
|
||||
if (!filepaths.length) return ScanResult.NOTHING
|
||||
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
|
||||
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths)
|
||||
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
|
||||
|
||||
var results = []
|
||||
for (const dir in fileGroupings) {
|
||||
@@ -336,23 +450,12 @@ class Scanner {
|
||||
return results
|
||||
}
|
||||
|
||||
async fetchMetadata(id, trackIndex = 0) {
|
||||
var audiobook = this.audiobooks.find(a => a.id === id)
|
||||
if (!audiobook) {
|
||||
return false
|
||||
}
|
||||
var tracks = audiobook.tracks
|
||||
var index = isNaN(trackIndex) ? 0 : Number(trackIndex)
|
||||
var firstTrack = tracks[index]
|
||||
var firstTrackFullPath = firstTrack.fullPath
|
||||
var scanResult = await audioFileScanner.scan(firstTrackFullPath)
|
||||
return scanResult
|
||||
}
|
||||
|
||||
async scanCovers() {
|
||||
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
|
||||
var found = 0
|
||||
var notFound = 0
|
||||
var failed = 0
|
||||
|
||||
for (let i = 0; i < audiobooksNeedingCover.length; i++) {
|
||||
var audiobook = audiobooksNeedingCover[i]
|
||||
var options = {
|
||||
@@ -362,10 +465,15 @@ class Scanner {
|
||||
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
|
||||
if (results.length) {
|
||||
Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
|
||||
audiobook.book.cover = results[0]
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
found++
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
var coverUrl = results[0]
|
||||
var result = await this.coverController.downloadCoverFromUrl(audiobook, coverUrl)
|
||||
if (result.error) {
|
||||
failed++
|
||||
} else {
|
||||
found++
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
}
|
||||
} else {
|
||||
notFound++
|
||||
}
|
||||
@@ -391,6 +499,39 @@ class Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
async saveMetadata(audiobookId) {
|
||||
if (audiobookId) {
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
if (!audiobook) {
|
||||
return {
|
||||
error: 'Audiobook not found'
|
||||
}
|
||||
}
|
||||
var savedPath = await audiobook.writeNfoFile()
|
||||
return {
|
||||
audiobookId,
|
||||
audiobookTitle: audiobook.title,
|
||||
savedPath
|
||||
}
|
||||
} else {
|
||||
var response = {
|
||||
success: 0,
|
||||
failed: 0
|
||||
}
|
||||
for (let i = 0; i < this.db.audiobooks.length; i++) {
|
||||
var audiobook = this.db.audiobooks[i]
|
||||
var savedPath = await audiobook.writeNfoFile()
|
||||
if (savedPath) {
|
||||
Logger.info(`[Scanner] Saved metadata nfo ${savedPath}`)
|
||||
response.success++
|
||||
} else {
|
||||
response.failed++
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
async find(req, res) {
|
||||
var method = req.params.method
|
||||
var query = req.query
|
||||
|
||||
104
server/Server.js
104
server/Server.js
@@ -4,6 +4,9 @@ const http = require('http')
|
||||
const SocketIO = require('socket.io')
|
||||
const fs = require('fs-extra')
|
||||
const fileUpload = require('express-fileupload')
|
||||
const rateLimit = require('express-rate-limit')
|
||||
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
const Watcher = require('./Watcher')
|
||||
@@ -14,6 +17,8 @@ const HlsController = require('./HlsController')
|
||||
const StreamManager = require('./StreamManager')
|
||||
const RssFeeds = require('./RssFeeds')
|
||||
const DownloadManager = require('./DownloadManager')
|
||||
const CoverController = require('./CoverController')
|
||||
// const EbookReader = require('./EbookReader')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
class Server {
|
||||
@@ -31,13 +36,16 @@ class Server {
|
||||
this.db = new Db(this.ConfigPath)
|
||||
this.auth = new Auth(this.db)
|
||||
this.watcher = new Watcher(this.AudiobookPath)
|
||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
|
||||
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
|
||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
||||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
||||
|
||||
// this.ebookReader = new EbookReader(this.db, this.MetadataPath, this.AudiobookPath)
|
||||
|
||||
this.server = null
|
||||
this.io = null
|
||||
|
||||
@@ -79,20 +87,31 @@ class Server {
|
||||
async filesChanged(files) {
|
||||
Logger.info('[Server]', files.length, 'Files Changed')
|
||||
var result = await this.scanner.filesChanged(files)
|
||||
Logger.info('[Server] Files changed result', result)
|
||||
Logger.debug('[Server] Files changed result', result)
|
||||
}
|
||||
|
||||
async scan() {
|
||||
async scan(forceAudioFileScan = false) {
|
||||
Logger.info('[Server] Starting Scan')
|
||||
this.isScanning = true
|
||||
this.isInitialized = true
|
||||
this.emitter('scan_start', 'files')
|
||||
var results = await this.scanner.scan()
|
||||
var results = await this.scanner.scan(forceAudioFileScan)
|
||||
this.isScanning = false
|
||||
this.emitter('scan_complete', { scanType: 'files', results })
|
||||
Logger.info('[Server] Scan complete')
|
||||
}
|
||||
|
||||
async scanAudiobook(socket, audiobookId) {
|
||||
var result = await this.scanner.scanAudiobookById(audiobookId)
|
||||
var scanResultName = ''
|
||||
for (const key in ScanResult) {
|
||||
if (ScanResult[key] === result) {
|
||||
scanResultName = key
|
||||
}
|
||||
}
|
||||
socket.emit('audiobook_scan_complete', scanResultName)
|
||||
}
|
||||
|
||||
async scanCovers() {
|
||||
Logger.info('[Server] Start cover scan')
|
||||
this.isScanningCovers = true
|
||||
@@ -108,6 +127,41 @@ class Server {
|
||||
this.scanner.cancelScan = true
|
||||
}
|
||||
|
||||
// Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
|
||||
async saveMetadata(socket, audiobookId = null) {
|
||||
Logger.info('[Server] Starting save metadata files')
|
||||
var response = await this.scanner.saveMetadata(audiobookId)
|
||||
Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
|
||||
socket.emit('save_metadata_complete', response)
|
||||
}
|
||||
|
||||
// Remove unused /metadata/books/{id} folders
|
||||
async purgeMetadata() {
|
||||
var booksMetadata = Path.join(this.MetadataPath, 'books')
|
||||
var booksMetadataExists = await fs.pathExists(booksMetadata)
|
||||
if (!booksMetadataExists) return
|
||||
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
|
||||
|
||||
var purged = 0
|
||||
await Promise.all(foldersInBooksMetadata.map(async foldername => {
|
||||
var hasMatchingAudiobook = this.audiobooks.find(ab => ab.id === foldername)
|
||||
if (!hasMatchingAudiobook) {
|
||||
var folderPath = Path.join(booksMetadata, foldername)
|
||||
Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
|
||||
|
||||
await fs.remove(folderPath).then(() => {
|
||||
purged++
|
||||
}).catch((err) => {
|
||||
Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
|
||||
})
|
||||
}
|
||||
}))
|
||||
if (purged > 0) {
|
||||
Logger.info(`[Server] Purged ${purged} unused audiobook metadata`)
|
||||
}
|
||||
return purged
|
||||
}
|
||||
|
||||
async init() {
|
||||
Logger.info('[Server] Init')
|
||||
await this.streamManager.ensureStreamsDir()
|
||||
@@ -117,6 +171,8 @@ class Server {
|
||||
await this.db.init()
|
||||
this.auth.init()
|
||||
|
||||
await this.purgeMetadata()
|
||||
|
||||
this.watcher.initWatcher()
|
||||
this.watcher.on('files', this.filesChanged.bind(this))
|
||||
}
|
||||
@@ -170,6 +226,21 @@ class Server {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// First time login rate limit is hit
|
||||
loginLimitReached(req, res, options) {
|
||||
Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
|
||||
options.message = 'Too many attempts. Login temporarily locked.'
|
||||
}
|
||||
|
||||
getLoginRateLimiter() {
|
||||
return rateLimit({
|
||||
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
|
||||
max: this.db.serverSettings.rateLimitLoginRequests,
|
||||
skipSuccessfulRequests: true,
|
||||
onLimitReached: this.loginLimitReached
|
||||
})
|
||||
}
|
||||
|
||||
async start() {
|
||||
Logger.info('=== Starting Server ===')
|
||||
await this.init()
|
||||
@@ -204,13 +275,18 @@ class Server {
|
||||
|
||||
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
|
||||
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
|
||||
// app.use('/hls', this.hlsController.router)
|
||||
app.use('/feeds', this.rssFeeds.router)
|
||||
|
||||
// Incomplete work in progress
|
||||
// app.use('/ebook', this.ebookReader.router)
|
||||
// app.use('/feeds', this.rssFeeds.router)
|
||||
|
||||
app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this))
|
||||
|
||||
app.post('/login', (req, res) => this.auth.login(req, res))
|
||||
var loginRateLimiter = this.getLoginRateLimiter()
|
||||
app.post('/login', loginRateLimiter, (req, res) => this.auth.login(req, res))
|
||||
|
||||
app.post('/logout', this.logout.bind(this))
|
||||
|
||||
app.get('/ping', (req, res) => {
|
||||
Logger.info('Recieved ping')
|
||||
res.json({ success: true })
|
||||
@@ -229,7 +305,6 @@ class Server {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
this.server.listen(this.Port, this.Host, () => {
|
||||
Logger.info(`Running on http://${this.Host}:${this.Port}`)
|
||||
})
|
||||
@@ -257,6 +332,8 @@ class Server {
|
||||
socket.on('scan', this.scan.bind(this))
|
||||
socket.on('scan_covers', this.scanCovers.bind(this))
|
||||
socket.on('cancel_scan', this.cancelScan.bind(this))
|
||||
socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId))
|
||||
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
|
||||
|
||||
// Streaming
|
||||
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
|
||||
@@ -269,11 +346,15 @@ class Server {
|
||||
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
|
||||
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
|
||||
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
|
||||
socket.on('test', () => {
|
||||
socket.emit('test_received', socket.id)
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
Logger.removeSocketListener(socket.id)
|
||||
|
||||
var _client = this.clients[socket.id]
|
||||
if (!_client) {
|
||||
Logger.warn('[SOCKET] Socket disconnect, no client ' + socket.id)
|
||||
@@ -337,6 +418,11 @@ class Server {
|
||||
stream: client.stream || null
|
||||
}
|
||||
client.socket.emit('init', initialPayload)
|
||||
|
||||
// Setup log listener for root user
|
||||
if (user.type === 'root') {
|
||||
Logger.addSocketListener(socket, this.db.serverSettings.logLevel || 0)
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
const Logger = require('../Logger')
|
||||
const AudioFileMetadata = require('./AudioFileMetadata')
|
||||
|
||||
class AudioFile {
|
||||
constructor(data) {
|
||||
this.index = null
|
||||
@@ -21,18 +24,19 @@ class AudioFile {
|
||||
this.channels = null
|
||||
this.channelLayout = null
|
||||
this.chapters = []
|
||||
this.embeddedCoverArt = null
|
||||
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
// Tags scraped from the audio file
|
||||
this.metadata = null
|
||||
|
||||
this.manuallyVerified = false
|
||||
this.invalid = false
|
||||
this.exclude = false
|
||||
this.error = null
|
||||
|
||||
// TEMP: For forcing rescan
|
||||
this.isOldAudioFile = false
|
||||
|
||||
if (data) {
|
||||
this.construct(data)
|
||||
}
|
||||
@@ -58,15 +62,13 @@ class AudioFile {
|
||||
size: this.size,
|
||||
bitRate: this.bitRate,
|
||||
language: this.language,
|
||||
codec: this.codec,
|
||||
timeBase: this.timeBase,
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout,
|
||||
chapters: this.chapters,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
tagTitle: this.tagTitle,
|
||||
tagTrack: this.tagTrack
|
||||
embeddedCoverArt: this.embeddedCoverArt,
|
||||
metadata: this.metadata ? this.metadata.toJSON() : {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,17 +93,21 @@ class AudioFile {
|
||||
this.size = data.size
|
||||
this.bitRate = data.bitRate
|
||||
this.language = data.language
|
||||
this.codec = data.codec
|
||||
this.codec = data.codec || null
|
||||
this.timeBase = data.timeBase
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channelLayout
|
||||
this.chapters = data.chapters
|
||||
this.embeddedCoverArt = data.embeddedCoverArt || null
|
||||
|
||||
this.tagAlbum = data.tagAlbum
|
||||
this.tagArtist = data.tagArtist
|
||||
this.tagGenre = data.tagGenre
|
||||
this.tagTitle = data.tagTitle
|
||||
this.tagTrack = data.tagTrack
|
||||
// Old version of AudioFile used `tagAlbum` etc.
|
||||
var isOldVersion = Object.keys(data).find(key => key.startsWith('tag'))
|
||||
if (isOldVersion) {
|
||||
this.isOldAudioFile = true
|
||||
this.metadata = new AudioFileMetadata(data)
|
||||
} else {
|
||||
this.metadata = new AudioFileMetadata(data.metadata || {})
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
@@ -126,23 +132,85 @@ class AudioFile {
|
||||
this.size = data.size
|
||||
this.bitRate = data.bit_rate || null
|
||||
this.language = data.language
|
||||
this.codec = data.codec
|
||||
this.codec = data.codec || null
|
||||
this.timeBase = data.time_base
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channel_layout
|
||||
this.chapters = data.chapters || []
|
||||
this.embeddedCoverArt = data.embedded_cover_art || null
|
||||
|
||||
this.tagAlbum = data.file_tag_album || null
|
||||
this.tagArtist = data.file_tag_artist || null
|
||||
this.tagGenre = data.file_tag_genre || null
|
||||
this.tagTitle = data.file_tag_title || null
|
||||
this.tagTrack = data.file_tag_track || null
|
||||
this.metadata = new AudioFileMetadata()
|
||||
this.metadata.setData(data)
|
||||
}
|
||||
|
||||
syncChapters(updatedChapters) {
|
||||
if (this.chapters.length !== updatedChapters.length) {
|
||||
this.chapters = updatedChapters.map(ch => ({ ...ch }))
|
||||
return true
|
||||
} else if (updatedChapters.length === 0) {
|
||||
if (this.chapters.length > 0) {
|
||||
this.chapters = []
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var hasUpdates = false
|
||||
for (let i = 0; i < updatedChapters.length; i++) {
|
||||
if (JSON.stringify(updatedChapters[i]) !== JSON.stringify(this.chapters[i])) {
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
if (hasUpdates) {
|
||||
this.chapters = updatedChapters.map(ch => ({ ...ch }))
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
// Called from audioFileScanner.js with scanData
|
||||
updateMetadata(data) {
|
||||
if (!this.metadata) this.metadata = new AudioFileMetadata()
|
||||
|
||||
var dataMap = {
|
||||
format: data.format,
|
||||
duration: data.duration,
|
||||
size: data.size,
|
||||
bitRate: data.bit_rate || null,
|
||||
language: data.language,
|
||||
codec: data.codec || null,
|
||||
timeBase: data.time_base,
|
||||
channels: data.channels,
|
||||
channelLayout: data.channel_layout,
|
||||
chapters: data.chapters || [],
|
||||
embeddedCoverArt: data.embedded_cover_art || null
|
||||
}
|
||||
|
||||
var hasUpdates = false
|
||||
for (const key in dataMap) {
|
||||
if (key === 'chapters') {
|
||||
var chaptersUpdated = this.syncChapters(dataMap.chapters)
|
||||
if (chaptersUpdated) {
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (dataMap[key] !== this[key]) {
|
||||
// Logger.debug(`[AudioFile] "${key}" from ${this[key]} => ${dataMap[key]}`)
|
||||
this[key] = dataMap[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (this.metadata.updateData(data)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new AudioFile(this.toJSON())
|
||||
}
|
||||
|
||||
// If the file or parent directory was renamed it is synced here
|
||||
syncFile(newFile) {
|
||||
var hasUpdates = false
|
||||
var keysToSync = ['path', 'fullPath', 'ext', 'filename']
|
||||
|
||||
97
server/objects/AudioFileMetadata.js
Normal file
97
server/objects/AudioFileMetadata.js
Normal file
@@ -0,0 +1,97 @@
|
||||
class AudioFileMetadata {
|
||||
constructor(metadata) {
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
this.tagSubtitle = null
|
||||
this.tagAlbumArtist = null
|
||||
this.tagDate = null
|
||||
this.tagComposer = null
|
||||
this.tagPublisher = null
|
||||
this.tagComment = null
|
||||
this.tagDescription = null
|
||||
this.tagEncoder = null
|
||||
this.tagEncodedBy = null
|
||||
|
||||
if (metadata) {
|
||||
this.construct(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
// Only return the tags that are actually set
|
||||
var json = {}
|
||||
for (const key in this) {
|
||||
if (key.startsWith('tag') && this[key]) {
|
||||
json[key] = this[key]
|
||||
}
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
construct(metadata) {
|
||||
this.tagAlbum = metadata.tagAlbum || null
|
||||
this.tagArtist = metadata.tagArtist || null
|
||||
this.tagGenre = metadata.tagGenre || null
|
||||
this.tagTitle = metadata.tagTitle || null
|
||||
this.tagTrack = metadata.tagTrack || null
|
||||
this.tagSubtitle = metadata.tagSubtitle || null
|
||||
this.tagAlbumArtist = metadata.tagAlbumArtist || null
|
||||
this.tagDate = metadata.tagDate || null
|
||||
this.tagComposer = metadata.tagComposer || null
|
||||
this.tagPublisher = metadata.tagPublisher || null
|
||||
this.tagComment = metadata.tagComment || null
|
||||
this.tagDescription = metadata.tagDescription || null
|
||||
this.tagEncoder = metadata.tagEncoder || null
|
||||
this.tagEncodedBy = metadata.tagEncodedBy || null
|
||||
}
|
||||
|
||||
// Data parsed in prober.js
|
||||
setData(payload) {
|
||||
this.tagAlbum = payload.file_tag_album || null
|
||||
this.tagArtist = payload.file_tag_artist || null
|
||||
this.tagGenre = payload.file_tag_genre || null
|
||||
this.tagTitle = payload.file_tag_title || null
|
||||
this.tagTrack = payload.file_tag_track || null
|
||||
this.tagSubtitle = payload.file_tag_subtitle || null
|
||||
this.tagAlbumArtist = payload.file_tag_albumartist || null
|
||||
this.tagDate = payload.file_tag_date || null
|
||||
this.tagComposer = payload.file_tag_composer || null
|
||||
this.tagPublisher = payload.file_tag_publisher || null
|
||||
this.tagComment = payload.file_tag_comment || null
|
||||
this.tagDescription = payload.file_tag_description || null
|
||||
this.tagEncoder = payload.file_tag_encoder || null
|
||||
this.tagEncodedBy = payload.file_tag_encodedby || null
|
||||
}
|
||||
|
||||
updateData(payload) {
|
||||
const dataMap = {
|
||||
tagAlbum: payload.file_tag_album || null,
|
||||
tagArtist: payload.file_tag_artist || null,
|
||||
tagGenre: payload.file_tag_genre || null,
|
||||
tagTitle: payload.file_tag_title || null,
|
||||
tagTrack: payload.file_tag_track || null,
|
||||
tagSubtitle: payload.file_tag_subtitle || null,
|
||||
tagAlbumArtist: payload.file_tag_albumartist || null,
|
||||
tagDate: payload.file_tag_date || null,
|
||||
tagComposer: payload.file_tag_composer || null,
|
||||
tagPublisher: payload.file_tag_publisher || null,
|
||||
tagComment: payload.file_tag_comment || null,
|
||||
tagDescription: payload.file_tag_description || null,
|
||||
tagEncoder: payload.file_tag_encoder || null,
|
||||
tagEncodedBy: payload.file_tag_encodedby || null
|
||||
}
|
||||
|
||||
var hasUpdates = false
|
||||
for (const key in dataMap) {
|
||||
if (dataMap[key] !== this[key]) {
|
||||
this[key] = dataMap[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = AudioFileMetadata
|
||||
@@ -20,12 +20,6 @@ class AudioTrack {
|
||||
this.channels = null
|
||||
this.channelLayout = null
|
||||
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
|
||||
if (audioTrack) {
|
||||
this.construct(audioTrack)
|
||||
}
|
||||
@@ -49,12 +43,6 @@ class AudioTrack {
|
||||
this.timeBase = audioTrack.timeBase
|
||||
this.channels = audioTrack.channels
|
||||
this.channelLayout = audioTrack.channelLayout
|
||||
|
||||
this.tagAlbum = audioTrack.tagAlbum
|
||||
this.tagArtist = audioTrack.tagArtist
|
||||
this.tagGenre = audioTrack.tagGenre
|
||||
this.tagTitle = audioTrack.tagTitle
|
||||
this.tagTrack = audioTrack.tagTrack
|
||||
}
|
||||
|
||||
get name() {
|
||||
@@ -74,14 +62,10 @@ class AudioTrack {
|
||||
size: this.size,
|
||||
bitRate: this.bitRate,
|
||||
language: this.language,
|
||||
codec: this.codec,
|
||||
timeBase: this.timeBase,
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
tagTitle: this.tagTitle,
|
||||
tagTrack: this.tagTrack
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,16 +83,22 @@ class AudioTrack {
|
||||
this.size = probeData.size
|
||||
this.bitRate = probeData.bitRate
|
||||
this.language = probeData.language
|
||||
this.codec = probeData.codec
|
||||
this.codec = probeData.codec || null
|
||||
this.timeBase = probeData.timeBase
|
||||
this.channels = probeData.channels
|
||||
this.channelLayout = probeData.channelLayout
|
||||
}
|
||||
|
||||
this.tagAlbum = probeData.file_tag_album || null
|
||||
this.tagArtist = probeData.file_tag_artist || null
|
||||
this.tagGenre = probeData.file_tag_genre || null
|
||||
this.tagTitle = probeData.file_tag_title || null
|
||||
this.tagTrack = probeData.file_tag_track || null
|
||||
syncMetadata(audioFile) {
|
||||
var hasUpdates = false
|
||||
var keysToSync = ['format', 'duration', 'size', 'bitRate', 'language', 'codec', 'timeBase', 'channels', 'channelLayout']
|
||||
keysToSync.forEach((key) => {
|
||||
if (audioFile[key] !== undefined && audioFile[key] !== this[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = audioFile[key]
|
||||
}
|
||||
})
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
syncFile(newFile) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const Path = require('path')
|
||||
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils')
|
||||
const { bytesPretty, elapsedPretty, readTextFile } = require('../utils/fileUtils')
|
||||
const { comparePaths, getIno } = require('../utils/index')
|
||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||
const nfoGenerator = require('../utils/nfoGenerator')
|
||||
const Logger = require('../Logger')
|
||||
const Book = require('./Book')
|
||||
const AudioTrack = require('./AudioTrack')
|
||||
@@ -103,7 +105,26 @@ class Audiobook {
|
||||
}
|
||||
|
||||
get invalidParts() {
|
||||
return (this.audioFiles || []).filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
|
||||
return this._audioFiles.filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
|
||||
}
|
||||
|
||||
get _audioFiles() { return this.audioFiles || [] }
|
||||
get _otherFiles() { return this.otherFiles || [] }
|
||||
|
||||
get ebooks() {
|
||||
return this.otherFiles.filter(file => file.filetype === 'ebook')
|
||||
}
|
||||
|
||||
get hasMissingIno() {
|
||||
return !this.ino || this._audioFiles.find(abf => !abf.ino) || this._otherFiles.find(f => !f.ino) || (this.tracks || []).find(t => !t.ino)
|
||||
}
|
||||
|
||||
get hasEmbeddedCoverArt() {
|
||||
return !!this._audioFiles.find(af => af.embeddedCoverArt)
|
||||
}
|
||||
|
||||
get hasDescriptionTextFile() {
|
||||
return !!this._otherFiles.find(of => of.filename === 'desc.txt')
|
||||
}
|
||||
|
||||
bookToJSON() {
|
||||
@@ -130,8 +151,8 @@ class Audiobook {
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
audioFiles: this._audioFiles.map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: this._otherFiles.map(otherFile => otherFile.toJSON()),
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
}
|
||||
@@ -152,6 +173,7 @@ class Audiobook {
|
||||
hasBookMatch: !!this.book,
|
||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||
numEbooks: this.ebooks.length,
|
||||
numTracks: this.tracks.length,
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
@@ -173,6 +195,7 @@ class Audiobook {
|
||||
invalidParts: this.invalidParts,
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
@@ -181,38 +204,74 @@ class Audiobook {
|
||||
}
|
||||
}
|
||||
|
||||
// Scanner had a bug that was saving a file path as the audiobook path.
|
||||
// audiobook path should be a directory.
|
||||
// fixing this before a scan prevents audiobooks being removed and re-added
|
||||
fixRelativePath(abRootPath) {
|
||||
var pathExt = Path.extname(this.path)
|
||||
if (pathExt) {
|
||||
this.path = Path.dirname(this.path)
|
||||
this.fullPath = Path.join(abRootPath, this.path)
|
||||
Logger.warn('Audiobook path has extname', pathExt, 'fixed path:', this.path)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Update was made to add ino values, ensure they are set
|
||||
// Originally files did not store the inode value
|
||||
// this function checks all files and sets the inode
|
||||
async checkUpdateInos() {
|
||||
var hasUpdates = false
|
||||
|
||||
// Audiobook folder needs inode
|
||||
if (!this.ino) {
|
||||
this.ino = await getIno(this.fullPath)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Check audio files have an inode
|
||||
for (let i = 0; i < this.audioFiles.length; i++) {
|
||||
var af = this.audioFiles[i]
|
||||
var at = this.tracks.find(t => t.ino === af.ino)
|
||||
if (!at) {
|
||||
at = this.tracks.find(t => comparePaths(t.path, af.path))
|
||||
if (!at && !af.exclude) {
|
||||
Logger.warn(`[Audiobook] No matching track for audio file "${af.filename}"`)
|
||||
}
|
||||
}
|
||||
if (!af.ino || af.ino === this.ino) {
|
||||
af.ino = await getIno(af.fullPath)
|
||||
if (!af.ino) {
|
||||
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath)
|
||||
} else {
|
||||
var track = this.tracks.find(t => comparePaths(t.path, af.path))
|
||||
if (track) {
|
||||
track.ino = af.ino
|
||||
Logger.debug(`[Audiobook] Set INO For audio file ${af.path}`)
|
||||
if (at) at.ino = af.ino
|
||||
}
|
||||
hasUpdates = true
|
||||
} else if (at && at.ino !== af.ino) {
|
||||
at.ino = af.ino
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.tracks.length; i++) {
|
||||
var at = this.tracks[i]
|
||||
if (!at.ino) {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} still does not have ino`)
|
||||
var atino = await getIno(at.fullPath)
|
||||
var af = this.audioFiles.find(_af => _af.ino === atino)
|
||||
if (!af) {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with ino ${atino}`)
|
||||
af = this.audioFiles.find(_af => _af.filename === at.filename)
|
||||
if (!af) {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with filename`)
|
||||
} else {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} found matching filename but mismatch ino ${atino}/${af.ino}`)
|
||||
// at.ino = af.ino
|
||||
// at.path = af.path
|
||||
// at.fullPath = af.fullPath
|
||||
// hasUpdates = true
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} found audio file with matching ino ${at.path}/${af.path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.otherFiles.length; i++) {
|
||||
var file = this.otherFiles[i]
|
||||
if (!file.ino || file.ino === this.ino) {
|
||||
file.ino = await getIno(file.fullPath)
|
||||
if (!file.ino) {
|
||||
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for other file', file.fullPath)
|
||||
} else {
|
||||
Logger.debug(`[Audiobook] Set INO For other file ${file.path}`)
|
||||
}
|
||||
hasUpdates = true
|
||||
}
|
||||
@@ -220,6 +279,11 @@ class Audiobook {
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
// Scans in v1.3.0 or lower will need to rescan audiofiles to pickup metadata and embedded cover
|
||||
checkNeedsAudioFileRescan() {
|
||||
return !!(this.audioFiles || []).find(af => af.isOldAudioFile || af.codec === null)
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.ino = data.ino || null
|
||||
@@ -329,6 +393,11 @@ class Audiobook {
|
||||
this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino)
|
||||
}
|
||||
|
||||
removeAudioTrack(track) {
|
||||
this.tracks = this.tracks.filter(t => t.ino !== track.ino)
|
||||
this.audioFiles = this.audioFiles.filter(f => f.ino !== track.ino)
|
||||
}
|
||||
|
||||
checkUpdateMissingParts() {
|
||||
var currMissingParts = (this.missingParts || []).join(',') || ''
|
||||
|
||||
@@ -357,22 +426,43 @@ class Audiobook {
|
||||
}
|
||||
|
||||
// On scan check other files found with other files saved
|
||||
syncOtherFiles(newOtherFiles) {
|
||||
async syncOtherFiles(newOtherFiles, forceRescan = false) {
|
||||
var hasUpdates = false
|
||||
|
||||
var currOtherFileNum = this.otherFiles.length
|
||||
|
||||
var alreadyHadDescTxt = this.otherFiles.find(of => of.filename === 'desc.txt')
|
||||
|
||||
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
||||
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
||||
|
||||
// Some files are not there anymore and filtered out
|
||||
if (currOtherFileNum !== this.otherFiles.length) {
|
||||
Logger.debug(`[Audiobook] ${currOtherFileNum - this.otherFiles.length} other files were removed for "${this.title}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// If desc.txt is new or forcing rescan then read it and update description if empty
|
||||
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
|
||||
if (descriptionTxt && (!alreadyHadDescTxt || forceRescan)) {
|
||||
var newDescription = await readTextFile(descriptionTxt.fullPath)
|
||||
if (newDescription) {
|
||||
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
|
||||
this.update({ book: { description: newDescription } })
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Should use inode
|
||||
newOtherFiles.forEach((file) => {
|
||||
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
|
||||
if (!existingOtherFile) {
|
||||
Logger.debug(`[Audiobook] New other file found on sync ${file.filename}/${file.filetype} | "${this.title}"`)
|
||||
Logger.debug(`[Audiobook] New other file found on sync ${file.filename} | "${this.title}"`)
|
||||
this.addOtherFile(file)
|
||||
hasUpdates = true
|
||||
}
|
||||
})
|
||||
|
||||
var hasUpdates = currOtherFileNum !== this.otherFiles.length
|
||||
|
||||
// Check if cover was a local image and that it still exists
|
||||
var imageFiles = this.otherFiles.filter(f => f.filetype === 'image')
|
||||
if (this.book.cover && this.book.cover.substr(1).startsWith('local')) {
|
||||
@@ -431,7 +521,6 @@ class Audiobook {
|
||||
|
||||
setChapters() {
|
||||
// If 1 audio file without chapters, then no chapters will be set
|
||||
|
||||
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
|
||||
if (includedAudioFiles.length === 1) {
|
||||
// 1 audio file with chapters
|
||||
@@ -467,12 +556,49 @@ class Audiobook {
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + file.duration,
|
||||
title: `Chapter ${currChapterId}`
|
||||
title: file.filename ? Path.basename(file.filename, Path.extname(file.filename)) : `Chapter ${currChapterId}`
|
||||
})
|
||||
currStartTime += file.duration
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeNfoFile(nfoFilename = 'metadata.nfo') {
|
||||
return nfoGenerator(this, nfoFilename)
|
||||
}
|
||||
|
||||
// Return cover filename
|
||||
async saveEmbeddedCoverArt(coverDirFullPath, coverDirRelPath) {
|
||||
var audioFileWithCover = this.audioFiles.find(af => af.embeddedCoverArt)
|
||||
if (!audioFileWithCover) return false
|
||||
|
||||
var coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
|
||||
var coverFilePath = Path.join(coverDirFullPath, coverFilename)
|
||||
|
||||
var success = await extractCoverArt(audioFileWithCover.fullPath, coverFilePath)
|
||||
if (success) {
|
||||
var coverRelPath = Path.join(coverDirRelPath, coverFilename)
|
||||
this.update({ book: { cover: coverRelPath } })
|
||||
return coverRelPath
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// If desc.txt exists then use it as description
|
||||
async saveDescriptionFromTextFile() {
|
||||
var descriptionTextFile = this.otherFiles.find(file => file.filename === 'desc.txt')
|
||||
if (!descriptionTextFile) return false
|
||||
var newDescription = await readTextFile(descriptionTextFile.fullPath)
|
||||
if (!newDescription) return false
|
||||
return this.update({ book: { description: newDescription } })
|
||||
}
|
||||
|
||||
// Audio file metadata tags map to EMPTY book details
|
||||
setDetailsFromFileMetadata() {
|
||||
if (!this.audioFiles.length) return false
|
||||
var audioFile = this.audioFiles[0]
|
||||
return this.book.setDetailsFromFileMetadata(audioFile.metadata)
|
||||
}
|
||||
}
|
||||
module.exports = Audiobook
|
||||
@@ -1,3 +1,4 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const parseAuthors = require('../utils/parseAuthors')
|
||||
@@ -182,5 +183,47 @@ class Book {
|
||||
isSearchMatch(search) {
|
||||
return this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(search)
|
||||
}
|
||||
|
||||
setDetailsFromFileMetadata(audioFileMetadata) {
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
tag: 'tagComposer',
|
||||
key: 'narrarator'
|
||||
},
|
||||
{
|
||||
tag: 'tagDescription',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
tag: 'tagPublisher',
|
||||
key: 'publisher'
|
||||
},
|
||||
{
|
||||
tag: 'tagDate',
|
||||
key: 'publishYear'
|
||||
},
|
||||
{
|
||||
tag: 'tagSubtitle',
|
||||
key: 'subtitle'
|
||||
},
|
||||
{
|
||||
tag: 'tagArtist',
|
||||
key: 'author'
|
||||
}
|
||||
]
|
||||
|
||||
var updatePayload = {}
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
if (!this[mapping.key] && audioFileMetadata[mapping.tag]) {
|
||||
updatePayload[mapping.key] = audioFileMetadata[mapping.tag]
|
||||
Logger.debug(`[Book] Mapping metadata to key ${mapping.tag} => ${mapping.key}: ${updatePayload[mapping.key]}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
return this.update(updatePayload)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
module.exports = Book
|
||||
@@ -1,12 +1,18 @@
|
||||
const { CoverDestination } = require('../utils/constants')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class ServerSettings {
|
||||
constructor(settings) {
|
||||
this.id = 'server-settings'
|
||||
|
||||
this.autoTagNew = false
|
||||
this.newTagExpireDays = 15
|
||||
this.scannerParseSubtitle = false
|
||||
this.coverDestination = CoverDestination.METADATA
|
||||
this.saveMetadataFile = false
|
||||
this.rateLimitLoginRequests = 10
|
||||
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
||||
this.logLevel = Logger.logLevel
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
@@ -18,6 +24,14 @@ class ServerSettings {
|
||||
this.newTagExpireDays = settings.newTagExpireDays
|
||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
|
||||
this.saveMetadataFile = !!settings.saveMetadataFile
|
||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
||||
this.logLevel = settings.logLevel || Logger.logLevel
|
||||
|
||||
if (this.logLevel !== Logger.logLevel) {
|
||||
Logger.setLogLevel(this.logLevel)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -26,7 +40,11 @@ class ServerSettings {
|
||||
autoTagNew: this.autoTagNew,
|
||||
newTagExpireDays: this.newTagExpireDays,
|
||||
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||
coverDestination: this.coverDestination
|
||||
coverDestination: this.coverDestination,
|
||||
saveMetadataFile: !!this.saveMetadataFile,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||
logLevel: this.logLevel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +52,9 @@ class ServerSettings {
|
||||
var hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (this[key] !== payload[key]) {
|
||||
if (key === 'logLevel') {
|
||||
Logger.setLogLevel(payload[key])
|
||||
}
|
||||
this[key] = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ class Stream extends EventEmitter {
|
||||
this.audiobook = audiobook
|
||||
|
||||
this.segmentLength = 6
|
||||
this.segmentBasename = 'output-%d.ts'
|
||||
this.streamPath = Path.join(streamPath, this.id)
|
||||
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
|
||||
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
|
||||
@@ -51,6 +50,16 @@ class Stream extends EventEmitter {
|
||||
return this.audiobook.totalDuration
|
||||
}
|
||||
|
||||
get hlsSegmentType() {
|
||||
var hasFlac = this.tracks.find(t => t.ext.toLowerCase() === '.flac')
|
||||
return hasFlac ? 'fmp4' : 'mpegts'
|
||||
}
|
||||
|
||||
get segmentBasename() {
|
||||
if (this.hlsSegmentType === 'fmp4') return 'output-%d.m4s'
|
||||
return 'output-%d.ts'
|
||||
}
|
||||
|
||||
get segmentStartNumber() {
|
||||
if (!this.startTime) return 0
|
||||
return Math.floor(this.startTime / this.segmentLength)
|
||||
@@ -98,7 +107,7 @@ class Stream extends EventEmitter {
|
||||
var userAudiobook = clientUserAudiobooks[this.audiobookId] || null
|
||||
if (userAudiobook) {
|
||||
var timeRemaining = this.totalDuration - userAudiobook.currentTime
|
||||
Logger.info('[STREAM] User has progress for audiobook', userAudiobook, `Time Remaining: ${timeRemaining}s`)
|
||||
Logger.info('[STREAM] User has progress for audiobook', userAudiobook.progress, `Time Remaining: ${timeRemaining}s`)
|
||||
if (timeRemaining > 15) {
|
||||
this.startTime = userAudiobook.currentTime
|
||||
this.clientCurrentTime = this.startTime
|
||||
@@ -133,7 +142,7 @@ class Stream extends EventEmitter {
|
||||
|
||||
async generatePlaylist() {
|
||||
fs.ensureDirSync(this.streamPath)
|
||||
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength)
|
||||
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength, this.hlsSegmentType)
|
||||
return this.clientPlaylistUri
|
||||
}
|
||||
|
||||
@@ -142,7 +151,7 @@ class Stream extends EventEmitter {
|
||||
var files = await fs.readdir(this.streamPath)
|
||||
files.forEach((file) => {
|
||||
var extname = Path.extname(file)
|
||||
if (extname === '.ts') {
|
||||
if (extname === '.ts' || extname === '.m4s') {
|
||||
var basename = Path.basename(file, extname)
|
||||
var num_part = basename.split('-')[1]
|
||||
var part_num = Number(num_part)
|
||||
@@ -238,24 +247,31 @@ class Stream extends EventEmitter {
|
||||
}
|
||||
|
||||
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||
const audioCodec = this.hlsSegmentType === 'fmp4' ? 'aac' : 'copy'
|
||||
this.ffmpeg.addOption([
|
||||
`-loglevel ${logLevel}`,
|
||||
'-map 0:a',
|
||||
'-c:a copy'
|
||||
`-c:a ${audioCodec}`
|
||||
])
|
||||
this.ffmpeg.addOption([
|
||||
const hlsOptions = [
|
||||
'-f hls',
|
||||
"-copyts",
|
||||
"-avoid_negative_ts disabled",
|
||||
"-max_delay 5000000",
|
||||
"-max_muxing_queue_size 2048",
|
||||
`-hls_time 6`,
|
||||
"-hls_segment_type mpegts",
|
||||
`-hls_segment_type ${this.hlsSegmentType}`,
|
||||
`-start_number ${this.segmentStartNumber}`,
|
||||
"-hls_playlist_type vod",
|
||||
"-hls_list_size 0",
|
||||
"-hls_allow_cache 0"
|
||||
])
|
||||
]
|
||||
if (this.hlsSegmentType === 'fmp4') {
|
||||
hlsOptions.push('-strict -2')
|
||||
var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
|
||||
hlsOptions.push(`-hls_fmp4_init_filename ${fmp4InitFilename}`)
|
||||
}
|
||||
this.ffmpeg.addOption(hlsOptions)
|
||||
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
|
||||
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
|
||||
this.ffmpeg.output(this.finalPlaylistPath)
|
||||
|
||||
@@ -9,6 +9,7 @@ class User {
|
||||
this.stream = null
|
||||
this.token = null
|
||||
this.isActive = true
|
||||
this.isLocked = false
|
||||
this.createdAt = null
|
||||
this.audiobooks = null
|
||||
|
||||
@@ -76,6 +77,7 @@ class User {
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooksToJSON(),
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
@@ -91,6 +93,7 @@ class User {
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooksToJSON(),
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
@@ -112,7 +115,8 @@ class User {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.isActive = (user.isActive === undefined || user.id === 'root') ? true : !!user.isActive
|
||||
this.isActive = (user.isActive === undefined || user.type === 'root') ? true : !!user.isActive
|
||||
this.isLocked = user.type === 'root' ? false : !!user.isLocked
|
||||
this.createdAt = user.createdAt || Date.now()
|
||||
this.settings = user.settings || this.getDefaultUserSettings()
|
||||
this.permissions = user.permissions || this.getDefaultUserPermissions()
|
||||
|
||||
@@ -2,6 +2,8 @@ const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const prober = require('./prober')
|
||||
|
||||
const ImageCodecs = ['mjpeg', 'jpeg', 'png']
|
||||
|
||||
function getDefaultAudioStream(audioStreams) {
|
||||
if (audioStreams.length === 1) return audioStreams[0]
|
||||
var defaultStream = audioStreams.find(a => a.is_default)
|
||||
@@ -37,6 +39,11 @@ async function scan(path) {
|
||||
chapters: probeData.chapters || []
|
||||
}
|
||||
|
||||
var hasCoverArt = probeData.video_stream ? ImageCodecs.includes(probeData.video_stream.codec) : false
|
||||
if (hasCoverArt) {
|
||||
finalData.embedded_cover_art = probeData.video_stream.codec
|
||||
}
|
||||
|
||||
for (const key in probeData) {
|
||||
if (probeData[key] && key.startsWith('file_tag')) {
|
||||
finalData[key] = probeData[key]
|
||||
@@ -76,6 +83,9 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
|
||||
if (series) partbasename = partbasename.replace(series, '')
|
||||
if (publishYear) partbasename = partbasename.replace(publishYear)
|
||||
|
||||
// Remove eg. "disc 1" from path
|
||||
partbasename = partbasename.replace(/ disc \d\d? /i, '')
|
||||
|
||||
var numbersinpath = partbasename.match(/\d+/g)
|
||||
if (!numbersinpath) return null
|
||||
|
||||
@@ -85,12 +95,14 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
|
||||
|
||||
async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
if (!newAudioFiles || !newAudioFiles.length) {
|
||||
Logger.error('[AudioFileScanner] Scan Audio Files no files', audiobook.title)
|
||||
Logger.error('[AudioFileScanner] Scan Audio Files no new files', audiobook.title)
|
||||
return
|
||||
}
|
||||
|
||||
var tracks = []
|
||||
var numDuplicateTracks = 0
|
||||
var numInvalidTracks = 0
|
||||
|
||||
for (let i = 0; i < newAudioFiles.length; i++) {
|
||||
var audioFile = newAudioFiles[i]
|
||||
var scanData = await scan(audioFile.fullPath)
|
||||
@@ -102,6 +114,7 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
|
||||
var trackNumFromMeta = getTrackNumberFromMeta(scanData)
|
||||
var book = audiobook.book || {}
|
||||
|
||||
var trackNumFromFilename = getTrackNumberFromFilename(book.title, book.author, book.series, book.publishYear, audioFile.filename)
|
||||
|
||||
var audioFileObj = {
|
||||
@@ -129,7 +142,7 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
}
|
||||
|
||||
if (tracks.find(t => t.index === trackNumber)) {
|
||||
Logger.debug('[AudioFileScanner] Duplicate track number for', audioFile.filename)
|
||||
// Logger.debug('[AudioFileScanner] Duplicate track number for', audioFile.filename)
|
||||
audioFile.invalid = true
|
||||
audioFile.error = 'Duplicate track number'
|
||||
numDuplicateTracks++
|
||||
@@ -175,4 +188,47 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
audiobook.tracks.sort((a, b) => a.index - b.index)
|
||||
}
|
||||
}
|
||||
module.exports.scanAudioFiles = scanAudioFiles
|
||||
module.exports.scanAudioFiles = scanAudioFiles
|
||||
|
||||
|
||||
async function rescanAudioFiles(audiobook) {
|
||||
|
||||
var audioFiles = audiobook.audioFiles
|
||||
var updates = 0
|
||||
|
||||
for (let i = 0; i < audioFiles.length; i++) {
|
||||
var audioFile = audioFiles[i]
|
||||
var scanData = await scan(audioFile.fullPath)
|
||||
if (!scanData || scanData.error) {
|
||||
Logger.error('[AudioFileScanner] Scan failed for', audioFile.path)
|
||||
// audiobook.invalidAudioFiles.push(parts[i])
|
||||
continue;
|
||||
}
|
||||
var hasUpdates = audioFile.updateMetadata(scanData)
|
||||
if (hasUpdates) {
|
||||
// Sync audio track with audio file
|
||||
var matchingAudioTrack = audiobook.tracks.find(t => t.ino === audioFile.ino)
|
||||
if (matchingAudioTrack) {
|
||||
matchingAudioTrack.syncMetadata(audioFile)
|
||||
} else if (!audioFile.exclude) { // If audio file is not excluded then it should have an audio track
|
||||
|
||||
// Fallback to checking path
|
||||
matchingAudioTrack = audiobook.tracks.find(t => t.path === audioFile.path)
|
||||
if (matchingAudioTrack) {
|
||||
Logger.warn(`[AudioFileScanner] Audio File mismatch ino with audio track "${audioFile.filename}"`)
|
||||
matchingAudioTrack.ino = audioFile.ino
|
||||
matchingAudioTrack.syncMetadata(audioFile)
|
||||
} else {
|
||||
Logger.error(`[AudioFileScanner] Audio File has no matching Track ${audioFile.filename} for "${audiobook.title}"`)
|
||||
|
||||
// Exclude audio file to prevent further errors
|
||||
// audioFile.exclude = true
|
||||
}
|
||||
}
|
||||
updates++
|
||||
}
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
module.exports.rescanAudioFiles = rescanAudioFiles
|
||||
@@ -9,4 +9,14 @@ module.exports.ScanResult = {
|
||||
module.exports.CoverDestination = {
|
||||
METADATA: 0,
|
||||
AUDIOBOOK: 1
|
||||
}
|
||||
|
||||
module.exports.LogLevel = {
|
||||
TRACE: 0,
|
||||
DEBUG: 1,
|
||||
INFO: 2,
|
||||
WARN: 3,
|
||||
ERROR: 4,
|
||||
FATAL: 5,
|
||||
NOTE: 6
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
const Ffmpeg = require('fluent-ffmpeg')
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const package = require('../../package.json')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
function escapeSingleQuotes(path) {
|
||||
// return path.replace(/'/g, '\'\\\'\'')
|
||||
@@ -64,4 +67,29 @@ async function writeMetadataFile(audiobook, outputPath) {
|
||||
await fs.writeFile(outputPath, inputstrs.join('\n'))
|
||||
return inputstrs
|
||||
}
|
||||
module.exports.writeMetadataFile = writeMetadataFile
|
||||
module.exports.writeMetadataFile = writeMetadataFile
|
||||
|
||||
async function extractCoverArt(filepath, outputpath) {
|
||||
var dirname = Path.dirname(outputpath)
|
||||
await fs.ensureDir(dirname)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
var ffmpeg = Ffmpeg(filepath)
|
||||
ffmpeg.addOption(['-map 0:v', '-frames:v 1'])
|
||||
ffmpeg.output(outputpath)
|
||||
|
||||
ffmpeg.on('start', (cmd) => {
|
||||
Logger.debug(`[FfmpegHelpers] Extract Cover Cmd: ${cmd}`)
|
||||
})
|
||||
ffmpeg.on('error', (err, stdout, stderr) => {
|
||||
Logger.error(`[FfmpegHelpers] Extract Cover Error ${err}`)
|
||||
resolve(false)
|
||||
})
|
||||
ffmpeg.on('end', () => {
|
||||
Logger.debug(`[FfmpegHelpers] Cover Art Extracted Successfully`)
|
||||
resolve(outputpath)
|
||||
})
|
||||
ffmpeg.run()
|
||||
})
|
||||
}
|
||||
module.exports.extractCoverArt = extractCoverArt
|
||||
@@ -1,4 +1,5 @@
|
||||
const fs = require('fs-extra')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
async function getFileStat(path) {
|
||||
try {
|
||||
@@ -24,14 +25,26 @@ async function getFileSize(path) {
|
||||
}
|
||||
module.exports.getFileSize = getFileSize
|
||||
|
||||
async function readTextFile(path) {
|
||||
try {
|
||||
var data = await fs.readFile(path)
|
||||
return String(data)
|
||||
} catch (error) {
|
||||
Logger.error(`[FileUtils] ReadTextFile error ${error}`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
module.exports.readTextFile = readTextFile
|
||||
|
||||
function bytesPretty(bytes, decimals = 0) {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes'
|
||||
}
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
var dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
if (i > 2 && dm === 0) dm = 1
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
module.exports.bytesPretty = bytesPretty
|
||||
|
||||
7
server/utils/globals.js
Normal file
7
server/utils/globals.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const globals = {
|
||||
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac'],
|
||||
SupportedEbookTypes: ['epub', 'pdf', 'mobi']
|
||||
}
|
||||
|
||||
module.exports = globals
|
||||
@@ -1,6 +1,8 @@
|
||||
const fs = require('fs-extra')
|
||||
|
||||
function getPlaylistStr(segmentName, duration, segmentLength) {
|
||||
function getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType) {
|
||||
var ext = hlsSegmentType === 'fmp4' ? 'm4s' : 'ts'
|
||||
|
||||
var lines = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
@@ -9,22 +11,25 @@ function getPlaylistStr(segmentName, duration, segmentLength) {
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXT-X-PLAYLIST-TYPE:VOD'
|
||||
]
|
||||
if (hlsSegmentType === 'fmp4') {
|
||||
lines.push('#EXT-X-MAP:URI="init.mp4"')
|
||||
}
|
||||
var numSegments = Math.floor(duration / segmentLength)
|
||||
var lastSegment = duration - (numSegments * segmentLength)
|
||||
for (let i = 0; i < numSegments; i++) {
|
||||
lines.push(`#EXTINF:6,`)
|
||||
lines.push(`${segmentName}-${i}.ts`)
|
||||
lines.push(`${segmentName}-${i}.${ext}`)
|
||||
}
|
||||
if (lastSegment > 0) {
|
||||
lines.push(`#EXTINF:${lastSegment},`)
|
||||
lines.push(`${segmentName}-${numSegments}.ts`)
|
||||
lines.push(`${segmentName}-${numSegments}.${ext}`)
|
||||
}
|
||||
lines.push('#EXT-X-ENDLIST')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function generatePlaylist(outputPath, segmentName, duration, segmentLength) {
|
||||
var playlistStr = getPlaylistStr(segmentName, duration, segmentLength)
|
||||
function generatePlaylist(outputPath, segmentName, duration, segmentLength, hlsSegmentType) {
|
||||
var playlistStr = getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType)
|
||||
return fs.writeFile(outputPath, playlistStr)
|
||||
}
|
||||
module.exports = generatePlaylist
|
||||
@@ -63,7 +63,3 @@ module.exports.getIno = (path) => {
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.isAcceptableCoverMimeType = (mimeType) => {
|
||||
return mimeType && mimeType.startsWith('image/')
|
||||
}
|
||||
91
server/utils/nfoGenerator.js
Normal file
91
server/utils/nfoGenerator.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const { bytesPretty } = require('./fileUtils')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const LEFT_COL_LEN = 25
|
||||
|
||||
function sectionHeaderLines(title) {
|
||||
return [title, ''.padEnd(10, '=')]
|
||||
}
|
||||
|
||||
function generateSection(sectionTitle, sectionData) {
|
||||
var lines = sectionHeaderLines(sectionTitle)
|
||||
for (const key in sectionData) {
|
||||
var line = key.padEnd(LEFT_COL_LEN) + (sectionData[key] || '')
|
||||
lines.push(line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
async function generate(audiobook, nfoFilename = 'metadata.nfo') {
|
||||
var jsonObj = audiobook.toJSON()
|
||||
var book = jsonObj.book
|
||||
|
||||
var generalSectionData = {
|
||||
'Title': book.title,
|
||||
'Subtitle': book.subtitle,
|
||||
'Author': book.author,
|
||||
'Narrator': book.narrarator,
|
||||
'Series': book.series,
|
||||
'Volume Number': book.volumeNumber,
|
||||
'Publish Year': book.publishYear,
|
||||
'Genre': book.genres ? book.genres.join(', ') : '',
|
||||
'Duration': audiobook.durationPretty,
|
||||
'Chapters': jsonObj.chapters.length
|
||||
}
|
||||
|
||||
if (!book.subtitle) {
|
||||
delete generalSectionData['Subtitle']
|
||||
}
|
||||
|
||||
if (!book.series) {
|
||||
delete generalSectionData['Series']
|
||||
delete generalSectionData['Volume Number']
|
||||
}
|
||||
|
||||
var tracks = audiobook.tracks
|
||||
var audioTrack = tracks.length ? audiobook.tracks[0] : {}
|
||||
|
||||
var totalBitrate = 0
|
||||
var numBitrates = 0
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
if (tracks[i].bitRate) {
|
||||
totalBitrate += tracks[i].bitRate
|
||||
numBitrates++
|
||||
}
|
||||
}
|
||||
var averageBitrate = numBitrates ? totalBitrate / numBitrates : 0
|
||||
|
||||
var mediaSectionData = {
|
||||
'Tracks': jsonObj.tracks.length,
|
||||
'Size': audiobook.sizePretty,
|
||||
'Codec': audioTrack.codec,
|
||||
'Ext': audioTrack.ext,
|
||||
'Channels': audioTrack.channels,
|
||||
'Channel Layout': audioTrack.channelLayout,
|
||||
'Average Bitrate': bytesPretty(averageBitrate)
|
||||
}
|
||||
|
||||
var bookSection = generateSection('Book Info', generalSectionData)
|
||||
|
||||
var descriptionSection = null
|
||||
if (book.description) {
|
||||
descriptionSection = sectionHeaderLines('Book Description')
|
||||
descriptionSection.push(book.description)
|
||||
}
|
||||
|
||||
var mediaSection = generateSection('Media Info', mediaSectionData)
|
||||
|
||||
var fullFile = bookSection.join('\n') + '\n\n'
|
||||
if (descriptionSection) fullFile += descriptionSection.join('\n') + '\n\n'
|
||||
fullFile += mediaSection.join('\n')
|
||||
|
||||
var nfoPath = Path.join(audiobook.fullPath, nfoFilename)
|
||||
var relativePath = Path.join(audiobook.path, nfoFilename)
|
||||
return fs.writeFile(nfoPath, fullFile).then(() => relativePath).catch((error) => {
|
||||
Logger.error(`Failed to write nfo file ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports = generate
|
||||
@@ -1,4 +1,6 @@
|
||||
var Ffmpeg = require('fluent-ffmpeg')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
function tryGrabBitRate(stream, all_streams, total_bit_rate) {
|
||||
if (!isNaN(stream.bit_rate) && stream.bit_rate) {
|
||||
@@ -72,6 +74,15 @@ function tryGrabTag(stream, tag) {
|
||||
return stream.tags[tag] || stream.tags[tag.toUpperCase()] || null
|
||||
}
|
||||
|
||||
function tryGrabTags(stream, ...tags) {
|
||||
if (!stream.tags) return null
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
var value = stream.tags[tags[i]] || stream.tags[tags[i].toUpperCase()]
|
||||
if (value) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
|
||||
var info = {
|
||||
index: stream.index,
|
||||
@@ -124,6 +135,53 @@ function parseChapters(chapters) {
|
||||
})
|
||||
}
|
||||
|
||||
function parseTags(format) {
|
||||
if (!format.tags) {
|
||||
return {}
|
||||
}
|
||||
// Logger.debug('Tags', format.tags)
|
||||
const tags = {
|
||||
file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'),
|
||||
file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'),
|
||||
file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'),
|
||||
file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'),
|
||||
file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'),
|
||||
file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'),
|
||||
file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'),
|
||||
file_tag_albumartist: tryGrabTags(format, 'albumartist', 'tpe2'),
|
||||
file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'),
|
||||
file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'),
|
||||
file_tag_publisher: tryGrabTags(format, 'publisher', 'tpub', 'tpb'),
|
||||
file_tag_comment: tryGrabTags(format, 'comment', 'comm', 'com'),
|
||||
file_tag_description: tryGrabTags(format, 'description', 'desc'),
|
||||
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
||||
|
||||
// Not sure if these are actually used yet or not
|
||||
file_tag_creation_time: tryGrabTag(format, 'creation_time'),
|
||||
file_tag_wwwaudiofile: tryGrabTags(format, 'wwwaudiofile', 'woaf', 'waf'),
|
||||
file_tag_contentgroup: tryGrabTags(format, 'contentgroup', 'tit1', 'tt1'),
|
||||
file_tag_releasetime: tryGrabTags(format, 'releasetime', 'tdrl'),
|
||||
file_tag_movementname: tryGrabTags(format, 'movementname', 'mvnm'),
|
||||
file_tag_movement: tryGrabTags(format, 'movement', 'mvin'),
|
||||
file_tag_series: tryGrabTag(format, 'series'),
|
||||
file_tag_seriespart: tryGrabTag(format, 'series-part'),
|
||||
file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
|
||||
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2')
|
||||
}
|
||||
for (const key in tags) {
|
||||
if (!tags[key]) {
|
||||
delete tags[key]
|
||||
}
|
||||
}
|
||||
|
||||
var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime']
|
||||
var success = keysToLookOutFor.find(key => !!tags[key])
|
||||
if (success) {
|
||||
Logger.debug('Notable!', success)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
function parseProbeData(data) {
|
||||
try {
|
||||
var { format, streams, chapters } = data
|
||||
@@ -131,20 +189,16 @@ function parseProbeData(data) {
|
||||
|
||||
var sizeBytes = !isNaN(size) ? Number(size) : null
|
||||
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
|
||||
|
||||
// Logger.debug('Parsing Data for', Path.basename(format.filename))
|
||||
var tags = parseTags(format)
|
||||
var cleanedData = {
|
||||
format: format_long_name,
|
||||
duration: !isNaN(duration) ? Number(duration) : null,
|
||||
size: sizeBytes,
|
||||
sizeMb,
|
||||
bit_rate: !isNaN(bit_rate) ? Number(bit_rate) : null,
|
||||
file_tag_encoder: tryGrabTag(format, 'encoder') || tryGrabTag(format, 'encoded_by'),
|
||||
file_tag_title: tryGrabTag(format, 'title'),
|
||||
file_tag_track: tryGrabTag(format, 'track') || tryGrabTag(format, 'trk'),
|
||||
file_tag_album: tryGrabTag(format, 'album') || tryGrabTag(format, 'tal'),
|
||||
file_tag_artist: tryGrabTag(format, 'artist') || tryGrabTag(format, 'tp1'),
|
||||
file_tag_date: tryGrabTag(format, 'date') || tryGrabTag(format, 'tye'),
|
||||
file_tag_genre: tryGrabTag(format, 'genre'),
|
||||
file_tag_creation_time: tryGrabTag(format, 'creation_time')
|
||||
...tags
|
||||
}
|
||||
|
||||
const cleaned_streams = streams.map(s => parseMediaStreamInfo(s, streams, cleanedData.bit_rate))
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
const Path = require('path')
|
||||
const dir = require('node-dir')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a']
|
||||
const INFO_FORMATS = ['nfo']
|
||||
const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp']
|
||||
const EBOOK_FORMATS = ['epub', 'pdf']
|
||||
const { getIno } = require('./index')
|
||||
const globals = require('./globals')
|
||||
|
||||
function getPaths(path) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -23,10 +20,10 @@ function isAudioFile(path) {
|
||||
if (!path) return false
|
||||
var ext = Path.extname(path)
|
||||
if (!ext) return false
|
||||
return AUDIO_FORMATS.includes(ext.slice(1).toLowerCase())
|
||||
return globals.SupportedAudioTypes.includes(ext.slice(1).toLowerCase())
|
||||
}
|
||||
|
||||
function groupFilesIntoAudiobookPaths(paths) {
|
||||
function groupFilesIntoAudiobookPaths(paths, useAllFileTypes = false) {
|
||||
// Step 1: Normalize path, Remove leading "/", Filter out files in root dir
|
||||
var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir)
|
||||
|
||||
@@ -37,11 +34,11 @@ function groupFilesIntoAudiobookPaths(paths) {
|
||||
return pathsA - pathsB
|
||||
})
|
||||
|
||||
// Step 2.5: Seperate audio files and other files
|
||||
// Step 2.5: Seperate audio files and other files (optional)
|
||||
var audioFilePaths = []
|
||||
var otherFilePaths = []
|
||||
pathsFiltered.forEach(path => {
|
||||
if (isAudioFile(path)) audioFilePaths.push(path)
|
||||
if (isAudioFile(path) || useAllFileTypes) audioFilePaths.push(path)
|
||||
else otherFilePaths.push(path)
|
||||
})
|
||||
|
||||
@@ -106,10 +103,10 @@ function cleanFileObjects(basepath, abrelpath, files) {
|
||||
function getFileType(ext) {
|
||||
var ext_cleaned = ext.toLowerCase()
|
||||
if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1)
|
||||
if (AUDIO_FORMATS.includes(ext_cleaned)) return 'audio'
|
||||
if (INFO_FORMATS.includes(ext_cleaned)) return 'info'
|
||||
if (IMAGE_FORMATS.includes(ext_cleaned)) return 'image'
|
||||
if (EBOOK_FORMATS.includes(ext_cleaned)) return 'ebook'
|
||||
if (globals.SupportedAudioTypes.includes(ext_cleaned)) return 'audio'
|
||||
if (ext_cleaned === 'nfo') return 'info'
|
||||
if (globals.SupportedImageTypes.includes(ext_cleaned)) return 'image'
|
||||
if (globals.SupportedEbookTypes.includes(ext_cleaned)) return 'ebook'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
@@ -134,7 +131,12 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
|
||||
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle)
|
||||
|
||||
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
||||
for (let i = 0; i < fileObjs.length; i++) {
|
||||
fileObjs[i].ino = await getIno(fileObjs[i].fullPath)
|
||||
}
|
||||
var audiobookIno = await getIno(audiobookData.fullPath)
|
||||
audiobooks.push({
|
||||
ino: audiobookIno,
|
||||
...audiobookData,
|
||||
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
|
||||
otherFiles: fileObjs.filter(f => f.filetype !== 'audio')
|
||||
@@ -241,11 +243,15 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
|
||||
otherFiles: []
|
||||
}
|
||||
|
||||
filepaths.forEach((filepath) => {
|
||||
for (let i = 0; i < filepaths.length; i++) {
|
||||
var filepath = filepaths[i]
|
||||
|
||||
var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
|
||||
var extname = Path.extname(filepath)
|
||||
var basename = Path.basename(filepath)
|
||||
var ino = await getIno(filepath)
|
||||
var fileObj = {
|
||||
ino,
|
||||
filetype: getFileType(extname),
|
||||
filename: basename,
|
||||
path: relpath,
|
||||
@@ -257,7 +263,7 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
|
||||
} else {
|
||||
audiobook.otherFiles.push(fileObj)
|
||||
}
|
||||
})
|
||||
}
|
||||
return audiobook
|
||||
}
|
||||
module.exports.getAudiobookFileData = getAudiobookFileData
|
||||
Reference in New Issue
Block a user