Update:Remove image path input from author modal, add API endpoints for uploading and removing author image

This commit is contained in:
advplyr 2023-10-13 17:37:37 -05:00
parent 290a377ef9
commit 656c81a1fa
4 changed files with 162 additions and 80 deletions

View File

@ -5,18 +5,23 @@
<p class="text-3xl text-white truncate">{{ title }}</p> <p class="text-3xl text-white truncate">{{ title }}</p>
</div> </div>
</template> </template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh"> <div v-if="author" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<form v-if="author" @submit.prevent="submitForm"> <div class="flex">
<div class="flex"> <div class="w-40 p-2">
<div class="w-40 p-2"> <div class="w-full h-45 relative">
<div class="w-full h-45 relative"> <covers-author-image :author="author" />
<covers-author-image :author="author" /> <div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100"> <span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
</div>
</div> </div>
</div> </div>
<div class="flex-grow"> </div>
<div class="flex-grow">
<form @submit.prevent="submitUploadCover" class="flex flex-grow mb-2 p-2">
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
<ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
</form>
<form v-if="author" @submit.prevent="submitForm">
<div class="flex"> <div class="flex">
<div class="w-3/4 p-2"> <div class="w-3/4 p-2">
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" /> <ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" />
@ -25,9 +30,9 @@
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" /> <ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
</div> </div>
</div> </div>
<div class="p-2"> <!-- <div class="p-2">
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" /> <ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
</div> </div> -->
<div class="p-2"> <div class="p-2">
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" /> <ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
</div> </div>
@ -39,9 +44,9 @@
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn> <ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div> </div>
</div> </form>
</div> </div>
</form> </div>
</div> </div>
</modals-modal> </modals-modal>
</template> </template>
@ -53,9 +58,9 @@ export default {
authorCopy: { authorCopy: {
name: '', name: '',
asin: '', asin: '',
description: '', description: ''
imagePath: ''
}, },
imageUrl: '',
processing: false processing: false
} }
}, },
@ -100,10 +105,10 @@ export default {
}, },
methods: { methods: {
init() { init() {
this.imageUrl = ''
this.authorCopy.name = this.author.name this.authorCopy.name = this.author.name
this.authorCopy.asin = this.author.asin this.authorCopy.asin = this.author.asin
this.authorCopy.description = this.author.description this.authorCopy.description = this.author.description
this.authorCopy.imagePath = this.author.imagePath
}, },
removeClick() { removeClick() {
const payload = { const payload = {
@ -131,7 +136,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload) this.$store.commit('globals/setConfirmPrompt', payload)
}, },
async submitForm() { async submitForm() {
var keysToCheck = ['name', 'asin', 'description', 'imagePath'] var keysToCheck = ['name', 'asin', 'description']
var updatePayload = {} var updatePayload = {}
keysToCheck.forEach((key) => { keysToCheck.forEach((key) => {
if (this.authorCopy[key] !== this.author[key]) { if (this.authorCopy[key] !== this.author[key]) {
@ -160,21 +165,46 @@ export default {
} }
this.processing = false this.processing = false
}, },
async removeCover() { removeCover() {
var updatePayload = {
imagePath: null
}
this.processing = true this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => { this.$axios
console.error('Failed', error) .$delete(`/api/authors/${this.authorId}/image`)
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed) .then((data) => {
return null this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
}) this.$store.commit('globals/showEditAuthorModal', data.author)
if (result && result.updated) { })
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) .catch((error) => {
this.$store.commit('globals/showEditAuthorModal', result.author) console.error('Failed', error)
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
})
.finally(() => {
this.processing = false
})
},
submitUploadCover() {
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
this.$toast.error('Invalid image url')
return
} }
this.processing = false
this.processing = true
const updatePayload = {
url: this.imageUrl
}
this.$axios
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
.then((data) => {
this.imageUrl = ''
this.$toast.success('Author image updated')
this.$store.commit('globals/showEditAuthorModal', data.author)
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error(error.response.data || 'Failed to remove author image')
})
.finally(() => {
this.processing = false
})
}, },
async searchAuthor() { async searchAuthor() {
if (!this.authorCopy.name && !this.authorCopy.asin) { if (!this.authorCopy.name && !this.authorCopy.asin) {

View File

@ -67,30 +67,10 @@ class AuthorController {
const payload = req.body const payload = req.body
let hasUpdated = false let hasUpdated = false
// Updating/removing cover image // author imagePath must be set through other endpoints as of v2.4.5
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) { if (payload.imagePath !== undefined) {
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file Logger.warn(`[AuthorController] Updating local author imagePath is not supported`)
await CacheManager.purgeImageCache(req.author.id) // Purge cache delete payload.imagePath
await CoverManager.removeFile(req.author.imagePath)
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
const imageData = await AuthorFinder.saveAuthorImage(req.author.id, payload.imagePath)
if (imageData) {
if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
payload.imagePath = imageData.path
hasUpdated = true
}
} else if (payload.imagePath && payload.imagePath !== req.author.imagePath) { // Changing image path locally
if (!await fs.pathExists(payload.imagePath)) { // Make sure image path exists
Logger.error(`[AuthorController] Image path does not exist: "${payload.imagePath}"`)
return res.status(400).send('Author image path does not exist')
}
if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
}
} }
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
@ -131,7 +111,7 @@ class AuthorController {
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id) Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
// Send updated num books for merged author // Send updated num books for merged author
const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length const numBooks = (await Database.libraryItemModel.getForAuthor(existingAuthor)).length
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks)) SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
res.json({ res.json({
@ -191,6 +171,75 @@ class AuthorController {
res.sendStatus(200) res.sendStatus(200)
} }
/**
* POST: /api/authors/:id/image
* Upload author image from web URL
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async uploadImage(req, res) {
if (!req.user.canUpload) {
Logger.warn('User attempted to upload an image without permission', req.user)
return res.sendStatus(403)
}
if (!req.body.url) {
Logger.error(`[AuthorController] Invalid request payload. 'url' not in request body`)
return res.status(400).send(`Invalid request payload. 'url' not in request body`)
}
if (!req.body.url.startsWith?.('http:') && !req.body.url.startsWith?.('https:')) {
Logger.error(`[AuthorController] Invalid request payload. Invalid url "${req.body.url}"`)
return res.status(400).send(`Invalid request payload. Invalid url "${req.body.url}"`)
}
Logger.debug(`[AuthorController] Requesting download author image from url "${req.body.url}"`)
const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url)
if (result?.error) {
return res.status(400).send(result.error)
} else if (!result?.path) {
return res.status(500).send('Unknown error occurred')
}
if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
req.author.imagePath = result.path
await Database.authorModel.updateFromOld(req.author)
const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
res.json({
author: req.author.toJSON()
})
}
/**
* DELETE: /api/authors/:id/image
* Remove author image & delete image file
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async deleteImage(req, res) {
if (!req.author.imagePath) {
Logger.error(`[AuthorController] Author "${req.author.imagePath}" has no imagePath set`)
return res.status(400).send('Author has no image path set')
}
Logger.info(`[AuthorController] Removing image for author "${req.author.name}" at "${req.author.imagePath}"`)
await CacheManager.purgeImageCache(req.author.id) // Purge cache
await CoverManager.removeFile(req.author.imagePath)
req.author.imagePath = null
await Database.authorModel.updateFromOld(req.author)
const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
res.json({
author: req.author.toJSON()
})
}
async match(req, res) { async match(req, res) {
let authorData = null let authorData = null
const region = req.body.region || 'us' const region = req.body.region || 'us'
@ -215,7 +264,7 @@ class AuthorController {
await CacheManager.purgeImageCache(req.author.id) await CacheManager.purgeImageCache(req.author.id)
const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image) const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image)
if (imageData) { if (imageData?.path) {
req.author.imagePath = imageData.path req.author.imagePath = imageData.path
hasUpdates = true hasUpdates = true
} }
@ -231,7 +280,7 @@ class AuthorController {
await Database.updateAuthor(req.author) await Database.updateAuthor(req.author)
const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
} }

View File

@ -10,13 +10,6 @@ class AuthorFinder {
this.audnexus = new Audnexus() this.audnexus = new Audnexus()
} }
async downloadImage(url, outputPath) {
return downloadFile(url, outputPath).then(() => true).catch((error) => {
Logger.error('[AuthorFinder] Failed to download author image', error)
return null
})
}
findAuthorByASIN(asin, region) { findAuthorByASIN(asin, region) {
if (!asin) return null if (!asin) return null
return this.audnexus.findAuthorByASIN(asin, region) return this.audnexus.findAuthorByASIN(asin, region)
@ -33,28 +26,36 @@ class AuthorFinder {
return author return author
} }
/**
* Download author image from url and save in authors folder
*
* @param {string} authorId
* @param {string} url
* @returns {Promise<{path:string, error:string}>}
*/
async saveAuthorImage(authorId, url) { async saveAuthorImage(authorId, url) {
var authorDir = Path.join(global.MetadataPath, 'authors') const authorDir = Path.join(global.MetadataPath, 'authors')
var relAuthorDir = Path.posix.join('/metadata', 'authors')
if (!await fs.pathExists(authorDir)) { if (!await fs.pathExists(authorDir)) {
await fs.ensureDir(authorDir) await fs.ensureDir(authorDir)
} }
var imageExtension = url.toLowerCase().split('.').pop() const imageExtension = url.toLowerCase().split('.').pop()
var ext = imageExtension === 'png' ? 'png' : 'jpg' const ext = imageExtension === 'png' ? 'png' : 'jpg'
var filename = authorId + '.' + ext const filename = authorId + '.' + ext
var outputPath = Path.posix.join(authorDir, filename) const outputPath = Path.posix.join(authorDir, filename)
var relPath = Path.posix.join(relAuthorDir, filename)
var success = await this.downloadImage(url, outputPath) return downloadFile(url, outputPath).then(() => {
if (!success) { return {
return null path: outputPath
} }
return { }).catch((err) => {
path: outputPath, let errorMsg = err.message || 'Unknown error'
relPath Logger.error(`[AuthorFinder] Download image file failed for "${url}"`, errorMsg)
} return {
error: errorMsg
}
})
} }
} }
module.exports = new AuthorFinder() module.exports = new AuthorFinder()

View File

@ -202,6 +202,8 @@ class ApiRouter {
this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this)) this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this))
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this)) this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this)) this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this))
this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this))
this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this))
// //
// Series Routes // Series Routes