mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-05 02:02:44 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dd8dc6dd4 | |||
| 8d9d5a8d1b | |||
| 0db34dcab5 | |||
| 47c6c1aaad |
@@ -162,7 +162,11 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', 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
|
this.processingUpload = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -204,20 +208,39 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
const updatePayload = {
|
var success = false
|
||||||
book: {
|
|
||||||
cover: cover
|
// 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) => {
|
if (success) {
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
this.isProcessing = false
|
|
||||||
if (updatedAudiobook) {
|
|
||||||
this.$toast.success('Update Successful')
|
this.$toast.success('Update Successful')
|
||||||
this.$emit('close')
|
this.$emit('close')
|
||||||
|
} else {
|
||||||
|
this.imageUrl = this.book.cover || ''
|
||||||
}
|
}
|
||||||
|
this.isProcessing = false
|
||||||
},
|
},
|
||||||
getSearchQuery() {
|
getSearchQuery() {
|
||||||
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
|
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
inputAccept: 'image/*'
|
inputAccept: '.png, .jpg, .jpeg, .webp'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {},
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
className() {
|
className() {
|
||||||
if (this.disabled) return 'bg-bg cursor-not-allowed'
|
if (this.disabled) return this.toggleValue ? `bg-${this.onColor} cursor-not-allowed` : `bg-${this.offColor} cursor-not-allowed`
|
||||||
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
|
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
|
||||||
},
|
},
|
||||||
switchClassName() {
|
switchClassName() {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "1.3.1",
|
"version": "1.3.4",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -42,9 +42,9 @@
|
|||||||
<div class="flex items-start py-2">
|
<div class="flex items-start py-2">
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" @input="updateScannerParseSubtitle" />
|
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
||||||
<ui-tooltip :text="parseSubtitleTooltip">
|
<ui-tooltip :text="parseSubtitleTooltip">
|
||||||
<p class="pl-4 text-lg">Parse Subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
<p class="pl-4 text-lg">Parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,12 +53,30 @@
|
|||||||
<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 mb-4">
|
<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="(Warning: Long running task!) Attempts to lookup and match a cover with all audiobooks that don't have one." class="w-full">
|
||||||
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
|
<ui-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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- <ui-btn color="primary" small @click="saveMetadataFiles">Save Metadata</ui-btn> -->
|
<div class="py-4 mb-4">
|
||||||
|
<p class="text-2xl">Metadata</p>
|
||||||
|
<div class="flex items-start py-2">
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
|
||||||
|
<ui-tooltip :text="coverDestinationTooltip">
|
||||||
|
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div class="w-40 flex flex-col">
|
||||||
|
<ui-tooltip :text="saveMetadataTooltip" direction="bottom" class="w-full">
|
||||||
|
<ui-btn color="primary" small class="w-full" @click="saveMetadataFiles">Save Metadata</ui-btn>
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,18 +119,21 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
storeCoversInAudiobookDir: false,
|
||||||
isResettingAudiobooks: false,
|
isResettingAudiobooks: false,
|
||||||
users: [],
|
users: [],
|
||||||
selectedAccount: null,
|
selectedAccount: null,
|
||||||
showAccountModal: false,
|
showAccountModal: false,
|
||||||
isDeletingUser: false,
|
isDeletingUser: false,
|
||||||
newServerSettings: {}
|
newServerSettings: {},
|
||||||
|
updatingServerSettings: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
serverSettings(newVal, oldVal) {
|
serverSettings(newVal, oldVal) {
|
||||||
if (newVal && !oldVal) {
|
if (newVal && !oldVal) {
|
||||||
this.newServerSettings = { ...this.serverSettings }
|
this.newServerSettings = { ...this.serverSettings }
|
||||||
|
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -120,6 +141,12 @@ export default {
|
|||||||
parseSubtitleTooltip() {
|
parseSubtitleTooltip() {
|
||||||
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
|
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
|
||||||
},
|
},
|
||||||
|
coverDestinationTooltip() {
|
||||||
|
return 'By default covers are stored in /metadata/books, enabling this setting will store covers inside your audiobooks directory. Only one file named "cover" will be kept.'
|
||||||
|
},
|
||||||
|
saveMetadataTooltip() {
|
||||||
|
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
|
||||||
|
},
|
||||||
serverSettings() {
|
serverSettings() {
|
||||||
return this.$store.state.serverSettings
|
return this.$store.state.serverSettings
|
||||||
},
|
},
|
||||||
@@ -134,6 +161,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updateCoverStorageDestination(val) {
|
||||||
|
this.newServerSettings.coverDestination = val ? this.$constants.CoverDestination.AUDIOBOOK : this.$constants.CoverDestination.METADATA
|
||||||
|
this.updateServerSettings({
|
||||||
|
coverDestination: this.newServerSettings.coverDestination
|
||||||
|
})
|
||||||
|
},
|
||||||
updateScannerParseSubtitle(val) {
|
updateScannerParseSubtitle(val) {
|
||||||
var payload = {
|
var payload = {
|
||||||
scannerParseSubtitle: val
|
scannerParseSubtitle: val
|
||||||
@@ -141,13 +174,16 @@ export default {
|
|||||||
this.updateServerSettings(payload)
|
this.updateServerSettings(payload)
|
||||||
},
|
},
|
||||||
updateServerSettings(payload) {
|
updateServerSettings(payload) {
|
||||||
|
this.updatingServerSettings = true
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('updateServerSettings', payload)
|
.dispatch('updateServerSettings', payload)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
console.log('Updated Server Settings', success)
|
console.log('Updated Server Settings', success)
|
||||||
|
this.updatingServerSettings = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update server settings', error)
|
console.error('Failed to update server settings', error)
|
||||||
|
this.updatingServerSettings = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
setDeveloperMode() {
|
setDeveloperMode() {
|
||||||
@@ -161,7 +197,14 @@ export default {
|
|||||||
scanCovers() {
|
scanCovers() {
|
||||||
this.$root.socket.emit('scan_covers')
|
this.$root.socket.emit('scan_covers')
|
||||||
},
|
},
|
||||||
|
saveMetadataComplete(result) {
|
||||||
|
this.savingMetadata = false
|
||||||
|
if (!result) return
|
||||||
|
this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
|
||||||
|
},
|
||||||
saveMetadataFiles() {
|
saveMetadataFiles() {
|
||||||
|
this.savingMetadata = true
|
||||||
|
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||||
this.$root.socket.emit('save_metadata')
|
this.$root.socket.emit('save_metadata')
|
||||||
},
|
},
|
||||||
loadUsers() {
|
loadUsers() {
|
||||||
@@ -247,6 +290,7 @@ export default {
|
|||||||
this.$root.socket.on('user_removed', this.userRemoved)
|
this.$root.socket.on('user_removed', this.userRemoved)
|
||||||
|
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
|
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||||
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
|
<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>
|
</header>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
|
<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,
|
title: null,
|
||||||
author: null,
|
author: null,
|
||||||
series: null,
|
series: null,
|
||||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a'],
|
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac'],
|
||||||
acceptedImageFormats: ['image/*'],
|
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
|
||||||
inputAccept: ['image/*, .mp3, .m4b, .m4a'],
|
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac',
|
||||||
isDragOver: false,
|
isDragOver: false,
|
||||||
showUploader: true,
|
showUploader: true,
|
||||||
validAudioFiles: [],
|
validAudioFiles: [],
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ const DownloadStatus = {
|
|||||||
FAILED: 3
|
FAILED: 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CoverDestination = {
|
||||||
|
METADATA: 0,
|
||||||
|
AUDIOBOOK: 1
|
||||||
|
}
|
||||||
|
|
||||||
const Constants = {
|
const Constants = {
|
||||||
DownloadStatus
|
DownloadStatus,
|
||||||
|
CoverDestination
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ app }, inject) => {
|
export default ({ app }, inject) => {
|
||||||
|
|||||||
Generated
+48
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.2.7",
|
"version": "1.3.2",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -573,6 +573,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
|
||||||
"integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew=="
|
"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": {
|
"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",
|
||||||
@@ -723,6 +728,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
|
"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": {
|
"inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="
|
"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": {
|
"parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
|
||||||
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
|
"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": {
|
"podcast": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/podcast/-/podcast-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/podcast/-/podcast-1.3.0.tgz",
|
||||||
@@ -1124,6 +1152,15 @@
|
|||||||
"unpipe": "1.0.0"
|
"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": {
|
"readable-stream": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
||||||
@@ -1424,6 +1461,16 @@
|
|||||||
"isexe": "^2.0.0"
|
"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": {
|
"wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.3.1",
|
"version": "1.3.4",
|
||||||
"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": {
|
||||||
@@ -32,12 +32,14 @@
|
|||||||
"express-rate-limit": "^5.3.0",
|
"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",
|
||||||
|
"image-type": "^4.1.0",
|
||||||
"ip": "^1.1.5",
|
"ip": "^1.1.5",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
"libgen": "^2.1.0",
|
"libgen": "^2.1.0",
|
||||||
"njodb": "^0.4.20",
|
"njodb": "^0.4.20",
|
||||||
"node-dir": "^0.1.17",
|
"node-dir": "^0.1.17",
|
||||||
"podcast": "^1.3.0",
|
"podcast": "^1.3.0",
|
||||||
|
"read-chunk": "^3.1.0",
|
||||||
"socket.io": "^4.1.3",
|
"socket.io": "^4.1.3",
|
||||||
"watcher": "^1.2.0"
|
"watcher": "^1.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,62 +11,14 @@ 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" />
|
<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.
|
See [documentation](https://audiobookshelf.org/docs) for supported directory structure, folder naming conventions, and audio file metadata usage.
|
||||||
|
|
||||||
**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/...`
|
|
||||||
|
|
||||||
|
|
||||||
### 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
|
## Installation
|
||||||
|
|
||||||
@@ -120,7 +72,7 @@ Get the `deb` file from the [github repo](https://github.com/advplyr/audiobooksh
|
|||||||
See [instructions](https://www.audiobookshelf.org/install#debian)
|
See [instructions](https://www.audiobookshelf.org/install#debian)
|
||||||
|
|
||||||
|
|
||||||
#### File locations
|
#### Linux file locations
|
||||||
|
|
||||||
Project directory: `/usr/share/audiobookshelf/`
|
Project directory: `/usr/share/audiobookshelf/`
|
||||||
|
|
||||||
|
|||||||
+18
-59
@@ -3,17 +3,17 @@ const Path = require('path')
|
|||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const User = require('./objects/User')
|
const User = require('./objects/User')
|
||||||
const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
|
const { isObject } = require('./utils/index')
|
||||||
const { CoverDestination } = require('./utils/constants')
|
|
||||||
|
|
||||||
class ApiController {
|
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.db = db
|
||||||
this.scanner = scanner
|
this.scanner = scanner
|
||||||
this.auth = auth
|
this.auth = auth
|
||||||
this.streamManager = streamManager
|
this.streamManager = streamManager
|
||||||
this.rssFeeds = rssFeeds
|
this.rssFeeds = rssFeeds
|
||||||
this.downloadManager = downloadManager
|
this.downloadManager = downloadManager
|
||||||
|
this.coverController = coverController
|
||||||
this.emitter = emitter
|
this.emitter = emitter
|
||||||
this.clientEmitter = clientEmitter
|
this.clientEmitter = clientEmitter
|
||||||
this.MetadataPath = MetadataPath
|
this.MetadataPath = MetadataPath
|
||||||
@@ -221,77 +221,36 @@ class ApiController {
|
|||||||
Logger.warn('User attempted to upload a cover without permission', req.user)
|
Logger.warn('User attempted to upload a cover without permission', req.user)
|
||||||
return res.sendStatus(403)
|
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 audiobookId = req.params.id
|
||||||
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||||
if (!audiobook) {
|
if (!audiobook) {
|
||||||
return res.status(404).send('Audiobook not found')
|
return res.status(404).send('Audiobook not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverFile = req.files.cover
|
var result = null
|
||||||
var mimeType = coverFile.mimetype
|
if (req.body && req.body.url) {
|
||||||
var extname = Path.extname(coverFile.name.toLowerCase()) || '.jpg'
|
Logger.debug(`[ApiController] Requesting download cover from url "${req.body.url}"`)
|
||||||
if (!isAcceptableCoverMimeType(mimeType)) {
|
result = await this.coverController.downloadCoverFromUrl(audiobook, req.body.url)
|
||||||
return res.status(400).send('Invalid image file type: ' + mimeType)
|
} else if (req.files && req.files.cover) {
|
||||||
}
|
Logger.debug(`[ApiController] Handling uploaded cover`)
|
||||||
|
var coverFile = req.files.cover
|
||||||
var coverDestination = this.db.serverSettings ? this.db.serverSettings.coverDestination : CoverDestination.METADATA
|
result = await this.coverController.uploadCover(audiobook, coverFile)
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
Logger.debug(`[ApiController] storing in audiobook | ${coverRelDirpath}`)
|
return res.status(400).send('Invalid request no file or url')
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverFilename = `cover${extname}`
|
if (result && result.error) {
|
||||||
var coverFullPath = Path.join(coverDirpath, coverFilename)
|
return res.status(400).send(result.error)
|
||||||
var coverPath = Path.join(coverRelDirpath, coverFilename)
|
} else if (!result || !result.cover) {
|
||||||
|
return res.status(500).send('Unknown error occurred')
|
||||||
// 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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
await this.db.updateAudiobook(audiobook)
|
||||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
cover: coverPath
|
cover: result.cover
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
const fs = require('fs-extra')
|
||||||
|
const Path = require('path')
|
||||||
|
const axios = require('axios')
|
||||||
|
const Logger = require('./Logger')
|
||||||
|
const readChunk = require('read-chunk')
|
||||||
|
const imageType = require('image-type')
|
||||||
|
|
||||||
|
const globals = require('./utils/globals')
|
||||||
|
const { CoverDestination } = require('./utils/constants')
|
||||||
|
|
||||||
|
class CoverController {
|
||||||
|
constructor(db, MetadataPath, AudiobookPath) {
|
||||||
|
this.db = db
|
||||||
|
this.MetadataPath = MetadataPath
|
||||||
|
this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
|
||||||
|
this.AudiobookPath = AudiobookPath
|
||||||
|
}
|
||||||
|
|
||||||
|
getCoverDirectory(audiobook) {
|
||||||
|
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||||
|
return {
|
||||||
|
fullPath: audiobook.fullPath,
|
||||||
|
relPath: Path.join('/local', audiobook.path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
fullPath: Path.join(this.BookMetadataPath, audiobook.id),
|
||||||
|
relPath: Path.join('/metadata', 'books', audiobook.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilesInDirectory(dir) {
|
||||||
|
try {
|
||||||
|
return fs.readdir(dir)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[CoverController] Failed to get files in dir ${dir}`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFile(filepath) {
|
||||||
|
try {
|
||||||
|
return fs.pathExists(filepath).then((exists) => {
|
||||||
|
if (!exists) Logger.warn(`[CoverController] Attempting to remove file that does not exist ${filepath}`)
|
||||||
|
return exists ? fs.unlink(filepath) : false
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[CoverController] Failed to remove file "${filepath}"`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove covers that dont have the same filename as the new cover
|
||||||
|
async removeOldCovers(dirpath, newCoverExt) {
|
||||||
|
var filesInDir = await this.getFilesInDirectory(dirpath)
|
||||||
|
|
||||||
|
for (let i = 0; i < filesInDir.length; i++) {
|
||||||
|
var file = filesInDir[i]
|
||||||
|
var _extname = Path.extname(file)
|
||||||
|
var _filename = Path.basename(file, _extname)
|
||||||
|
if (_filename === 'cover' && _extname !== newCoverExt) {
|
||||||
|
var filepath = Path.join(dirpath, file)
|
||||||
|
Logger.debug(`[CoverController] Removing old cover from metadata "${filepath}"`)
|
||||||
|
await this.removeFile(filepath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkFileIsValidImage(imagepath) {
|
||||||
|
const buffer = await readChunk(imagepath, 0, 12)
|
||||||
|
const imgType = imageType(buffer)
|
||||||
|
if (!imgType) {
|
||||||
|
await this.removeFile(imagepath)
|
||||||
|
return {
|
||||||
|
error: 'Invalid image'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globals.SupportedImageTypes.includes(imgType.ext)) {
|
||||||
|
await this.removeFile(imagepath)
|
||||||
|
return {
|
||||||
|
error: `Invalid image type ${imgType.ext} (Supported: ${globals.SupportedImageTypes.join(',')})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imgType
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadCover(audiobook, coverFile) {
|
||||||
|
var extname = Path.extname(coverFile.name.toLowerCase())
|
||||||
|
if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {
|
||||||
|
return {
|
||||||
|
error: `Invalid image type ${extname} (Supported: ${globals.SupportedImageTypes.join(',')})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var { fullPath, relPath } = this.getCoverDirectory(audiobook)
|
||||||
|
await fs.ensureDir(fullPath)
|
||||||
|
|
||||||
|
var coverFilename = `cover${extname}`
|
||||||
|
var coverFullPath = Path.join(fullPath, coverFilename)
|
||||||
|
var coverPath = Path.join(relPath, coverFilename)
|
||||||
|
|
||||||
|
// Move cover from temp upload dir to destination
|
||||||
|
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
|
||||||
|
Logger.error('[CoverController] Failed to move cover file', path, error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return {
|
||||||
|
error: 'Failed to move cover into destination'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.removeOldCovers(fullPath, extname)
|
||||||
|
|
||||||
|
Logger.info(`[CoverController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
|
||||||
|
|
||||||
|
audiobook.updateBookCover(coverPath)
|
||||||
|
return {
|
||||||
|
cover: coverPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(url, filepath) {
|
||||||
|
Logger.debug(`[CoverController] Starting file download to ${filepath}`)
|
||||||
|
const writer = fs.createWriteStream(filepath)
|
||||||
|
const response = await axios({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
responseType: 'stream'
|
||||||
|
})
|
||||||
|
response.data.pipe(writer)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
writer.on('finish', resolve)
|
||||||
|
writer.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadCoverFromUrl(audiobook, url) {
|
||||||
|
try {
|
||||||
|
var { fullPath, relPath } = this.getCoverDirectory(audiobook)
|
||||||
|
await fs.ensureDir(fullPath)
|
||||||
|
|
||||||
|
var temppath = Path.join(fullPath, 'cover')
|
||||||
|
var success = await this.downloadFile(url, temppath).then(() => true).catch((err) => {
|
||||||
|
Logger.error(`[CoverController] Download image file failed for "${url}"`, err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!success) {
|
||||||
|
return {
|
||||||
|
error: 'Failed to download image from url'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var imgtype = await this.checkFileIsValidImage(temppath)
|
||||||
|
|
||||||
|
if (imgtype.error) {
|
||||||
|
return imgtype
|
||||||
|
}
|
||||||
|
|
||||||
|
var coverFilename = `cover.${imgtype.ext}`
|
||||||
|
var coverPath = Path.join(relPath, coverFilename)
|
||||||
|
var coverFullPath = Path.join(fullPath, coverFilename)
|
||||||
|
await fs.rename(temppath, coverFullPath)
|
||||||
|
|
||||||
|
await this.removeOldCovers(fullPath, '.' + imgtype.ext)
|
||||||
|
|
||||||
|
Logger.info(`[CoverController] Downloaded audiobook cover "${coverPath}" from url "${url}" for "${audiobook.title}"`)
|
||||||
|
|
||||||
|
audiobook.updateBookCover(coverPath)
|
||||||
|
return {
|
||||||
|
cover: coverPath
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[CoverController] Fetch cover image from url "${url}" failed`, error)
|
||||||
|
return {
|
||||||
|
error: 'Failed to fetch image from url'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = CoverController
|
||||||
+71
-7
@@ -10,12 +10,13 @@ const { secondsToTimestamp } = require('./utils/fileUtils')
|
|||||||
const { ScanResult, CoverDestination } = 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, coverController, 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.BookMetadataPath = Path.join(this.MetadataPath, 'books')
|
||||||
|
|
||||||
this.db = db
|
this.db = db
|
||||||
|
this.coverController = coverController
|
||||||
this.emitter = emitter
|
this.emitter = emitter
|
||||||
|
|
||||||
this.cancelScan = false
|
this.cancelScan = false
|
||||||
@@ -60,11 +61,65 @@ class Scanner {
|
|||||||
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
|
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
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'}`)
|
|
||||||
|
// 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) {
|
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
|
// TEMP: Check if is older audiobook and needs force rescan
|
||||||
if (!forceAudioFileScan && existingAudiobook.checkNeedsAudioFileRescan()) {
|
if (!forceAudioFileScan && existingAudiobook.checkNeedsAudioFileRescan()) {
|
||||||
@@ -158,7 +213,7 @@ class Scanner {
|
|||||||
return ScanResult.REMOVED
|
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
|
// Check that audio tracks are in sequential order with no gaps
|
||||||
if (existingAudiobook.checkUpdateMissingParts()) {
|
if (existingAudiobook.checkUpdateMissingParts()) {
|
||||||
@@ -177,12 +232,14 @@ class Scanner {
|
|||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If audiobook was missing before, it is now found
|
||||||
if (existingAudiobook.isMissing) {
|
if (existingAudiobook.isMissing) {
|
||||||
existingAudiobook.isMissing = false
|
existingAudiobook.isMissing = false
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save changes and notify users
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
existingAudiobook.setChapters()
|
existingAudiobook.setChapters()
|
||||||
|
|
||||||
@@ -397,6 +454,8 @@ class Scanner {
|
|||||||
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
|
||||||
var notFound = 0
|
var notFound = 0
|
||||||
|
var failed = 0
|
||||||
|
|
||||||
for (let i = 0; i < audiobooksNeedingCover.length; i++) {
|
for (let i = 0; i < audiobooksNeedingCover.length; i++) {
|
||||||
var audiobook = audiobooksNeedingCover[i]
|
var audiobook = audiobooksNeedingCover[i]
|
||||||
var options = {
|
var options = {
|
||||||
@@ -406,10 +465,15 @@ class Scanner {
|
|||||||
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
|
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
|
||||||
if (results.length) {
|
if (results.length) {
|
||||||
Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
|
Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
|
||||||
audiobook.book.cover = results[0]
|
var coverUrl = results[0]
|
||||||
await this.db.updateAudiobook(audiobook)
|
var result = await this.coverController.downloadCoverFromUrl(audiobook, coverUrl)
|
||||||
found++
|
if (result.error) {
|
||||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
failed++
|
||||||
|
} else {
|
||||||
|
found++
|
||||||
|
await this.db.updateAudiobook(audiobook)
|
||||||
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
notFound++
|
notFound++
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-2
@@ -17,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 CoverController = require('./CoverController')
|
||||||
// const EbookReader = require('./EbookReader')
|
// const EbookReader = require('./EbookReader')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
@@ -35,12 +36,14 @@ class Server {
|
|||||||
this.db = new Db(this.ConfigPath)
|
this.db = new Db(this.ConfigPath)
|
||||||
this.auth = new Auth(this.db)
|
this.auth = new Auth(this.db)
|
||||||
this.watcher = new Watcher(this.AudiobookPath)
|
this.watcher = new Watcher(this.AudiobookPath)
|
||||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
|
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
|
||||||
|
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
||||||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||||
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.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.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.ebookReader = new EbookReader(this.db, this.MetadataPath, this.AudiobookPath)
|
||||||
|
|
||||||
this.server = null
|
this.server = null
|
||||||
@@ -132,6 +135,33 @@ class Server {
|
|||||||
socket.emit('save_metadata_complete', response)
|
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() {
|
async init() {
|
||||||
Logger.info('[Server] Init')
|
Logger.info('[Server] Init')
|
||||||
await this.streamManager.ensureStreamsDir()
|
await this.streamManager.ensureStreamsDir()
|
||||||
@@ -141,6 +171,8 @@ class Server {
|
|||||||
await this.db.init()
|
await this.db.init()
|
||||||
this.auth.init()
|
this.auth.init()
|
||||||
|
|
||||||
|
await this.purgeMetadata()
|
||||||
|
|
||||||
this.watcher.initWatcher()
|
this.watcher.initWatcher()
|
||||||
this.watcher.on('files', this.filesChanged.bind(this))
|
this.watcher.on('files', this.filesChanged.bind(this))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class AudioTrack {
|
|||||||
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,
|
||||||
@@ -82,7 +83,7 @@ class AudioTrack {
|
|||||||
this.size = probeData.size
|
this.size = probeData.size
|
||||||
this.bitRate = probeData.bitRate
|
this.bitRate = probeData.bitRate
|
||||||
this.language = probeData.language
|
this.language = probeData.language
|
||||||
this.codec = probeData.codec
|
this.codec = probeData.codec || null
|
||||||
this.timeBase = probeData.timeBase
|
this.timeBase = probeData.timeBase
|
||||||
this.channels = probeData.channels
|
this.channels = probeData.channels
|
||||||
this.channelLayout = probeData.channelLayout
|
this.channelLayout = probeData.channelLayout
|
||||||
|
|||||||
@@ -105,23 +105,26 @@ class Audiobook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get invalidParts() {
|
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() {
|
get ebooks() {
|
||||||
return this.otherFiles.filter(file => file.filetype === 'ebook')
|
return this.otherFiles.filter(file => file.filetype === 'ebook')
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasMissingIno() {
|
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() {
|
get hasEmbeddedCoverArt() {
|
||||||
return !!(this.audioFiles || []).find(af => af.embeddedCoverArt)
|
return !!this._audioFiles.find(af => af.embeddedCoverArt)
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasDescriptionTextFile() {
|
get hasDescriptionTextFile() {
|
||||||
return !!(this.otherFiles || []).find(of => of.filename === 'desc.txt')
|
return !!this._otherFiles.find(of => of.filename === 'desc.txt')
|
||||||
}
|
}
|
||||||
|
|
||||||
bookToJSON() {
|
bookToJSON() {
|
||||||
@@ -148,8 +151,8 @@ class Audiobook {
|
|||||||
tags: this.tags,
|
tags: this.tags,
|
||||||
book: this.bookToJSON(),
|
book: this.bookToJSON(),
|
||||||
tracks: this.tracksToJSON(),
|
tracks: this.tracksToJSON(),
|
||||||
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()),
|
||||||
chapters: this.chapters || [],
|
chapters: this.chapters || [],
|
||||||
isMissing: !!this.isMissing
|
isMissing: !!this.isMissing
|
||||||
}
|
}
|
||||||
@@ -434,7 +437,10 @@ class Audiobook {
|
|||||||
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
|
// Some files are not there anymore and filtered out
|
||||||
if (currOtherFileNum !== this.otherFiles.length) hasUpdates = true
|
if (currOtherFileNum !== this.otherFiles.length) {
|
||||||
|
Logger.debug(`[Audiobook] ${currOtherFileNum - this.otherFiles.length} other files were removed for "${this.title}"`)
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
// If desc.txt is new or forcing rescan then read it and update description if empty
|
// 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')
|
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const globals = {
|
||||||
|
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||||
|
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac'],
|
||||||
|
SupportedEbookTypes: ['epub', 'pdf', 'mobi']
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globals
|
||||||
@@ -63,7 +63,3 @@ module.exports.getIno = (path) => {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.isAcceptableCoverMimeType = (mimeType) => {
|
|
||||||
return mimeType && mimeType.startsWith('image/')
|
|
||||||
}
|
|
||||||
+6
-10
@@ -2,11 +2,7 @@ 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 { getIno } = require('./index')
|
||||||
|
const globals = require('./globals')
|
||||||
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a', 'flac']
|
|
||||||
const INFO_FORMATS = ['nfo']
|
|
||||||
const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp']
|
|
||||||
const EBOOK_FORMATS = ['epub', 'pdf', 'mobi']
|
|
||||||
|
|
||||||
function getPaths(path) {
|
function getPaths(path) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -24,7 +20,7 @@ function isAudioFile(path) {
|
|||||||
if (!path) return false
|
if (!path) return false
|
||||||
var ext = Path.extname(path)
|
var ext = Path.extname(path)
|
||||||
if (!ext) return false
|
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) {
|
function groupFilesIntoAudiobookPaths(paths, useAllFileTypes = false) {
|
||||||
@@ -107,10 +103,10 @@ function cleanFileObjects(basepath, abrelpath, files) {
|
|||||||
function getFileType(ext) {
|
function getFileType(ext) {
|
||||||
var ext_cleaned = ext.toLowerCase()
|
var ext_cleaned = ext.toLowerCase()
|
||||||
if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1)
|
if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1)
|
||||||
if (AUDIO_FORMATS.includes(ext_cleaned)) return 'audio'
|
if (globals.SupportedAudioTypes.includes(ext_cleaned)) return 'audio'
|
||||||
if (INFO_FORMATS.includes(ext_cleaned)) return 'info'
|
if (ext_cleaned === 'nfo') return 'info'
|
||||||
if (IMAGE_FORMATS.includes(ext_cleaned)) return 'image'
|
if (globals.SupportedImageTypes.includes(ext_cleaned)) return 'image'
|
||||||
if (EBOOK_FORMATS.includes(ext_cleaned)) return 'ebook'
|
if (globals.SupportedEbookTypes.includes(ext_cleaned)) return 'ebook'
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user