mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-03 09:44:41 +02:00
Merge master
This commit is contained in:
commit
ab14b561f5
147 changed files with 4669 additions and 5036 deletions
|
@ -67,30 +67,10 @@ class AuthorController {
|
|||
const payload = req.body
|
||||
let hasUpdated = false
|
||||
|
||||
// Updating/removing cover image
|
||||
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
|
||||
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
|
||||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
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
|
||||
}
|
||||
}
|
||||
// author imagePath must be set through other endpoints as of v2.4.5
|
||||
if (payload.imagePath !== undefined) {
|
||||
Logger.warn(`[AuthorController] Updating local author imagePath is not supported`)
|
||||
delete payload.imagePath
|
||||
}
|
||||
|
||||
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
||||
|
@ -131,7 +111,7 @@ class AuthorController {
|
|||
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
|
||||
|
||||
// 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))
|
||||
|
||||
res.json({
|
||||
|
@ -191,6 +171,75 @@ class AuthorController {
|
|||
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) {
|
||||
let authorData = null
|
||||
const region = req.body.region || 'us'
|
||||
|
@ -215,7 +264,7 @@ class AuthorController {
|
|||
await CacheManager.purgeImageCache(req.author.id)
|
||||
|
||||
const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||
if (imageData) {
|
||||
if (imageData?.path) {
|
||||
req.author.imagePath = imageData.path
|
||||
hasUpdates = true
|
||||
}
|
||||
|
@ -231,7 +280,7 @@ class AuthorController {
|
|||
|
||||
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))
|
||||
}
|
||||
|
||||
|
|
|
@ -51,32 +51,45 @@ class EmailController {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ebook to device
|
||||
* User must have access to device and library item
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async sendEBookToDevice(req, res) {
|
||||
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
|
||||
Logger.debug(`[EmailController] Send ebook to device requested by user "${req.user.username}" for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
|
||||
|
||||
const device = Database.emailSettings.getEReaderDevice(req.body.deviceName)
|
||||
if (!device) {
|
||||
return res.status(404).send('Ereader device not found')
|
||||
}
|
||||
|
||||
// Check user has access to device
|
||||
if (!Database.emailSettings.checkUserCanAccessDevice(device, req.user)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Library item not found')
|
||||
}
|
||||
|
||||
// Check user has access to library item
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const ebookFile = libraryItem.media.ebookFile
|
||||
if (!ebookFile) {
|
||||
return res.status(404).send('EBook file not found')
|
||||
}
|
||||
|
||||
const device = Database.emailSettings.getEReaderDevice(req.body.deviceName)
|
||||
if (!device) {
|
||||
return res.status(404).send('E-reader device not found')
|
||||
return res.status(404).send('Ebook file not found')
|
||||
}
|
||||
|
||||
this.emailManager.sendEBookToDevice(ebookFile, device, res)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
adminMiddleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter
|
|||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||
const seriesFilters = require('../utils/queries/seriesFilters')
|
||||
const fileUtils = require('../utils/fileUtils')
|
||||
const { sort, createNewSortInstance } = require('../libs/fastSort')
|
||||
const { asciiOnlyToLowerCase } = require('../utils/index')
|
||||
const { createNewSortInstance } = require('../libs/fastSort')
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
})
|
||||
|
@ -555,7 +556,7 @@ class LibraryController {
|
|||
return res.status(400).send('No query string')
|
||||
}
|
||||
const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
|
||||
const query = req.query.q.trim().toLowerCase()
|
||||
const query = asciiOnlyToLowerCase(req.query.q.trim())
|
||||
|
||||
const matches = await libraryItemFilters.search(req.user, req.library, query, limit)
|
||||
res.json(matches)
|
||||
|
@ -620,7 +621,7 @@ class LibraryController {
|
|||
model: Database.bookModel,
|
||||
attributes: ['id', 'tags', 'explicit'],
|
||||
where: bookWhere,
|
||||
required: false,
|
||||
required: !req.user.isAdminOrUp, // Only show authors with 0 books for admin users or up
|
||||
through: {
|
||||
attributes: []
|
||||
}
|
||||
|
@ -774,6 +775,13 @@ class LibraryController {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/libraries/:id/matchall
|
||||
* Quick match all library items. Book libraries only.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async matchAll(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user)
|
||||
|
@ -783,7 +791,14 @@ class LibraryController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/libraries/:id/scan
|
||||
/**
|
||||
* POST: /api/libraries/:id/scan
|
||||
* Optional query:
|
||||
* ?force=1
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async scan(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
|
||||
|
@ -791,7 +806,8 @@ class LibraryController {
|
|||
}
|
||||
res.sendStatus(200)
|
||||
|
||||
await LibraryScanner.scan(req.library)
|
||||
const forceRescan = req.query.force === '1'
|
||||
await LibraryScanner.scan(req.library, forceRescan)
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(req.library.id)
|
||||
Logger.info('[LibraryController] Scan complete')
|
||||
|
@ -845,6 +861,56 @@ class LibraryController {
|
|||
res.send(opmlText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all metadata.json or metadata.abs files in library item folders
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async removeAllMetadataFiles(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-admin user attempted to remove all metadata files`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const fileExt = req.query.ext === 'abs' ? 'abs' : 'json'
|
||||
const metadataFilename = `metadata.${fileExt}`
|
||||
const libraryItemsWithMetadata = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'libraryFiles'],
|
||||
where: [
|
||||
{
|
||||
libraryId: req.library.id
|
||||
},
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(libraryFiles) AND json_extract(json_each.value, "$.metadata.filename") = "${metadataFilename}")`), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
})
|
||||
]
|
||||
})
|
||||
if (!libraryItemsWithMetadata.length) {
|
||||
Logger.info(`[LibraryController] No ${metadataFilename} files found to remove`)
|
||||
return res.json({
|
||||
found: 0
|
||||
})
|
||||
}
|
||||
|
||||
Logger.info(`[LibraryController] Found ${libraryItemsWithMetadata.length} ${metadataFilename} files to remove`)
|
||||
|
||||
let numRemoved = 0
|
||||
for (const libraryItem of libraryItemsWithMetadata) {
|
||||
const metadataFilepath = libraryItem.libraryFiles.find(lf => lf.metadata.filename === metadataFilename)?.metadata.path
|
||||
if (!metadataFilepath) continue
|
||||
Logger.debug(`[LibraryController] Removing file "${metadataFilepath}"`)
|
||||
if ((await fileUtils.removeFile(metadataFilepath))) {
|
||||
numRemoved++
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
found: libraryItemsWithMetadata.length,
|
||||
removed: numRemoved
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that is not using libraryItems from memory
|
||||
* @param {import('express').Request} req
|
||||
|
|
|
@ -85,12 +85,31 @@ class LibraryItemController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/items/:id/download
|
||||
* Download library item. Zip file if multiple files.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
download(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.warn('User attempted to download without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// If library item is a single file in root dir then no need to zip
|
||||
if (req.libraryItem.isFile) {
|
||||
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
|
||||
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(req.libraryItem.path))
|
||||
if (audioMimeType) {
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
|
||||
res.download(req.libraryItem.path, req.libraryItem.relPath)
|
||||
return
|
||||
}
|
||||
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
const itemTitle = req.libraryItem.media.metadata.title
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
|
||||
|
@ -163,22 +182,22 @@ class LibraryItemController {
|
|||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var libraryItem = req.libraryItem
|
||||
let libraryItem = req.libraryItem
|
||||
|
||||
var result = null
|
||||
if (req.body && req.body.url) {
|
||||
let result = null
|
||||
if (req.body?.url) {
|
||||
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
|
||||
result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url)
|
||||
} else if (req.files && req.files.cover) {
|
||||
} else if (req.files?.cover) {
|
||||
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
|
||||
result = await CoverManager.uploadCover(libraryItem, req.files.cover)
|
||||
} else {
|
||||
return res.status(400).send('Invalid request no file or url')
|
||||
}
|
||||
|
||||
if (result && result.error) {
|
||||
if (result?.error) {
|
||||
return res.status(400).send(result.error)
|
||||
} else if (!result || !result.cover) {
|
||||
} else if (!result?.cover) {
|
||||
return res.status(500).send('Unknown error occurred')
|
||||
}
|
||||
|
||||
|
@ -259,7 +278,6 @@ class LibraryItemController {
|
|||
|
||||
// Check if library item media has a cover path
|
||||
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
|
||||
Logger.debug(`[LibraryItemController] getCover: Library item "${req.params.id}" has no cover path`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,8 @@ const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
|||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
||||
|
||||
const TaskManager = require('../managers/TaskManager')
|
||||
|
||||
//
|
||||
// This is a controller for routes that don't have a home yet :(
|
||||
//
|
||||
|
@ -102,7 +104,7 @@ class MiscController {
|
|||
const includeArray = (req.query.include || '').split(',')
|
||||
|
||||
const data = {
|
||||
tasks: this.taskManager.tasks.map(t => t.toJSON())
|
||||
tasks: TaskManager.tasks.map(t => t.toJSON())
|
||||
}
|
||||
|
||||
if (includeArray.includes('queue')) {
|
||||
|
@ -526,6 +528,54 @@ class MiscController {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/watcher/update
|
||||
* Update a watch path
|
||||
* Req.body { libraryId, path, type, [oldPath] }
|
||||
* type = add, unlink, rename
|
||||
* oldPath = required only for rename
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
updateWatchedPath(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to updateWatchedPath`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const libraryId = req.body.libraryId
|
||||
const path = req.body.path
|
||||
const type = req.body.type
|
||||
if (!libraryId || !path || !type) {
|
||||
Logger.error(`[MiscController] Invalid request body for updateWatchedPath. libraryId: "${libraryId}", path: "${path}", type: "${type}"`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'add':
|
||||
this.watcher.onFileAdded(libraryId, path)
|
||||
break;
|
||||
case 'unlink':
|
||||
this.watcher.onFileRemoved(libraryId, path)
|
||||
break;
|
||||
case 'rename':
|
||||
const oldPath = req.body.oldPath
|
||||
if (!oldPath) {
|
||||
Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
this.watcher.onFileRename(libraryId, oldPath, path)
|
||||
break;
|
||||
default:
|
||||
Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
validateCronExpression(req, res) {
|
||||
const expression = req.body.expression
|
||||
if (!expression) {
|
||||
|
|
|
@ -184,10 +184,9 @@ class PodcastController {
|
|||
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var libraryItem = req.libraryItem
|
||||
|
||||
var episodes = req.body
|
||||
if (!episodes || !episodes.length) {
|
||||
const libraryItem = req.libraryItem
|
||||
const episodes = req.body
|
||||
if (!episodes?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
|
@ -286,7 +285,7 @@ class PodcastController {
|
|||
const numItems = pmi.playlist.playlistMediaItems.length - 1
|
||||
|
||||
if (!numItems) {
|
||||
Logger.info(`[PodcastController] Playlist "${playlist.name}" has no more items - removing it`)
|
||||
Logger.info(`[PodcastController] Playlist "${pmi.playlist.name}" has no more items - removing it`)
|
||||
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
||||
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
|
||||
await pmi.playlist.destroy()
|
||||
|
|
|
@ -26,7 +26,7 @@ class SearchController {
|
|||
|
||||
let results = null
|
||||
if (podcast) results = await PodcastFinder.findCovers(query.title)
|
||||
else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
|
||||
else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || '')
|
||||
res.json({
|
||||
results
|
||||
})
|
||||
|
|
|
@ -115,6 +115,13 @@ class UserController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/users/:id
|
||||
* Update user
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const user = req.reqUser
|
||||
|
||||
|
@ -126,6 +133,7 @@ class UserController {
|
|||
var account = req.body
|
||||
var shouldUpdateToken = false
|
||||
|
||||
// When changing username create a new API token
|
||||
if (account.username !== undefined && account.username !== user.username) {
|
||||
const usernameExists = await Database.userModel.getUserByUsername(account.username)
|
||||
if (usernameExists) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue