Compare commits

...

7 Commits

40 changed files with 1438 additions and 339 deletions

View File

@@ -11,7 +11,6 @@ RUN npm run generate
### STAGE 2: Build server ###
FROM node:12-alpine
ENV NODE_ENV=production
ENV LOG_LEVEL=INFO
COPY --from=build /client/dist /client/dist
COPY --from=ffmpeg / /
COPY index.js index.js

View File

@@ -440,7 +440,7 @@ export default {
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details)
console.error('[HLS] Error', data.type, data.details, data)
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR')
}

View File

@@ -102,7 +102,8 @@ export default {
return 16 * this.sizeMultiplier
},
bookWidth() {
return this.bookCoverWidth + this.paddingX * 2
var _width = this.bookCoverWidth + this.paddingX * 2
return this.showGroups ? _width * 1.6 : _width
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected']
@@ -161,6 +162,7 @@ export default {
setBookshelfEntities() {
this.wrapperClientWidth = this.$refs.wrapper.clientWidth
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
var booksPerRow = Math.floor(width / this.bookWidth)
var entities = this.entities

View File

@@ -94,13 +94,17 @@ export default {
return audiobooks.slice(0, 10)
},
shelves() {
var shelves = [
{ books: this.mostRecentPlayed, label: 'Continue Reading' },
{ books: this.mostRecentAdded, label: 'Recently Added' }
]
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' })
}

View File

@@ -162,7 +162,11 @@ export default {
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Oops, something went wrong...')
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Oops, something went wrong...')
}
this.processingUpload = false
})
},
@@ -204,20 +208,39 @@ export default {
}
this.isProcessing = true
const updatePayload = {
book: {
cover: cover
var success = false
// Download cover from url and use
if (cover.startsWith('http:') || cover.startsWith('https:')) {
success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
console.error('Failed to download cover from url', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
})
} else {
// Update local cover url
const updatePayload = {
book: {
cover: cover
}
}
success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
})
}
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updatedAudiobook) {
if (success) {
this.$toast.success('Update Successful')
this.$emit('close')
} else {
this.imageUrl = this.book.cover || ''
}
this.isProcessing = false
},
getSearchQuery() {
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`

View File

@@ -56,10 +56,14 @@
<div class="flex px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" class="mx-4">
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="ml-4">
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<ui-tooltip text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="ml-4">
<ui-btn v-if="isRootUser" :loading="rescanning" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div>
@@ -93,7 +97,8 @@ export default {
newTags: [],
resettingProgress: false,
isScrollable: false,
savingMetadata: false
savingMetadata: false,
rescanning: false
}
},
watch: {
@@ -136,6 +141,23 @@ export default {
}
},
methods: {
audiobookScanComplete(result) {
this.rescanning = false
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete audiobook was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete audiobook was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete audiobook was removed`)
}
},
rescan() {
this.rescanning = true
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
this.$root.socket.emit('scan_audiobook', this.audiobookId)
},
saveMetadataComplete(result) {
this.savingMetadata = false
if (result.error) {

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>

View File

@@ -9,7 +9,7 @@
export default {
data() {
return {
inputAccept: 'image/*'
inputAccept: '.png, .jpg, .jpeg, .webp'
}
},
computed: {},

View File

@@ -127,21 +127,6 @@ export default {
this.$store.commit('setScanProgress', progress)
}
},
saveMetadataComplete(result) {
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.audiobookId})`)
}
} else {
var { success, failed } = result
this.$toast.success(`Metadata save complete\n${success} Succeeded\n${failed} Failed`)
}
},
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user)
@@ -205,6 +190,9 @@ export default {
download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download)
},
logEvtReceived(payload) {
this.$store.commit('logs/logEvt', payload)
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -245,7 +233,6 @@ export default {
this.socket.on('scan_start', this.scanStart)
this.socket.on('scan_complete', this.scanComplete)
this.socket.on('scan_progress', this.scanProgress)
// this.socket.on('save_metadata_complete', this.saveMetadataComplete)
// Download Listeners
this.socket.on('download_started', this.downloadStarted)
@@ -253,6 +240,8 @@ export default {
this.socket.on('download_failed', this.downloadFailed)
this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired)
this.socket.on('log', this.logEvtReceived)
},
showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion')

View File

@@ -6,8 +6,13 @@ export default function (context) {
if (route.name === 'login' || from.name === 'login') return
if (route.name === '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 !== 'upload' && from.name !== 'account') {
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)

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.2.8",
"version": "1.3.3",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {

View File

@@ -34,8 +34,10 @@
</tr>
</table>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="py-4 mb-8">
<div class="py-4 mb-4">
<p class="text-2xl">Scanner</p>
<div class="flex items-start py-2">
<div class="py-2">
@@ -65,6 +67,8 @@
<div class="flex items-center py-4">
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
<div class="flex-grow" />
<ui-btn to="/config/log">View Logger</ui-btn>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
@@ -134,6 +138,9 @@ export default {
var payload = {
scannerParseSubtitle: val
}
this.updateServerSettings(payload)
},
updateServerSettings(payload) {
this.$store
.dispatch('updateServerSettings', payload)
.then((success) => {
@@ -175,11 +182,12 @@ export default {
.then(() => {
this.isResettingAudiobooks = false
this.$toast.success('Successfully reset audiobooks')
location.reload()
})
.catch((error) => {
console.error('failed to reset audiobooks', error)
this.isResettingAudiobooks = false
this.$toast.error('Failed to reset audiobooks - stop docker and manually remove appdata')
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
})
}
},

136
client/pages/config/log.vue Normal file
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>

View File

@@ -31,7 +31,7 @@
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept.join(', ') }}</p>
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept }}</p>
</header>
</section>
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
@@ -120,9 +120,9 @@ export default {
title: null,
author: null,
series: null,
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a'],
acceptedImageFormats: ['image/*'],
inputAccept: ['image/*, .mp3, .m4b, .m4a'],
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac'],
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac',
isDragOver: false,
showUploader: true,
validAudioFiles: [],

31
client/store/logs.js Normal file
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)
}
}
}

49
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.2.7",
"version": "1.3.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -573,6 +573,11 @@
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
"integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew=="
},
"file-type": {
"version": "10.11.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz",
"integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw=="
},
"finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
@@ -723,6 +728,14 @@
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
"image-type": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/image-type/-/image-type-4.1.0.tgz",
"integrity": "sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==",
"requires": {
"file-type": "^10.10.0"
}
},
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -1032,6 +1045,16 @@
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="
},
"p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1047,6 +1070,11 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
},
"pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
},
"podcast": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/podcast/-/podcast-1.3.0.tgz",
@@ -1124,6 +1152,15 @@
"unpipe": "1.0.0"
}
},
"read-chunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.1.0.tgz",
"integrity": "sha512-ZdiZJXXoZYE08SzZvTipHhI+ZW0FpzxmFtLI3vIeMuRN9ySbIZ+SZawKogqJ7dxW9fJ/W73BNtxu4Zu/bZp+Ng==",
"requires": {
"pify": "^4.0.1",
"with-open-file": "^0.1.5"
}
},
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
@@ -1424,6 +1461,16 @@
"isexe": "^2.0.0"
}
},
"with-open-file": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz",
"integrity": "sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==",
"requires": {
"p-finally": "^1.0.0",
"p-try": "^2.1.0",
"pify": "^4.0.1"
}
},
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.2.8",
"version": "1.3.3",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {
@@ -32,12 +32,14 @@
"express-rate-limit": "^5.3.0",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
"image-type": "^4.1.0",
"ip": "^1.1.5",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
"njodb": "^0.4.20",
"node-dir": "^0.1.17",
"podcast": "^1.3.0",
"read-chunk": "^3.1.0",
"socket.io": "^4.1.3",
"watcher": "^1.2.0"
},

View File

@@ -11,65 +11,19 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
## Directory Structure
## Organizing your audiobooks
See [documentation](https://audiobookshelf.org/docs) for directory structure and naming.
#### Directory structure and folder names are critical to AudioBookshelf!
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
**1 Folder:** `/Title/...`\
**2 Folders:** `/Author/Title/...`\
**3 Folders:** `/Author/Series/Title/...`
### Parsing publish year
`/1984 - Hackers/...`\
Will save the publish year as `1984` and the title as `Hackers`
### Parsing volume number (only for series)
`/Book 3 - Hackers/...`\
Will save the volume number as `3` and the title as `Hackers`
`Book` `Volume` `Vol` `Vol.` are all supported case insensitive
These combinations will also work:\
`/Hackers - Vol. 3/...`\
`/1984 - Volume 3 - Hackers/...`\
`/1984 - Hackers Book 3/...`
See [documentation](https://audiobookshelf.org/docs) for supported directory structure, folder naming conventions, and audio file metadata usage.
### Parsing subtitles (optional in settings)
Title Folder: `/Hackers - Heroes of the Computer Revolution/...`
Will save the title as `Hackers` and the subtitle as `Heroes of the Computer Revolution`
### Full example
`/Steven Levy/The Hacker Series/1984 - Hackers - Heroes of the Computer Revolution - Vol. 1/...`
**Becomes:**
| Key | Value |
|---------------|-----------------------------------|
| Author | Steven Levy |
| Series | The Hacker Series |
| Publish Year | 1984 |
| Title | Hackers |
| Subtitle | Heroes of the Computer Revolution |
| Volume Number | 1 |
## Features coming soon
* Support different views to see more details of each audiobook
* iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app))
## Installation
** Default username is "root" with no password
### Docker Install
Available in Unraid Community Apps
@@ -115,14 +69,10 @@ curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | su
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
```bash
wget https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf_1.2.7_amd64.deb
sudo apt install ./audiobookshelf_1.2.7_amd64.deb
```
See [instructions](https://www.audiobookshelf.org/install#debian)
#### File locations
#### Linux file locations
Project directory: `/usr/share/audiobookshelf/`

View File

@@ -3,17 +3,17 @@ const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger')
const User = require('./objects/User')
const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
const { CoverDestination } = require('./utils/constants')
const { isObject } = require('./utils/index')
class ApiController {
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, emitter, clientEmitter) {
this.db = db
this.scanner = scanner
this.auth = auth
this.streamManager = streamManager
this.rssFeeds = rssFeeds
this.downloadManager = downloadManager
this.coverController = coverController
this.emitter = emitter
this.clientEmitter = clientEmitter
this.MetadataPath = MetadataPath
@@ -37,7 +37,6 @@ class ApiController {
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
this.router.patch('/match/:id', this.match.bind(this))
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
@@ -70,11 +69,6 @@ class ApiController {
this.scanner.findCovers(req, res)
}
async getMetadata(req, res) {
var metadata = await this.scanner.fetchMetadata(req.params.id, req.params.trackIndex)
res.json(metadata)
}
authorize(req, res) {
if (!req.user) {
Logger.error('Invalid user in authorize')
@@ -227,77 +221,36 @@ class ApiController {
Logger.warn('User attempted to upload a cover without permission', req.user)
return res.sendStatus(403)
}
if (!req.files || !req.files.cover) {
return res.status(400).send('No files were uploaded')
}
var audiobookId = req.params.id
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
if (!audiobook) {
return res.status(404).send('Audiobook not found')
}
var coverFile = req.files.cover
var mimeType = coverFile.mimetype
var extname = Path.extname(coverFile.name.toLowerCase()) || '.jpg'
if (!isAcceptableCoverMimeType(mimeType)) {
return res.status(400).send('Invalid image file type: ' + mimeType)
}
var coverDestination = this.db.serverSettings ? this.db.serverSettings.coverDestination : CoverDestination.METADATA
Logger.info(`[ApiController] Cover Upload destination ${coverDestination}`)
var coverDirpath = audiobook.fullPath
var coverRelDirpath = Path.join('/local', audiobook.path)
if (coverDestination === CoverDestination.METADATA) {
coverDirpath = Path.join(this.MetadataPath, 'books', audiobookId)
coverRelDirpath = Path.join('/metadata', 'books', audiobookId)
Logger.debug(`[ApiController] storing in metadata | ${coverDirpath}`)
await fs.ensureDir(coverDirpath)
var result = null
if (req.body && req.body.url) {
Logger.debug(`[ApiController] Requesting download cover from url "${req.body.url}"`)
result = await this.coverController.downloadCoverFromUrl(audiobook, req.body.url)
} else if (req.files && req.files.cover) {
Logger.debug(`[ApiController] Handling uploaded cover`)
var coverFile = req.files.cover
result = await this.coverController.uploadCover(audiobook, coverFile)
} else {
Logger.debug(`[ApiController] storing in audiobook | ${coverRelDirpath}`)
return res.status(400).send('Invalid request no file or url')
}
var coverFilename = `cover${extname}`
var coverFullPath = Path.join(coverDirpath, coverFilename)
var coverPath = Path.join(coverRelDirpath, coverFilename)
// If current cover is a metadata cover and does not match replacement, then remove it
var currentBookCover = audiobook.book.cover
if (currentBookCover && currentBookCover.startsWith(Path.sep + 'metadata')) {
Logger.debug(`Current Book Cover is metadata ${currentBookCover}`)
if (currentBookCover !== coverPath) {
Logger.info(`[ApiController] removing old metadata cover "${currentBookCover}"`)
var oldFullBookCoverPath = Path.join(this.MetadataPath, currentBookCover.replace(Path.sep + 'metadata', ''))
// Metadata path may have changed, check if exists first
var exists = await fs.pathExists(oldFullBookCoverPath)
if (exists) {
try {
await fs.remove(oldFullBookCoverPath)
} catch (error) {
Logger.error(`[ApiController] Failed to remove old metadata book cover ${oldFullBookCoverPath}`)
}
}
}
if (result && result.error) {
return res.status(400).send(result.error)
} else if (!result || !result.cover) {
return res.status(500).send('Unknown error occurred')
}
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
Logger.error('Failed to move cover file', path, error)
return false
})
if (!success) {
return res.status(500).send('Failed to move cover into destination')
}
Logger.info(`[ApiController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
audiobook.updateBookCover(coverPath)
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
res.json({
success: true,
cover: coverPath
cover: result.cover
})
}

193
server/CoverController.js Normal file
View File

@@ -0,0 +1,193 @@
const fs = require('fs-extra')
const Path = require('path')
const axios = require('axios')
const Logger = require('./Logger')
const readChunk = require('read-chunk')
const imageType = require('image-type')
const globals = require('./utils/globals')
const { CoverDestination } = require('./utils/constants')
class CoverController {
constructor(db, MetadataPath, AudiobookPath) {
this.db = db
this.MetadataPath = MetadataPath
this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
this.AudiobookPath = AudiobookPath
}
getCoverDirectory(audiobook) {
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
return {
fullPath: audiobook.fullPath,
relPath: Path.join('/local', audiobook.path)
}
} else {
return {
fullPath: Path.join(this.BookMetadataPath, audiobook.id),
relPath: Path.join('/metadata', 'books', audiobook.id)
}
}
}
getFilesInDirectory(dir) {
try {
return fs.readdir(dir)
} catch (error) {
Logger.error(`[CoverController] Failed to get files in dir ${dir}`, error)
return []
}
}
removeFile(filepath) {
try {
return fs.pathExists(filepath).then((exists) => {
if (!exists) Logger.warn(`[CoverController] Attempting to remove file that does not exist ${filepath}`)
return exists ? fs.unlink(filepath) : false
})
} catch (error) {
Logger.error(`[CoverController] Failed to remove file "${filepath}"`, error)
return false
}
}
// Remove covers in metadata/books/{ID} that dont have the same filename as the new cover
async checkBookMetadataCovers(dirpath, newCoverExt) {
var filesInDir = await this.getFilesInDirectory(dirpath)
for (let i = 0; i < filesInDir.length; i++) {
var file = filesInDir[i]
var _extname = Path.extname(file)
var _filename = Path.basename(file, _extname)
if (_filename === 'cover' && _extname !== newCoverExt) {
var filepath = Path.join(dirpath, file)
Logger.debug(`[CoverController] Removing old cover from metadata "${filepath}"`)
await this.removeFile(filepath)
}
}
}
async checkFileIsValidImage(imagepath) {
const buffer = await readChunk(imagepath, 0, 12)
const imgType = imageType(buffer)
if (!imgType) {
await this.removeFile(imagepath)
return {
error: 'Invalid image'
}
}
if (!globals.SupportedImageTypes.includes(imgType.ext)) {
await this.removeFile(imagepath)
return {
error: `Invalid image type ${imgType.ext} (Supported: ${globals.SupportedImageTypes.join(',')})`
}
}
return imgType
}
async uploadCover(audiobook, coverFile) {
var extname = Path.extname(coverFile.name.toLowerCase())
if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {
return {
error: `Invalid image type ${extname} (Supported: ${globals.SupportedImageTypes.join(',')})`
}
}
var { fullPath, relPath } = this.getCoverDirectory(audiobook)
await fs.ensureDir(fullPath)
var isStoringInMetadata = relPath.slice(1).startsWith('metadata')
var coverFilename = `cover${extname}`
var coverFullPath = Path.join(fullPath, coverFilename)
var coverPath = Path.join(relPath, coverFilename)
if (isStoringInMetadata) {
await this.checkBookMetadataCovers(fullPath, extname)
}
// Move cover from temp upload dir to destination
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
Logger.error('[CoverController] Failed to move cover file', path, error)
return false
})
if (!success) {
// return res.status(500).send('Failed to move cover into destination')
return {
error: 'Failed to move cover into destination'
}
}
Logger.info(`[CoverController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
audiobook.updateBookCover(coverPath)
return {
cover: coverPath
}
}
async downloadFile(url, filepath) {
Logger.debug(`[CoverController] Starting file download to ${filepath}`)
const writer = fs.createWriteStream(filepath)
const response = await axios({
url,
method: 'GET',
responseType: 'stream'
})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', resolve)
writer.on('error', reject)
})
}
async downloadCoverFromUrl(audiobook, url) {
try {
var { fullPath, relPath } = this.getCoverDirectory(audiobook)
await fs.ensureDir(fullPath)
var temppath = Path.join(fullPath, 'cover')
var success = await this.downloadFile(url, temppath).then(() => true).catch((err) => {
Logger.error(`[CoverController] Download image file failed for "${url}"`, err)
return false
})
if (!success) {
return {
error: 'Failed to download image from url'
}
}
var imgtype = await this.checkFileIsValidImage(temppath)
if (imgtype.error) {
return imgtype
}
var coverFilename = `cover.${imgtype.ext}`
var coverPath = Path.join(relPath, coverFilename)
var coverFullPath = Path.join(fullPath, coverFilename)
await fs.rename(temppath, coverFullPath)
var isStoringInMetadata = relPath.slice(1).startsWith('metadata')
if (isStoringInMetadata) {
await this.checkBookMetadataCovers(fullPath, '.' + imgtype.ext)
}
Logger.info(`[CoverController] Downloaded audiobook cover "${coverPath}" from url "${url}" for "${audiobook.title}"`)
audiobook.updateBookCover(coverPath)
return {
cover: coverPath
}
} catch (error) {
Logger.error(`[CoverController] Fetch cover image from url "${url}" failed`, error)
return {
error: 'Failed to fetch image from url'
}
}
}
}
module.exports = CoverController

View File

@@ -21,7 +21,7 @@ class HlsController {
}
parseSegmentFilename(filename) {
var basename = Path.basename(filename, '.ts')
var basename = Path.basename(filename, Path.extname(filename))
var num_part = basename.split('-')[1]
return Number(num_part)
}
@@ -41,7 +41,7 @@ class HlsController {
Logger.warn('File path does not exist', fullFilePath)
var fileExt = Path.extname(req.params.file)
if (fileExt === '.ts') {
if (fileExt === '.ts' || fileExt === '.m4s') {
var segNum = this.parseSegmentFilename(req.params.file)
var stream = this.streamManager.getStream(streamId)
if (!stream) {
@@ -66,6 +66,7 @@ class HlsController {
}
}
}
// Logger.info('Sending file', fullFilePath)
res.sendFile(fullFilePath)
}

View File

@@ -1,55 +1,110 @@
const LOG_LEVEL = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
FATAL: 5
}
const { LogLevel } = require('./utils/constants')
class Logger {
constructor() {
let env_log_level = process.env.LOG_LEVEL || 'TRACE'
this.LogLevel = LOG_LEVEL[env_log_level] || LOG_LEVEL.TRACE
this.info(`Log Level: ${this.LogLevel}`)
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
this.socketListeners = []
}
get timestamp() {
return (new Date()).toISOString()
}
get levelString() {
for (const key in LogLevel) {
if (LogLevel[key] === this.logLevel) {
return key
}
}
return 'UNKNOWN'
}
getLogLevelString(level) {
for (const key in LogLevel) {
if (LogLevel[key] === level) {
return key
}
}
return 'UNKNOWN'
}
addSocketListener(socket, level) {
var index = this.socketListeners.findIndex(s => s.id === socket.id)
if (index >= 0) {
this.socketListeners.splice(index, 1, {
id: socket.id,
socket,
level
})
} else {
this.socketListeners.push({
id: socket.id,
socket,
level
})
}
}
removeSocketListener(socketId) {
this.socketListeners = this.socketListeners.filter(s => s.id !== socketId)
}
logToSockets(level, args) {
this.socketListeners.forEach((socketListener) => {
if (socketListener.level <= level) {
socketListener.socket.emit('log', {
timestamp: this.timestamp,
message: args.join(' '),
levelName: this.getLogLevelString(level),
level
})
}
})
}
setLogLevel(level) {
this.logLevel = level
this.debug(`Set Log Level to ${this.levelString}`)
}
trace(...args) {
if (this.LogLevel > LOG_LEVEL.TRACE) return
if (this.logLevel > LogLevel.TRACE) return
console.trace(`[${this.timestamp}] TRACE:`, ...args)
this.logToSockets(LogLevel.TRACE, args)
}
debug(...args) {
if (this.LogLevel > LOG_LEVEL.DEBUG) return
if (this.logLevel > LogLevel.DEBUG) return
console.debug(`[${this.timestamp}] DEBUG:`, ...args)
this.logToSockets(LogLevel.DEBUG, args)
}
info(...args) {
if (this.LogLevel > LOG_LEVEL.INFO) return
if (this.logLevel > LogLevel.INFO) return
console.info(`[${this.timestamp}] INFO:`, ...args)
}
note(...args) {
if (this.LogLevel > LOG_LEVEL.INFO) return
console.log(`[${this.timestamp}] NOTE:`, ...args)
this.logToSockets(LogLevel.INFO, args)
}
warn(...args) {
if (this.LogLevel > LOG_LEVEL.WARN) return
if (this.logLevel > LogLevel.WARN) return
console.warn(`[${this.timestamp}] WARN:`, ...args)
this.logToSockets(LogLevel.WARN, args)
}
error(...args) {
if (this.LogLevel > LOG_LEVEL.ERROR) return
if (this.logLevel > LogLevel.ERROR) return
console.error(`[${this.timestamp}] ERROR:`, ...args)
this.logToSockets(LogLevel.ERROR, args)
}
fatal(...args) {
console.error(`[${this.timestamp}] FATAL:`, ...args)
this.logToSockets(LogLevel.FATAL, args)
}
note(...args) {
console.log(`[${this.timestamp}] NOTE:`, ...args)
this.logToSockets(LogLevel.NOTE, args)
}
}
module.exports = new Logger()

View File

@@ -7,7 +7,7 @@ const audioFileScanner = require('./utils/audioFileScanner')
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
const { comparePaths, getIno } = require('./utils/index')
const { secondsToTimestamp } = require('./utils/fileUtils')
const { ScanResult } = require('./utils/constants')
const { ScanResult, CoverDestination } = require('./utils/constants')
class Scanner {
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
@@ -27,6 +27,20 @@ class Scanner {
return this.db.audiobooks
}
getCoverDirectory(audiobook) {
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
return {
fullPath: audiobook.fullPath,
relPath: Path.join('/local', audiobook.path)
}
} else {
return {
fullPath: Path.join(this.BookMetadataPath, audiobook.id),
relPath: Path.join('/metadata', 'books', audiobook.id)
}
}
}
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
for (let i = 0; i < audiobookDataAudioFiles.length; i++) {
var abdFile = audiobookDataAudioFiles[i]
@@ -46,11 +60,73 @@ class Scanner {
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
}
async scanAudiobookData(audiobookData) {
// Only updates audio files with matching paths
syncAudiobookInodeValues(audiobook, { audioFiles, otherFiles }) {
var filesUpdated = 0
// Sync audio files & audio tracks with updated inodes
audiobook._audioFiles.forEach((audioFile) => {
var matchingAudioFile = audioFiles.find(af => af.ino !== audioFile.ino && af.path === audioFile.path)
if (matchingAudioFile) {
// Audio Track should always have the same ino as the equivalent audio file (not all audio files have a track)
var audioTrack = audiobook.tracks.find(t => t.ino === audioFile.ino)
if (audioTrack) {
Logger.debug(`[Scanner] Found audio file & track with mismatched inode "${audioFile.filename}" - updating it`)
audioTrack.ino = matchingAudioFile.ino
filesUpdated++
} else {
Logger.debug(`[Scanner] Found audio file with mismatched inode "${audioFile.filename}" - updating it`)
}
audioFile.ino = matchingAudioFile.ino
filesUpdated++
}
})
// Sync other files with updated inodes
audiobook._otherFiles.forEach((otherFile) => {
var matchingOtherFile = otherFiles.find(of => of.ino !== otherFile.ino && of.path === otherFile.path)
if (matchingOtherFile) {
Logger.debug(`[Scanner] Found other file with mismatched inode "${otherFile.filename}" - updating it`)
otherFile.ino = matchingOtherFile.ino
filesUpdated++
}
})
return filesUpdated
}
async scanAudiobookData(audiobookData, forceAudioFileScan = false) {
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
// inode value may change when using shared drives, update inode if matching path is found
// Note: inode will not change on rename
var hasUpdatedIno = false
if (!existingAudiobook) {
// check an audiobook exists with matching path, then update inodes
existingAudiobook = this.audiobooks.find(a => a.path === audiobookData.path)
if (existingAudiobook) {
existingAudiobook.ino = audiobookData.ino
hasUpdatedIno = true
}
}
if (existingAudiobook) {
// Always sync files and inode values
var filesInodeUpdated = this.syncAudiobookInodeValues(existingAudiobook, audiobookData)
if (hasUpdatedIno || filesInodeUpdated > 0) {
Logger.info(`[Scanner] Updating inode value for "${existingAudiobook.title}" - ${filesInodeUpdated} files updated`)
hasUpdatedIno = true
}
// TEMP: Check if is older audiobook and needs force rescan
if (!forceAudioFileScan && existingAudiobook.checkNeedsAudioFileRescan()) {
Logger.info(`[Scanner] Re-Scanning all audio files for "${existingAudiobook.title}" (last scan <= 1.3.0)`)
forceAudioFileScan = true
}
// REMOVE: No valid audio files
// TODO: Label as incomplete, do not actually delete
if (!audiobookData.audioFiles.length) {
@@ -64,8 +140,6 @@ class Scanner {
// ino is now set for every file in scandir
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
// audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
// Check for audio files that were removed
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
@@ -82,7 +156,6 @@ class Scanner {
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
}
// Check for new audio files and sync existing audio files
var newAudioFiles = []
var hasUpdatedAudioFiles = false
@@ -101,13 +174,35 @@ class Scanner {
}
}
})
// Rescan audio file metadata
if (forceAudioFileScan) {
Logger.info(`[Scanner] Rescanning ${existingAudiobook.audioFiles.length} audio files for "${existingAudiobook.title}"`)
var numAudioFilesUpdated = await audioFileScanner.rescanAudioFiles(existingAudiobook)
if (numAudioFilesUpdated > 0) {
Logger.info(`[Scanner] Rescan complete, ${numAudioFilesUpdated} audio files were updated for "${existingAudiobook.title}"`)
hasUpdatedAudioFiles = true
// Use embedded cover art if audiobook has no cover
if (existingAudiobook.hasEmbeddedCoverArt && !existingAudiobook.cover) {
var outputCoverDirs = this.getCoverDirectory(existingAudiobook)
var relativeDir = await existingAudiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
if (relativeDir) {
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
}
}
} else {
Logger.info(`[Scanner] Rescan complete, audio files were up to date for "${existingAudiobook.title}"`)
}
}
// Scan and add new audio files found and set tracks
if (newAudioFiles.length) {
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
// Scan new audio files found - sets tracks
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
}
// REMOVE: No valid audio tracks
// If after a scan no valid audio tracks remain
// TODO: Label as incomplete, do not actually delete
if (!existingAudiobook.tracks.length) {
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
@@ -117,14 +212,17 @@ class Scanner {
return ScanResult.REMOVED
}
var hasUpdates = removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
var hasUpdates = hasUpdatedIno || removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
// Check that audio tracks are in sequential order with no gaps
if (existingAudiobook.checkUpdateMissingParts()) {
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
hasUpdates = true
}
if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) {
// Sync other files (all files that are not audio files)
var otherFilesUpdated = await existingAudiobook.syncOtherFiles(audiobookData.otherFiles, forceAudioFileScan)
if (otherFilesUpdated) {
hasUpdates = true
}
@@ -133,12 +231,14 @@ class Scanner {
hasUpdates = true
}
// If audiobook was missing before, it is now found
if (existingAudiobook.isMissing) {
existingAudiobook.isMissing = false
hasUpdates = true
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
}
// Save changes and notify users
if (hasUpdates) {
existingAudiobook.setChapters()
@@ -167,6 +267,19 @@ class Scanner {
return ScanResult.NOTHING
}
if (audiobook.hasDescriptionTextFile) {
await audiobook.saveDescriptionFromTextFile()
}
if (audiobook.hasEmbeddedCoverArt) {
var outputCoverDirs = this.getCoverDirectory(audiobook)
var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
if (relativeDir) {
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
}
}
audiobook.setDetailsFromFileMetadata()
audiobook.checkUpdateMissingParts()
audiobook.setChapters()
@@ -176,15 +289,12 @@ class Scanner {
return ScanResult.ADDED
}
async scan() {
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos"
// TEMP - fix relative file paths
async scan(forceAudioFileScan = false) {
// TODO: This temporary fix from pre-release should be removed soon, "checkUpdateInos"
// TEMP - update ino for each audiobook
if (this.audiobooks.length) {
for (let i = 0; i < this.audiobooks.length; i++) {
var ab = this.audiobooks[i]
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
// Update ino if inos are not set
var shouldUpdateIno = ab.hasMissingIno
if (shouldUpdateIno) {
@@ -235,8 +345,7 @@ class Scanner {
// Check for new and updated audiobooks
for (let i = 0; i < audiobookDataFound.length; i++) {
var audiobookData = audiobookDataFound[i]
var result = await this.scanAudiobookData(audiobookData)
var result = await this.scanAudiobookData(audiobookDataFound[i], forceAudioFileScan)
if (result === ScanResult.ADDED) scanResults.added++
if (result === ScanResult.REMOVED) scanResults.removed++
if (result === ScanResult.UPDATED) scanResults.updated++
@@ -260,14 +369,24 @@ class Scanner {
return scanResults
}
async scanAudiobook(audiobookPath) {
async scanAudiobookById(audiobookId) {
const audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
if (!audiobook) {
Logger.error(`[Scanner] Scan audiobook by id not found ${audiobookId}`)
return ScanResult.NOTHING
}
Logger.info(`[Scanner] Scanning Audiobook "${audiobook.title}"`)
return this.scanAudiobook(audiobook.fullPath, true)
}
async scanAudiobook(audiobookPath, forceAudioFileScan = false) {
Logger.debug('[Scanner] scanAudiobook', audiobookPath)
var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings)
if (!audiobookData) {
return ScanResult.NOTHING
}
audiobookData.ino = await getIno(audiobookData.fullPath)
return this.scanAudiobookData(audiobookData)
return this.scanAudiobookData(audiobookData, forceAudioFileScan)
}
// Files were modified in this directory, check it out
@@ -319,10 +438,6 @@ class Scanner {
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
Logger.debug(`[Scanner] fileGroupings `, filepaths, fileGroupings)
var results = []
for (const dir in fileGroupings) {
Logger.debug(`[Scanner] Check dir ${dir}`)
@@ -334,19 +449,6 @@ class Scanner {
return results
}
async fetchMetadata(id, trackIndex = 0) {
var audiobook = this.audiobooks.find(a => a.id === id)
if (!audiobook) {
return false
}
var tracks = audiobook.tracks
var index = isNaN(trackIndex) ? 0 : Number(trackIndex)
var firstTrack = tracks[index]
var firstTrackFullPath = firstTrack.fullPath
var scanResult = await audioFileScanner.scan(firstTrackFullPath)
return scanResult
}
async scanCovers() {
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
var found = 0

View File

@@ -6,6 +6,8 @@ const fs = require('fs-extra')
const fileUpload = require('express-fileupload')
const rateLimit = require('express-rate-limit')
const { ScanResult } = require('./utils/constants')
const Auth = require('./Auth')
const Watcher = require('./Watcher')
const Scanner = require('./Scanner')
@@ -15,6 +17,7 @@ const HlsController = require('./HlsController')
const StreamManager = require('./StreamManager')
const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager')
const CoverController = require('./CoverController')
// const EbookReader = require('./EbookReader')
const Logger = require('./Logger')
@@ -36,9 +39,11 @@ class Server {
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath)
this.rssFeeds = new RssFeeds(this.Port, this.db)
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.emitter.bind(this), this.clientEmitter.bind(this))
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
// this.ebookReader = new EbookReader(this.db, this.MetadataPath, this.AudiobookPath)
this.server = null
@@ -82,20 +87,31 @@ class Server {
async filesChanged(files) {
Logger.info('[Server]', files.length, 'Files Changed')
var result = await this.scanner.filesChanged(files)
Logger.info('[Server] Files changed result', result)
Logger.debug('[Server] Files changed result', result)
}
async scan() {
async scan(forceAudioFileScan = false) {
Logger.info('[Server] Starting Scan')
this.isScanning = true
this.isInitialized = true
this.emitter('scan_start', 'files')
var results = await this.scanner.scan()
var results = await this.scanner.scan(forceAudioFileScan)
this.isScanning = false
this.emitter('scan_complete', { scanType: 'files', results })
Logger.info('[Server] Scan complete')
}
async scanAudiobook(socket, audiobookId) {
var result = await this.scanner.scanAudiobookById(audiobookId)
var scanResultName = ''
for (const key in ScanResult) {
if (ScanResult[key] === result) {
scanResultName = key
}
}
socket.emit('audiobook_scan_complete', scanResultName)
}
async scanCovers() {
Logger.info('[Server] Start cover scan')
this.isScanningCovers = true
@@ -119,6 +135,33 @@ class Server {
socket.emit('save_metadata_complete', response)
}
// Remove unused /metadata/books/{id} folders
async purgeMetadata() {
var booksMetadata = Path.join(this.MetadataPath, 'books')
var booksMetadataExists = await fs.pathExists(booksMetadata)
if (!booksMetadataExists) return
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
var purged = 0
await Promise.all(foldersInBooksMetadata.map(async foldername => {
var hasMatchingAudiobook = this.audiobooks.find(ab => ab.id === foldername)
if (!hasMatchingAudiobook) {
var folderPath = Path.join(booksMetadata, foldername)
Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
await fs.remove(folderPath).then(() => {
purged++
}).catch((err) => {
Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
})
}
}))
if (purged > 0) {
Logger.info(`[Server] Purged ${purged} unused audiobook metadata`)
}
return purged
}
async init() {
Logger.info('[Server] Init')
await this.streamManager.ensureStreamsDir()
@@ -128,6 +171,8 @@ class Server {
await this.db.init()
this.auth.init()
await this.purgeMetadata()
this.watcher.initWatcher()
this.watcher.on('files', this.filesChanged.bind(this))
}
@@ -287,6 +332,7 @@ class Server {
socket.on('scan', this.scan.bind(this))
socket.on('scan_covers', this.scanCovers.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this))
socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId))
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
// Streaming
@@ -300,11 +346,15 @@ class Server {
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('test', () => {
socket.emit('test_received', socket.id)
})
socket.on('disconnect', () => {
Logger.removeSocketListener(socket.id)
var _client = this.clients[socket.id]
if (!_client) {
Logger.warn('[SOCKET] Socket disconnect, no client ' + socket.id)
@@ -368,6 +418,11 @@ class Server {
stream: client.stream || null
}
client.socket.emit('init', initialPayload)
// Setup log listener for root user
if (user.type === 'root') {
Logger.addSocketListener(socket, this.db.serverSettings.logLevel || 0)
}
}
async stop() {

View File

@@ -1,3 +1,6 @@
const Logger = require('../Logger')
const AudioFileMetadata = require('./AudioFileMetadata')
class AudioFile {
constructor(data) {
this.index = null
@@ -21,18 +24,19 @@ class AudioFile {
this.channels = null
this.channelLayout = null
this.chapters = []
this.embeddedCoverArt = null
this.tagAlbum = null
this.tagArtist = null
this.tagGenre = null
this.tagTitle = null
this.tagTrack = null
// Tags scraped from the audio file
this.metadata = null
this.manuallyVerified = false
this.invalid = false
this.exclude = false
this.error = null
// TEMP: For forcing rescan
this.isOldAudioFile = false
if (data) {
this.construct(data)
}
@@ -58,15 +62,13 @@ class AudioFile {
size: this.size,
bitRate: this.bitRate,
language: this.language,
codec: this.codec,
timeBase: this.timeBase,
channels: this.channels,
channelLayout: this.channelLayout,
chapters: this.chapters,
tagAlbum: this.tagAlbum,
tagArtist: this.tagArtist,
tagGenre: this.tagGenre,
tagTitle: this.tagTitle,
tagTrack: this.tagTrack
embeddedCoverArt: this.embeddedCoverArt,
metadata: this.metadata ? this.metadata.toJSON() : {}
}
}
@@ -91,17 +93,21 @@ class AudioFile {
this.size = data.size
this.bitRate = data.bitRate
this.language = data.language
this.codec = data.codec
this.codec = data.codec || null
this.timeBase = data.timeBase
this.channels = data.channels
this.channelLayout = data.channelLayout
this.chapters = data.chapters
this.embeddedCoverArt = data.embeddedCoverArt || null
this.tagAlbum = data.tagAlbum
this.tagArtist = data.tagArtist
this.tagGenre = data.tagGenre
this.tagTitle = data.tagTitle
this.tagTrack = data.tagTrack
// Old version of AudioFile used `tagAlbum` etc.
var isOldVersion = Object.keys(data).find(key => key.startsWith('tag'))
if (isOldVersion) {
this.isOldAudioFile = true
this.metadata = new AudioFileMetadata(data)
} else {
this.metadata = new AudioFileMetadata(data.metadata || {})
}
}
setData(data) {
@@ -126,23 +132,85 @@ class AudioFile {
this.size = data.size
this.bitRate = data.bit_rate || null
this.language = data.language
this.codec = data.codec
this.codec = data.codec || null
this.timeBase = data.time_base
this.channels = data.channels
this.channelLayout = data.channel_layout
this.chapters = data.chapters || []
this.embeddedCoverArt = data.embedded_cover_art || null
this.tagAlbum = data.file_tag_album || null
this.tagArtist = data.file_tag_artist || null
this.tagGenre = data.file_tag_genre || null
this.tagTitle = data.file_tag_title || null
this.tagTrack = data.file_tag_track || null
this.metadata = new AudioFileMetadata()
this.metadata.setData(data)
}
syncChapters(updatedChapters) {
if (this.chapters.length !== updatedChapters.length) {
this.chapters = updatedChapters.map(ch => ({ ...ch }))
return true
} else if (updatedChapters.length === 0) {
if (this.chapters.length > 0) {
this.chapters = []
return true
}
return false
}
var hasUpdates = false
for (let i = 0; i < updatedChapters.length; i++) {
if (JSON.stringify(updatedChapters[i]) !== JSON.stringify(this.chapters[i])) {
hasUpdates = true
}
}
if (hasUpdates) {
this.chapters = updatedChapters.map(ch => ({ ...ch }))
}
return hasUpdates
}
// Called from audioFileScanner.js with scanData
updateMetadata(data) {
if (!this.metadata) this.metadata = new AudioFileMetadata()
var dataMap = {
format: data.format,
duration: data.duration,
size: data.size,
bitRate: data.bit_rate || null,
language: data.language,
codec: data.codec || null,
timeBase: data.time_base,
channels: data.channels,
channelLayout: data.channel_layout,
chapters: data.chapters || [],
embeddedCoverArt: data.embedded_cover_art || null
}
var hasUpdates = false
for (const key in dataMap) {
if (key === 'chapters') {
var chaptersUpdated = this.syncChapters(dataMap.chapters)
if (chaptersUpdated) {
hasUpdates = true
}
} else if (dataMap[key] !== this[key]) {
// Logger.debug(`[AudioFile] "${key}" from ${this[key]} => ${dataMap[key]}`)
this[key] = dataMap[key]
hasUpdates = true
}
}
if (this.metadata.updateData(data)) {
hasUpdates = true
}
return hasUpdates
}
clone() {
return new AudioFile(this.toJSON())
}
// If the file or parent directory was renamed it is synced here
syncFile(newFile) {
var hasUpdates = false
var keysToSync = ['path', 'fullPath', 'ext', 'filename']

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

View File

@@ -20,12 +20,6 @@ class AudioTrack {
this.channels = null
this.channelLayout = null
this.tagAlbum = null
this.tagArtist = null
this.tagGenre = null
this.tagTitle = null
this.tagTrack = null
if (audioTrack) {
this.construct(audioTrack)
}
@@ -49,12 +43,6 @@ class AudioTrack {
this.timeBase = audioTrack.timeBase
this.channels = audioTrack.channels
this.channelLayout = audioTrack.channelLayout
this.tagAlbum = audioTrack.tagAlbum
this.tagArtist = audioTrack.tagArtist
this.tagGenre = audioTrack.tagGenre
this.tagTitle = audioTrack.tagTitle
this.tagTrack = audioTrack.tagTrack
}
get name() {
@@ -74,14 +62,10 @@ class AudioTrack {
size: this.size,
bitRate: this.bitRate,
language: this.language,
codec: this.codec,
timeBase: this.timeBase,
channels: this.channels,
channelLayout: this.channelLayout,
tagAlbum: this.tagAlbum,
tagArtist: this.tagArtist,
tagGenre: this.tagGenre,
tagTitle: this.tagTitle,
tagTrack: this.tagTrack
}
}
@@ -99,16 +83,22 @@ class AudioTrack {
this.size = probeData.size
this.bitRate = probeData.bitRate
this.language = probeData.language
this.codec = probeData.codec
this.codec = probeData.codec || null
this.timeBase = probeData.timeBase
this.channels = probeData.channels
this.channelLayout = probeData.channelLayout
}
this.tagAlbum = probeData.file_tag_album || null
this.tagArtist = probeData.file_tag_artist || null
this.tagGenre = probeData.file_tag_genre || null
this.tagTitle = probeData.file_tag_title || null
this.tagTrack = probeData.file_tag_track || null
syncMetadata(audioFile) {
var hasUpdates = false
var keysToSync = ['format', 'duration', 'size', 'bitRate', 'language', 'codec', 'timeBase', 'channels', 'channelLayout']
keysToSync.forEach((key) => {
if (audioFile[key] !== undefined && audioFile[key] !== this[key]) {
hasUpdates = true
this[key] = audioFile[key]
}
})
return hasUpdates
}
syncFile(newFile) {

View File

@@ -1,6 +1,7 @@
const Path = require('path')
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils')
const { bytesPretty, elapsedPretty, readTextFile } = require('../utils/fileUtils')
const { comparePaths, getIno } = require('../utils/index')
const { extractCoverArt } = require('../utils/ffmpegHelpers')
const nfoGenerator = require('../utils/nfoGenerator')
const Logger = require('../Logger')
const Book = require('./Book')
@@ -104,15 +105,26 @@ class Audiobook {
}
get invalidParts() {
return (this.audioFiles || []).filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
return this._audioFiles.filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
}
get _audioFiles() { return this.audioFiles || [] }
get _otherFiles() { return this.otherFiles || [] }
get ebooks() {
return this.otherFiles.filter(file => file.filetype === 'ebook')
}
get hasMissingIno() {
return !this.ino || (this.audioFiles || []).find(abf => !abf.ino) || (this.otherFiles || []).find(f => !f.ino) || (this.tracks || []).find(t => !t.ino)
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() {
@@ -139,8 +151,8 @@ class Audiobook {
tags: this.tags,
book: this.bookToJSON(),
tracks: this.tracksToJSON(),
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
audioFiles: this._audioFiles.map(audioFile => audioFile.toJSON()),
otherFiles: this._otherFiles.map(otherFile => otherFile.toJSON()),
chapters: this.chapters || [],
isMissing: !!this.isMissing
}
@@ -192,33 +204,26 @@ class Audiobook {
}
}
// Scanner had a bug that was saving a file path as the audiobook path.
// audiobook path should be a directory.
// fixing this before a scan prevents audiobooks being removed and re-added
fixRelativePath(abRootPath) {
var pathExt = Path.extname(this.path)
if (pathExt) {
this.path = Path.dirname(this.path)
this.fullPath = Path.join(abRootPath, this.path)
Logger.warn('Audiobook path has extname', pathExt, 'fixed path:', this.path)
return true
}
return false
}
// Originally files did not store the inode value
// this function checks all files and sets the inode
async checkUpdateInos() {
var hasUpdates = false
// Audiobook folder needs inode
if (!this.ino) {
this.ino = await getIno(this.fullPath)
hasUpdates = true
}
// Check audio files have an inode
for (let i = 0; i < this.audioFiles.length; i++) {
var af = this.audioFiles[i]
var at = this.tracks.find(t => t.ino === af.ino)
if (!at) {
at = this.tracks.find(t => comparePaths(t.path, af.path))
if (!at && !af.exclude) {
Logger.warn(`[Audiobook] No matching track for audio file "${af.filename}"`)
}
}
if (!af.ino || af.ino === this.ino) {
af.ino = await getIno(af.fullPath)
@@ -234,6 +239,7 @@ class Audiobook {
hasUpdates = true
}
}
for (let i = 0; i < this.tracks.length; i++) {
var at = this.tracks[i]
if (!at.ino) {
@@ -257,6 +263,7 @@ class Audiobook {
}
}
}
for (let i = 0; i < this.otherFiles.length; i++) {
var file = this.otherFiles[i]
if (!file.ino || file.ino === this.ino) {
@@ -272,6 +279,11 @@ class Audiobook {
return hasUpdates
}
// Scans in v1.3.0 or lower will need to rescan audiofiles to pickup metadata and embedded cover
checkNeedsAudioFileRescan() {
return !!(this.audioFiles || []).find(af => af.isOldAudioFile || af.codec === null)
}
setData(data) {
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
this.ino = data.ino || null
@@ -414,23 +426,40 @@ class Audiobook {
}
// On scan check other files found with other files saved
syncOtherFiles(newOtherFiles) {
async syncOtherFiles(newOtherFiles, forceRescan = false) {
var hasUpdates = false
var currOtherFileNum = this.otherFiles.length
var alreadyHadDescTxt = this.otherFiles.find(of => of.filename === 'desc.txt')
var newOtherFilePaths = newOtherFiles.map(f => f.path)
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
// Some files are not there anymore and filtered out
if (currOtherFileNum !== this.otherFiles.length) hasUpdates = true
// If desc.txt is new or forcing rescan then read it and update description if empty
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
if (descriptionTxt && (!alreadyHadDescTxt || forceRescan)) {
var newDescription = await readTextFile(descriptionTxt.fullPath)
if (newDescription) {
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
this.update({ book: { description: newDescription } })
hasUpdates = true
}
}
// TODO: Should use inode
newOtherFiles.forEach((file) => {
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
if (!existingOtherFile) {
Logger.debug(`[Audiobook] New other file found on sync ${file.filename}/${file.filetype} | "${this.title}"`)
Logger.debug(`[Audiobook] New other file found on sync ${file.filename} | "${this.title}"`)
this.addOtherFile(file)
hasUpdates = true
}
})
var hasUpdates = currOtherFileNum !== this.otherFiles.length
// Check if cover was a local image and that it still exists
var imageFiles = this.otherFiles.filter(f => f.filetype === 'image')
if (this.book.cover && this.book.cover.substr(1).startsWith('local')) {
@@ -535,5 +564,38 @@ class Audiobook {
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

View File

@@ -183,5 +183,47 @@ class Book {
isSearchMatch(search) {
return this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(search)
}
setDetailsFromFileMetadata(audioFileMetadata) {
const MetadataMapArray = [
{
tag: 'tagComposer',
key: 'narrarator'
},
{
tag: 'tagDescription',
key: 'description'
},
{
tag: 'tagPublisher',
key: 'publisher'
},
{
tag: 'tagDate',
key: 'publishYear'
},
{
tag: 'tagSubtitle',
key: 'subtitle'
},
{
tag: 'tagArtist',
key: 'author'
}
]
var updatePayload = {}
MetadataMapArray.forEach((mapping) => {
if (!this[mapping.key] && audioFileMetadata[mapping.tag]) {
updatePayload[mapping.key] = audioFileMetadata[mapping.tag]
Logger.debug(`[Book] Mapping metadata to key ${mapping.tag} => ${mapping.key}: ${updatePayload[mapping.key]}`)
}
})
if (Object.keys(updatePayload).length) {
return this.update(updatePayload)
}
return false
}
}
module.exports = Book

View File

@@ -1,4 +1,5 @@
const { CoverDestination } = require('../utils/constants')
const Logger = require('../Logger')
class ServerSettings {
constructor(settings) {
@@ -11,6 +12,7 @@ class ServerSettings {
this.saveMetadataFile = false
this.rateLimitLoginRequests = 10
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
this.logLevel = Logger.logLevel
if (settings) {
this.construct(settings)
@@ -25,6 +27,11 @@ class ServerSettings {
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() {
@@ -36,7 +43,8 @@ class ServerSettings {
coverDestination: this.coverDestination,
saveMetadataFile: !!this.saveMetadataFile,
rateLimitLoginRequests: this.rateLimitLoginRequests,
rateLimitLoginWindow: this.rateLimitLoginWindow
rateLimitLoginWindow: this.rateLimitLoginWindow,
logLevel: this.logLevel
}
}
@@ -44,6 +52,9 @@ class ServerSettings {
var hasUpdates = false
for (const key in payload) {
if (this[key] !== payload[key]) {
if (key === 'logLevel') {
Logger.setLogLevel(payload[key])
}
this[key] = payload[key]
hasUpdates = true
}

View File

@@ -16,7 +16,6 @@ class Stream extends EventEmitter {
this.audiobook = audiobook
this.segmentLength = 6
this.segmentBasename = 'output-%d.ts'
this.streamPath = Path.join(streamPath, this.id)
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
@@ -51,6 +50,16 @@ class Stream extends EventEmitter {
return this.audiobook.totalDuration
}
get hlsSegmentType() {
var hasFlac = this.tracks.find(t => t.ext.toLowerCase() === '.flac')
return hasFlac ? 'fmp4' : 'mpegts'
}
get segmentBasename() {
if (this.hlsSegmentType === 'fmp4') return 'output-%d.m4s'
return 'output-%d.ts'
}
get segmentStartNumber() {
if (!this.startTime) return 0
return Math.floor(this.startTime / this.segmentLength)
@@ -98,7 +107,7 @@ class Stream extends EventEmitter {
var userAudiobook = clientUserAudiobooks[this.audiobookId] || null
if (userAudiobook) {
var timeRemaining = this.totalDuration - userAudiobook.currentTime
Logger.info('[STREAM] User has progress for audiobook', userAudiobook, `Time Remaining: ${timeRemaining}s`)
Logger.info('[STREAM] User has progress for audiobook', userAudiobook.progress, `Time Remaining: ${timeRemaining}s`)
if (timeRemaining > 15) {
this.startTime = userAudiobook.currentTime
this.clientCurrentTime = this.startTime
@@ -133,7 +142,7 @@ class Stream extends EventEmitter {
async generatePlaylist() {
fs.ensureDirSync(this.streamPath)
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength)
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength, this.hlsSegmentType)
return this.clientPlaylistUri
}
@@ -142,7 +151,7 @@ class Stream extends EventEmitter {
var files = await fs.readdir(this.streamPath)
files.forEach((file) => {
var extname = Path.extname(file)
if (extname === '.ts') {
if (extname === '.ts' || extname === '.m4s') {
var basename = Path.basename(file, extname)
var num_part = basename.split('-')[1]
var part_num = Number(num_part)
@@ -238,24 +247,31 @@ class Stream extends EventEmitter {
}
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
const audioCodec = this.hlsSegmentType === 'fmp4' ? 'aac' : 'copy'
this.ffmpeg.addOption([
`-loglevel ${logLevel}`,
'-map 0:a',
'-c:a copy'
`-c:a ${audioCodec}`
])
this.ffmpeg.addOption([
const hlsOptions = [
'-f hls',
"-copyts",
"-avoid_negative_ts disabled",
"-max_delay 5000000",
"-max_muxing_queue_size 2048",
`-hls_time 6`,
"-hls_segment_type mpegts",
`-hls_segment_type ${this.hlsSegmentType}`,
`-start_number ${this.segmentStartNumber}`,
"-hls_playlist_type vod",
"-hls_list_size 0",
"-hls_allow_cache 0"
])
]
if (this.hlsSegmentType === 'fmp4') {
hlsOptions.push('-strict -2')
var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
hlsOptions.push(`-hls_fmp4_init_filename ${fmp4InitFilename}`)
}
this.ffmpeg.addOption(hlsOptions)
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
this.ffmpeg.output(this.finalPlaylistPath)

View File

@@ -2,6 +2,8 @@ const Path = require('path')
const Logger = require('../Logger')
const prober = require('./prober')
const ImageCodecs = ['mjpeg', 'jpeg', 'png']
function getDefaultAudioStream(audioStreams) {
if (audioStreams.length === 1) return audioStreams[0]
var defaultStream = audioStreams.find(a => a.is_default)
@@ -37,6 +39,11 @@ async function scan(path) {
chapters: probeData.chapters || []
}
var hasCoverArt = probeData.video_stream ? ImageCodecs.includes(probeData.video_stream.codec) : false
if (hasCoverArt) {
finalData.embedded_cover_art = probeData.video_stream.codec
}
for (const key in probeData) {
if (probeData[key] && key.startsWith('file_tag')) {
finalData[key] = probeData[key]
@@ -76,6 +83,9 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
if (series) partbasename = partbasename.replace(series, '')
if (publishYear) partbasename = partbasename.replace(publishYear)
// Remove eg. "disc 1" from path
partbasename = partbasename.replace(/ disc \d\d? /i, '')
var numbersinpath = partbasename.match(/\d+/g)
if (!numbersinpath) return null
@@ -88,9 +98,11 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
Logger.error('[AudioFileScanner] Scan Audio Files no new files', audiobook.title)
return
}
var tracks = []
var numDuplicateTracks = 0
var numInvalidTracks = 0
for (let i = 0; i < newAudioFiles.length; i++) {
var audioFile = newAudioFiles[i]
var scanData = await scan(audioFile.fullPath)
@@ -102,6 +114,7 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
var trackNumFromMeta = getTrackNumberFromMeta(scanData)
var book = audiobook.book || {}
var trackNumFromFilename = getTrackNumberFromFilename(book.title, book.author, book.series, book.publishYear, audioFile.filename)
var audioFileObj = {
@@ -129,7 +142,7 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
}
if (tracks.find(t => t.index === trackNumber)) {
Logger.debug('[AudioFileScanner] Duplicate track number for', audioFile.filename)
// Logger.debug('[AudioFileScanner] Duplicate track number for', audioFile.filename)
audioFile.invalid = true
audioFile.error = 'Duplicate track number'
numDuplicateTracks++
@@ -175,4 +188,47 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
audiobook.tracks.sort((a, b) => a.index - b.index)
}
}
module.exports.scanAudioFiles = scanAudioFiles
module.exports.scanAudioFiles = scanAudioFiles
async function rescanAudioFiles(audiobook) {
var audioFiles = audiobook.audioFiles
var updates = 0
for (let i = 0; i < audioFiles.length; i++) {
var audioFile = audioFiles[i]
var scanData = await scan(audioFile.fullPath)
if (!scanData || scanData.error) {
Logger.error('[AudioFileScanner] Scan failed for', audioFile.path)
// audiobook.invalidAudioFiles.push(parts[i])
continue;
}
var hasUpdates = audioFile.updateMetadata(scanData)
if (hasUpdates) {
// Sync audio track with audio file
var matchingAudioTrack = audiobook.tracks.find(t => t.ino === audioFile.ino)
if (matchingAudioTrack) {
matchingAudioTrack.syncMetadata(audioFile)
} else if (!audioFile.exclude) { // If audio file is not excluded then it should have an audio track
// Fallback to checking path
matchingAudioTrack = audiobook.tracks.find(t => t.path === audioFile.path)
if (matchingAudioTrack) {
Logger.warn(`[AudioFileScanner] Audio File mismatch ino with audio track "${audioFile.filename}"`)
matchingAudioTrack.ino = audioFile.ino
matchingAudioTrack.syncMetadata(audioFile)
} else {
Logger.error(`[AudioFileScanner] Audio File has no matching Track ${audioFile.filename} for "${audiobook.title}"`)
// Exclude audio file to prevent further errors
// audioFile.exclude = true
}
}
updates++
}
}
return updates
}
module.exports.rescanAudioFiles = rescanAudioFiles

View File

@@ -9,4 +9,14 @@ module.exports.ScanResult = {
module.exports.CoverDestination = {
METADATA: 0,
AUDIOBOOK: 1
}
module.exports.LogLevel = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
FATAL: 5,
NOTE: 6
}

View File

@@ -1,5 +1,8 @@
const Ffmpeg = require('fluent-ffmpeg')
const fs = require('fs-extra')
const Path = require('path')
const package = require('../../package.json')
const Logger = require('../Logger')
function escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'')
@@ -64,4 +67,29 @@ async function writeMetadataFile(audiobook, outputPath) {
await fs.writeFile(outputPath, inputstrs.join('\n'))
return inputstrs
}
module.exports.writeMetadataFile = writeMetadataFile
module.exports.writeMetadataFile = writeMetadataFile
async function extractCoverArt(filepath, outputpath) {
var dirname = Path.dirname(outputpath)
await fs.ensureDir(dirname)
return new Promise((resolve) => {
var ffmpeg = Ffmpeg(filepath)
ffmpeg.addOption(['-map 0:v', '-frames:v 1'])
ffmpeg.output(outputpath)
ffmpeg.on('start', (cmd) => {
Logger.debug(`[FfmpegHelpers] Extract Cover Cmd: ${cmd}`)
})
ffmpeg.on('error', (err, stdout, stderr) => {
Logger.error(`[FfmpegHelpers] Extract Cover Error ${err}`)
resolve(false)
})
ffmpeg.on('end', () => {
Logger.debug(`[FfmpegHelpers] Cover Art Extracted Successfully`)
resolve(outputpath)
})
ffmpeg.run()
})
}
module.exports.extractCoverArt = extractCoverArt

View File

@@ -1,4 +1,5 @@
const fs = require('fs-extra')
const Logger = require('../Logger')
async function getFileStat(path) {
try {
@@ -24,6 +25,17 @@ async function getFileSize(path) {
}
module.exports.getFileSize = getFileSize
async function readTextFile(path) {
try {
var data = await fs.readFile(path)
return String(data)
} catch (error) {
Logger.error(`[FileUtils] ReadTextFile error ${error}`)
return ''
}
}
module.exports.readTextFile = readTextFile
function bytesPretty(bytes, decimals = 0) {
if (bytes === 0) {
return '0 Bytes'

7
server/utils/globals.js Normal file
View File

@@ -0,0 +1,7 @@
const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi']
}
module.exports = globals

View File

@@ -1,6 +1,8 @@
const fs = require('fs-extra')
function getPlaylistStr(segmentName, duration, segmentLength) {
function getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType) {
var ext = hlsSegmentType === 'fmp4' ? 'm4s' : 'ts'
var lines = [
'#EXTM3U',
'#EXT-X-VERSION:3',
@@ -9,22 +11,25 @@ function getPlaylistStr(segmentName, duration, segmentLength) {
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXT-X-PLAYLIST-TYPE:VOD'
]
if (hlsSegmentType === 'fmp4') {
lines.push('#EXT-X-MAP:URI="init.mp4"')
}
var numSegments = Math.floor(duration / segmentLength)
var lastSegment = duration - (numSegments * segmentLength)
for (let i = 0; i < numSegments; i++) {
lines.push(`#EXTINF:6,`)
lines.push(`${segmentName}-${i}.ts`)
lines.push(`${segmentName}-${i}.${ext}`)
}
if (lastSegment > 0) {
lines.push(`#EXTINF:${lastSegment},`)
lines.push(`${segmentName}-${numSegments}.ts`)
lines.push(`${segmentName}-${numSegments}.${ext}`)
}
lines.push('#EXT-X-ENDLIST')
return lines.join('\n')
}
function generatePlaylist(outputPath, segmentName, duration, segmentLength) {
var playlistStr = getPlaylistStr(segmentName, duration, segmentLength)
function generatePlaylist(outputPath, segmentName, duration, segmentLength, hlsSegmentType) {
var playlistStr = getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType)
return fs.writeFile(outputPath, playlistStr)
}
module.exports = generatePlaylist

View File

@@ -63,7 +63,3 @@ module.exports.getIno = (path) => {
return null
})
}
module.exports.isAcceptableCoverMimeType = (mimeType) => {
return mimeType && mimeType.startsWith('image/')
}

View File

@@ -1,4 +1,6 @@
var Ffmpeg = require('fluent-ffmpeg')
const Path = require('path')
const Logger = require('../Logger')
function tryGrabBitRate(stream, all_streams, total_bit_rate) {
if (!isNaN(stream.bit_rate) && stream.bit_rate) {
@@ -72,6 +74,15 @@ function tryGrabTag(stream, tag) {
return stream.tags[tag] || stream.tags[tag.toUpperCase()] || null
}
function tryGrabTags(stream, ...tags) {
if (!stream.tags) return null
for (let i = 0; i < tags.length; i++) {
var value = stream.tags[tags[i]] || stream.tags[tags[i].toUpperCase()]
if (value) return value
}
return null
}
function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
var info = {
index: stream.index,
@@ -124,6 +135,53 @@ function parseChapters(chapters) {
})
}
function parseTags(format) {
if (!format.tags) {
return {}
}
// Logger.debug('Tags', format.tags)
const tags = {
file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'),
file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'),
file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'),
file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'),
file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'),
file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'),
file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'),
file_tag_albumartist: tryGrabTags(format, 'albumartist', 'tpe2'),
file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'),
file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'),
file_tag_publisher: tryGrabTags(format, 'publisher', 'tpub', 'tpb'),
file_tag_comment: tryGrabTags(format, 'comment', 'comm', 'com'),
file_tag_description: tryGrabTags(format, 'description', 'desc'),
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
// Not sure if these are actually used yet or not
file_tag_creation_time: tryGrabTag(format, 'creation_time'),
file_tag_wwwaudiofile: tryGrabTags(format, 'wwwaudiofile', 'woaf', 'waf'),
file_tag_contentgroup: tryGrabTags(format, 'contentgroup', 'tit1', 'tt1'),
file_tag_releasetime: tryGrabTags(format, 'releasetime', 'tdrl'),
file_tag_movementname: tryGrabTags(format, 'movementname', 'mvnm'),
file_tag_movement: tryGrabTags(format, 'movement', 'mvin'),
file_tag_series: tryGrabTag(format, 'series'),
file_tag_seriespart: tryGrabTag(format, 'series-part'),
file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2')
}
for (const key in tags) {
if (!tags[key]) {
delete tags[key]
}
}
var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime']
var success = keysToLookOutFor.find(key => !!tags[key])
if (success) {
Logger.debug('Notable!', success)
}
return tags
}
function parseProbeData(data) {
try {
var { format, streams, chapters } = data
@@ -131,20 +189,16 @@ function parseProbeData(data) {
var sizeBytes = !isNaN(size) ? Number(size) : null
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
// Logger.debug('Parsing Data for', Path.basename(format.filename))
var tags = parseTags(format)
var cleanedData = {
format: format_long_name,
duration: !isNaN(duration) ? Number(duration) : null,
size: sizeBytes,
sizeMb,
bit_rate: !isNaN(bit_rate) ? Number(bit_rate) : null,
file_tag_encoder: tryGrabTag(format, 'encoder') || tryGrabTag(format, 'encoded_by'),
file_tag_title: tryGrabTag(format, 'title'),
file_tag_track: tryGrabTag(format, 'track') || tryGrabTag(format, 'trk'),
file_tag_album: tryGrabTag(format, 'album') || tryGrabTag(format, 'tal'),
file_tag_artist: tryGrabTag(format, 'artist') || tryGrabTag(format, 'tp1'),
file_tag_date: tryGrabTag(format, 'date') || tryGrabTag(format, 'tye'),
file_tag_genre: tryGrabTag(format, 'genre'),
file_tag_creation_time: tryGrabTag(format, 'creation_time')
...tags
}
const cleaned_streams = streams.map(s => parseMediaStreamInfo(s, streams, cleanedData.bit_rate))

View File

@@ -2,11 +2,7 @@ const Path = require('path')
const dir = require('node-dir')
const Logger = require('../Logger')
const { getIno } = require('./index')
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a']
const INFO_FORMATS = ['nfo']
const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp']
const EBOOK_FORMATS = ['epub', 'pdf']
const globals = require('./globals')
function getPaths(path) {
return new Promise((resolve) => {
@@ -24,7 +20,7 @@ function isAudioFile(path) {
if (!path) return false
var ext = Path.extname(path)
if (!ext) return false
return AUDIO_FORMATS.includes(ext.slice(1).toLowerCase())
return globals.SupportedAudioTypes.includes(ext.slice(1).toLowerCase())
}
function groupFilesIntoAudiobookPaths(paths, useAllFileTypes = false) {
@@ -107,10 +103,10 @@ function cleanFileObjects(basepath, abrelpath, files) {
function getFileType(ext) {
var ext_cleaned = ext.toLowerCase()
if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1)
if (AUDIO_FORMATS.includes(ext_cleaned)) return 'audio'
if (INFO_FORMATS.includes(ext_cleaned)) return 'info'
if (IMAGE_FORMATS.includes(ext_cleaned)) return 'image'
if (EBOOK_FORMATS.includes(ext_cleaned)) return 'ebook'
if (globals.SupportedAudioTypes.includes(ext_cleaned)) return 'audio'
if (ext_cleaned === 'nfo') return 'info'
if (globals.SupportedImageTypes.includes(ext_cleaned)) return 'image'
if (globals.SupportedEbookTypes.includes(ext_cleaned)) return 'ebook'
return 'unknown'
}