diff --git a/client/components/cards/BookCover.vue b/client/components/cards/BookCover.vue index 2477f803..f1ece3a4 100644 --- a/client/components/cards/BookCover.vue +++ b/client/components/cards/BookCover.vue @@ -4,7 +4,7 @@
- +
@@ -53,6 +53,9 @@ export default { book() { return this.audiobook.book || {} }, + bookLastUpdate() { + return this.book.lastUpdate || Date.now() + }, title() { return this.book.title || 'No Title' }, @@ -76,11 +79,11 @@ export default { return '/book_placeholder.jpg' }, fullCoverUrl() { - if (!this.cover || this.cover === this.placeholderUrl) return '' + if (!this.cover || this.cover === this.placeholderUrl) return this.placeholderUrl if (this.cover.startsWith('http:') || this.cover.startsWith('https:')) return this.cover try { var url = new URL(this.cover, document.baseURI) - return url.href + return url.href + `?token=${this.userToken}&ts=${this.bookLastUpdate}` } catch (err) { console.error(err) return '' @@ -106,6 +109,9 @@ export default { }, authorBottom() { return 0.75 * this.sizeMultiplier + }, + userToken() { + return this.$store.getters['user/getToken'] } }, methods: { diff --git a/client/components/cards/PreviewCover.vue b/client/components/cards/PreviewCover.vue new file mode 100644 index 00000000..9af64987 --- /dev/null +++ b/client/components/cards/PreviewCover.vue @@ -0,0 +1,82 @@ + + + \ No newline at end of file diff --git a/client/components/modals/edit-tabs/Cover.vue b/client/components/modals/edit-tabs/Cover.vue index 3397f5e8..cae24a89 100644 --- a/client/components/modals/edit-tabs/Cover.vue +++ b/client/components/modals/edit-tabs/Cover.vue @@ -1,5 +1,5 @@ @@ -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 @@ + + + \ 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