Compare commits

...

13 Commits

Author SHA1 Message Date
Mark Cooper d6cab8e591 logLevel as server setting, logger config page, re-scan audiobook option, fix embedded cover extraction, flac and mobi support, fix series bookshelf not wrapping 2021-09-30 18:52:32 -05:00
Mark Cooper dc18eb408e Scanner v4, audio file metadata used in setting book details, embedded cover art extracted and used 2021-09-29 20:43:36 -05:00
Mark Cooper 6f891208d0 Update readme 2021-09-29 11:04:35 -05:00
Mark Cooper 643040e635 Readme update 2021-09-29 10:52:59 -05:00
Mark Cooper 4c07f9ec25 Write metadata file option, rate limiting login attempts, generic failed login message 2021-09-29 10:16:38 -05:00
Mark Cooper 0ba38d45bc Readme update 2021-09-28 17:59:44 -05:00
Mark Cooper 0da327222e Fix config page not scrolling, add scroll arrows on home page, fix routing issue on back button, fix continue reading shelf 2021-09-28 17:36:41 -05:00
Mark Cooper 868e1af28a Starting point for home page 2021-09-28 06:44:40 -05:00
Mark Cooper a343a1038c Remove ino from file tables 2021-09-27 07:02:31 -05:00
Mark Cooper 3e5338ec8e Fixing scanner inodes, select all fix, starting ebook reader 2021-09-27 06:52:21 -05:00
Mark Cooper 01fdca4bf9 Fix docs link url 2021-09-26 17:21:10 -05:00
Mark Cooper ed96dd7c81 Readme update docs 2021-09-26 17:20:41 -05:00
Mark Cooper 1ead5de9f5 Hide volume number on selection mode 2021-09-26 15:44:25 -05:00
55 changed files with 1918 additions and 275 deletions
-1
View File
@@ -11,7 +11,6 @@ RUN npm run generate
### STAGE 2: Build server ### ### STAGE 2: Build server ###
FROM node:12-alpine FROM node:12-alpine
ENV NODE_ENV=production ENV NODE_ENV=production
ENV LOG_LEVEL=INFO
COPY --from=build /client/dist /client/dist COPY --from=build /client/dist /client/dist
COPY --from=ffmpeg / / COPY --from=ffmpeg / /
COPY index.js index.js COPY index.js index.js
+18
View File
@@ -9,20 +9,38 @@
height: calc(100% - 64px - 165px); height: calc(100% - 64px - 165px);
max-height: calc(100% - 64px - 165px); max-height: calc(100% - 64px - 165px);
} }
#bookshelf {
height: calc(100% - 40px);
}
/* width */ /* width */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
} }
/* ::-webkit-scrollbar:horizontal { */
/* height: 16px; */
/* height: 24px;
} */
/* Track */ /* Track */
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background-color: rgba(0,0,0,0); 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 */ /* Handle */
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #855620; background: #855620;
border-radius: 4px; 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 */ /* Handle on hover */
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #704922; background: #704922;
+1 -1
View File
@@ -440,7 +440,7 @@ export default {
}) })
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => { 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) { if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR') console.error('[HLS] BUFFER STALLED ERROR')
} }
+18 -10
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-16 bg-primary relative"> <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"> <div class="flex h-full items-center">
<img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" /> <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"> <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"> <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> <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" /> <div class="flex-grow" />
@@ -62,8 +64,11 @@ export default {
} }
}, },
computed: { computed: {
isHome() {
return this.$route.name === 'index'
},
showBack() { showBack() {
return this.$route.name !== 'library-id' return this.$route.name !== 'library-id' && !this.isHome
}, },
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
@@ -71,6 +76,7 @@ export default {
isRootUser() { isRootUser() {
return this.$store.getters['user/getIsRoot'] return this.$store.getters['user/getIsRoot']
}, },
username() { username() {
return this.user ? this.user.username : 'err' return this.user ? this.user.username : 'err'
}, },
@@ -87,7 +93,11 @@ export default {
return this.$store.state.user.user.audiobooks || {} return this.$store.state.user.user.audiobooks || {}
}, },
audiobooksShowing() { 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() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
@@ -110,12 +120,10 @@ export default {
} }
}, },
methods: { methods: {
back() { async back() {
if (this.$route.name === 'audiobook-id-edit') { var popped = await this.$store.dispatch('popRoute')
this.$router.push(`/audiobook/${this.$route.params.id}`) var backTo = popped || '/'
} else { this.$router.push(backTo)
this.$router.push('/library')
}
}, },
cancelSelectionMode() { cancelSelectionMode() {
if (this.processingBatchDelete) return if (this.processingBatchDelete) return
+6 -5
View File
@@ -23,7 +23,7 @@
<template v-for="entity in shelf"> <template v-for="entity in shelf">
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" /> <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-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> </template>
</div> </div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" /> <div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
@@ -68,11 +68,13 @@ export default {
}, },
selectedSeries() { selectedSeries() {
this.$nextTick(() => { this.$nextTick(() => {
this.$store.commit('audiobooks/setSelectedSeries', this.selectedSeries)
this.setBookshelfEntities() this.setBookshelfEntities()
}) })
}, },
searchResults() { searchResults() {
this.$nextTick(() => { this.$nextTick(() => {
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
this.setBookshelfEntities() this.setBookshelfEntities()
}) })
} }
@@ -100,7 +102,8 @@ export default {
return 16 * this.sizeMultiplier return 16 * this.sizeMultiplier
}, },
bookWidth() { bookWidth() {
return this.bookCoverWidth + this.paddingX * 2 var _width = this.bookCoverWidth + this.paddingX * 2
return this.showGroups ? _width * 1.6 : _width
}, },
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected'] return this.$store.getters['getNumAudiobooksSelected']
@@ -130,9 +133,6 @@ export default {
clickGroup(group) { clickGroup(group) {
this.$emit('update:selectedSeries', group.name) this.$emit('update:selectedSeries', group.name)
}, },
changeRotation() {
this.rotation = 'show-right'
},
clearFilter() { clearFilter() {
this.$store.commit('audiobooks/setKeywordFilter', null) this.$store.commit('audiobooks/setKeywordFilter', null)
if (this.filterBy !== 'all') { if (this.filterBy !== 'all') {
@@ -162,6 +162,7 @@ export default {
setBookshelfEntities() { setBookshelfEntities() {
this.wrapperClientWidth = this.$refs.wrapper.clientWidth this.wrapperClientWidth = this.$refs.wrapper.clientWidth
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2) var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
var booksPerRow = Math.floor(width / this.bookWidth) var booksPerRow = Math.floor(width / this.bookWidth)
var entities = this.entities var entities = this.entities
@@ -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
View 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>
+4 -3
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full h-10 relative"> <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"> <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'"> <template v-if="page !== 'search' && !isHome">
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p> <p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
<div v-else class="flex items-center"> <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"> <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-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" /> <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>
<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"> <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> <span class="material-icons text-3xl text-white">west</span>
</div> </div>
@@ -35,6 +35,7 @@
export default { export default {
props: { props: {
page: String, page: String,
isHome: Boolean,
selectedSeries: String, selectedSeries: String,
searchResults: { searchResults: {
type: Array, type: Array,
+16 -3
View File
@@ -1,14 +1,24 @@
<template> <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" /> <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"> <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" /> <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> </svg>
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p> <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>
<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'"> <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() { selectedClassName() {
return '' return ''
},
homePage() {
return this.$route.name === 'index'
} }
}, },
methods: {}, methods: {},
+13 -6
View File
@@ -8,9 +8,9 @@
<div class="absolute -bottom-4 left-0 triangle-right" /> <div class="absolute -bottom-4 left-0 triangle-right" />
</div> </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"> <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" /> <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"> <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> </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> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
</div> </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> <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"> <ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
@@ -78,8 +82,12 @@ export default {
audiobookId() { audiobookId() {
return this.audiobook.id return this.audiobook.id
}, },
hasEbook() {
return this.audiobook.numEbooks
},
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected'] // return this.$store.getters['getNumAudiobooksSelected']
return !!this.selectedAudiobooks.length
}, },
selectedAudiobooks() { selectedAudiobooks() {
return this.$store.state.selectedAudiobooks return this.$store.state.selectedAudiobooks
@@ -199,7 +207,6 @@ export default {
this.selectBtnClick() this.selectBtnClick()
} }
} }
}, }
mounted() {}
} }
</script> </script>
+50 -1
View File
@@ -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="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"> <div class="flex px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn> <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" /> <div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn> <ui-btn type="submit">Submit</ui-btn>
</div> </div>
@@ -87,7 +96,9 @@ export default {
}, },
newTags: [], newTags: [],
resettingProgress: false, resettingProgress: false,
isScrollable: false isScrollable: false,
savingMetadata: false,
rescanning: false
} }
}, },
watch: { watch: {
@@ -107,6 +118,9 @@ export default {
this.$emit('update:processing', val) this.$emit('update:processing', val)
} }
}, },
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
audiobookId() { audiobookId() {
return this.audiobook ? this.audiobook.id : null return this.audiobook ? this.audiobook.id : null
}, },
@@ -127,6 +141,41 @@ export default {
} }
}, },
methods: { 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() { async submitForm() {
if (this.isProcessing) { if (this.isProcessing) {
return return
+72
View 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>
+5
View File
@@ -190,6 +190,9 @@ export default {
download.status = this.$constants.DownloadStatus.EXPIRED download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download) this.$store.commit('downloads/addUpdateDownload', download)
}, },
logEvtReceived(payload) {
this.$store.commit('logs/logEvt', payload)
},
initializeSocket() { initializeSocket() {
this.socket = this.$nuxtSocket({ this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', 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_failed', this.downloadFailed)
this.socket.on('download_killed', this.downloadKilled) this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired) this.socket.on('download_expired', this.downloadExpired)
this.socket.on('log', this.logEvtReceived)
}, },
showUpdateToast(versionData) { showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion') var ignoreVersion = localStorage.getItem('ignoreVersion')
+1 -1
View File
@@ -1,7 +1,7 @@
export default function ({ store, redirect, route, app }) { export default function ({ store, redirect, route, app }) {
// If the user is not authenticated // If the user is not authenticated
if (!store.state.user.user) { 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}`) return redirect(`/login?redirect=${route.fullPath}`)
} }
} }
+24
View 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)
}
}
}
}
}
+4
View File
@@ -40,6 +40,10 @@ module.exports = {
] ]
}, },
router: {
middleware: ['routed']
},
// Global CSS: https://go.nuxtjs.dev/config-css // Global CSS: https://go.nuxtjs.dev/config-css
css: [ css: [
'@/assets/app.css' '@/assets/app.css'
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.1.13", "version": "1.2.4",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.2.4", "version": "1.3.1",
"description": "Audiobook manager and player", "description": "Audiobook manager and player",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+22
View File
@@ -42,6 +42,11 @@
Missing Missing
</ui-btn> </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-tooltip v-if="userCanUpdate" text="Edit" direction="top">
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" /> <ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
</ui-tooltip> </ui-tooltip>
@@ -86,6 +91,8 @@
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" /> <tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
</div> </div>
</div> </div>
<div id="area"></div>
</div> </div>
</div> </div>
</template> </template>
@@ -223,6 +230,9 @@ export default {
audioFiles() { audioFiles() {
return this.audiobook.audioFiles || [] return this.audiobook.audioFiles || []
}, },
ebooks() {
return this.audiobook.ebooks
},
description() { description() {
return this.book.description || '' return this.book.description || ''
}, },
@@ -261,6 +271,18 @@ export default {
} }
}, },
methods: { 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() { toggleRead() {
var updatePayload = { var updatePayload = {
isRead: !this.isRead isRead: !this.isRead
+17 -4
View File
@@ -1,5 +1,5 @@
<template> <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="w-full max-w-4xl mx-auto">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<h1 class="text-2xl">Users</h1> <h1 class="text-2xl">Users</h1>
@@ -34,8 +34,10 @@
</tr> </tr>
</table> </table>
</div> </div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" /> <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> <p class="text-2xl">Scanner</p>
<div class="flex items-start py-2"> <div class="flex items-start py-2">
<div class="py-2"> <div class="py-2">
@@ -50,11 +52,13 @@
<div class="w-40 flex flex-col"> <div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn> <ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
<div class="w-full"> <div class="w-full mb-4">
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." 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">
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn> <ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
</ui-tooltip> </ui-tooltip>
</div> </div>
<!-- <ui-btn color="primary" small @click="saveMetadataFiles">Save Metadata</ui-btn> -->
</div> </div>
</div> </div>
</div> </div>
@@ -63,6 +67,8 @@
<div class="flex items-center py-4"> <div class="flex items-center py-4">
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn> <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>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" /> <div class="h-0.5 bg-primary bg-opacity-50 w-full" />
@@ -132,6 +138,9 @@ export default {
var payload = { var payload = {
scannerParseSubtitle: val scannerParseSubtitle: val
} }
this.updateServerSettings(payload)
},
updateServerSettings(payload) {
this.$store this.$store
.dispatch('updateServerSettings', payload) .dispatch('updateServerSettings', payload)
.then((success) => { .then((success) => {
@@ -152,6 +161,9 @@ export default {
scanCovers() { scanCovers() {
this.$root.socket.emit('scan_covers') this.$root.socket.emit('scan_covers')
}, },
saveMetadataFiles() {
this.$root.socket.emit('save_metadata')
},
loadUsers() { loadUsers() {
this.$axios this.$axios
.$get('/api/users') .$get('/api/users')
@@ -170,11 +182,12 @@ export default {
.then(() => { .then(() => {
this.isResettingAudiobooks = false this.isResettingAudiobooks = false
this.$toast.success('Successfully reset audiobooks') this.$toast.success('Successfully reset audiobooks')
location.reload()
}) })
.catch((error) => { .catch((error) => {
console.error('failed to reset audiobooks', error) console.error('failed to reset audiobooks', error)
this.isResettingAudiobooks = false 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')
}) })
} }
}, },
+136
View 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>
+11 -3
View File
@@ -7,14 +7,22 @@
<!-- <app-book-shelf /> --> <!-- <app-book-shelf /> -->
<!-- </div> --> <!-- </div> -->
<!-- </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> </div>
</template> </template>
<script> <script>
export default { export default {
asyncData({ redirect }) { // asyncData({ redirect }) {
redirect('/library') // redirect('/library')
}, // },
data() { data() {
return {} return {}
}, },
+8 -2
View File
@@ -24,12 +24,18 @@ export default {
console.error('Search error', error) console.error('Search error', error)
return [] 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 { return {
id: params.id, id: libraryPage,
searchQuery, searchQuery,
searchResults, searchResults,
selectedSeries: query.series ? app.$decode(query.series) : null selectedSeries
} }
}, },
data() { data() {
+6 -8
View File
@@ -37,7 +37,7 @@ export default {
if (this.$route.query.redirect) { if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect) this.$router.replace(this.$route.query.redirect)
} else { } else {
this.$router.replace('/library') this.$router.replace('/')
} }
} }
} }
@@ -57,15 +57,14 @@ export default {
password: this.password || '' password: this.password || ''
} }
var authRes = await this.$axios.$post('/login', payload).catch((error) => { 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 return false
}) })
console.log('Auth res', authRes) if (authRes && authRes.error) {
if (!authRes) {
this.error = 'Unknown Failure'
} else if (authRes.error) {
this.error = authRes.error this.error = authRes.error
} else { } else if (authRes) {
this.$store.commit('user/setUser', authRes.user) this.$store.commit('user/setUser', authRes.user)
} }
this.processing = false this.processing = false
@@ -77,7 +76,6 @@ export default {
if (token) { if (token) {
this.processing = true this.processing = true
console.log('Authorize', token)
this.$axios this.$axios
.$post('/api/authorize', null, { .$post('/api/authorize', null, {
headers: { headers: {
+2 -1
View File
@@ -16,6 +16,7 @@ export default function ({ $axios, store }) {
$axios.onError(error => { $axios.onError(error => {
const code = parseInt(error.response && error.response.status) 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)
}) })
} }
+33 -2
View File
@@ -10,13 +10,32 @@ export const state = () => ({
genres: [...STANDARD_GENRES], genres: [...STANDARD_GENRES],
tags: [], tags: [],
series: [], series: [],
keywordFilter: null keywordFilter: null,
selectedSeries: null,
libraryPage: null,
searchResults: []
}) })
export const getters = { export const getters = {
getAudiobook: (state) => id => { getAudiobook: (state) => id => {
return state.audiobooks.find(ab => ab.id === 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) => () => { getFiltered: (state, getters, rootState, rootGetters) => () => {
var filtered = state.audiobooks var filtered = state.audiobooks
var settings = rootState.user.settings || {} var settings = rootState.user.settings || {}
@@ -69,12 +88,15 @@ export const getters = {
state.audiobooks.forEach((audiobook) => { state.audiobooks.forEach((audiobook) => {
if (audiobook.book && audiobook.book.series) { if (audiobook.book && audiobook.book.series) {
if (series[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) series[audiobook.book.series].books.push(audiobook)
} else { } else {
series[audiobook.book.series] = { series[audiobook.book.series] = {
type: 'series', type: 'series',
name: audiobook.book.series || '', name: audiobook.book.series || '',
books: [audiobook] books: [audiobook],
lastUpdate: audiobook.book.lastUpdate
} }
} }
} }
@@ -159,6 +181,15 @@ export const mutations = {
setKeywordFilter(state, val) { setKeywordFilter(state, val) {
state.keywordFilter = 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) { set(state, audiobooks) {
// GENRES // GENRES
var genres = [...state.genres] var genres = [...state.genres]
+26 -3
View File
@@ -1,4 +1,5 @@
import { checkForUpdate } from '@/plugins/version' import { checkForUpdate } from '@/plugins/version'
import Vue from 'vue'
export const state = () => ({ export const state = () => ({
versionData: null, versionData: null,
@@ -14,7 +15,9 @@ export const state = () => ({
coverScanProgress: null, coverScanProgress: null,
developerMode: false, developerMode: false,
selectedAudiobooks: [], selectedAudiobooks: [],
processingBatch: false processingBatch: false,
previousPath: '/',
routeHistory: []
}) })
export const getters = { export const getters = {
@@ -51,10 +54,25 @@ export const actions = {
console.error('Update check failed', error) console.error('Update check failed', error)
return false 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 = { export const mutations = {
setRouteHistory(state, val) {
state.routeHistory = val
},
setPreviousPath(state, val) {
state.previousPath = val
},
setVersionData(state, versionData) { setVersionData(state, versionData) {
state.versionData = versionData state.versionData = versionData
}, },
@@ -112,13 +130,18 @@ export const mutations = {
state.developerMode = val state.developerMode = val
}, },
setSelectedAudiobooks(state, audiobooks) { setSelectedAudiobooks(state, audiobooks) {
state.selectedAudiobooks = audiobooks Vue.set(state, 'selectedAudiobooks', audiobooks)
// state.selectedAudiobooks = audiobooks
}, },
toggleAudiobookSelected(state, audiobookId) { toggleAudiobookSelected(state, audiobookId) {
if (state.selectedAudiobooks.includes(audiobookId)) { if (state.selectedAudiobooks.includes(audiobookId)) {
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId) state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
} else { } 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) { setProcessingBatch(state, val) {
+31
View 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)
}
}
}
+6
View File
@@ -16,6 +16,12 @@ module.exports = {
height: { height: {
'7.5': '1.75rem' '7.5': '1.75rem'
}, },
spacing: {
'-54': '-13.5rem'
},
rotate: {
'-60': '-60deg'
},
colors: { colors: {
bg: '#373838', bg: '#373838',
primary: '#232323', primary: '#232323',
+6 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.1.13", "version": "1.2.7",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -568,6 +568,11 @@
"busboy": "^0.3.1" "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=="
},
"finalhandler": { "finalhandler": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.2.4", "version": "1.3.1",
"description": "Self-hosted audiobook server for managing and playing audiobooks", "description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -29,6 +29,7 @@
"cookie-parser": "^1.4.5", "cookie-parser": "^1.4.5",
"express": "^4.17.1", "express": "^4.17.1",
"express-fileupload": "^1.2.1", "express-fileupload": "^1.2.1",
"express-rate-limit": "^5.3.0",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"ip": "^1.1.5", "ip": "^1.1.5",
+6 -6
View File
@@ -2,7 +2,7 @@
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks. 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) Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
@@ -13,6 +13,8 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
## Directory Structure ## Directory Structure
See [documentation](https://audiobookshelf.org/docs) for directory structure and naming.
Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure. Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure.
**Note**: Files in the root directory `/audiobooks` will be ignored, all audiobooks should be in a directory **Note**: Files in the root directory `/audiobooks` will be ignored, all audiobooks should be in a directory
@@ -68,6 +70,8 @@ Will save the title as `Hackers` and the subtitle as `Heroes of the Computer Rev
## Installation ## Installation
** Default username is "root" with no password
### Docker Install ### Docker Install
Available in Unraid Community Apps Available in Unraid Community Apps
@@ -113,11 +117,7 @@ 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). Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
```bash See [instructions](https://www.audiobookshelf.org/install#debian)
wget https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf_1.2.3_amd64.deb
sudo apt install ./audiobookshelf_1.2.3_amd64.deb
```
#### File locations #### File locations
-6
View File
@@ -37,7 +37,6 @@ class ApiController {
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this)) this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
this.router.patch('/audiobook/:id', this.updateAudiobook.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.patch('/match/:id', this.match.bind(this))
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this)) this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
@@ -70,11 +69,6 @@ class ApiController {
this.scanner.findCovers(req, res) 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) { authorize(req, res) {
if (!req.user) { if (!req.user) {
Logger.error('Invalid user in authorize') Logger.error('Invalid user in authorize')
+22 -10
View File
@@ -103,18 +103,18 @@ class Auth {
var user = this.users.find(u => u.username === username) var user = this.users.find(u => u.username === username)
if (!user) { if (!user || !user.isActive) {
return res.json({ error: 'User not found' }) 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`})`)
if (!user.isActive) { }
return res.json({ error: 'User unavailable' }) return res.status(401).send('Invalid user or password')
} }
// Check passwordless root user // Check passwordless root user
if (user.id === 'root' && (!user.pash || user.pash === '')) { if (user.id === 'root' && (!user.pash || user.pash === '')) {
if (password) { 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 { } else {
return res.json({ user: user.toJSONForBrowser() }) return res.json({ user: user.toJSONForBrowser() })
} }
@@ -127,12 +127,24 @@ class Auth {
user: user.toJSONForBrowser() user: user.toJSONForBrowser()
}) })
} else { } else {
res.json({ Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
error: 'Invalid Password' 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) { comparePassword(password, user) {
if (user.type === 'root' && !password && !user.pash) return true if (user.type === 'root' && !password && !user.pash) return true
if (!password || !user.pash) return false if (!password || !user.pash) return false
+42
View 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
+3 -2
View File
@@ -21,7 +21,7 @@ class HlsController {
} }
parseSegmentFilename(filename) { parseSegmentFilename(filename) {
var basename = Path.basename(filename, '.ts') var basename = Path.basename(filename, Path.extname(filename))
var num_part = basename.split('-')[1] var num_part = basename.split('-')[1]
return Number(num_part) return Number(num_part)
} }
@@ -41,7 +41,7 @@ class HlsController {
Logger.warn('File path does not exist', fullFilePath) Logger.warn('File path does not exist', fullFilePath)
var fileExt = Path.extname(req.params.file) var fileExt = Path.extname(req.params.file)
if (fileExt === '.ts') { if (fileExt === '.ts' || fileExt === '.m4s') {
var segNum = this.parseSegmentFilename(req.params.file) var segNum = this.parseSegmentFilename(req.params.file)
var stream = this.streamManager.getStream(streamId) var stream = this.streamManager.getStream(streamId)
if (!stream) { if (!stream) {
@@ -66,6 +66,7 @@ class HlsController {
} }
} }
} }
// Logger.info('Sending file', fullFilePath) // Logger.info('Sending file', fullFilePath)
res.sendFile(fullFilePath) res.sendFile(fullFilePath)
} }
+76 -21
View File
@@ -1,55 +1,110 @@
const LOG_LEVEL = { const { LogLevel } = require('./utils/constants')
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
FATAL: 5
}
class Logger { class Logger {
constructor() { constructor() {
let env_log_level = process.env.LOG_LEVEL || 'TRACE' this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
this.LogLevel = LOG_LEVEL[env_log_level] || LOG_LEVEL.TRACE this.socketListeners = []
this.info(`Log Level: ${this.LogLevel}`)
} }
get timestamp() { get timestamp() {
return (new Date()).toISOString() 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) { trace(...args) {
if (this.LogLevel > LOG_LEVEL.TRACE) return if (this.logLevel > LogLevel.TRACE) return
console.trace(`[${this.timestamp}] TRACE:`, ...args) console.trace(`[${this.timestamp}] TRACE:`, ...args)
this.logToSockets(LogLevel.TRACE, args)
} }
debug(...args) { debug(...args) {
if (this.LogLevel > LOG_LEVEL.DEBUG) return if (this.logLevel > LogLevel.DEBUG) return
console.debug(`[${this.timestamp}] DEBUG:`, ...args) console.debug(`[${this.timestamp}] DEBUG:`, ...args)
this.logToSockets(LogLevel.DEBUG, args)
} }
info(...args) { info(...args) {
if (this.LogLevel > LOG_LEVEL.INFO) return if (this.logLevel > LogLevel.INFO) return
console.info(`[${this.timestamp}] INFO:`, ...args) console.info(`[${this.timestamp}] INFO:`, ...args)
} this.logToSockets(LogLevel.INFO, args)
note(...args) {
if (this.LogLevel > LOG_LEVEL.INFO) return
console.log(`[${this.timestamp}] NOTE:`, ...args)
} }
warn(...args) { warn(...args) {
if (this.LogLevel > LOG_LEVEL.WARN) return if (this.logLevel > LogLevel.WARN) return
console.warn(`[${this.timestamp}] WARN:`, ...args) console.warn(`[${this.timestamp}] WARN:`, ...args)
this.logToSockets(LogLevel.WARN, args)
} }
error(...args) { error(...args) {
if (this.LogLevel > LOG_LEVEL.ERROR) return if (this.logLevel > LogLevel.ERROR) return
console.error(`[${this.timestamp}] ERROR:`, ...args) console.error(`[${this.timestamp}] ERROR:`, ...args)
this.logToSockets(LogLevel.ERROR, args)
} }
fatal(...args) { fatal(...args) {
console.error(`[${this.timestamp}] 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() module.exports = new Logger()
+138 -61
View File
@@ -7,12 +7,14 @@ const audioFileScanner = require('./utils/audioFileScanner')
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir') const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
const { comparePaths, getIno } = require('./utils/index') const { comparePaths, getIno } = require('./utils/index')
const { secondsToTimestamp } = require('./utils/fileUtils') const { secondsToTimestamp } = require('./utils/fileUtils')
const { ScanResult } = require('./utils/constants') const { ScanResult, CoverDestination } = require('./utils/constants')
class Scanner { class Scanner {
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) { constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
this.AudiobookPath = AUDIOBOOK_PATH this.AudiobookPath = AUDIOBOOK_PATH
this.MetadataPath = METADATA_PATH this.MetadataPath = METADATA_PATH
this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
this.db = db this.db = db
this.emitter = emitter this.emitter = emitter
@@ -25,23 +27,18 @@ class Scanner {
return this.db.audiobooks return this.db.audiobooks
} }
async setAudiobookDataInos(audiobookData) { getCoverDirectory(audiobook) {
for (let i = 0; i < audiobookData.length; i++) { if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
var abd = audiobookData[i] return {
var matchingAB = this.db.audiobooks.find(_ab => comparePaths(_ab.path, abd.path)) fullPath: audiobook.fullPath,
if (matchingAB) { relPath: Path.join('/local', audiobook.path)
if (!matchingAB.ino) { }
matchingAB.ino = await getIno(matchingAB.fullPath) } else {
} return {
abd.ino = matchingAB.ino fullPath: Path.join(this.BookMetadataPath, audiobook.id),
} else { relPath: Path.join('/metadata', 'books', audiobook.id)
abd.ino = await getIno(abd.fullPath)
if (!abd.ino) {
Logger.error('[Scanner] Invalid ino - ignoring audiobook data', abd.path)
}
} }
} }
return audiobookData.filter(abd => !!abd.ino)
} }
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) { async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
@@ -63,12 +60,19 @@ class Scanner {
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino) return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
} }
async scanAudiobookData(audiobookData) { async scanAudiobookData(audiobookData, forceAudioFileScan = false) {
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino) var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`) // Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
if (existingAudiobook) { if (existingAudiobook) {
// 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 // REMOVE: No valid audio files
// TODO: Label as incomplete, do not actually delete // TODO: Label as incomplete, do not actually delete
if (!audiobookData.audioFiles.length) { if (!audiobookData.audioFiles.length) {
@@ -80,7 +84,8 @@ class Scanner {
return ScanResult.REMOVED 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 // Check for audio files that were removed
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino) var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
@@ -90,6 +95,13 @@ class Scanner {
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af)) 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 // Check for new audio files and sync existing audio files
var newAudioFiles = [] var newAudioFiles = []
var hasUpdatedAudioFiles = false var hasUpdatedAudioFiles = false
@@ -108,13 +120,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) { if (newAudioFiles.length) {
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`) 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) 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 // TODO: Label as incomplete, do not actually delete
if (!existingAudiobook.tracks.length) { if (!existingAudiobook.tracks.length) {
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`) Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
@@ -124,14 +158,17 @@ class Scanner {
return ScanResult.REMOVED return ScanResult.REMOVED
} }
var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles var hasUpdates = removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
// Check that audio tracks are in sequential order with no gaps
if (existingAudiobook.checkUpdateMissingParts()) { if (existingAudiobook.checkUpdateMissingParts()) {
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`) Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
hasUpdates = true 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 hasUpdates = true
} }
@@ -174,6 +211,19 @@ class Scanner {
return ScanResult.NOTHING 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.checkUpdateMissingParts()
audiobook.setChapters() audiobook.setChapters()
@@ -183,31 +233,29 @@ class Scanner {
return ScanResult.ADDED return ScanResult.ADDED
} }
async scan() { async scan(forceAudioFileScan = false) {
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos" // TODO: This temporary fix from pre-release should be removed soon, "checkUpdateInos"
// TEMP - fix relative file paths
// TEMP - update ino for each audiobook // TEMP - update ino for each audiobook
// if (this.audiobooks.length) { if (this.audiobooks.length) {
// for (let i = 0; i < this.audiobooks.length; i++) { for (let i = 0; i < this.audiobooks.length; i++) {
// var ab = this.audiobooks[i] var ab = this.audiobooks[i]
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino // Update ino if inos are not set
var shouldUpdateIno = ab.hasMissingIno
// // Update ino if an audio file has the same ino as the audiobook if (shouldUpdateIno) {
// var shouldUpdateIno = !ab.ino || (ab.audioFiles || []).find(abf => abf.ino === ab.ino) Logger.debug(`Updating inos for ${ab.title}`)
// if (shouldUpdateIno) { var hasUpdates = await ab.checkUpdateInos()
// await ab.checkUpdateInos() if (hasUpdates) {
// } await this.db.updateAudiobook(ab)
// if (shouldUpdate) { }
// await this.db.updateAudiobook(ab) }
// } }
// } }
// }
const scanStart = Date.now() const scanStart = Date.now()
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings) var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
// Set ino for each ab data as a string // Remove audiobooks with no inode
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound) audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
if (this.cancelScan) { if (this.cancelScan) {
this.cancelScan = false this.cancelScan = false
@@ -241,8 +289,7 @@ class Scanner {
// Check for new and updated audiobooks // Check for new and updated audiobooks
for (let i = 0; i < audiobookDataFound.length; i++) { for (let i = 0; i < audiobookDataFound.length; i++) {
var audiobookData = audiobookDataFound[i] var result = await this.scanAudiobookData(audiobookDataFound[i], forceAudioFileScan)
var result = await this.scanAudiobookData(audiobookData)
if (result === ScanResult.ADDED) scanResults.added++ if (result === ScanResult.ADDED) scanResults.added++
if (result === ScanResult.REMOVED) scanResults.removed++ if (result === ScanResult.REMOVED) scanResults.removed++
if (result === ScanResult.UPDATED) scanResults.updated++ if (result === ScanResult.UPDATED) scanResults.updated++
@@ -266,14 +313,24 @@ class Scanner {
return scanResults 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) Logger.debug('[Scanner] scanAudiobook', audiobookPath)
var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings) var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings)
if (!audiobookData) { if (!audiobookData) {
return ScanResult.NOTHING return ScanResult.NOTHING
} }
audiobookData.ino = await getIno(audiobookData.fullPath) audiobookData.ino = await getIno(audiobookData.fullPath)
return this.scanAudiobookData(audiobookData) return this.scanAudiobookData(audiobookData, forceAudioFileScan)
} }
// Files were modified in this directory, check it out // Files were modified in this directory, check it out
@@ -323,7 +380,7 @@ class Scanner {
async filesChanged(filepaths) { async filesChanged(filepaths) {
if (!filepaths.length) return ScanResult.NOTHING if (!filepaths.length) return ScanResult.NOTHING
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, '')) var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths) var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
var results = [] var results = []
for (const dir in fileGroupings) { for (const dir in fileGroupings) {
@@ -336,19 +393,6 @@ class Scanner {
return results 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() { async scanCovers() {
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author) var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
var found = 0 var found = 0
@@ -391,6 +435,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) { async find(req, res) {
var method = req.params.method var method = req.params.method
var query = req.query var query = req.query
+61 -7
View File
@@ -4,6 +4,9 @@ const http = require('http')
const SocketIO = require('socket.io') const SocketIO = require('socket.io')
const fs = require('fs-extra') const fs = require('fs-extra')
const fileUpload = require('express-fileupload') const fileUpload = require('express-fileupload')
const rateLimit = require('express-rate-limit')
const { ScanResult } = require('./utils/constants')
const Auth = require('./Auth') const Auth = require('./Auth')
const Watcher = require('./Watcher') const Watcher = require('./Watcher')
@@ -14,6 +17,7 @@ const HlsController = require('./HlsController')
const StreamManager = require('./StreamManager') const StreamManager = require('./StreamManager')
const RssFeeds = require('./RssFeeds') const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager') const DownloadManager = require('./DownloadManager')
// const EbookReader = require('./EbookReader')
const Logger = require('./Logger') const Logger = require('./Logger')
class Server { class Server {
@@ -37,6 +41,7 @@ class Server {
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this)) 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.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.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.server = null
this.io = null this.io = null
@@ -79,20 +84,31 @@ class Server {
async filesChanged(files) { async filesChanged(files) {
Logger.info('[Server]', files.length, 'Files Changed') Logger.info('[Server]', files.length, 'Files Changed')
var result = await this.scanner.filesChanged(files) 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') Logger.info('[Server] Starting Scan')
this.isScanning = true this.isScanning = true
this.isInitialized = true this.isInitialized = true
this.emitter('scan_start', 'files') this.emitter('scan_start', 'files')
var results = await this.scanner.scan() var results = await this.scanner.scan(forceAudioFileScan)
this.isScanning = false this.isScanning = false
this.emitter('scan_complete', { scanType: 'files', results }) this.emitter('scan_complete', { scanType: 'files', results })
Logger.info('[Server] Scan complete') 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() { async scanCovers() {
Logger.info('[Server] Start cover scan') Logger.info('[Server] Start cover scan')
this.isScanningCovers = true this.isScanningCovers = true
@@ -108,6 +124,14 @@ class Server {
this.scanner.cancelScan = true 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)
}
async init() { async init() {
Logger.info('[Server] Init') Logger.info('[Server] Init')
await this.streamManager.ensureStreamsDir() await this.streamManager.ensureStreamsDir()
@@ -170,6 +194,21 @@ class Server {
res.sendStatus(200) 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() { async start() {
Logger.info('=== Starting Server ===') Logger.info('=== Starting Server ===')
await this.init() await this.init()
@@ -204,13 +243,18 @@ class Server {
app.use('/api', this.authMiddleware.bind(this), this.apiController.router) app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.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('/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.post('/logout', this.logout.bind(this))
app.get('/ping', (req, res) => { app.get('/ping', (req, res) => {
Logger.info('Recieved ping') Logger.info('Recieved ping')
res.json({ success: true }) res.json({ success: true })
@@ -229,7 +273,6 @@ class Server {
}) })
} }
this.server.listen(this.Port, this.Host, () => { this.server.listen(this.Port, this.Host, () => {
Logger.info(`Running on http://${this.Host}:${this.Port}`) Logger.info(`Running on http://${this.Host}:${this.Port}`)
}) })
@@ -257,6 +300,8 @@ class Server {
socket.on('scan', this.scan.bind(this)) socket.on('scan', this.scan.bind(this))
socket.on('scan_covers', this.scanCovers.bind(this)) socket.on('scan_covers', this.scanCovers.bind(this))
socket.on('cancel_scan', this.cancelScan.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 // Streaming
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
@@ -269,11 +314,15 @@ class Server {
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload)) socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId)) socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('test', () => { socket.on('test', () => {
socket.emit('test_received', socket.id) socket.emit('test_received', socket.id)
}) })
socket.on('disconnect', () => { socket.on('disconnect', () => {
Logger.removeSocketListener(socket.id)
var _client = this.clients[socket.id] var _client = this.clients[socket.id]
if (!_client) { if (!_client) {
Logger.warn('[SOCKET] Socket disconnect, no client ' + socket.id) Logger.warn('[SOCKET] Socket disconnect, no client ' + socket.id)
@@ -337,6 +386,11 @@ class Server {
stream: client.stream || null stream: client.stream || null
} }
client.socket.emit('init', initialPayload) 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() { async stop() {
+90 -22
View File
@@ -1,3 +1,6 @@
const Logger = require('../Logger')
const AudioFileMetadata = require('./AudioFileMetadata')
class AudioFile { class AudioFile {
constructor(data) { constructor(data) {
this.index = null this.index = null
@@ -21,18 +24,19 @@ class AudioFile {
this.channels = null this.channels = null
this.channelLayout = null this.channelLayout = null
this.chapters = [] this.chapters = []
this.embeddedCoverArt = null
this.tagAlbum = null // Tags scraped from the audio file
this.tagArtist = null this.metadata = null
this.tagGenre = null
this.tagTitle = null
this.tagTrack = null
this.manuallyVerified = false this.manuallyVerified = false
this.invalid = false this.invalid = false
this.exclude = false this.exclude = false
this.error = null this.error = null
// TEMP: For forcing rescan
this.isOldAudioFile = false
if (data) { if (data) {
this.construct(data) this.construct(data)
} }
@@ -58,15 +62,13 @@ class AudioFile {
size: this.size, size: this.size,
bitRate: this.bitRate, bitRate: this.bitRate,
language: this.language, language: this.language,
codec: this.codec,
timeBase: this.timeBase, timeBase: this.timeBase,
channels: this.channels, channels: this.channels,
channelLayout: this.channelLayout, channelLayout: this.channelLayout,
chapters: this.chapters, chapters: this.chapters,
tagAlbum: this.tagAlbum, embeddedCoverArt: this.embeddedCoverArt,
tagArtist: this.tagArtist, metadata: this.metadata ? this.metadata.toJSON() : {}
tagGenre: this.tagGenre,
tagTitle: this.tagTitle,
tagTrack: this.tagTrack
} }
} }
@@ -91,17 +93,21 @@ class AudioFile {
this.size = data.size this.size = data.size
this.bitRate = data.bitRate this.bitRate = data.bitRate
this.language = data.language this.language = data.language
this.codec = data.codec this.codec = data.codec || null
this.timeBase = data.timeBase this.timeBase = data.timeBase
this.channels = data.channels this.channels = data.channels
this.channelLayout = data.channelLayout this.channelLayout = data.channelLayout
this.chapters = data.chapters this.chapters = data.chapters
this.embeddedCoverArt = data.embeddedCoverArt || null
this.tagAlbum = data.tagAlbum // Old version of AudioFile used `tagAlbum` etc.
this.tagArtist = data.tagArtist var isOldVersion = Object.keys(data).find(key => key.startsWith('tag'))
this.tagGenre = data.tagGenre if (isOldVersion) {
this.tagTitle = data.tagTitle this.isOldAudioFile = true
this.tagTrack = data.tagTrack this.metadata = new AudioFileMetadata(data)
} else {
this.metadata = new AudioFileMetadata(data.metadata || {})
}
} }
setData(data) { setData(data) {
@@ -126,23 +132,85 @@ class AudioFile {
this.size = data.size this.size = data.size
this.bitRate = data.bit_rate || null this.bitRate = data.bit_rate || null
this.language = data.language this.language = data.language
this.codec = data.codec this.codec = data.codec || null
this.timeBase = data.time_base this.timeBase = data.time_base
this.channels = data.channels this.channels = data.channels
this.channelLayout = data.channel_layout this.channelLayout = data.channel_layout
this.chapters = data.chapters || [] this.chapters = data.chapters || []
this.embeddedCoverArt = data.embedded_cover_art || null
this.tagAlbum = data.file_tag_album || null this.metadata = new AudioFileMetadata()
this.tagArtist = data.file_tag_artist || null this.metadata.setData(data)
this.tagGenre = data.file_tag_genre || null }
this.tagTitle = data.file_tag_title || null
this.tagTrack = data.file_tag_track || null 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() { clone() {
return new AudioFile(this.toJSON()) return new AudioFile(this.toJSON())
} }
// If the file or parent directory was renamed it is synced here
syncFile(newFile) { syncFile(newFile) {
var hasUpdates = false var hasUpdates = false
var keysToSync = ['path', 'fullPath', 'ext', 'filename'] var keysToSync = ['path', 'fullPath', 'ext', 'filename']
+97
View 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
+11 -22
View File
@@ -20,12 +20,6 @@ class AudioTrack {
this.channels = null this.channels = null
this.channelLayout = null this.channelLayout = null
this.tagAlbum = null
this.tagArtist = null
this.tagGenre = null
this.tagTitle = null
this.tagTrack = null
if (audioTrack) { if (audioTrack) {
this.construct(audioTrack) this.construct(audioTrack)
} }
@@ -49,12 +43,6 @@ class AudioTrack {
this.timeBase = audioTrack.timeBase this.timeBase = audioTrack.timeBase
this.channels = audioTrack.channels this.channels = audioTrack.channels
this.channelLayout = audioTrack.channelLayout 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() { get name() {
@@ -77,11 +65,6 @@ class AudioTrack {
timeBase: this.timeBase, timeBase: this.timeBase,
channels: this.channels, channels: this.channels,
channelLayout: this.channelLayout, channelLayout: this.channelLayout,
tagAlbum: this.tagAlbum,
tagArtist: this.tagArtist,
tagGenre: this.tagGenre,
tagTitle: this.tagTitle,
tagTrack: this.tagTrack
} }
} }
@@ -103,12 +86,18 @@ class AudioTrack {
this.timeBase = probeData.timeBase this.timeBase = probeData.timeBase
this.channels = probeData.channels this.channels = probeData.channels
this.channelLayout = probeData.channelLayout this.channelLayout = probeData.channelLayout
}
this.tagAlbum = probeData.file_tag_album || null syncMetadata(audioFile) {
this.tagArtist = probeData.file_tag_artist || null var hasUpdates = false
this.tagGenre = probeData.file_tag_genre || null var keysToSync = ['format', 'duration', 'size', 'bitRate', 'language', 'codec', 'timeBase', 'channels', 'channelLayout']
this.tagTitle = probeData.file_tag_title || null keysToSync.forEach((key) => {
this.tagTrack = probeData.file_tag_track || null if (audioFile[key] !== undefined && audioFile[key] !== this[key]) {
hasUpdates = true
this[key] = audioFile[key]
}
})
return hasUpdates
} }
syncFile(newFile) { syncFile(newFile) {
+145 -25
View File
@@ -1,6 +1,8 @@
const Path = require('path') const Path = require('path')
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils') const { bytesPretty, elapsedPretty, readTextFile } = require('../utils/fileUtils')
const { comparePaths, getIno } = require('../utils/index') const { comparePaths, getIno } = require('../utils/index')
const { extractCoverArt } = require('../utils/ffmpegHelpers')
const nfoGenerator = require('../utils/nfoGenerator')
const Logger = require('../Logger') const Logger = require('../Logger')
const Book = require('./Book') const Book = require('./Book')
const AudioTrack = require('./AudioTrack') const AudioTrack = require('./AudioTrack')
@@ -106,6 +108,22 @@ class Audiobook {
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 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() { bookToJSON() {
return this.book ? this.book.toJSON() : null return this.book ? this.book.toJSON() : null
} }
@@ -152,6 +170,7 @@ class Audiobook {
hasBookMatch: !!this.book, hasBookMatch: !!this.book,
hasMissingParts: this.missingParts ? this.missingParts.length : 0, hasMissingParts: this.missingParts ? this.missingParts.length : 0,
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0, hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
numEbooks: this.ebooks.length,
numTracks: this.tracks.length, numTracks: this.tracks.length,
chapters: this.chapters || [], chapters: this.chapters || [],
isMissing: !!this.isMissing isMissing: !!this.isMissing
@@ -173,6 +192,7 @@ class Audiobook {
invalidParts: this.invalidParts, invalidParts: this.invalidParts,
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()), audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()), otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
tags: this.tags, tags: this.tags,
book: this.bookToJSON(), book: this.bookToJSON(),
tracks: this.tracksToJSON(), tracks: this.tracksToJSON(),
@@ -181,38 +201,74 @@ class Audiobook {
} }
} }
// Scanner had a bug that was saving a file path as the audiobook path. // Originally files did not store the inode value
// audiobook path should be a directory. // this function checks all files and sets the inode
// 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
async checkUpdateInos() { async checkUpdateInos() {
var hasUpdates = false var hasUpdates = false
// Audiobook folder needs inode
if (!this.ino) { if (!this.ino) {
this.ino = await getIno(this.fullPath) this.ino = await getIno(this.fullPath)
hasUpdates = true hasUpdates = true
} }
// Check audio files have an inode
for (let i = 0; i < this.audioFiles.length; i++) { for (let i = 0; i < this.audioFiles.length; i++) {
var af = this.audioFiles[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) { if (!af.ino || af.ino === this.ino) {
af.ino = await getIno(af.fullPath) af.ino = await getIno(af.fullPath)
if (!af.ino) { if (!af.ino) {
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath) Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath)
} else { } else {
var track = this.tracks.find(t => comparePaths(t.path, af.path)) Logger.debug(`[Audiobook] Set INO For audio file ${af.path}`)
if (track) { if (at) at.ino = af.ino
track.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 hasUpdates = true
} }
@@ -220,6 +276,11 @@ class Audiobook {
return hasUpdates 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) { setData(data) {
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36) this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
this.ino = data.ino || null this.ino = data.ino || null
@@ -329,6 +390,11 @@ class Audiobook {
this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino) 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() { checkUpdateMissingParts() {
var currMissingParts = (this.missingParts || []).join(',') || '' var currMissingParts = (this.missingParts || []).join(',') || ''
@@ -357,22 +423,40 @@ class Audiobook {
} }
// On scan check other files found with other files saved // 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 currOtherFileNum = this.otherFiles.length
var alreadyHadDescTxt = this.otherFiles.find(of => of.filename === 'desc.txt')
var newOtherFilePaths = newOtherFiles.map(f => f.path) var newOtherFilePaths = newOtherFiles.map(f => f.path)
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(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) 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) => { newOtherFiles.forEach((file) => {
var existingOtherFile = this.otherFiles.find(f => f.path === file.path) var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
if (!existingOtherFile) { 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) this.addOtherFile(file)
hasUpdates = true
} }
}) })
var hasUpdates = currOtherFileNum !== this.otherFiles.length
// Check if cover was a local image and that it still exists // Check if cover was a local image and that it still exists
var imageFiles = this.otherFiles.filter(f => f.filetype === 'image') var imageFiles = this.otherFiles.filter(f => f.filetype === 'image')
if (this.book.cover && this.book.cover.substr(1).startsWith('local')) { if (this.book.cover && this.book.cover.substr(1).startsWith('local')) {
@@ -431,7 +515,6 @@ class Audiobook {
setChapters() { setChapters() {
// If 1 audio file without chapters, then no chapters will be set // If 1 audio file without chapters, then no chapters will be set
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude) var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
if (includedAudioFiles.length === 1) { if (includedAudioFiles.length === 1) {
// 1 audio file with chapters // 1 audio file with chapters
@@ -467,12 +550,49 @@ class Audiobook {
id: currChapterId++, id: currChapterId++,
start: currStartTime, start: currStartTime,
end: currStartTime + file.duration, end: currStartTime + file.duration,
title: `Chapter ${currChapterId}` title: file.filename ? Path.basename(file.filename, Path.extname(file.filename)) : `Chapter ${currChapterId}`
}) })
currStartTime += file.duration 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 module.exports = Audiobook
+43
View File
@@ -1,3 +1,4 @@
const fs = require('fs-extra')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const parseAuthors = require('../utils/parseAuthors') const parseAuthors = require('../utils/parseAuthors')
@@ -182,5 +183,47 @@ class Book {
isSearchMatch(search) { isSearchMatch(search) {
return this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(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 module.exports = Book
+22 -1
View File
@@ -1,12 +1,18 @@
const { CoverDestination } = require('../utils/constants') const { CoverDestination } = require('../utils/constants')
const Logger = require('../Logger')
class ServerSettings { class ServerSettings {
constructor(settings) { constructor(settings) {
this.id = 'server-settings' this.id = 'server-settings'
this.autoTagNew = false this.autoTagNew = false
this.newTagExpireDays = 15 this.newTagExpireDays = 15
this.scannerParseSubtitle = false this.scannerParseSubtitle = false
this.coverDestination = CoverDestination.METADATA this.coverDestination = CoverDestination.METADATA
this.saveMetadataFile = false
this.rateLimitLoginRequests = 10
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
this.logLevel = Logger.logLevel
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
@@ -18,6 +24,14 @@ class ServerSettings {
this.newTagExpireDays = settings.newTagExpireDays this.newTagExpireDays = settings.newTagExpireDays
this.scannerParseSubtitle = settings.scannerParseSubtitle this.scannerParseSubtitle = settings.scannerParseSubtitle
this.coverDestination = settings.coverDestination || CoverDestination.METADATA 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() { toJSON() {
@@ -26,7 +40,11 @@ class ServerSettings {
autoTagNew: this.autoTagNew, autoTagNew: this.autoTagNew,
newTagExpireDays: this.newTagExpireDays, newTagExpireDays: this.newTagExpireDays,
scannerParseSubtitle: this.scannerParseSubtitle, 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 var hasUpdates = false
for (const key in payload) { for (const key in payload) {
if (this[key] !== payload[key]) { if (this[key] !== payload[key]) {
if (key === 'logLevel') {
Logger.setLogLevel(payload[key])
}
this[key] = payload[key] this[key] = payload[key]
hasUpdates = true hasUpdates = true
} }
+24 -8
View File
@@ -16,7 +16,6 @@ class Stream extends EventEmitter {
this.audiobook = audiobook this.audiobook = audiobook
this.segmentLength = 6 this.segmentLength = 6
this.segmentBasename = 'output-%d.ts'
this.streamPath = Path.join(streamPath, this.id) this.streamPath = Path.join(streamPath, this.id)
this.concatFilesPath = Path.join(this.streamPath, 'files.txt') this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
this.playlistPath = Path.join(this.streamPath, 'output.m3u8') this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
@@ -51,6 +50,16 @@ class Stream extends EventEmitter {
return this.audiobook.totalDuration 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() { get segmentStartNumber() {
if (!this.startTime) return 0 if (!this.startTime) return 0
return Math.floor(this.startTime / this.segmentLength) return Math.floor(this.startTime / this.segmentLength)
@@ -98,7 +107,7 @@ class Stream extends EventEmitter {
var userAudiobook = clientUserAudiobooks[this.audiobookId] || null var userAudiobook = clientUserAudiobooks[this.audiobookId] || null
if (userAudiobook) { if (userAudiobook) {
var timeRemaining = this.totalDuration - userAudiobook.currentTime 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) { if (timeRemaining > 15) {
this.startTime = userAudiobook.currentTime this.startTime = userAudiobook.currentTime
this.clientCurrentTime = this.startTime this.clientCurrentTime = this.startTime
@@ -133,7 +142,7 @@ class Stream extends EventEmitter {
async generatePlaylist() { async generatePlaylist() {
fs.ensureDirSync(this.streamPath) 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 return this.clientPlaylistUri
} }
@@ -142,7 +151,7 @@ class Stream extends EventEmitter {
var files = await fs.readdir(this.streamPath) var files = await fs.readdir(this.streamPath)
files.forEach((file) => { files.forEach((file) => {
var extname = Path.extname(file) var extname = Path.extname(file)
if (extname === '.ts') { if (extname === '.ts' || extname === '.m4s') {
var basename = Path.basename(file, extname) var basename = Path.basename(file, extname)
var num_part = basename.split('-')[1] var num_part = basename.split('-')[1]
var part_num = Number(num_part) var part_num = Number(num_part)
@@ -238,24 +247,31 @@ class Stream extends EventEmitter {
} }
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning' const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
const audioCodec = this.hlsSegmentType === 'fmp4' ? 'aac' : 'copy'
this.ffmpeg.addOption([ this.ffmpeg.addOption([
`-loglevel ${logLevel}`, `-loglevel ${logLevel}`,
'-map 0:a', '-map 0:a',
'-c:a copy' `-c:a ${audioCodec}`
]) ])
this.ffmpeg.addOption([ const hlsOptions = [
'-f hls', '-f hls',
"-copyts", "-copyts",
"-avoid_negative_ts disabled", "-avoid_negative_ts disabled",
"-max_delay 5000000", "-max_delay 5000000",
"-max_muxing_queue_size 2048", "-max_muxing_queue_size 2048",
`-hls_time 6`, `-hls_time 6`,
"-hls_segment_type mpegts", `-hls_segment_type ${this.hlsSegmentType}`,
`-start_number ${this.segmentStartNumber}`, `-start_number ${this.segmentStartNumber}`,
"-hls_playlist_type vod", "-hls_playlist_type vod",
"-hls_list_size 0", "-hls_list_size 0",
"-hls_allow_cache 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) var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`) this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
this.ffmpeg.output(this.finalPlaylistPath) this.ffmpeg.output(this.finalPlaylistPath)
+5 -1
View File
@@ -9,6 +9,7 @@ class User {
this.stream = null this.stream = null
this.token = null this.token = null
this.isActive = true this.isActive = true
this.isLocked = false
this.createdAt = null this.createdAt = null
this.audiobooks = null this.audiobooks = null
@@ -76,6 +77,7 @@ class User {
token: this.token, token: this.token,
audiobooks: this.audiobooksToJSON(), audiobooks: this.audiobooksToJSON(),
isActive: this.isActive, isActive: this.isActive,
isLocked: this.isLocked,
createdAt: this.createdAt, createdAt: this.createdAt,
settings: this.settings, settings: this.settings,
permissions: this.permissions permissions: this.permissions
@@ -91,6 +93,7 @@ class User {
token: this.token, token: this.token,
audiobooks: this.audiobooksToJSON(), audiobooks: this.audiobooksToJSON(),
isActive: this.isActive, isActive: this.isActive,
isLocked: this.isLocked,
createdAt: this.createdAt, createdAt: this.createdAt,
settings: this.settings, settings: this.settings,
permissions: this.permissions 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.createdAt = user.createdAt || Date.now()
this.settings = user.settings || this.getDefaultUserSettings() this.settings = user.settings || this.getDefaultUserSettings()
this.permissions = user.permissions || this.getDefaultUserPermissions() this.permissions = user.permissions || this.getDefaultUserPermissions()
+58 -2
View File
@@ -2,6 +2,8 @@ const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const prober = require('./prober') const prober = require('./prober')
const ImageCodecs = ['mjpeg', 'jpeg', 'png']
function getDefaultAudioStream(audioStreams) { function getDefaultAudioStream(audioStreams) {
if (audioStreams.length === 1) return audioStreams[0] if (audioStreams.length === 1) return audioStreams[0]
var defaultStream = audioStreams.find(a => a.is_default) var defaultStream = audioStreams.find(a => a.is_default)
@@ -37,6 +39,11 @@ async function scan(path) {
chapters: probeData.chapters || [] 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) { for (const key in probeData) {
if (probeData[key] && key.startsWith('file_tag')) { if (probeData[key] && key.startsWith('file_tag')) {
finalData[key] = probeData[key] finalData[key] = probeData[key]
@@ -76,6 +83,9 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
if (series) partbasename = partbasename.replace(series, '') if (series) partbasename = partbasename.replace(series, '')
if (publishYear) partbasename = partbasename.replace(publishYear) 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) var numbersinpath = partbasename.match(/\d+/g)
if (!numbersinpath) return null if (!numbersinpath) return null
@@ -85,12 +95,14 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
async function scanAudioFiles(audiobook, newAudioFiles) { async function scanAudioFiles(audiobook, newAudioFiles) {
if (!newAudioFiles || !newAudioFiles.length) { 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 return
} }
var tracks = [] var tracks = []
var numDuplicateTracks = 0 var numDuplicateTracks = 0
var numInvalidTracks = 0 var numInvalidTracks = 0
for (let i = 0; i < newAudioFiles.length; i++) { for (let i = 0; i < newAudioFiles.length; i++) {
var audioFile = newAudioFiles[i] var audioFile = newAudioFiles[i]
var scanData = await scan(audioFile.fullPath) var scanData = await scan(audioFile.fullPath)
@@ -102,6 +114,7 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
var trackNumFromMeta = getTrackNumberFromMeta(scanData) var trackNumFromMeta = getTrackNumberFromMeta(scanData)
var book = audiobook.book || {} var book = audiobook.book || {}
var trackNumFromFilename = getTrackNumberFromFilename(book.title, book.author, book.series, book.publishYear, audioFile.filename) var trackNumFromFilename = getTrackNumberFromFilename(book.title, book.author, book.series, book.publishYear, audioFile.filename)
var audioFileObj = { var audioFileObj = {
@@ -129,7 +142,7 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
} }
if (tracks.find(t => t.index === trackNumber)) { 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.invalid = true
audioFile.error = 'Duplicate track number' audioFile.error = 'Duplicate track number'
numDuplicateTracks++ numDuplicateTracks++
@@ -176,3 +189,46 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
} }
} }
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
+10
View File
@@ -10,3 +10,13 @@ module.exports.CoverDestination = {
METADATA: 0, METADATA: 0,
AUDIOBOOK: 1 AUDIOBOOK: 1
} }
module.exports.LogLevel = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
FATAL: 5,
NOTE: 6
}
+28
View File
@@ -1,5 +1,8 @@
const Ffmpeg = require('fluent-ffmpeg')
const fs = require('fs-extra') const fs = require('fs-extra')
const Path = require('path')
const package = require('../../package.json') const package = require('../../package.json')
const Logger = require('../Logger')
function escapeSingleQuotes(path) { function escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'') // return path.replace(/'/g, '\'\\\'\'')
@@ -65,3 +68,28 @@ async function writeMetadataFile(audiobook, outputPath) {
return inputstrs 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
+14 -1
View File
@@ -1,4 +1,5 @@
const fs = require('fs-extra') const fs = require('fs-extra')
const Logger = require('../Logger')
async function getFileStat(path) { async function getFileStat(path) {
try { try {
@@ -24,14 +25,26 @@ async function getFileSize(path) {
} }
module.exports.getFileSize = getFileSize 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) { function bytesPretty(bytes, decimals = 0) {
if (bytes === 0) { if (bytes === 0) {
return '0 Bytes' return '0 Bytes'
} }
const k = 1024 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 sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k)) 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] return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
} }
module.exports.bytesPretty = bytesPretty module.exports.bytesPretty = bytesPretty
+10 -5
View File
@@ -1,6 +1,8 @@
const fs = require('fs-extra') const fs = require('fs-extra')
function getPlaylistStr(segmentName, duration, segmentLength) { function getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType) {
var ext = hlsSegmentType === 'fmp4' ? 'm4s' : 'ts'
var lines = [ var lines = [
'#EXTM3U', '#EXTM3U',
'#EXT-X-VERSION:3', '#EXT-X-VERSION:3',
@@ -9,22 +11,25 @@ function getPlaylistStr(segmentName, duration, segmentLength) {
'#EXT-X-MEDIA-SEQUENCE:0', '#EXT-X-MEDIA-SEQUENCE:0',
'#EXT-X-PLAYLIST-TYPE:VOD' '#EXT-X-PLAYLIST-TYPE:VOD'
] ]
if (hlsSegmentType === 'fmp4') {
lines.push('#EXT-X-MAP:URI="init.mp4"')
}
var numSegments = Math.floor(duration / segmentLength) var numSegments = Math.floor(duration / segmentLength)
var lastSegment = duration - (numSegments * segmentLength) var lastSegment = duration - (numSegments * segmentLength)
for (let i = 0; i < numSegments; i++) { for (let i = 0; i < numSegments; i++) {
lines.push(`#EXTINF:6,`) lines.push(`#EXTINF:6,`)
lines.push(`${segmentName}-${i}.ts`) lines.push(`${segmentName}-${i}.${ext}`)
} }
if (lastSegment > 0) { if (lastSegment > 0) {
lines.push(`#EXTINF:${lastSegment},`) lines.push(`#EXTINF:${lastSegment},`)
lines.push(`${segmentName}-${numSegments}.ts`) lines.push(`${segmentName}-${numSegments}.${ext}`)
} }
lines.push('#EXT-X-ENDLIST') lines.push('#EXT-X-ENDLIST')
return lines.join('\n') return lines.join('\n')
} }
function generatePlaylist(outputPath, segmentName, duration, segmentLength) { function generatePlaylist(outputPath, segmentName, duration, segmentLength, hlsSegmentType) {
var playlistStr = getPlaylistStr(segmentName, duration, segmentLength) var playlistStr = getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType)
return fs.writeFile(outputPath, playlistStr) return fs.writeFile(outputPath, playlistStr)
} }
module.exports = generatePlaylist module.exports = generatePlaylist
+91
View 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
+62 -8
View File
@@ -1,4 +1,6 @@
var Ffmpeg = require('fluent-ffmpeg') var Ffmpeg = require('fluent-ffmpeg')
const Path = require('path')
const Logger = require('../Logger')
function tryGrabBitRate(stream, all_streams, total_bit_rate) { function tryGrabBitRate(stream, all_streams, total_bit_rate) {
if (!isNaN(stream.bit_rate) && stream.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 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) { function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
var info = { var info = {
index: stream.index, 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) { function parseProbeData(data) {
try { try {
var { format, streams, chapters } = data var { format, streams, chapters } = data
@@ -131,20 +189,16 @@ function parseProbeData(data) {
var sizeBytes = !isNaN(size) ? Number(size) : null var sizeBytes = !isNaN(size) ? Number(size) : null
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
// Logger.debug('Parsing Data for', Path.basename(format.filename))
var tags = parseTags(format)
var cleanedData = { var cleanedData = {
format: format_long_name, format: format_long_name,
duration: !isNaN(duration) ? Number(duration) : null, duration: !isNaN(duration) ? Number(duration) : null,
size: sizeBytes, size: sizeBytes,
sizeMb, sizeMb,
bit_rate: !isNaN(bit_rate) ? Number(bit_rate) : null, bit_rate: !isNaN(bit_rate) ? Number(bit_rate) : null,
file_tag_encoder: tryGrabTag(format, 'encoder') || tryGrabTag(format, 'encoded_by'), ...tags
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')
} }
const cleaned_streams = streams.map(s => parseMediaStreamInfo(s, streams, cleanedData.bit_rate)) const cleaned_streams = streams.map(s => parseMediaStreamInfo(s, streams, cleanedData.bit_rate))
+17 -7
View File
@@ -1,11 +1,12 @@
const Path = require('path') const Path = require('path')
const dir = require('node-dir') const dir = require('node-dir')
const Logger = require('../Logger') const Logger = require('../Logger')
const { getIno } = require('./index')
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a'] const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a', 'flac']
const INFO_FORMATS = ['nfo'] const INFO_FORMATS = ['nfo']
const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp'] const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp']
const EBOOK_FORMATS = ['epub', 'pdf'] const EBOOK_FORMATS = ['epub', 'pdf', 'mobi']
function getPaths(path) { function getPaths(path) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -26,7 +27,7 @@ function isAudioFile(path) {
return AUDIO_FORMATS.includes(ext.slice(1).toLowerCase()) return AUDIO_FORMATS.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 // 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) var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir)
@@ -37,11 +38,11 @@ function groupFilesIntoAudiobookPaths(paths) {
return pathsA - pathsB 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 audioFilePaths = []
var otherFilePaths = [] var otherFilePaths = []
pathsFiltered.forEach(path => { pathsFiltered.forEach(path => {
if (isAudioFile(path)) audioFilePaths.push(path) if (isAudioFile(path) || useAllFileTypes) audioFilePaths.push(path)
else otherFilePaths.push(path) else otherFilePaths.push(path)
}) })
@@ -134,7 +135,12 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle) var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle)
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath]) 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({ audiobooks.push({
ino: audiobookIno,
...audiobookData, ...audiobookData,
audioFiles: fileObjs.filter(f => f.filetype === 'audio'), audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
otherFiles: fileObjs.filter(f => f.filetype !== 'audio') otherFiles: fileObjs.filter(f => f.filetype !== 'audio')
@@ -241,11 +247,15 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
otherFiles: [] 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 relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
var extname = Path.extname(filepath) var extname = Path.extname(filepath)
var basename = Path.basename(filepath) var basename = Path.basename(filepath)
var ino = await getIno(filepath)
var fileObj = { var fileObj = {
ino,
filetype: getFileType(extname), filetype: getFileType(extname),
filename: basename, filename: basename,
path: relpath, path: relpath,
@@ -257,7 +267,7 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
} else { } else {
audiobook.otherFiles.push(fileObj) audiobook.otherFiles.push(fileObj)
} }
}) }
return audiobook return audiobook
} }
module.exports.getAudiobookFileData = getAudiobookFileData module.exports.getAudiobookFileData = getAudiobookFileData