+
+
+
+
Preview Cover
+
close
+
+
+
+
+ Clear
+ Upload
+
+
@@ -79,12 +94,15 @@ export default {
},
data() {
return {
+ processingUpload: false,
searchTitle: null,
searchAuthor: null,
imageUrl: null,
coversFound: [],
hasSearched: false,
- showLocalCovers: false
+ showLocalCovers: false,
+ previewUpload: null,
+ selectedFile: null
}
},
watch: {
@@ -112,6 +130,9 @@ export default {
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
},
+ userCanUpload() {
+ return this.$store.getters['user/getUserCanUpload']
+ },
localCovers() {
return this.otherFiles
.filter((f) => f.filetype === 'image')
@@ -123,6 +144,39 @@ export default {
}
},
methods: {
+ submitCoverUpload() {
+ this.processingUpload = true
+ var form = new FormData()
+ form.set('cover', this.selectedFile)
+
+ this.$axios
+ .$post(`/api/audiobook/${this.audiobook.id}/cover`, form)
+ .then((data) => {
+ if (data.error) {
+ this.$toast.error(data.error)
+ } else {
+ this.$toast.success('Cover Uploaded')
+ this.resetCoverPreview()
+ }
+ this.processingUpload = false
+ })
+ .catch((error) => {
+ console.error('Failed', error)
+ this.$toast.error('Oops, something went wrong...')
+ this.processingUpload = false
+ })
+ },
+ resetCoverPreview() {
+ if (this.$refs.fileInput) {
+ this.$refs.fileInput.reset()
+ }
+ this.previewUpload = null
+ this.selectedFile = null
+ },
+ fileUploadSelected(file) {
+ this.previewUpload = URL.createObjectURL(file)
+ this.selectedFile = file
+ },
init() {
this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
diff --git a/client/components/ui/FileInput.vue b/client/components/ui/FileInput.vue
new file mode 100644
index 00000000..a85859b3
--- /dev/null
+++ b/client/components/ui/FileInput.vue
@@ -0,0 +1,39 @@
+
+
+
+ Upload Cover
+
+
+
+
\ No newline at end of file
diff --git a/client/nuxt.config.js b/client/nuxt.config.js
index 50939a33..87376fb6 100644
--- a/client/nuxt.config.js
+++ b/client/nuxt.config.js
@@ -71,7 +71,8 @@ module.exports = {
proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
- '/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } }
+ '/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } },
+ '/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
},
io: {
diff --git a/client/package.json b/client/package.json
index c4a388b6..b6893829 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
- "version": "1.1.14",
+ "version": "1.1.15",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
diff --git a/package.json b/package.json
index 1e88fad8..6653ba08 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "1.1.14",
+ "version": "1.1.15",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js",
"scripts": {
diff --git a/server/ApiController.js b/server/ApiController.js
index 423f7ec3..692e816a 100644
--- a/server/ApiController.js
+++ b/server/ApiController.js
@@ -1,10 +1,13 @@
const express = require('express')
+const Path = require('path')
+const fs = require('fs-extra')
const Logger = require('./Logger')
const User = require('./objects/User')
-const { isObject } = require('./utils/index')
+const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
+const { CoverDestination } = require('./utils/constants')
class ApiController {
- constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
+ constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
this.db = db
this.scanner = scanner
this.auth = auth
@@ -13,6 +16,7 @@ class ApiController {
this.downloadManager = downloadManager
this.emitter = emitter
this.clientEmitter = clientEmitter
+ this.MetadataPath = MetadataPath
this.router = express()
this.init()
@@ -30,6 +34,7 @@ class ApiController {
this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
+ 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))
@@ -217,6 +222,85 @@ class ApiController {
res.json(audiobook.toJSON())
}
+ async uploadAudiobookCover(req, res) {
+ if (!req.user.canUpload || !req.user.canUpdate) {
+ 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)
+ } else {
+ Logger.debug(`[ApiController] storing in audiobook | ${coverRelDirpath}`)
+ }
+
+ 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}`)
+ }
+ }
+ }
+ }
+
+ 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
+ })
+ }
+
async updateAudiobook(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
diff --git a/server/Auth.js b/server/Auth.js
index fbd4de40..c3188f04 100644
--- a/server/Auth.js
+++ b/server/Auth.js
@@ -38,8 +38,16 @@ class Auth {
}
async authMiddleware(req, res, next) {
- const authHeader = req.headers['authorization']
- const token = authHeader && authHeader.split(' ')[1]
+ var token = null
+
+ // If using a get request, the token can be passed as a query string
+ if (req.method === 'GET' && req.query && req.query.token) {
+ token = req.query.token
+ } else {
+ const authHeader = req.headers['authorization']
+ token = authHeader && authHeader.split(' ')[1]
+ }
+
if (token == null) {
Logger.error('Api called without a token', req.path)
return res.sendStatus(401)
diff --git a/server/HlsController.js b/server/HlsController.js
index 4ac0f30e..b1bd08ef 100644
--- a/server/HlsController.js
+++ b/server/HlsController.js
@@ -4,13 +4,13 @@ const fs = require('fs-extra')
const Logger = require('./Logger')
class HlsController {
- constructor(db, scanner, auth, streamManager, emitter, MetadataPath) {
+ constructor(db, scanner, auth, streamManager, emitter, StreamsPath) {
this.db = db
this.scanner = scanner
this.auth = auth
this.streamManager = streamManager
this.emitter = emitter
- this.MetadataPath = MetadataPath
+ this.StreamsPath = StreamsPath
this.router = express()
this.init()
@@ -28,7 +28,7 @@ class HlsController {
async streamFileRequest(req, res) {
var streamId = req.params.stream
- var fullFilePath = Path.join(this.MetadataPath, streamId, req.params.file)
+ var fullFilePath = Path.join(this.StreamsPath, streamId, req.params.file)
// development test stream - ignore
if (streamId === 'test') {
diff --git a/server/Server.js b/server/Server.js
index b4701ce7..8ec318cf 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -35,8 +35,8 @@ class Server {
this.streamManager = new StreamManager(this.db, this.MetadataPath)
this.rssFeeds = new RssFeeds(this.Port, this.db)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
- this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this), this.clientEmitter.bind(this))
- this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath)
+ this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this), this.clientEmitter.bind(this))
+ this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
this.server = null
this.io = null
@@ -110,6 +110,7 @@ class Server {
async init() {
Logger.info('[Server] Init')
+ await this.streamManager.ensureStreamsDir()
await this.streamManager.removeOrphanStreams()
await this.downloadManager.removeOrphanDownloads()
await this.db.init()
@@ -189,6 +190,8 @@ class Server {
app.use(express.static(this.AudiobookPath))
}
+ app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
+
app.use(express.static(this.MetadataPath))
app.use(express.static(Path.join(global.appRoot, 'static')))
app.use(express.urlencoded({ extended: true }));
diff --git a/server/StreamManager.js b/server/StreamManager.js
index 6170321f..a0108908 100644
--- a/server/StreamManager.js
+++ b/server/StreamManager.js
@@ -5,11 +5,12 @@ const fs = require('fs-extra')
const Path = require('path')
class StreamManager {
- constructor(db, STREAM_PATH) {
+ constructor(db, MetadataPath) {
this.db = db
+ this.MetadataPath = MetadataPath
this.streams = []
- this.streamPath = STREAM_PATH
+ this.StreamsPath = Path.join(this.MetadataPath, 'streams')
}
get audiobooks() {
@@ -25,7 +26,7 @@ class StreamManager {
}
async openStream(client, audiobook) {
- var stream = new Stream(this.streamPath, client, audiobook)
+ var stream = new Stream(this.StreamsPath, client, audiobook)
stream.on('closed', () => {
this.removeStream(stream)
@@ -44,29 +45,53 @@ class StreamManager {
return stream
}
+ ensureStreamsDir() {
+ return fs.ensureDir(this.StreamsPath)
+ }
+
removeOrphanStreamFiles(streamId) {
try {
- var streamPath = Path.join(this.streamPath, streamId)
- return fs.remove(streamPath)
+ var StreamsPath = Path.join(this.StreamsPath, streamId)
+ return fs.remove(StreamsPath)
} catch (error) {
Logger.debug('No orphan stream', streamId)
return false
}
}
- async removeOrphanStreams() {
+ async tempCheckStrayStreams() {
try {
- var dirs = await fs.readdir(this.streamPath)
+ var dirs = await fs.readdir(this.MetadataPath)
if (!dirs || !dirs.length) return true
await Promise.all(dirs.map(async (dirname) => {
- var fullPath = Path.join(this.streamPath, dirname)
+ if (dirname !== 'streams' && dirname !== 'books') {
+ var fullPath = Path.join(this.MetadataPath, dirname)
+ Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
+ return fs.remove(fullPath)
+ }
+ }))
+ return true
+ } catch (error) {
+ Logger.debug('No old orphan streams', error)
+ return false
+ }
+ }
+
+ async removeOrphanStreams() {
+ await this.tempCheckStrayStreams()
+ try {
+ var dirs = await fs.readdir(this.StreamsPath)
+ if (!dirs || !dirs.length) return true
+
+ await Promise.all(dirs.map(async (dirname) => {
+ var fullPath = Path.join(this.StreamsPath, dirname)
Logger.info(`Removing Orphan Stream ${dirname}`)
return fs.remove(fullPath)
}))
return true
} catch (error) {
- Logger.debug('No orphan stream', streamId)
+ Logger.debug('No orphan stream', error)
return false
}
}
@@ -102,10 +127,10 @@ class StreamManager {
this.db.updateUserStream(client.user.id, null)
}
- async openTestStream(streamPath, audiobookId) {
+ async openTestStream(StreamsPath, audiobookId) {
Logger.info('Open Stream Test Request', audiobookId)
// var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
- // var stream = new StreamTest(streamPath, audiobook)
+ // var stream = new StreamTest(StreamsPath, audiobook)
// stream.on('closed', () => {
// console.log('Stream closed')
diff --git a/server/objects/Audiobook.js b/server/objects/Audiobook.js
index 5389a89c..de579261 100644
--- a/server/objects/Audiobook.js
+++ b/server/objects/Audiobook.js
@@ -288,6 +288,12 @@ class Audiobook {
return hasUpdates
}
+ // Cover Url may be the same, this ensures the lastUpdate is updated
+ updateBookCover(cover) {
+ if (!this.book) return false
+ return this.book.updateCover(cover)
+ }
+
updateAudioTracks(orderedFileData) {
var index = 1
this.audioFiles = orderedFileData.map((fileData) => {
diff --git a/server/objects/Book.js b/server/objects/Book.js
index 7b44659a..d83a906a 100644
--- a/server/objects/Book.js
+++ b/server/objects/Book.js
@@ -18,6 +18,7 @@ class Book {
this.description = null
this.cover = null
this.genres = []
+ this.lastUpdate = null
if (book) {
this.construct(book)
@@ -45,6 +46,7 @@ class Book {
this.description = book.description
this.cover = book.cover
this.genres = book.genres
+ this.lastUpdate = book.lastUpdate || Date.now()
}
toJSON() {
@@ -62,7 +64,8 @@ class Book {
publisher: this.publisher,
description: this.description,
cover: this.cover,
- genres: this.genres
+ genres: this.genres,
+ lastUpdate: this.lastUpdate
}
}
@@ -97,6 +100,7 @@ class Book {
this.description = data.description || null
this.cover = data.cover || null
this.genres = data.genres || []
+ this.lastUpdate = Date.now()
if (data.author) {
this.setParseAuthor(this.author)
@@ -145,9 +149,24 @@ class Book {
hasUpdates = true
}
}
+
+ if (hasUpdates) {
+ this.lastUpdate = Date.now()
+ }
+
return hasUpdates
}
+ updateCover(cover) {
+ if (!cover) return false
+ if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
+ cover = Path.normalize(cover)
+ }
+ this.cover = cover
+ this.lastUpdate = Date.now()
+ return true
+ }
+
// If audiobook directory path was changed, check and update properties set from dirnames
// May be worthwhile checking if these were manually updated and not override manual updates
syncPathsUpdated(audiobookData) {
diff --git a/server/objects/ServerSettings.js b/server/objects/ServerSettings.js
index e031cb45..73cd24f8 100644
--- a/server/objects/ServerSettings.js
+++ b/server/objects/ServerSettings.js
@@ -1,9 +1,12 @@
+const { CoverDestination } = require('../utils/constants')
+
class ServerSettings {
constructor(settings) {
this.id = 'server-settings'
this.autoTagNew = false
this.newTagExpireDays = 15
this.scannerParseSubtitle = false
+ this.coverDestination = CoverDestination.METADATA
if (settings) {
this.construct(settings)
@@ -14,6 +17,7 @@ class ServerSettings {
this.autoTagNew = settings.autoTagNew
this.newTagExpireDays = settings.newTagExpireDays
this.scannerParseSubtitle = settings.scannerParseSubtitle
+ this.coverDestination = settings.coverDestination || CoverDestination.METADATA
}
toJSON() {
@@ -21,7 +25,8 @@ class ServerSettings {
id: this.id,
autoTagNew: this.autoTagNew,
newTagExpireDays: this.newTagExpireDays,
- scannerParseSubtitle: this.scannerParseSubtitle
+ scannerParseSubtitle: this.scannerParseSubtitle,
+ coverDestination: this.coverDestination
}
}
diff --git a/server/objects/Stream.js b/server/objects/Stream.js
index 2bb1a6ae..e54e9ebb 100644
--- a/server/objects/Stream.js
+++ b/server/objects/Stream.js
@@ -189,7 +189,7 @@ class Stream extends EventEmitter {
var perc = (this.segmentsCreated.size * 100 / this.numSegments).toFixed(2) + '%'
Logger.info('[STREAM-CHECK] Check Files', this.segmentsCreated.size, 'of', this.numSegments, perc, `Furthest Segment: ${this.furthestSegmentCreated}`)
- Logger.info('[STREAM-CHECK] Chunks', chunks.join(', '))
+ Logger.debug('[STREAM-CHECK] Chunks', chunks.join(', '))
this.socket.emit('stream_progress', {
stream: this.id,
@@ -198,7 +198,7 @@ class Stream extends EventEmitter {
numSegments: this.numSegments
})
} catch (error) {
- Logger.error('Failed checkign files', error)
+ Logger.error('Failed checking files', error)
}
}
diff --git a/server/utils/constants.js b/server/utils/constants.js
index cdac37a5..97fabd51 100644
--- a/server/utils/constants.js
+++ b/server/utils/constants.js
@@ -4,4 +4,9 @@ module.exports.ScanResult = {
UPDATED: 2,
REMOVED: 3,
UPTODATE: 4
+}
+
+module.exports.CoverDestination = {
+ METADATA: 0,
+ AUDIOBOOK: 1
}
\ No newline at end of file
diff --git a/server/utils/index.js b/server/utils/index.js
index d5bfd088..944eb39d 100644
--- a/server/utils/index.js
+++ b/server/utils/index.js
@@ -62,4 +62,8 @@ module.exports.getIno = (path) => {
Logger.error('[Utils] Failed to get ino for path', path, err)
return null
})
+}
+
+module.exports.isAcceptableCoverMimeType = (mimeType) => {
+ return mimeType && mimeType.startsWith('image/')
}
\ No newline at end of file