mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-10 11:21:36 +00:00
Merge branch 'advplyr:master' into feat/all-stats
This commit is contained in:
commit
d6cee0e569
336 changed files with 21709 additions and 13151 deletions
|
|
@ -21,6 +21,11 @@ const naturalSort = createNewSortInstance({
|
|||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/Author')} author
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} AuthorControllerRequest
|
||||
*/
|
||||
|
||||
class AuthorController {
|
||||
|
|
@ -29,13 +34,13 @@ class AuthorController {
|
|||
/**
|
||||
* GET: /api/authors/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {AuthorControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
const include = (req.query.include || '').split(',')
|
||||
|
||||
const authorJson = req.author.toJSON()
|
||||
const authorJson = req.author.toOldJSON()
|
||||
|
||||
// Used on author landing page to include library items and items grouped in series
|
||||
if (include.includes('items')) {
|
||||
|
|
@ -80,25 +85,33 @@ class AuthorController {
|
|||
/**
|
||||
* PATCH: /api/authors/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {AuthorControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const payload = req.body
|
||||
let hasUpdated = false
|
||||
|
||||
// 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 keysToUpdate = ['name', 'description', 'asin']
|
||||
const payload = {}
|
||||
for (const key in req.body) {
|
||||
if (keysToUpdate.includes(key) && (typeof req.body[key] === 'string' || req.body[key] === null)) {
|
||||
payload[key] = req.body[key]
|
||||
}
|
||||
}
|
||||
if (!Object.keys(payload).length) {
|
||||
Logger.error(`[AuthorController] Invalid request payload. No valid keys found`, req.body)
|
||||
return res.status(400).send('Invalid request payload. No valid keys found')
|
||||
}
|
||||
|
||||
let hasUpdated = false
|
||||
|
||||
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
||||
if (authorNameUpdate) {
|
||||
payload.lastFirst = Database.authorModel.getLastFirst(payload.name)
|
||||
}
|
||||
|
||||
// Check if author name matches another author and merge the authors
|
||||
let existingAuthor = null
|
||||
if (authorNameUpdate) {
|
||||
const author = await Database.authorModel.findOne({
|
||||
existingAuthor = await Database.authorModel.findOne({
|
||||
where: {
|
||||
id: {
|
||||
[sequelize.Op.not]: req.author.id
|
||||
|
|
@ -106,7 +119,6 @@ class AuthorController {
|
|||
name: payload.name
|
||||
}
|
||||
})
|
||||
existingAuthor = author?.getOldAuthor()
|
||||
}
|
||||
if (existingAuthor) {
|
||||
Logger.info(`[AuthorController] Merging author "${req.author.name}" with "${existingAuthor.name}"`)
|
||||
|
|
@ -143,86 +155,92 @@ class AuthorController {
|
|||
}
|
||||
|
||||
// Remove old author
|
||||
await Database.removeAuthor(req.author.id)
|
||||
SocketAuthority.emitter('author_removed', req.author.toJSON())
|
||||
const oldAuthorJSON = req.author.toOldJSON()
|
||||
await req.author.destroy()
|
||||
SocketAuthority.emitter('author_removed', oldAuthorJSON)
|
||||
// Update filter data
|
||||
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
|
||||
Database.removeAuthorFromFilterData(oldAuthorJSON.libraryId, oldAuthorJSON.id)
|
||||
|
||||
// Send updated num books for merged author
|
||||
const numBooks = await Database.bookAuthorModel.getCountForAuthor(existingAuthor.id)
|
||||
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
|
||||
SocketAuthority.emitter('author_updated', existingAuthor.toOldJSONExpanded(numBooks))
|
||||
|
||||
res.json({
|
||||
author: existingAuthor.toJSON(),
|
||||
author: existingAuthor.toOldJSON(),
|
||||
merged: true
|
||||
})
|
||||
} else {
|
||||
// Regular author update
|
||||
if (req.author.update(payload)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
req.author.updatedAt = Date.now()
|
||||
// If lastFirst is not set, get it from the name
|
||||
if (!authorNameUpdate && !req.author.lastFirst) {
|
||||
payload.lastFirst = Database.authorModel.getLastFirst(req.author.name)
|
||||
}
|
||||
|
||||
let numBooksForAuthor = 0
|
||||
if (authorNameUpdate) {
|
||||
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
|
||||
// Regular author update
|
||||
req.author.set(payload)
|
||||
if (req.author.changed()) {
|
||||
await req.author.save()
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
numBooksForAuthor = allItemsWithAuthor.length
|
||||
const oldLibraryItems = []
|
||||
// Update author name on all books
|
||||
for (const libraryItem of allItemsWithAuthor) {
|
||||
libraryItem.media.authors = libraryItem.media.authors.map((au) => {
|
||||
if (au.id === req.author.id) {
|
||||
au.name = req.author.name
|
||||
}
|
||||
return au
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
oldLibraryItems.push(oldLibraryItem)
|
||||
if (hasUpdated) {
|
||||
let numBooksForAuthor = 0
|
||||
if (authorNameUpdate) {
|
||||
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
|
||||
|
||||
await libraryItem.saveMetadataFile()
|
||||
}
|
||||
numBooksForAuthor = allItemsWithAuthor.length
|
||||
const oldLibraryItems = []
|
||||
// Update author name on all books
|
||||
for (const libraryItem of allItemsWithAuthor) {
|
||||
libraryItem.media.authors = libraryItem.media.authors.map((au) => {
|
||||
if (au.id === req.author.id) {
|
||||
au.name = req.author.name
|
||||
}
|
||||
return au
|
||||
})
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
oldLibraryItems.push(oldLibraryItem)
|
||||
|
||||
if (oldLibraryItems.length) {
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
oldLibraryItems.map((li) => li.toJSONExpanded())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
numBooksForAuthor = await Database.bookAuthorModel.getCountForAuthor(req.author.id)
|
||||
await libraryItem.saveMetadataFile()
|
||||
}
|
||||
|
||||
await Database.updateAuthor(req.author)
|
||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooksForAuthor))
|
||||
if (oldLibraryItems.length) {
|
||||
SocketAuthority.emitter(
|
||||
'items_updated',
|
||||
oldLibraryItems.map((li) => li.toJSONExpanded())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
numBooksForAuthor = await Database.bookAuthorModel.getCountForAuthor(req.author.id)
|
||||
}
|
||||
|
||||
res.json({
|
||||
author: req.author.toJSON(),
|
||||
updated: hasUpdated
|
||||
})
|
||||
SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooksForAuthor))
|
||||
}
|
||||
|
||||
res.json({
|
||||
author: req.author.toOldJSON(),
|
||||
updated: hasUpdated
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/authors/:id
|
||||
* Remove author from all books and delete
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {AuthorControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
Logger.info(`[AuthorController] Removing author "${req.author.name}"`)
|
||||
|
||||
await Database.authorModel.removeById(req.author.id)
|
||||
|
||||
if (req.author.imagePath) {
|
||||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
}
|
||||
|
||||
SocketAuthority.emitter('author_removed', req.author.toJSON())
|
||||
await req.author.destroy()
|
||||
|
||||
SocketAuthority.emitter('author_removed', req.author.toOldJSON())
|
||||
|
||||
// Update filter data
|
||||
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
|
||||
|
|
@ -234,7 +252,7 @@ class AuthorController {
|
|||
* POST: /api/authors/:id/image
|
||||
* Upload author image from web URL
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {AuthorControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async uploadImage(req, res) {
|
||||
|
|
@ -265,13 +283,14 @@ class AuthorController {
|
|||
}
|
||||
|
||||
req.author.imagePath = result.path
|
||||
req.author.updatedAt = Date.now()
|
||||
await Database.authorModel.updateFromOld(req.author)
|
||||
// imagePath may not have changed, but we still want to update the updatedAt field to bust image cache
|
||||
req.author.changed('imagePath', true)
|
||||
await req.author.save()
|
||||
|
||||
const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id)
|
||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks))
|
||||
res.json({
|
||||
author: req.author.toJSON()
|
||||
author: req.author.toOldJSON()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -279,7 +298,7 @@ class AuthorController {
|
|||
* DELETE: /api/authors/:id/image
|
||||
* Remove author image & delete image file
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {AuthorControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async deleteImage(req, res) {
|
||||
|
|
@ -291,19 +310,19 @@ class AuthorController {
|
|||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
||||
await CoverManager.removeFile(req.author.imagePath)
|
||||
req.author.imagePath = null
|
||||
await Database.authorModel.updateFromOld(req.author)
|
||||
await req.author.save()
|
||||
|
||||
const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id)
|
||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks))
|
||||
res.json({
|
||||
author: req.author.toJSON()
|
||||
author: req.author.toOldJSON()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/authors/:id/match
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {AuthorControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async match(req, res) {
|
||||
|
|
@ -342,38 +361,43 @@ class AuthorController {
|
|||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
req.author.updatedAt = Date.now()
|
||||
|
||||
await Database.updateAuthor(req.author)
|
||||
await req.author.save()
|
||||
|
||||
const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id)
|
||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks))
|
||||
}
|
||||
|
||||
res.json({
|
||||
updated: hasUpdates,
|
||||
author: req.author
|
||||
author: req.author.toOldJSON()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/authors/:id/image
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {AuthorControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getImage(req, res) {
|
||||
const {
|
||||
query: { width, height, format, raw },
|
||||
author
|
||||
query: { width, height, format, raw }
|
||||
} = req
|
||||
|
||||
if (!author.imagePath || !(await fs.pathExists(author.imagePath))) {
|
||||
Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
const authorId = req.params.id
|
||||
|
||||
if (raw) {
|
||||
const author = await Database.authorModel.findByPk(authorId)
|
||||
if (!author) {
|
||||
Logger.warn(`[AuthorController] Author "${authorId}" not found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (!author.imagePath || !(await fs.pathExists(author.imagePath))) {
|
||||
Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
return res.sendFile(author.imagePath)
|
||||
}
|
||||
|
||||
|
|
@ -382,7 +406,7 @@ class AuthorController {
|
|||
height: height ? parseInt(height) : null,
|
||||
width: width ? parseInt(width) : null
|
||||
}
|
||||
return CacheManager.handleAuthorCache(res, author, options)
|
||||
return CacheManager.handleAuthorCache(res, authorId, options)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -392,7 +416,7 @@ class AuthorController {
|
|||
* @param {NextFunction} next
|
||||
*/
|
||||
async middleware(req, res, next) {
|
||||
const author = await Database.authorModel.getOldById(req.params.id)
|
||||
const author = await Database.authorModel.findByPk(req.params.id)
|
||||
if (!author) return res.sendStatus(404)
|
||||
|
||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const Logger = require('../Logger')
|
|||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const Collection = require('../objects/Collection')
|
||||
|
||||
/**
|
||||
|
|
@ -115,6 +116,7 @@ class CollectionController {
|
|||
}
|
||||
|
||||
// If books array is passed in then update order in collection
|
||||
let collectionBooksUpdated = false
|
||||
if (req.body.books?.length) {
|
||||
const collectionBooks = await req.collection.getCollectionBooks({
|
||||
include: {
|
||||
|
|
@ -133,9 +135,15 @@ class CollectionController {
|
|||
await collectionBooks[i].update({
|
||||
order: i + 1
|
||||
})
|
||||
wasUpdated = true
|
||||
collectionBooksUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (collectionBooksUpdated) {
|
||||
req.collection.changed('updatedAt', true)
|
||||
await req.collection.save()
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
|
|
@ -148,6 +156,8 @@ class CollectionController {
|
|||
/**
|
||||
* DELETE: /api/collections/:id
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
|
|
@ -155,7 +165,7 @@ class CollectionController {
|
|||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
|
||||
// Close rss feed - remove from db and emit socket event
|
||||
await this.rssFeedManager.closeFeedForEntityId(req.collection.id)
|
||||
await RssFeedManager.closeFeedForEntityId(req.collection.id)
|
||||
|
||||
await req.collection.destroy()
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
|
|||
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||
const AudioFileScanner = require('../scanner/AudioFileScanner')
|
||||
const Scanner = require('../scanner/Scanner')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
|
|
@ -48,8 +50,8 @@ class LibraryItemController {
|
|||
}
|
||||
|
||||
if (includeEntities.includes('rssfeed')) {
|
||||
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id)
|
||||
item.rssFeed = feedData?.toJSONMinified() || null
|
||||
const feedData = await RssFeedManager.findFeedForEntityId(item.id)
|
||||
item.rssFeed = feedData?.toOldJSONMinified() || null
|
||||
}
|
||||
|
||||
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
|
||||
|
|
@ -96,6 +98,8 @@ class LibraryItemController {
|
|||
* Optional query params:
|
||||
* ?hard=1
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
|
|
@ -103,18 +107,50 @@ class LibraryItemController {
|
|||
const hardDelete = req.query.hard == 1 // Delete from file system
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
|
||||
const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id]
|
||||
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds)
|
||||
const mediaItemIds = []
|
||||
const authorIds = []
|
||||
const seriesIds = []
|
||||
if (req.libraryItem.isPodcast) {
|
||||
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
|
||||
} else {
|
||||
mediaItemIds.push(req.libraryItem.media.id)
|
||||
if (req.libraryItem.media.metadata.authors?.length) {
|
||||
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
|
||||
}
|
||||
if (req.libraryItem.media.metadata.series?.length) {
|
||||
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
|
||||
}
|
||||
}
|
||||
|
||||
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||
})
|
||||
}
|
||||
|
||||
if (authorIds.length) {
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||
}
|
||||
if (seriesIds.length) {
|
||||
await this.checkRemoveEmptySeries(seriesIds)
|
||||
}
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
static handleDownloadError(error, res) {
|
||||
if (!res.headersSent) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).send('File not found')
|
||||
} else {
|
||||
return res.status(500).send('Download failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/items/:id/download
|
||||
* Download library item. Zip file if multiple files.
|
||||
|
|
@ -122,7 +158,7 @@ class LibraryItemController {
|
|||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
download(req, res) {
|
||||
async download(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.warn(`User "${req.user.username}" attempted to download without permission`)
|
||||
return res.sendStatus(403)
|
||||
|
|
@ -130,27 +166,34 @@ class LibraryItemController {
|
|||
const libraryItemPath = req.libraryItem.path
|
||||
const itemTitle = req.libraryItem.media.metadata.title
|
||||
|
||||
// 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(libraryItemPath))
|
||||
if (audioMimeType) {
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
|
||||
res.download(libraryItemPath, req.libraryItem.relPath)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
|
||||
const filename = `${itemTitle}.zip`
|
||||
zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
|
||||
|
||||
try {
|
||||
// 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(libraryItemPath))
|
||||
if (audioMimeType) {
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
await new Promise((resolve, reject) => res.download(libraryItemPath, req.libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
|
||||
} else {
|
||||
const filename = `${itemTitle}.zip`
|
||||
await zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
|
||||
}
|
||||
Logger.info(`[LibraryItemController] Downloaded item "${itemTitle}" at "${libraryItemPath}"`)
|
||||
} catch (error) {
|
||||
Logger.error(`[LibraryItemController] Download failed for item "${itemTitle}" at "${libraryItemPath}"`, error)
|
||||
LibraryItemController.handleDownloadError(error, res)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /items/:id/media
|
||||
* Update media for a library item. Will create new authors & series when necessary
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
|
|
@ -185,19 +228,16 @@ class LibraryItemController {
|
|||
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
}
|
||||
|
||||
let authorsRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.authors) {
|
||||
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
|
||||
authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
||||
}
|
||||
|
||||
const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url
|
||||
if (hasUpdates) {
|
||||
libraryItem.updatedAt = Date.now()
|
||||
|
||||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
||||
await this.checkRemoveEmptySeries(
|
||||
libraryItem.media.id,
|
||||
seriesRemoved.map((se) => se.id)
|
||||
)
|
||||
}
|
||||
|
||||
if (isPodcastAutoDownloadUpdated) {
|
||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
}
|
||||
|
|
@ -205,6 +245,17 @@ class LibraryItemController {
|
|||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
|
||||
if (authorsRemoved.length) {
|
||||
// Check remove empty authors
|
||||
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
|
||||
}
|
||||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
|
||||
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
updated: hasUpdates,
|
||||
|
|
@ -310,44 +361,25 @@ class LibraryItemController {
|
|||
query: { width, height, format, raw }
|
||||
} = req
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(req.params.id, {
|
||||
attributes: ['id', 'mediaType', 'mediaId', 'libraryId'],
|
||||
include: [
|
||||
{
|
||||
model: Database.bookModel,
|
||||
attributes: ['id', 'coverPath', 'tags', 'explicit']
|
||||
},
|
||||
{
|
||||
model: Database.podcastModel,
|
||||
attributes: ['id', 'coverPath', 'tags', 'explicit']
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!libraryItem) {
|
||||
Logger.warn(`[LibraryItemController] getCover: Library item "${req.params.id}" does not exist`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
// Check if user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Check if library item media has a cover path
|
||||
if (!libraryItem.media.coverPath || !(await fs.pathExists(libraryItem.media.coverPath))) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400')
|
||||
|
||||
const libraryItemId = req.params.id
|
||||
if (!libraryItemId) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId)
|
||||
if (!coverPath || !(await fs.pathExists(coverPath))) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
// any value
|
||||
if (global.XAccel) {
|
||||
const encodedURI = encodeUriPath(global.XAccel + libraryItem.media.coverPath)
|
||||
const encodedURI = encodeUriPath(global.XAccel + coverPath)
|
||||
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
|
||||
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
|
||||
}
|
||||
return res.sendFile(libraryItem.media.coverPath)
|
||||
return res.sendFile(coverPath)
|
||||
}
|
||||
|
||||
const options = {
|
||||
|
|
@ -355,7 +387,7 @@ class LibraryItemController {
|
|||
height: height ? parseInt(height) : null,
|
||||
width: width ? parseInt(width) : null
|
||||
}
|
||||
return CacheManager.handleCoverCache(res, libraryItem.id, libraryItem.media.coverPath, options)
|
||||
return CacheManager.handleCoverCache(res, libraryItemId, options)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -367,7 +399,7 @@ class LibraryItemController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
startPlaybackSession(req, res) {
|
||||
if (!req.libraryItem.media.numTracks && req.libraryItem.mediaType !== 'video') {
|
||||
if (!req.libraryItem.media.numTracks) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
|
@ -424,10 +456,24 @@ class LibraryItemController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async match(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
const libraryItem = req.libraryItem
|
||||
const reqBody = req.body || {}
|
||||
|
||||
var options = req.body || {}
|
||||
var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
const options = {}
|
||||
const matchOptions = ['provider', 'title', 'author', 'isbn', 'asin']
|
||||
for (const key of matchOptions) {
|
||||
if (reqBody[key] && typeof reqBody[key] === 'string') {
|
||||
options[key] = reqBody[key]
|
||||
}
|
||||
}
|
||||
if (reqBody.overrideCover !== undefined) {
|
||||
options.overrideCover = !!reqBody.overrideCover
|
||||
}
|
||||
if (reqBody.overrideDetails !== undefined) {
|
||||
options.overrideDetails = !!reqBody.overrideDetails
|
||||
}
|
||||
|
||||
var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
|
||||
res.json(matchResult)
|
||||
}
|
||||
|
||||
|
|
@ -437,6 +483,8 @@ class LibraryItemController {
|
|||
* Optional query params:
|
||||
* ?hard=1
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
|
|
@ -463,15 +511,34 @@ class LibraryItemController {
|
|||
const libraryId = itemsToDelete[0].libraryId
|
||||
for (const libraryItem of itemsToDelete) {
|
||||
const libraryItemPath = libraryItem.path
|
||||
Logger.info(`[LibraryItemController] Deleting Library Item "${libraryItem.media.metadata.title}"`)
|
||||
const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id]
|
||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
||||
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
|
||||
const mediaItemIds = []
|
||||
const seriesIds = []
|
||||
const authorIds = []
|
||||
if (libraryItem.isPodcast) {
|
||||
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
|
||||
} else {
|
||||
mediaItemIds.push(libraryItem.media.id)
|
||||
if (libraryItem.media.metadata.series?.length) {
|
||||
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
|
||||
}
|
||||
if (libraryItem.media.metadata.authors?.length) {
|
||||
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
|
||||
}
|
||||
}
|
||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||
})
|
||||
}
|
||||
if (seriesIds.length) {
|
||||
await this.checkRemoveEmptySeries(seriesIds)
|
||||
}
|
||||
if (authorIds.length) {
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||
}
|
||||
}
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||
|
|
@ -481,48 +548,74 @@ class LibraryItemController {
|
|||
/**
|
||||
* POST: /api/items/batch/update
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async batchUpdate(req, res) {
|
||||
const updatePayloads = req.body
|
||||
if (!updatePayloads?.length) {
|
||||
return res.sendStatus(500)
|
||||
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
|
||||
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
// Ensure that each update payload has a unique library item id
|
||||
const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))]
|
||||
if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) {
|
||||
Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
// Get all library items to update
|
||||
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||
id: libraryItemIds
|
||||
})
|
||||
if (updatePayloads.length !== libraryItems.length) {
|
||||
Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
let itemsUpdated = 0
|
||||
|
||||
const seriesIdsRemoved = []
|
||||
const authorIdsRemoved = []
|
||||
|
||||
for (const updatePayload of updatePayloads) {
|
||||
const mediaPayload = updatePayload.mediaPayload
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
|
||||
if (!libraryItem) return null
|
||||
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
||||
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||
|
||||
let seriesRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id)
|
||||
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
if (libraryItem.isBook) {
|
||||
if (Array.isArray(mediaPayload.metadata?.series)) {
|
||||
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
|
||||
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
|
||||
}
|
||||
if (Array.isArray(mediaPayload.metadata?.authors)) {
|
||||
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
|
||||
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
||||
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItem.media.update(mediaPayload)) {
|
||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||
|
||||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
||||
await this.checkRemoveEmptySeries(
|
||||
libraryItem.media.id,
|
||||
seriesRemoved.map((se) => se.id)
|
||||
)
|
||||
}
|
||||
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
itemsUpdated++
|
||||
}
|
||||
}
|
||||
|
||||
if (seriesIdsRemoved.length) {
|
||||
await this.checkRemoveEmptySeries(seriesIdsRemoved)
|
||||
}
|
||||
if (authorIdsRemoved.length) {
|
||||
await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
updates: itemsUpdated
|
||||
|
|
@ -563,7 +656,6 @@ class LibraryItemController {
|
|||
let itemsUpdated = 0
|
||||
let itemsUnmatched = 0
|
||||
|
||||
const options = req.body.options || {}
|
||||
if (!req.body.libraryItemIds?.length) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
|
@ -577,8 +669,20 @@ class LibraryItemController {
|
|||
|
||||
res.sendStatus(200)
|
||||
|
||||
const reqBodyOptions = req.body.options || {}
|
||||
const options = {}
|
||||
if (reqBodyOptions.provider && typeof reqBodyOptions.provider === 'string') {
|
||||
options.provider = reqBodyOptions.provider
|
||||
}
|
||||
if (reqBodyOptions.overrideCover !== undefined) {
|
||||
options.overrideCover = !!reqBodyOptions.overrideCover
|
||||
}
|
||||
if (reqBodyOptions.overrideDetails !== undefined) {
|
||||
options.overrideDetails = !!reqBodyOptions.overrideDetails
|
||||
}
|
||||
|
||||
for (const libraryItem of libraryItems) {
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
|
||||
if (matchResult.updated) {
|
||||
itemsUpdated++
|
||||
} else if (matchResult.warning) {
|
||||
|
|
@ -823,12 +927,18 @@ class LibraryItemController {
|
|||
// We actually need to check for Webkit on Apple mobile devices because this issue impacts all browsers on iOS/iPadOS/etc, not just Safari.
|
||||
const isAppleMobileBrowser = ua.device.vendor === 'Apple' && ua.device.type === 'mobile' && ua.engine.name === 'WebKit'
|
||||
if (isAppleMobileBrowser && audioMimeType === AudioMimeType.M4B) {
|
||||
audioMimeType = 'audio/m4b'
|
||||
audioMimeType = 'audio/m4b'
|
||||
}
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
|
||||
res.download(libraryFile.metadata.path, libraryFile.metadata.filename)
|
||||
try {
|
||||
await new Promise((resolve, reject) => res.download(libraryFile.metadata.path, libraryFile.metadata.filename, (error) => (error ? reject(error) : resolve())))
|
||||
Logger.info(`[LibraryItemController] Downloaded file "${libraryFile.metadata.path}"`)
|
||||
} catch (error) {
|
||||
Logger.error(`[LibraryItemController] Failed to download file "${libraryFile.metadata.path}"`, error)
|
||||
LibraryItemController.handleDownloadError(error, res)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -866,7 +976,13 @@ class LibraryItemController {
|
|||
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
|
||||
}
|
||||
|
||||
res.sendFile(ebookFilePath)
|
||||
try {
|
||||
await new Promise((resolve, reject) => res.sendFile(ebookFilePath, (error) => (error ? reject(error) : resolve())))
|
||||
Logger.info(`[LibraryItemController] Downloaded ebook file "${ebookFilePath}"`)
|
||||
} catch (error) {
|
||||
Logger.error(`[LibraryItemController] Failed to download ebook file "${ebookFilePath}"`, error)
|
||||
LibraryItemController.handleDownloadError(error, res)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -394,6 +394,58 @@ class MeController {
|
|||
res.json(req.user.toOldJSONForBrowser())
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/me/ereader-devices
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updateUserEReaderDevices(req, res) {
|
||||
if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) {
|
||||
return res.status(400).send('Invalid payload. ereaderDevices array required')
|
||||
}
|
||||
|
||||
const userEReaderDevices = req.body.ereaderDevices
|
||||
for (const device of userEReaderDevices) {
|
||||
if (!device.name || !device.email) {
|
||||
return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email')
|
||||
} else if (device.availabilityOption !== 'specificUsers' || device.users?.length !== 1 || device.users[0] !== req.user.id) {
|
||||
return res.status(400).send('Invalid payload. ereaderDevices array items must have availabilityOption "specificUsers" and only the current user')
|
||||
}
|
||||
}
|
||||
|
||||
const otherDevices = Database.emailSettings.ereaderDevices.filter((device) => {
|
||||
return !Database.emailSettings.checkUserCanAccessDevice(device, req.user) || device.users?.length !== 1
|
||||
})
|
||||
|
||||
const ereaderDevices = otherDevices.concat(userEReaderDevices)
|
||||
|
||||
// Check for duplicate names
|
||||
const nameSet = new Set()
|
||||
const hasDupes = ereaderDevices.some((device) => {
|
||||
if (nameSet.has(device.name)) {
|
||||
return true // Duplicate found
|
||||
}
|
||||
nameSet.add(device.name)
|
||||
return false
|
||||
})
|
||||
|
||||
if (hasDupes) {
|
||||
return res.status(400).send('Invalid payload. Duplicate "name" field found.')
|
||||
}
|
||||
|
||||
const updated = Database.emailSettings.update({ ereaderDevices })
|
||||
if (updated) {
|
||||
await Database.updateSetting(Database.emailSettings)
|
||||
SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', {
|
||||
ereaderDevices: Database.emailSettings.ereaderDevices
|
||||
})
|
||||
}
|
||||
res.json({
|
||||
ereaderDevices: Database.emailSettings.getEReaderDevices(req.user)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/stats/year/:year
|
||||
*
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const fs = require('../libs/fsExtra')
|
|||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
const Watcher = require('../Watcher')
|
||||
|
||||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||
|
|
@ -44,11 +45,11 @@ class MiscController {
|
|||
const files = Object.values(req.files)
|
||||
const { title, author, series, folder: folderId, library: libraryId } = req.body
|
||||
|
||||
const library = await Database.libraryModel.getOldById(libraryId)
|
||||
const library = await Database.libraryModel.findByIdWithFolders(libraryId)
|
||||
if (!library) {
|
||||
return res.status(404).send(`Library not found with id ${libraryId}`)
|
||||
}
|
||||
const folder = library.folders.find((fold) => fold.id === folderId)
|
||||
const folder = library.libraryFolders.find((fold) => fold.id === folderId)
|
||||
if (!folder) {
|
||||
return res.status(404).send(`Folder not found with id ${folderId} in library ${library.name}`)
|
||||
}
|
||||
|
|
@ -63,7 +64,7 @@ class MiscController {
|
|||
// before sanitizing all the directory parts to remove illegal chars and finally prepending
|
||||
// the base folder path
|
||||
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
|
||||
const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts])
|
||||
const outputDirectory = Path.join(...[folder.path, ...cleanedOutputDirectoryParts])
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
|
||||
|
|
@ -125,6 +126,10 @@ class MiscController {
|
|||
if (!isObject(settingsUpdate)) {
|
||||
return res.status(400).send('Invalid settings update object')
|
||||
}
|
||||
if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') {
|
||||
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
|
||||
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
|
||||
}
|
||||
|
||||
const madeUpdates = Database.serverSettings.update(settingsUpdate)
|
||||
if (madeUpdates) {
|
||||
|
|
@ -136,7 +141,6 @@ class MiscController {
|
|||
}
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||
})
|
||||
}
|
||||
|
|
@ -557,10 +561,10 @@ class MiscController {
|
|||
|
||||
switch (type) {
|
||||
case 'add':
|
||||
this.watcher.onFileAdded(libraryId, path)
|
||||
Watcher.onFileAdded(libraryId, path)
|
||||
break
|
||||
case 'unlink':
|
||||
this.watcher.onFileRemoved(libraryId, path)
|
||||
Watcher.onFileRemoved(libraryId, path)
|
||||
break
|
||||
case 'rename':
|
||||
const oldPath = req.body.oldPath
|
||||
|
|
@ -568,7 +572,7 @@ class MiscController {
|
|||
Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
this.watcher.onFileRename(libraryId, oldPath, path)
|
||||
Watcher.onFileRename(libraryId, oldPath, path)
|
||||
break
|
||||
default:
|
||||
Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`)
|
||||
|
|
@ -678,9 +682,9 @@ class MiscController {
|
|||
continue
|
||||
}
|
||||
let updatedValue = settingsUpdate[key]
|
||||
if (updatedValue === '') updatedValue = null
|
||||
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
|
||||
let currentValue = currentAuthenticationSettings[key]
|
||||
if (currentValue === '') currentValue = null
|
||||
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
|
||||
|
||||
if (updatedValue !== currentValue) {
|
||||
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const { Request, Response, NextFunction } = require('express')
|
||||
const Database = require('../Database')
|
||||
const { version } = require('../../package.json')
|
||||
const NotificationManager = require('../managers/NotificationManager')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
|
|
@ -23,7 +24,7 @@ class NotificationController {
|
|||
*/
|
||||
get(req, res) {
|
||||
res.json({
|
||||
data: this.notificationManager.getData(),
|
||||
data: NotificationManager.getData(),
|
||||
settings: Database.notificationSettings
|
||||
})
|
||||
}
|
||||
|
|
@ -52,7 +53,7 @@ class NotificationController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
getData(req, res) {
|
||||
res.json(this.notificationManager.getData())
|
||||
res.json(NotificationManager.getData())
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,7 +65,7 @@ class NotificationController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async fireTestEvent(req, res) {
|
||||
await this.notificationManager.triggerNotification('onTest', { version: `v${version}` }, req.query.fail === '1')
|
||||
await NotificationManager.triggerNotification('onTest', { version: `v${version}` }, req.query.fail === '1')
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +122,7 @@ class NotificationController {
|
|||
async sendNotificationTest(req, res) {
|
||||
if (!Database.notificationSettings.isUseable) return res.status(400).send('Apprise is not configured')
|
||||
|
||||
const success = await this.notificationManager.sendTestNotification(req.notification)
|
||||
const success = await NotificationManager.sendTestNotification(req.notification)
|
||||
if (success) res.sendStatus(200)
|
||||
else res.sendStatus(500)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,13 +38,13 @@ class PodcastController {
|
|||
}
|
||||
const payload = req.body
|
||||
|
||||
const library = await Database.libraryModel.getOldById(payload.libraryId)
|
||||
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
|
||||
const folder = library.folders.find((fold) => fold.id === payload.folderId)
|
||||
const folder = library.libraryFolders.find((fold) => fold.id === payload.folderId)
|
||||
if (!folder) {
|
||||
Logger.error(`[PodcastController] Create: Folder not found "${payload.folderId}"`)
|
||||
return res.status(404).send('Folder not found')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
const { Request, Response, NextFunction } = require('express')
|
||||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
|
|
@ -22,10 +23,10 @@ class RSSFeedController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async getAll(req, res) {
|
||||
const feeds = await this.rssFeedManager.getFeeds()
|
||||
const feeds = await RssFeedManager.getFeeds()
|
||||
res.json({
|
||||
feeds: feeds.map((f) => f.toJSON()),
|
||||
minified: feeds.map((f) => f.toJSONMinified())
|
||||
feeds: feeds.map((f) => f.toOldJSON()),
|
||||
minified: feeds.map((f) => f.toOldJSONMinified())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -38,38 +39,43 @@ class RSSFeedController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async openRSSFeedForItem(req, res) {
|
||||
const options = req.body || {}
|
||||
const reqBody = req.body || {}
|
||||
|
||||
const item = await Database.libraryItemModel.getOldById(req.params.itemId)
|
||||
if (!item) return res.sendStatus(404)
|
||||
const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)
|
||||
if (!itemExpanded) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${item.media.metadata.title}" that they don\'t have access to`)
|
||||
if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {
|
||||
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${itemExpanded.media.title}" that they don\'t have access to`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Check request body options exist
|
||||
if (!options.serverAddress || !options.slug) {
|
||||
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
||||
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Check item has audio tracks
|
||||
if (!item.media.numTracks) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`)
|
||||
if (!itemExpanded.hasAudioTracks()) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
|
||||
return res.status(400).send('Item has no audio tracks')
|
||||
}
|
||||
|
||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||
return res.status(400).send('Slug already in use')
|
||||
}
|
||||
|
||||
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body)
|
||||
const feed = await RssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)
|
||||
if (!feed) {
|
||||
Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`)
|
||||
return res.status(500).send('Failed to open RSS feed')
|
||||
}
|
||||
|
||||
res.json({
|
||||
feed: feed.toJSONMinified()
|
||||
feed: feed.toOldJSONMinified()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -82,35 +88,37 @@ class RSSFeedController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async openRSSFeedForCollection(req, res) {
|
||||
const options = req.body || {}
|
||||
|
||||
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
|
||||
if (!collection) return res.sendStatus(404)
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Check request body options exist
|
||||
if (!options.serverAddress || !options.slug) {
|
||||
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
||||
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||
return res.status(400).send('Slug already in use')
|
||||
}
|
||||
|
||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length)
|
||||
const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)
|
||||
if (!collection) return res.sendStatus(404)
|
||||
|
||||
// Check collection has audio tracks
|
||||
if (!collectionItemsWithTracks.length) {
|
||||
if (!collection.books.some((book) => book.includedAudioFiles.length)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
|
||||
return res.status(400).send('Collection has no audio tracks')
|
||||
}
|
||||
|
||||
const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collectionExpanded, req.body)
|
||||
const feed = await RssFeedManager.openFeedForCollection(req.user.id, collection, reqBody)
|
||||
if (!feed) {
|
||||
Logger.error(`[RSSFeedController] Failed to open RSS feed for collection "${collection.name}"`)
|
||||
return res.status(500).send('Failed to open RSS feed')
|
||||
}
|
||||
|
||||
res.json({
|
||||
feed: feed.toJSONMinified()
|
||||
feed: feed.toOldJSONMinified()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -123,37 +131,37 @@ class RSSFeedController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async openRSSFeedForSeries(req, res) {
|
||||
const options = req.body || {}
|
||||
|
||||
const series = await Database.seriesModel.getOldById(req.params.seriesId)
|
||||
if (!series) return res.sendStatus(404)
|
||||
const reqBody = req.body || {}
|
||||
|
||||
// Check request body options exist
|
||||
if (!options.serverAddress || !options.slug) {
|
||||
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
||||
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
|
||||
return res.status(400).send('Invalid request body')
|
||||
}
|
||||
|
||||
// Check that this slug is not being used for another feed (slug will also be the Feed id)
|
||||
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
|
||||
if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
|
||||
return res.status(400).send('Slug already in use')
|
||||
}
|
||||
|
||||
const seriesJson = series.toJSON()
|
||||
|
||||
// Get books in series that have audio tracks
|
||||
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
|
||||
const series = await Database.seriesModel.getExpandedById(req.params.seriesId)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
||||
// Check series has audio tracks
|
||||
if (!seriesJson.books.length) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`)
|
||||
if (!series.books.some((book) => book.includedAudioFiles.length)) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${series.name}" because it has no audio tracks`)
|
||||
return res.status(400).send('Series has no audio tracks')
|
||||
}
|
||||
|
||||
const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body)
|
||||
const feed = await RssFeedManager.openFeedForSeries(req.user.id, series, req.body)
|
||||
if (!feed) {
|
||||
Logger.error(`[RSSFeedController] Failed to open RSS feed for series "${series.name}"`)
|
||||
return res.status(500).send('Failed to open RSS feed')
|
||||
}
|
||||
|
||||
res.json({
|
||||
feed: feed.toJSONMinified()
|
||||
feed: feed.toOldJSONMinified()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -165,8 +173,16 @@ class RSSFeedController {
|
|||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
closeRSSFeed(req, res) {
|
||||
this.rssFeedManager.closeRssFeed(req, res)
|
||||
async closeRSSFeed(req, res) {
|
||||
const feed = await Database.feedModel.findByPk(req.params.id)
|
||||
if (!feed) {
|
||||
Logger.error(`[RSSFeedController] Cannot close RSS feed because feed "${req.params.id}" does not exist`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
await RssFeedManager.handleCloseFeed(feed)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ const Logger = require('../Logger')
|
|||
const BookFinder = require('../finders/BookFinder')
|
||||
const PodcastFinder = require('../finders/PodcastFinder')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
const MusicFinder = require('../finders/MusicFinder')
|
||||
const Database = require('../Database')
|
||||
const { isValidASIN } = require('../utils')
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ const { Request, Response, NextFunction } = require('express')
|
|||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
|
||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||
|
||||
/**
|
||||
|
|
@ -9,6 +12,11 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter
|
|||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*
|
||||
* @typedef RequestEntityObject
|
||||
* @property {import('../models/Series')} series
|
||||
*
|
||||
* @typedef {RequestWithUser & RequestEntityObject} SeriesControllerRequest
|
||||
*/
|
||||
|
||||
class SeriesController {
|
||||
|
|
@ -21,7 +29,7 @@ class SeriesController {
|
|||
* TODO: Update mobile app to use /api/libraries/:id/series/:seriesId API route instead
|
||||
* Series are not library specific so we need to know what the library id is
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {SeriesControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
|
|
@ -30,7 +38,7 @@ class SeriesController {
|
|||
.map((v) => v.trim())
|
||||
.filter((v) => !!v)
|
||||
|
||||
const seriesJson = req.series.toJSON()
|
||||
const seriesJson = req.series.toOldJSON()
|
||||
|
||||
// Add progress map with isFinished flag
|
||||
if (include.includes('progress')) {
|
||||
|
|
@ -46,25 +54,36 @@ class SeriesController {
|
|||
}
|
||||
|
||||
if (include.includes('rssfeed')) {
|
||||
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
|
||||
const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
|
||||
seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
|
||||
}
|
||||
|
||||
res.json(seriesJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Currently unused in the client, should check for duplicate name
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {SeriesControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const hasUpdated = req.series.update(req.body)
|
||||
if (hasUpdated) {
|
||||
await Database.updateSeries(req.series)
|
||||
SocketAuthority.emitter('series_updated', req.series.toJSON())
|
||||
const keysToUpdate = ['name', 'description']
|
||||
const payload = {}
|
||||
for (const key of keysToUpdate) {
|
||||
if (req.body[key] !== undefined && typeof req.body[key] === 'string') {
|
||||
payload[key] = req.body[key]
|
||||
}
|
||||
}
|
||||
res.json(req.series.toJSON())
|
||||
if (!Object.keys(payload).length) {
|
||||
return res.status(400).send('No valid fields to update')
|
||||
}
|
||||
req.series.set(payload)
|
||||
if (req.series.changed()) {
|
||||
await req.series.save()
|
||||
SocketAuthority.emitter('series_updated', req.series.toOldJSON())
|
||||
}
|
||||
res.json(req.series.toOldJSON())
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,7 +93,7 @@ class SeriesController {
|
|||
* @param {NextFunction} next
|
||||
*/
|
||||
async middleware(req, res, next) {
|
||||
const series = await Database.seriesModel.getOldById(req.params.id)
|
||||
const series = await Database.seriesModel.findByPk(req.params.id)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -29,12 +29,17 @@ class ToolsController {
|
|||
|
||||
if (req.libraryItem.mediaType !== 'book') {
|
||||
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
|
||||
return res.status(500).send('Invalid library item: not a book')
|
||||
return res.status(400).send('Invalid library item: not a book')
|
||||
}
|
||||
|
||||
if (req.libraryItem.media.tracks.length <= 0) {
|
||||
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
|
||||
return res.status(500).send('Invalid audiobook: no audio tracks')
|
||||
return res.status(400).send('Invalid audiobook: no audio tracks')
|
||||
}
|
||||
|
||||
if (this.abMergeManager.getPendingTaskByLibraryItemId(req.libraryItem.id)) {
|
||||
Logger.error(`[MiscController] encodeM4b: Audiobook ${req.params.id} is already processing`)
|
||||
return res.status(400).send('Audiobook is already processing')
|
||||
}
|
||||
|
||||
const options = req.query || {}
|
||||
|
|
@ -73,12 +78,12 @@ class ToolsController {
|
|||
async embedAudioFileMetadata(req, res) {
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
Logger.error(`[ToolsController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(req.libraryItem.id)) {
|
||||
Logger.error(`[ToolsController] Library item (${req.libraryItem.id}) is already in queue or processing`)
|
||||
return res.status(500).send('Library item is already in queue or processing')
|
||||
return res.status(400).send('Library item is already in queue or processing')
|
||||
}
|
||||
|
||||
const options = {
|
||||
|
|
@ -120,12 +125,12 @@ class ToolsController {
|
|||
|
||||
if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) {
|
||||
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
|
||||
return res.sendStatus(500)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(libraryItemId)) {
|
||||
Logger.error(`[ToolsController] Batch embed library item (${libraryItemId}) is already in queue or processing`)
|
||||
return res.status(500).send('Library item is already in queue or processing')
|
||||
return res.status(400).send('Library item is already in queue or processing')
|
||||
}
|
||||
|
||||
libraryItems.push(libraryItem)
|
||||
|
|
|
|||
|
|
@ -205,9 +205,12 @@ class UserController {
|
|||
async update(req, res) {
|
||||
const user = req.reqUser
|
||||
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
if (user.isRoot && !req.user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user "${req.user.username}" attempted to update root user`)
|
||||
return res.sendStatus(403)
|
||||
} else if (user.isRoot) {
|
||||
// Root user cannot update type
|
||||
delete req.body.type
|
||||
}
|
||||
|
||||
const updatePayload = req.body
|
||||
|
|
@ -270,8 +273,10 @@ class UserController {
|
|||
const permissions = {
|
||||
...user.permissions
|
||||
}
|
||||
const defaultPermissions = Database.userModel.getDefaultPermissionsForUserType(updatePayload.type || user.type || 'user')
|
||||
for (const key in updatePayload.permissions) {
|
||||
if (permissions[key] !== undefined) {
|
||||
// Check that the key is a valid permission key or is included in the default permissions
|
||||
if (permissions[key] !== undefined || defaultPermissions[key] !== undefined) {
|
||||
if (typeof updatePayload.permissions[key] !== 'boolean') {
|
||||
Logger.warn(`[UserController] update: Invalid permission value for key ${key}. Should be boolean`)
|
||||
} else if (permissions[key] !== updatePayload.permissions[key]) {
|
||||
|
|
@ -363,6 +368,19 @@ class UserController {
|
|||
await playlist.destroy()
|
||||
}
|
||||
|
||||
// Set PlaybackSessions userId to null
|
||||
const [sessionsUpdated] = await Database.playbackSessionModel.update(
|
||||
{
|
||||
userId: null
|
||||
},
|
||||
{
|
||||
where: {
|
||||
userId: user.id
|
||||
}
|
||||
}
|
||||
)
|
||||
Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`)
|
||||
|
||||
const userJson = user.toOldJSONForBrowser()
|
||||
await user.destroy()
|
||||
SocketAuthority.adminEmitter('user_removed', userJson)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue